diff --git a/src/ui/.gitignore b/src/ui/.gitignore index 379650394..11220623e 100644 --- a/src/ui/.gitignore +++ b/src/ui/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +/coverage-e2e # next.js /.next/ diff --git a/src/ui/e2e/journeys/compact-mode-toggle.spec.ts b/src/ui/e2e/journeys/compact-mode-toggle.spec.ts new file mode 100644 index 000000000..4908ef6b4 --- /dev/null +++ b/src/ui/e2e/journeys/compact-mode-toggle.spec.ts @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + createPoolResponse, + PoolStatus, +} from "@/mocks/factories"; +import { + setupDefaultMocks, + setupPools, + setupProfile, +} from "@/e2e/utils/mock-setup"; + +/** + * Compact Mode Toggle Tests + * + * The TableToolbar includes a compact/comfortable view toggle (SemiStatefulButton) + * that persists state in localStorage via Zustand shared-preferences-store. + * + * Architecture notes: + * - Component: src/components/data-table/table-toolbar.tsx + * - Uses useCompactMode() hook (hydration-safe) + * - aria-label: "Currently in compact view" or "Currently in comfortable view" + * - Toggle label: "Switch to Compact" or "Switch to Comfortable" + * - Affects row height across all table pages (Pools, Resources, Workflows, etc.) + */ + +test.describe("Compact Mode Toggle — Pools Page", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + await setupPools( + page, + createPoolResponse([ + { name: "prod-pool", status: PoolStatus.ONLINE }, + { name: "dev-pool", status: PoolStatus.ONLINE }, + ]), + ); + }); + + test("compact mode toggle button is visible in toolbar", async ({ page }) => { + // ACT + await page.goto("/pools?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — the toggle button is visible with default "comfortable" state + const toggleButton = page.getByRole("button", { name: /currently in comfortable view/i }); + await expect(toggleButton).toBeVisible(); + }); + + test("default mode shows 'Currently in comfortable view' label", async ({ page }) => { + // ACT + await page.goto("/pools?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — default is comfortable (not compact) + await expect( + page.getByRole("button", { name: /currently in comfortable view/i }), + ).toBeVisible(); + }); + + test("clicking toggle switches to compact view", async ({ page }) => { + // ACT + await page.goto("/pools?all=true"); + await page.waitForLoadState("networkidle"); + + // Click the compact mode toggle + const toggleButton = page.getByRole("button", { name: /currently in comfortable view/i }); + await toggleButton.click(); + + // ASSERT — now in compact mode + await expect( + page.getByRole("button", { name: /currently in compact view/i }), + ).toBeVisible(); + }); + + test("clicking toggle twice returns to comfortable view", async ({ page }) => { + // ACT + await page.goto("/pools?all=true"); + await page.waitForLoadState("networkidle"); + + // Toggle to compact + const toggleButton = page.getByRole("button", { name: /currently in comfortable view/i }); + await toggleButton.click(); + await expect( + page.getByRole("button", { name: /currently in compact view/i }), + ).toBeVisible(); + + // Toggle back to comfortable + await page.getByRole("button", { name: /currently in compact view/i }).click(); + + // ASSERT — back to comfortable + await expect( + page.getByRole("button", { name: /currently in comfortable view/i }), + ).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/credential-data-generic-types.spec.ts b/src/ui/e2e/journeys/credential-data-generic-types.spec.ts new file mode 100644 index 000000000..bf3301062 --- /dev/null +++ b/src/ui/e2e/journeys/credential-data-generic-types.spec.ts @@ -0,0 +1,326 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { setupDefaultMocks, setupProfile } from "@/e2e/utils/mock-setup"; + +/** + * Profile Credentials Data & Generic Type Tests + * + * Tests credential form interactions for Data and Generic credential types, + * complementing the existing profile-credentials-form.spec.ts which covers Registry. + * + * - Switching to Data type shows endpoint, access key, and secret key fields + * - Switching to Generic type shows key-value pair fields + * - Data type: secret key visibility toggle works + * - Generic type: add/remove key-value pair buttons work + * - Generic type: value visibility toggle works + * - Save becomes enabled when all required fields are filled for each type + * + * Architecture notes: + * - CredentialsSection at /profile#credentials + * - Credential Type is a Select dropdown with options: Registry, Data, Generic + * - Data type fields: Endpoint, Access Key ID, Secret Key (with show/hide toggle) + * - Generic type fields: Key-Value Pairs (dynamic rows) with add/remove buttons + * - The Select is wrapped in useMounted() guard for hydration safety + */ + +const CT_JSON = "application/json"; + +async function setupProfileSettings( + page: Parameters[0], +) { + await page.route("**/api/profile/settings*", (route) => + route.fulfill({ + status: 200, + contentType: CT_JSON, + body: JSON.stringify({ + profile: { + email_notification: true, + slack_notification: false, + bucket: "default-bucket", + pool: "default-pool", + }, + roles: [], + pools: ["pool-alpha"], + }), + }), + ); +} + +async function setupBuckets(page: Parameters[0]) { + await page.route("**/api/bucket*", (route) => + route.fulfill({ + status: 200, + contentType: CT_JSON, + body: JSON.stringify({ + buckets: [{ name: "default-bucket", path: "s3://default-bucket", description: "", mode: "rw", default_credential: false }], + default: "default-bucket", + }), + }), + ); +} + +async function setupCredentials( + page: Parameters[0], + credentials: Array<{ cred_name: string; cred_type: string; profile?: string }> = [], +) { + await page.route("**/api/credentials*", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + status: 200, + contentType: CT_JSON, + body: JSON.stringify({ credentials }), + }); + } + return route.fulfill({ + status: 200, + contentType: CT_JSON, + body: JSON.stringify({ status: "ok" }), + }); + }); +} + +async function scrollToCredentials(page: Parameters[0]) { + await page.locator("#credentials").scrollIntoViewIfNeeded(); +} + +async function openNewCredentialForm(page: Parameters[0]) { + await page.goto("/profile"); + await page.waitForLoadState("networkidle"); + await scrollToCredentials(page); + await page.getByRole("button", { name: /new credential/i }).click(); +} + +test.describe("Credential Form Data Type Fields", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + await setupProfileSettings(page); + await setupBuckets(page); + await setupCredentials(page, []); + }); + + test("switching to Data type shows endpoint, access key, and secret key fields", async ({ page }) => { + // ARRANGE + await openNewCredentialForm(page); + + // ACT — switch from Registry (default) to Data type + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: /data/i }).click(); + + // ASSERT — Data credential fields visible, registry fields gone + await expect(page.getByLabel("Endpoint")).toBeVisible(); + await expect(page.getByLabel("Access Key ID")).toBeVisible(); + await expect(page.getByLabel("Secret Key")).toBeVisible(); + await expect(page.getByLabel("Registry URL")).not.toBeVisible(); + }); + + test("Data type secret key visibility toggle works", async ({ page }) => { + // ARRANGE + await openNewCredentialForm(page); + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: /data/i }).click(); + + // Fill in secret key + const secretKeyInput = page.getByLabel("Secret Key"); + await secretKeyInput.fill("my-secret-access-key"); + + // ASSERT — secret key is hidden by default + await expect(secretKeyInput).toHaveAttribute("type", "password"); + + // ACT — click show secret toggle + await page.getByTitle("Show secret").click(); + + // ASSERT — secret key is now visible + await expect(secretKeyInput).toHaveAttribute("type", "text"); + + // ACT — click hide secret toggle + await page.getByTitle("Hide secret").click(); + + // ASSERT — secret key is hidden again + await expect(secretKeyInput).toHaveAttribute("type", "password"); + }); + + test("Save is enabled when all Data type fields are filled", async ({ page }) => { + // ARRANGE + await openNewCredentialForm(page); + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: /data/i }).click(); + + const credSection = page.locator("#credentials"); + + // Initially disabled + await expect(credSection.getByRole("button", { name: "Save" })).toBeDisabled(); + + // ACT — fill all required fields + await page.getByLabel("Credential Name").fill("my-s3-cred"); + await page.getByLabel("Endpoint").fill("s3.amazonaws.com"); + await page.getByLabel("Access Key ID").fill("AKIAIOSFODNN7EXAMPLE"); + await page.getByLabel("Secret Key").fill("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); + + // ASSERT — Save is now enabled + await expect(credSection.getByRole("button", { name: "Save" })).toBeEnabled(); + }); +}); + +test.describe("Credential Form Generic Type Fields", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + await setupProfileSettings(page); + await setupBuckets(page); + await setupCredentials(page, []); + }); + + test("switching to Generic type shows key-value pair fields", async ({ page }) => { + // ARRANGE + await openNewCredentialForm(page); + + // ACT — switch to Generic type + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: /generic/i }).click(); + + // ASSERT — Key-Value Pairs heading visible with input fields + await expect(page.getByText("Key-Value Pairs")).toBeVisible(); + await expect(page.getByPlaceholder("Key").first()).toBeVisible(); + await expect(page.getByPlaceholder("Value").first()).toBeVisible(); + // Registry fields should not be visible + await expect(page.getByLabel("Registry URL")).not.toBeVisible(); + }); + + test("Generic type: add pair button adds a new key-value row", async ({ page }) => { + // ARRANGE + await openNewCredentialForm(page); + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: /generic/i }).click(); + + // Initially one row + const keyInputs = page.getByPlaceholder("Key"); + await expect(keyInputs).toHaveCount(1); + + // ACT — click add pair button + await page.getByTitle("Add another pair").click(); + + // ASSERT — two rows now + await expect(keyInputs).toHaveCount(2); + }); + + test("Generic type: remove pair button removes a row (requires 2+ rows)", async ({ page }) => { + // ARRANGE + await openNewCredentialForm(page); + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: /generic/i }).click(); + + // Add second row + await page.getByTitle("Add another pair").click(); + await expect(page.getByPlaceholder("Key")).toHaveCount(2); + + // ACT — remove the second row (last "Remove pair" button) + const removeButtons = page.getByTitle("Remove pair"); + await removeButtons.last().click(); + + // ASSERT — back to one row + await expect(page.getByPlaceholder("Key")).toHaveCount(1); + }); + + test("Generic type: remove button is disabled when only one pair exists", async ({ page }) => { + // ARRANGE + await openNewCredentialForm(page); + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: /generic/i }).click(); + + // ASSERT — single row's remove button is disabled + await expect( + page.getByTitle("At least one pair required"), + ).toBeDisabled(); + }); + + test("Generic type value visibility toggle works", async ({ page }) => { + // ARRANGE + await openNewCredentialForm(page); + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: /generic/i }).click(); + + // Fill a value + const valueInput = page.getByPlaceholder("Value").first(); + await valueInput.fill("secret-token-123"); + + // ASSERT — value is hidden by default + await expect(valueInput).toHaveAttribute("type", "password"); + + // ACT — click show values toggle + await page.getByTitle("Show values").click(); + + // ASSERT — value is now visible + await expect(valueInput).toHaveAttribute("type", "text"); + }); + + test("Save is enabled when all Generic type fields are filled", async ({ page }) => { + // ARRANGE + await openNewCredentialForm(page); + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: /generic/i }).click(); + + const credSection = page.locator("#credentials"); + + // Initially disabled + await expect(credSection.getByRole("button", { name: "Save" })).toBeDisabled(); + + // ACT — fill all required fields + await page.getByLabel("Credential Name").fill("my-api-token"); + await page.getByPlaceholder("Key").first().fill("api_key"); + await page.getByPlaceholder("Value").first().fill("sk-1234567890"); + + // ASSERT — Save is now enabled + await expect(credSection.getByRole("button", { name: "Save" })).toBeEnabled(); + }); +}); + +test.describe("Credential Type Grouping Display", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + await setupProfileSettings(page); + await setupBuckets(page); + }); + + test("credentials are grouped by type with group headers", async ({ page }) => { + // ARRANGE — multiple credential types + await setupCredentials(page, [ + { cred_name: "docker-hub", cred_type: "REGISTRY" }, + { cred_name: "ghcr-io", cred_type: "REGISTRY" }, + { cred_name: "s3-prod", cred_type: "DATA" }, + { cred_name: "api-token", cred_type: "GENERIC" }, + ]); + + // ACT + await page.goto("/profile"); + await page.waitForLoadState("networkidle"); + await scrollToCredentials(page); + + // ASSERT — group headers visible + await expect(page.getByText("Registry").first()).toBeVisible(); + await expect(page.getByText("Data").first()).toBeVisible(); + await expect(page.getByText("Generic").first()).toBeVisible(); + + // All credential names visible + await expect(page.getByText("docker-hub").first()).toBeVisible(); + await expect(page.getByText("ghcr-io").first()).toBeVisible(); + await expect(page.getByText("s3-prod").first()).toBeVisible(); + await expect(page.getByText("api-token").first()).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/cross-page-navigation.spec.ts b/src/ui/e2e/journeys/cross-page-navigation.spec.ts new file mode 100644 index 000000000..543d7ce9f --- /dev/null +++ b/src/ui/e2e/journeys/cross-page-navigation.spec.ts @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + createPoolResponse, + PoolStatus, +} from "@/mocks/factories"; +import { + setupDefaultMocks, + setupProfile, + setupPools, +} from "@/e2e/utils/mock-setup"; + +/** + * Pool Quick Links Navigation Tests + * + * Tests pool panel quick links that navigate to Resources, Workflows, + * and Occupancy pages pre-filtered by pool name. + * + * Architecture notes: + * - Pool panel has 3 quick links: Resources, Workflows, Occupancy + * - Each link pre-applies a pool filter to the destination page URL + * - Links are rendered as Next.js Link components in the pool panel + * - Panel is at role="complementary" with aria-label="Pool details: {name}" + */ + +test.describe("Pool Quick Links Navigation", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + await setupPools( + page, + createPoolResponse([ + { name: "prod-gpu", status: PoolStatus.ONLINE }, + { name: "staging", status: PoolStatus.OFFLINE }, + ]), + ); + }); + + test("resources quick link navigates to resources filtered by pool", async ({ + page, + }) => { + // ACT + await page.goto("/pools?all=true&view=prod-gpu"); + await page.waitForLoadState("networkidle"); + + // Click Resources quick link in panel + const panel = page.getByRole("complementary", { + name: "Pool details: prod-gpu", + }); + await expect(panel).toBeVisible(); + + const resourcesLink = panel.getByRole("link", { name: /resources/i }); + await expect(resourcesLink).toBeVisible(); + await resourcesLink.click(); + + // ASSERT — navigated to resources page with pool filter + await expect(page).toHaveURL(/\/resources/); + await expect(page).toHaveURL(/prod-gpu/); + }); + + test("workflows quick link navigates to workflows filtered by pool", async ({ + page, + }) => { + // ACT + await page.goto("/pools?all=true&view=prod-gpu"); + await page.waitForLoadState("networkidle"); + + const panel = page.getByRole("complementary", { + name: "Pool details: prod-gpu", + }); + const workflowsLink = panel.getByRole("link", { name: /workflows/i }); + await expect(workflowsLink).toBeVisible(); + await workflowsLink.click(); + + // ASSERT — navigated to workflows page with pool in URL + await expect(page).toHaveURL(/\/workflows/); + await expect(page).toHaveURL(/prod-gpu/); + }); + + test("occupancy quick link navigates to occupancy filtered by pool", async ({ + page, + }) => { + // ACT + await page.goto("/pools?all=true&view=prod-gpu"); + await page.waitForLoadState("networkidle"); + + const panel = page.getByRole("complementary", { + name: "Pool details: prod-gpu", + }); + const occupancyLink = panel.getByRole("link", { name: /occupancy/i }); + await expect(occupancyLink).toBeVisible(); + await occupancyLink.click(); + + // ASSERT — navigated to occupancy page with pool in URL + await expect(page).toHaveURL(/\/occupancy/); + await expect(page).toHaveURL(/prod-gpu/); + }); + + test("quick links show correct href attributes before clicking", async ({ + page, + }) => { + // ACT + await page.goto("/pools?all=true&view=prod-gpu"); + await page.waitForLoadState("networkidle"); + + const panel = page.getByRole("complementary", { + name: "Pool details: prod-gpu", + }); + await expect(panel).toBeVisible(); + + // ASSERT — each link has pool-filtered href + const resourcesLink = panel.getByRole("link", { name: /resources/i }); + await expect(resourcesLink).toHaveAttribute("href", /prod-gpu/); + + const workflowsLink = panel.getByRole("link", { name: /workflows/i }); + await expect(workflowsLink).toHaveAttribute("href", /prod-gpu/); + + const occupancyLink = panel.getByRole("link", { name: /occupancy/i }); + await expect(occupancyLink).toHaveAttribute("href", /prod-gpu/); + }); +}); diff --git a/src/ui/e2e/journeys/dashboard-errors.spec.ts b/src/ui/e2e/journeys/dashboard-errors.spec.ts new file mode 100644 index 000000000..7bc1421cc --- /dev/null +++ b/src/ui/e2e/journeys/dashboard-errors.spec.ts @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + createPoolResponse, + createWorkflowsResponse, + PoolStatus, + WorkflowStatus, +} from "@/mocks/factories"; +import { + setupDefaultMocks, + setupProfile, + setupPools, + setupWorkflows, +} from "@/e2e/utils/mock-setup"; + +/** + * Dashboard Error & Edge Case Tests + * + * Tests dashboard behavior under error conditions and unusual data scenarios: + * - Pool API failures (dashboard should still render workflows) + * - Workflow API failures (dashboard should still render pools) + * - All APIs failing (dashboard should show error states) + * - Large numbers of workflows/pools (performance) + * - Mixed status counts + * + * Architecture notes: + * - Dashboard fetches pools + workflows in parallel + * - Each section has its own error boundary + * - Stat cards compute counts from API responses + * - Recent workflows shows up to 5 items + */ + +test.describe("Dashboard API Error Resilience", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("shows pools stat card even when workflow API fails", async ({ page }) => { + // ARRANGE — pools succeed, workflows fail + await setupPools( + page, + createPoolResponse([ + { name: "prod", status: PoolStatus.ONLINE }, + { name: "dev", status: PoolStatus.ONLINE }, + ]), + ); + await page.route("**/api/workflow*", (route) => + route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ detail: "Service unavailable" }), + }), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — page renders without crash, pools stat is available + await expect(page.getByText("Pools Online").first()).toBeVisible(); + }); + + test("shows workflow stat cards even when pool API fails", async ({ page }) => { + // ARRANGE — pools fail, workflows succeed + await setupPools(page, { status: 400, detail: "Service unavailable" }); + await setupWorkflows( + page, + createWorkflowsResponse([ + { name: "wf-1", status: WorkflowStatus.RUNNING }, + { name: "wf-2", status: WorkflowStatus.COMPLETED }, + ]), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — page renders without crash, workflow stats are available + await expect(page.getByText("Active Workflows").first()).toBeVisible(); + }); + + test("page does not crash when both APIs fail", async ({ page }) => { + // ARRANGE — both APIs return errors + await setupPools(page, { status: 400, detail: "Pools unavailable" }); + await page.route("**/api/workflow*", (route) => + route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ detail: "Workflows unavailable" }), + }), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — page does not crash + await expect(page.locator("body")).not.toBeEmpty(); + // Dashboard breadcrumb should still render + const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" }); + await expect(breadcrumb.getByText("Dashboard").first()).toBeVisible(); + }); +}); + +test.describe("Dashboard Stat Count Verification", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("counts multiple running workflows correctly", async ({ page }) => { + // ARRANGE — 3 running workflows + await setupPools(page, createPoolResponse([{ name: "prod", status: PoolStatus.ONLINE }])); + await setupWorkflows( + page, + createWorkflowsResponse([ + { name: "run-1", status: WorkflowStatus.RUNNING }, + { name: "run-2", status: WorkflowStatus.RUNNING }, + { name: "run-3", status: WorkflowStatus.RUNNING }, + { name: "done-1", status: WorkflowStatus.COMPLETED }, + ]), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Active Workflows card shows the count + await expect(page.getByText("Active Workflows").first()).toBeVisible(); + // The count should be 3 (three running workflows) + await expect(page.getByText("3").first()).toBeVisible(); + }); + + test("shows correct online pools count", async ({ page }) => { + // ARRANGE — 2 online pools, 1 offline + await setupPools( + page, + createPoolResponse([ + { name: "prod", status: PoolStatus.ONLINE }, + { name: "staging", status: PoolStatus.ONLINE }, + { name: "maintenance", status: PoolStatus.OFFLINE }, + ]), + ); + await setupWorkflows(page, createWorkflowsResponse([])); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Pools Online card visible + await expect(page.getByText("Pools Online").first()).toBeVisible(); + // The count should be 2 (two online pools) + await expect(page.getByText("2").first()).toBeVisible(); + }); + + test("recent workflows list shows workflow names as links", async ({ page }) => { + // ARRANGE — multiple workflows + await setupPools(page, createPoolResponse([{ name: "prod", status: PoolStatus.ONLINE }])); + await setupWorkflows( + page, + createWorkflowsResponse([ + { name: "ml-training-job", status: WorkflowStatus.RUNNING, user: "alice" }, + { name: "data-pipeline", status: WorkflowStatus.COMPLETED, user: "bob" }, + { name: "inference-test", status: WorkflowStatus.FAILED, user: "charlie" }, + ]), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — workflow names visible in recent workflows + await expect(page.getByText("Recent Workflows").first()).toBeVisible(); + // At least one workflow link is visible + const workflowLinks = page.locator('a[href^="/workflows/"]'); + await expect(workflowLinks.first()).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/dashboard-recent-workflows.spec.ts b/src/ui/e2e/journeys/dashboard-recent-workflows.spec.ts new file mode 100644 index 000000000..c495329b4 --- /dev/null +++ b/src/ui/e2e/journeys/dashboard-recent-workflows.spec.ts @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + createPoolResponse, + createWorkflowsResponse, + PoolStatus, + WorkflowStatus, +} from "@/mocks/factories"; +import { + setupDefaultMocks, + setupPools, + setupProfile, + setupWorkflows, +} from "@/e2e/utils/mock-setup"; + +/** + * Dashboard Recent Workflow Interaction Tests + * + * Tests the interactive behavior of recent workflow items on the dashboard: + * - Clicking a recent workflow navigates to workflow detail page + * - Empty state shows appropriate message + * - Multiple status types display correctly + * - Workflow user names display in the list + * + * Architecture notes: + * - Dashboard at / shows "Recent Workflows" section with up to 5 items + * - Each recent workflow is a Link to /workflows/{name} + * - Status badges use WorkflowStatus enum → getStatusDisplay → StatusBadge + * - Dashboard auto-fetches all pages to compute 24h stats + * - Profile accessible pools filter which pools appear in stats + */ + +test.describe("Dashboard Recent Workflow Interactions", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("clicking a recent workflow navigates to its detail page", async ({ page }) => { + // ARRANGE + await setupPools(page, createPoolResponse([{ name: "prod", status: PoolStatus.ONLINE }])); + await setupWorkflows( + page, + createWorkflowsResponse([ + { name: "my-training-job", status: WorkflowStatus.RUNNING, user: "alice" }, + ]), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + // Avoid networkidle: Next + TanStack can keep long-polling / background + // requests alive and make networkidle flaky in CI. + const workflowLink = page.locator('a[href="/workflows/my-training-job"]'); + await expect(workflowLink.first()).toBeVisible({ timeout: 20_000 }); + + // ASSERT — navigates to workflow detail (race-free with client-side routing) + await Promise.all([ + page.waitForURL(/\/workflows\/my-training-job/, { timeout: 20_000 }), + workflowLink.first().click(), + ]); + }); + + test("shows 'No workflows to display' when recent list is empty", async ({ page }) => { + // ARRANGE — no workflows + await setupPools(page, createPoolResponse([{ name: "prod", status: PoolStatus.ONLINE }])); + await setupWorkflows(page, createWorkflowsResponse([])); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — empty message + await expect(page.getByText("No workflows to display").first()).toBeVisible(); + }); + + test("recent workflows show user names", async ({ page }) => { + // ARRANGE + await setupPools(page, createPoolResponse([{ name: "prod", status: PoolStatus.ONLINE }])); + await setupWorkflows( + page, + createWorkflowsResponse([ + { name: "job-alice", status: WorkflowStatus.RUNNING, user: "alice@nvidia.com" }, + { name: "job-bob", status: WorkflowStatus.COMPLETED, user: "bob@nvidia.com" }, + ]), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — user names visible + await expect(page.getByText("alice@nvidia.com").first()).toBeVisible(); + await expect(page.getByText("bob@nvidia.com").first()).toBeVisible(); + }); + + test("recent workflows display at most 5 items", async ({ page }) => { + // ARRANGE — 7 workflows + await setupPools(page, createPoolResponse([{ name: "prod", status: PoolStatus.ONLINE }])); + await setupWorkflows( + page, + createWorkflowsResponse([ + { name: "wf-1", status: WorkflowStatus.RUNNING }, + { name: "wf-2", status: WorkflowStatus.RUNNING }, + { name: "wf-3", status: WorkflowStatus.COMPLETED }, + { name: "wf-4", status: WorkflowStatus.COMPLETED }, + { name: "wf-5", status: WorkflowStatus.FAILED }, + { name: "wf-6", status: WorkflowStatus.FAILED }, + { name: "wf-7", status: WorkflowStatus.RUNNING }, + ]), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — at most 5 workflow links in the recent section + const recentSection = page.locator("text=Recent Workflows").first().locator("..").locator(".."); + const workflowLinks = recentSection.locator('a[href^="/workflows/"]'); + const count = await workflowLinks.count(); + expect(count).toBeLessThanOrEqual(5); + expect(count).toBeGreaterThan(0); + }); +}); + +test.describe("Dashboard Profile Pool Filtering", () => { + test("stat cards reflect only accessible pools when profile has pool restrictions", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + + // Profile with only "prod" accessible + await page.route("**/api/profile/settings*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + profile: { + username: "restricted-user", + email_notification: true, + slack_notification: false, + bucket: "", + pool: "prod", + }, + roles: [], + pools: ["prod"], + }), + }), + ); + + // 3 pools but user can only see "prod" + await setupPools( + page, + createPoolResponse([ + { name: "prod", status: PoolStatus.ONLINE }, + { name: "staging", status: PoolStatus.ONLINE }, + { name: "dev", status: PoolStatus.OFFLINE }, + ]), + ); + await setupWorkflows(page, createWorkflowsResponse([])); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Pools Online shows 1/1 (only "prod" accessible and online) + // The stat card displays "{online}/{total}" format + await expect(page.getByText("Pools Online").first()).toBeVisible(); + await expect(page.getByText("1/1").first()).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/src/ui/e2e/journeys/dashboard-stat-values.spec.ts b/src/ui/e2e/journeys/dashboard-stat-values.spec.ts new file mode 100644 index 000000000..481a5443b --- /dev/null +++ b/src/ui/e2e/journeys/dashboard-stat-values.spec.ts @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + createPoolResponse, + createWorkflowsResponse, + PoolStatus, + WorkflowStatus, +} from "@/mocks/factories"; +import { + setupDefaultMocks, + setupPools, + setupProfile, + setupWorkflows, +} from "@/e2e/utils/mock-setup"; + +/** + * Dashboard Stat Card Values Tests + * + * Tests that the dashboard stat cards render correct numeric values: + * - Active Workflows shows count of RUNNING workflows + * - Completed (24h) shows count of COMPLETED workflows + * - Failed (24h) shows count of all failed-category workflows + * - Pools Online shows "online/total" format + * + * Architecture notes: + * - DashboardContent auto-fetches all pages to cover full 24h window + * - workflowStats are simple counts by status + * - poolStats filtered by accessible pools from profile + * - Stat cards show value as large text, or "—" when loading + */ + +test.describe("Dashboard Stat Card Values", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("Active Workflows card shows count of running workflows", async ({ page }) => { + // ARRANGE — 3 running, 1 completed + await setupPools(page, createPoolResponse([{ name: "prod", status: PoolStatus.ONLINE }])); + await setupWorkflows( + page, + createWorkflowsResponse([ + { name: "running-1", status: WorkflowStatus.RUNNING }, + { name: "running-2", status: WorkflowStatus.RUNNING }, + { name: "running-3", status: WorkflowStatus.RUNNING }, + { name: "completed-1", status: WorkflowStatus.COMPLETED }, + ]), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Active Workflows card shows "3" + const activeCard = page.locator("a[href*='status:RUNNING']"); + await expect(activeCard.getByText("3")).toBeVisible(); + }); + + test("Completed (24h) card shows count of completed workflows", async ({ page }) => { + // ARRANGE — 2 completed + await setupPools(page, createPoolResponse([{ name: "prod", status: PoolStatus.ONLINE }])); + await setupWorkflows( + page, + createWorkflowsResponse([ + { name: "completed-1", status: WorkflowStatus.COMPLETED }, + { name: "completed-2", status: WorkflowStatus.COMPLETED }, + { name: "running-1", status: WorkflowStatus.RUNNING }, + ]), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Completed card shows "2" + const completedCard = page.locator("a[href*='status:COMPLETED']"); + await expect(completedCard.locator("p.text-2xl")).toHaveText("2"); + }); + + test("Failed (24h) card shows count of failed workflows", async ({ page }) => { + // ARRANGE — 1 FAILED + await setupPools(page, createPoolResponse([{ name: "prod", status: PoolStatus.ONLINE }])); + await setupWorkflows( + page, + createWorkflowsResponse([ + { name: "failed-1", status: WorkflowStatus.FAILED }, + { name: "completed-1", status: WorkflowStatus.COMPLETED }, + ]), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Failed card shows "1" (text-red when failed > 0) + const failedCard = page.locator("a[href*='FAILED']"); + await expect(failedCard.getByText("1")).toBeVisible(); + }); + + test("Pools Online card shows online/total format", async ({ page }) => { + // ARRANGE — 2 online out of 3 total + await setupPools( + page, + createPoolResponse([ + { name: "prod", status: PoolStatus.ONLINE }, + { name: "staging", status: PoolStatus.ONLINE }, + { name: "dev", status: PoolStatus.OFFLINE }, + ]), + ); + await setupWorkflows(page, createWorkflowsResponse([])); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Pools Online card shows "2/3" (online/total) + const poolsCard = page.locator("a[href*='status:ONLINE'][href*='pools']"); + await expect(poolsCard.getByText("2/3")).toBeVisible(); + }); + + test("stat cards show zero values when no data matches", async ({ page }) => { + // ARRANGE — 1 completed, no running, no failed + await setupPools(page, createPoolResponse([{ name: "prod", status: PoolStatus.ONLINE }])); + await setupWorkflows( + page, + createWorkflowsResponse([{ name: "completed-1", status: WorkflowStatus.COMPLETED }]), + ); + + // ACT + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Active Workflows shows "0" (no running workflows) + const activeCard = page.locator("a[href*='status:RUNNING']"); + await expect(activeCard.getByText("0")).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/dataset-file-browser.spec.ts b/src/ui/e2e/journeys/dataset-file-browser.spec.ts new file mode 100644 index 000000000..41686538f --- /dev/null +++ b/src/ui/e2e/journeys/dataset-file-browser.spec.ts @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { DatasetStatus } from "@/lib/api/generated"; +import { setupDefaultMocks, setupProfile } from "@/e2e/utils/mock-setup"; + +/** + * Dataset Detail File Browser Navigation Tests + * + * Tests the file browser component's navigation behavior: + * - Folder navigation (clicking a folder row) + * - Breadcrumb navigation (clicking parent segments) + * - Details panel toggle + * - Sorting behavior + * + * Architecture notes: + * - FileBrowserTable renders a DataTable with folder/file rows + * - Folders are clickable (onNavigate) — updates ?path= URL param + * - Files are clickable (onSelectFile) — opens preview panel, updates ?file= URL param + * - Breadcrumb trail shows current path with clickable segments + * - Manifest data comes from e2e/mock-api-backend.mjs on port 9999 + * - "data-bucket"/"file-dataset" → GRID fixture: readme.md, data/train.csv, data/test.csv, models/model.pt + */ + +const CT_JSON = "application/json"; + +function createDatasetWithFolders(bucket: string, name: string) { + const now = new Date().toISOString(); + const location = `s3://${bucket}/datasets/${name}/v1/`; + + return { + name, + id: `${bucket}/${name}`, + bucket, + labels: {}, + type: "DATASET", + versions: [ + { + name, + version: "1", + status: DatasetStatus.READY, + created_by: "e2e-user", + created_date: now, + last_used: now, + size: 100 * 1024 * 1024, + checksum: "abc123", + location, + uri: location, + metadata: {}, + tags: ["latest"], + collections: [], + }, + ], + }; +} + +async function setupDatasetInfo( + page: Parameters[0], + bucket: string, + name: string, + data: ReturnType, +) { + await page.route(`**/api/bucket/${bucket}/dataset/${name}/info*`, (route) => + route.fulfill({ + status: 200, + contentType: CT_JSON, + body: JSON.stringify(data), + }), + ); +} + +test.describe("Dataset File Browser Navigation", () => { + test.describe.configure({ timeout: 30_000 }); + + // Uses "data-bucket"/"file-dataset" which maps to GRID fixture in mock-api-backend.mjs: + // readme.md, data/train.csv, data/test.csv, models/model.pt + const bucket = "data-bucket"; + const datasetName = "file-dataset"; + + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + await setupDatasetInfo(page, bucket, datasetName, createDatasetWithFolders(bucket, datasetName)); + }); + + test("file browser shows folders and files at root", async ({ page }) => { + // ACT + await page.goto(`/datasets/${bucket}/${datasetName}`); + await page.waitForLoadState("networkidle"); + + // ASSERT — grid renders with folder and file entries from GRID fixture + const grid = page.getByRole("grid"); + await expect(grid).toBeVisible({ timeout: 15_000 }); + + // Root should show: data/ folder, models/ folder, readme.md file + await expect(grid.getByText("data").first()).toBeVisible(); + await expect(grid.getByText("models").first()).toBeVisible(); + await expect(grid.getByText("readme.md").first()).toBeVisible(); + }); + + test("clicking a folder navigates into it and updates path", async ({ page }) => { + // ACT + await page.goto(`/datasets/${bucket}/${datasetName}`); + await page.waitForLoadState("networkidle"); + + const grid = page.getByRole("grid"); + await expect(grid).toBeVisible({ timeout: 15_000 }); + + // Click the "data" folder row + await grid.getByText("data").first().click(); + + // ASSERT — URL updates with path param + await expect(page).toHaveURL(/path=data/); + + // Folder contents visible (train.csv, test.csv) + await expect(grid.getByText("train.csv").first()).toBeVisible({ timeout: 5_000 }); + await expect(grid.getByText("test.csv").first()).toBeVisible(); + }); + + test("breadcrumb shows dataset name at root level", async ({ page }) => { + // ACT + await page.goto(`/datasets/${bucket}/${datasetName}`); + await page.waitForLoadState("networkidle"); + + const grid = page.getByRole("grid"); + await expect(grid).toBeVisible({ timeout: 15_000 }); + + // ASSERT — dataset name visible in the control strip + await expect(page.getByText(datasetName).first()).toBeVisible(); + }); + + test("navigating into folder and then clicking dataset name returns to root", async ({ page }) => { + // ACT + await page.goto(`/datasets/${bucket}/${datasetName}?path=data`); + await page.waitForLoadState("networkidle"); + + const grid = page.getByRole("grid"); + await expect(grid).toBeVisible({ timeout: 15_000 }); + + // Click dataset name in breadcrumb to go back to root + await page.getByRole("button", { name: datasetName }).first().click(); + + // ASSERT — back at root with folders visible + await expect(page).not.toHaveURL(/path=/); + await expect(grid.getByText("readme.md").first()).toBeVisible({ timeout: 5_000 }); + }); + + test("details panel toggle button works", async ({ page }) => { + // ACT + await page.goto(`/datasets/${bucket}/${datasetName}`); + await page.waitForLoadState("networkidle"); + + const grid = page.getByRole("grid"); + await expect(grid).toBeVisible({ timeout: 15_000 }); + + // Find the details toggle button + const detailsButton = page.getByRole("button", { name: /show details|hide details/i }); + await expect(detailsButton).toBeVisible(); + + // Click to toggle panel visibility — check aria-pressed changes + const initialPressed = await detailsButton.getAttribute("aria-pressed"); + await detailsButton.click(); + const newPressed = await detailsButton.getAttribute("aria-pressed"); + + // ASSERT — aria-pressed toggled + expect(newPressed).not.toBe(initialPressed); + }); +}); diff --git a/src/ui/e2e/journeys/dataset-panel-versions.spec.ts b/src/ui/e2e/journeys/dataset-panel-versions.spec.ts new file mode 100644 index 000000000..11c8b8f3d --- /dev/null +++ b/src/ui/e2e/journeys/dataset-panel-versions.spec.ts @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { createDatasetsResponse, DatasetType } from "@/mocks/factories"; +import { setupDefaultMocks, setupDatasets, setupProfile } from "@/e2e/utils/mock-setup"; + +/** + * Dataset Panel Versions Section Tests + * + * Tests the dataset-panel-versions.tsx component inside the dataset panel: + * - Version table with column headers (Version, Created by, Date, Size, Tags) + * - Multiple versions sorted latest-first + * - Tags displayed as badges + * - Active version highlighting + * + * Also tests the collection-panel-members.tsx component: + * - Members table with column headers (Dataset, Version, Size) + * - Member dataset names visible + * - Empty members state + */ + +const CT_JSON = "application/json"; + +function createDatasetInfoWithVersions( + bucket: string, + name: string, + versions: Array<{ + version: string; + created_by?: string; + size?: number; + tags?: string[]; + }>, +) { + const now = new Date().toISOString(); + return { + name, + id: `${bucket}/${name}`, + bucket, + labels: {}, + type: "DATASET", + versions: versions.map((v) => ({ + name, + version: v.version, + status: "READY", + created_by: v.created_by ?? "e2e-user", + created_date: now, + last_used: now, + size: v.size ?? 1024 * 1024 * 1024, + checksum: "abc123", + location: `s3://${bucket}/datasets/${name}/v${v.version}/`, + uri: `s3://${bucket}/datasets/${name}/v${v.version}/`, + metadata: {}, + tags: v.tags ?? [], + collections: [], + })), + }; +} + +function createCollectionInfoWithMembers( + bucket: string, + name: string, + members: Array<{ name: string; version: string; size?: number }>, +) { + return { + name, + id: `${bucket}/${name}`, + bucket, + labels: {}, + type: "COLLECTION", + versions: members.map((m) => ({ + name: m.name, + version: m.version, + location: `s3://${bucket}/datasets/${m.name}/v${m.version}/`, + uri: `s3://${bucket}/datasets/${m.name}/v${m.version}/`, + size: m.size ?? 512 * 1024 * 1024, + })), + }; +} + +async function setupDatasetInfo( + page: Parameters[0], + bucket: string, + name: string, + data: Record, +) { + await page.route(`**/api/bucket/${bucket}/dataset/${encodeURIComponent(name)}/info*`, (route) => + route.fulfill({ status: 200, contentType: CT_JSON, body: JSON.stringify(data) }), + ); +} + +test.describe("Dataset Panel — Version Table", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("version table shows column headers", async ({ page }) => { + // ARRANGE + const bucket = "ver-bucket"; + const name = "ver-headers-ds"; + await setupDatasets(page, createDatasetsResponse([{ name, bucket, type: DatasetType.DATASET }])); + await setupDatasetInfo( + page, + bucket, + name, + createDatasetInfoWithVersions(bucket, name, [{ version: "1", tags: ["latest"] }]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: `Open details for ${name}` }).click(); + + // ASSERT — version table column headers visible + await expect(page.getByText("Versions").first()).toBeVisible(); + await expect(page.getByText("Version", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Created by").first()).toBeVisible(); + await expect(page.getByText("Date").first()).toBeVisible(); + await expect(page.getByText("Size", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Tags").first()).toBeVisible(); + }); + + test("multiple versions are displayed in the table", async ({ page }) => { + // ARRANGE + const bucket = "multi-ver-bucket"; + const name = "multi-ver-ds"; + await setupDatasets(page, createDatasetsResponse([{ name, bucket, type: DatasetType.DATASET }])); + await setupDatasetInfo( + page, + bucket, + name, + createDatasetInfoWithVersions(bucket, name, [ + { version: "1", created_by: "alice", tags: [] }, + { version: "2", created_by: "bob", tags: ["stable"] }, + { version: "3", created_by: "charlie", tags: ["latest"] }, + ]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: `Open details for ${name}` }).click(); + + // ASSERT — all version creators are visible (proxy for row visibility) + await expect(page.getByText("alice").first()).toBeVisible(); + await expect(page.getByText("bob").first()).toBeVisible(); + await expect(page.getByText("charlie").first()).toBeVisible(); + }); + + test("version tags are shown as badges", async ({ page }) => { + // ARRANGE + const bucket = "tags-bucket"; + const name = "tags-ds"; + await setupDatasets(page, createDatasetsResponse([{ name, bucket, type: DatasetType.DATASET }])); + await setupDatasetInfo( + page, + bucket, + name, + createDatasetInfoWithVersions(bucket, name, [ + { version: "1", tags: ["latest", "production"] }, + ]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: `Open details for ${name}` }).click(); + + // ASSERT — tags visible in the versions table + await expect(page.getByText("latest").first()).toBeVisible(); + await expect(page.getByText("production").first()).toBeVisible(); + }); + + test("version without tags shows dash placeholder", async ({ page }) => { + // ARRANGE + const bucket = "notag-bucket"; + const name = "notag-ds"; + await setupDatasets(page, createDatasetsResponse([{ name, bucket, type: DatasetType.DATASET }])); + await setupDatasetInfo( + page, + bucket, + name, + createDatasetInfoWithVersions(bucket, name, [{ version: "1", tags: [] }]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: `Open details for ${name}` }).click(); + + // ASSERT — dash placeholder shown for empty tags + await expect(page.getByText("Versions").first()).toBeVisible(); + await expect(page.getByText("—").first()).toBeVisible(); + }); +}); + +test.describe("Dataset Panel — Collection Members Table", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("collection members table shows column headers", async ({ page }) => { + // ARRANGE + const bucket = "coll-bucket"; + const name = "coll-members"; + await setupDatasets(page, createDatasetsResponse([{ name, bucket, type: DatasetType.COLLECTION }])); + await setupDatasetInfo( + page, + bucket, + name, + createCollectionInfoWithMembers(bucket, name, [{ name: "sub-ds-1", version: "1" }]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: `Open details for ${name}` }).click(); + + // ASSERT — Members section with column headers + await expect(page.getByText("Members").first()).toBeVisible(); + await expect(page.getByText("Dataset", { exact: true }).first()).toBeVisible(); + }); + + test("collection shows multiple member datasets", async ({ page }) => { + // ARRANGE + const bucket = "multi-member-bucket"; + const name = "multi-coll"; + await setupDatasets(page, createDatasetsResponse([{ name, bucket, type: DatasetType.COLLECTION }])); + await setupDatasetInfo( + page, + bucket, + name, + createCollectionInfoWithMembers(bucket, name, [ + { name: "training-data", version: "3" }, + { name: "validation-set", version: "1" }, + { name: "test-holdout", version: "2" }, + ]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: `Open details for ${name}` }).click(); + + // ASSERT — all member names visible + await expect(page.getByText("training-data").first()).toBeVisible(); + await expect(page.getByText("validation-set").first()).toBeVisible(); + await expect(page.getByText("test-holdout").first()).toBeVisible(); + }); + + test("collection members show version numbers with v prefix", async ({ page }) => { + // ARRANGE + const bucket = "ver-coll-bucket"; + const name = "ver-coll"; + await setupDatasets(page, createDatasetsResponse([{ name, bucket, type: DatasetType.COLLECTION }])); + await setupDatasetInfo( + page, + bucket, + name, + createCollectionInfoWithMembers(bucket, name, [{ name: "member-ds", version: "5" }]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: `Open details for ${name}` }).click(); + + // ASSERT — version shown as "v5" + await expect(page.getByText("v5").first()).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/dataset-table-columns.spec.ts b/src/ui/e2e/journeys/dataset-table-columns.spec.ts new file mode 100644 index 000000000..ac24620e2 --- /dev/null +++ b/src/ui/e2e/journeys/dataset-table-columns.spec.ts @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { createDatasetsResponse, DatasetType } from "@/mocks/factories"; +import { setupDefaultMocks, setupDatasets, setupProfile } from "@/e2e/utils/mock-setup"; + +/** + * Dataset Table Column Rendering Tests + * + * Tests the dataset-column-defs.tsx column definitions: + * - Type column: "Dataset" vs "Collection" badge rendering + * - Version column: "v{N}" format or "—" dash for no version + * - Size column: formatted byte display + * - Bucket column: bucket name text + * - Date columns: formatted timestamps + * - Name column with "Open details" button + */ + +test.describe("Dataset Table — Type Column", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("shows 'Dataset' badge for dataset type entries", async ({ page }) => { + // ARRANGE + await setupDatasets( + page, + createDatasetsResponse([ + { name: "my-training-data", bucket: "prod-bucket", type: DatasetType.DATASET }, + ]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — "Dataset" badge visible in the type column + await expect(page.getByText("Dataset", { exact: true }).first()).toBeVisible(); + }); + + test("shows 'Collection' badge for collection type entries", async ({ page }) => { + // ARRANGE + await setupDatasets( + page, + createDatasetsResponse([ + { name: "my-collection", bucket: "prod-bucket", type: DatasetType.COLLECTION }, + ]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — "Collection" badge visible + await expect(page.getByText("Collection", { exact: true }).first()).toBeVisible(); + }); + + test("mixed types show both Dataset and Collection badges", async ({ page }) => { + // ARRANGE + await setupDatasets( + page, + createDatasetsResponse([ + { name: "training-set", bucket: "bucket-a", type: DatasetType.DATASET }, + { name: "combo-collection", bucket: "bucket-b", type: DatasetType.COLLECTION }, + ]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — both badges visible + await expect(page.getByText("Dataset", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Collection", { exact: true }).first()).toBeVisible(); + }); +}); + +test.describe("Dataset Table — Name & Details Button", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("shows dataset names in the table", async ({ page }) => { + // ARRANGE + await setupDatasets( + page, + createDatasetsResponse([ + { name: "imagenet-v2", bucket: "ml-bucket", type: DatasetType.DATASET }, + { name: "cifar-10", bucket: "ml-bucket", type: DatasetType.DATASET }, + ]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — dataset names visible + await expect(page.getByText("imagenet-v2").first()).toBeVisible(); + await expect(page.getByText("cifar-10").first()).toBeVisible(); + }); + + test("shows Open details button for each dataset", async ({ page }) => { + // ARRANGE + await setupDatasets( + page, + createDatasetsResponse([ + { name: "detail-test-ds", bucket: "test-bucket", type: DatasetType.DATASET }, + ]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Open details button has correct aria-label + await expect(page.getByRole("button", { name: "Open details for detail-test-ds" })).toBeVisible(); + }); +}); + +test.describe("Dataset Table — Bucket Column", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("shows bucket names in the table", async ({ page }) => { + // ARRANGE + await setupDatasets( + page, + createDatasetsResponse([ + { name: "ds-1", bucket: "production-bucket", type: DatasetType.DATASET }, + { name: "ds-2", bucket: "staging-bucket", type: DatasetType.DATASET }, + ]), + ); + + // ACT + await page.goto("/datasets?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — bucket names visible + await expect(page.getByText("production-bucket").first()).toBeVisible(); + await expect(page.getByText("staging-bucket").first()).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/dataset-version-picker.spec.ts b/src/ui/e2e/journeys/dataset-version-picker.spec.ts new file mode 100644 index 000000000..54166f771 --- /dev/null +++ b/src/ui/e2e/journeys/dataset-version-picker.spec.ts @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { DatasetStatus } from "@/lib/api/generated"; +import { setupDefaultMocks, setupProfile } from "@/e2e/utils/mock-setup"; + +/** + * Dataset Detail Version Picker Tests + * + * Tests the VersionPicker component in the dataset detail page: + * - Version picker renders with current version label + * - Switching between versions and tags tabs + * - Selecting a different version + * - Version picker shows "latest" tag + * + * Architecture notes: + * - Dataset detail at /datasets/{bucket}/{name} + * - VersionPicker uses Radix Popover + tabs (Versions / Tags) + * - Data comes entirely from the `versions` prop (no additional API calls) + * - SSR: dataset info comes from /api/bucket/{bucket}/dataset/{name}/info + * - Manifest: served by e2e/mock-api-backend.mjs on port 9999 + * - Version picker only visible for datasets with versions (not collections) + */ + +const CT_JSON = "application/json"; + +function createDatasetInfoWithVersions(bucket: string, name: string) { + const now = new Date().toISOString(); + const location = `s3://${bucket}/datasets/${name}/v3/`; + + return { + name, + id: `${bucket}/${name}`, + bucket, + labels: {}, + type: "DATASET", + versions: [ + { + name, + version: "3", + status: DatasetStatus.READY, + created_by: "e2e-user", + created_date: now, + last_used: now, + size: 1024 * 1024 * 50, + checksum: "abc333", + location, + uri: location, + metadata: {}, + tags: ["latest", "production"], + collections: [], + }, + { + name, + version: "2", + status: DatasetStatus.READY, + created_by: "e2e-user", + created_date: now, + last_used: now, + size: 1024 * 1024 * 40, + checksum: "abc222", + location: `s3://${bucket}/datasets/${name}/v2/`, + uri: `s3://${bucket}/datasets/${name}/v2/`, + metadata: {}, + tags: ["staging"], + collections: [], + }, + { + name, + version: "1", + status: DatasetStatus.READY, + created_by: "e2e-user", + created_date: now, + last_used: now, + size: 1024 * 1024 * 30, + checksum: "abc111", + location: `s3://${bucket}/datasets/${name}/v1/`, + uri: `s3://${bucket}/datasets/${name}/v1/`, + metadata: {}, + tags: [], + collections: [], + }, + ], + }; +} + +async function setupDatasetInfo( + page: Parameters[0], + bucket: string, + name: string, + data: ReturnType, +) { + await page.route(`**/api/bucket/${bucket}/dataset/${name}/info*`, (route) => + route.fulfill({ + status: 200, + contentType: CT_JSON, + body: JSON.stringify(data), + }), + ); +} + +test.describe("Dataset Detail Version Picker", () => { + test.describe.configure({ timeout: 30_000 }); + + const bucket = "my-bucket"; + const datasetName = "my-dataset"; + + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + await setupDatasetInfo( + page, + bucket, + datasetName, + createDatasetInfoWithVersions(bucket, datasetName), + ); + }); + + test("shows version picker with latest version label", async ({ page }) => { + // ACT + await page.goto(`/datasets/${bucket}/${datasetName}`); + await page.waitForLoadState("networkidle"); + + // ASSERT — version picker trigger is visible with "v3" (latest) + const versionButton = page.getByRole("button", { name: /Version:.*Click to change/i }); + await expect(versionButton).toBeVisible({ timeout: 10_000 }); + await expect(versionButton).toContainText("v3"); + }); + + test("opening version picker shows versions tab with all versions", async ({ page }) => { + // ACT + await page.goto(`/datasets/${bucket}/${datasetName}`); + await page.waitForLoadState("networkidle"); + + // Click version picker trigger + const versionButton = page.getByRole("button", { name: /Version:.*Click to change/i }); + await versionButton.click(); + + // ASSERT — popover opens with version list + await expect(page.getByText("Switch versions / tags")).toBeVisible(); + + // Versions tab is active by default + const versionsTab = page.getByRole("tab", { name: "Versions" }); + await expect(versionsTab).toHaveAttribute("aria-selected", "true"); + + // All versions visible in the list + const versionList = page.getByRole("listbox", { name: "Versions" }); + await expect(versionList.getByText("v3")).toBeVisible(); + await expect(versionList.getByText("v2")).toBeVisible(); + await expect(versionList.getByText("v1")).toBeVisible(); + }); + + test("switching to tags tab shows named tags", async ({ page }) => { + // ACT + await page.goto(`/datasets/${bucket}/${datasetName}`); + await page.waitForLoadState("networkidle"); + + // Open version picker + const versionButton = page.getByRole("button", { name: /Version:.*Click to change/i }); + await versionButton.click(); + + // Click Tags tab + const tagsTab = page.getByRole("tab", { name: "Tags" }); + await tagsTab.click(); + + // ASSERT — tags are shown + await expect(tagsTab).toHaveAttribute("aria-selected", "true"); + const tagList = page.getByRole("listbox", { name: "Tags" }); + await expect(tagList.getByText("latest")).toBeVisible(); + await expect(tagList.getByText("production")).toBeVisible(); + await expect(tagList.getByText("staging")).toBeVisible(); + }); + + test("selecting a different version updates the picker label", async ({ page }) => { + // ACT + await page.goto(`/datasets/${bucket}/${datasetName}`); + await page.waitForLoadState("networkidle"); + + // Open version picker and select v2 + const versionButton = page.getByRole("button", { name: /Version:.*Click to change/i }); + await versionButton.click(); + + const versionList = page.getByRole("listbox", { name: "Versions" }); + await versionList.getByText("v2").click(); + + // ASSERT — picker label updates to v2 and URL includes version param + await expect(versionButton).toContainText("v2"); + await expect(page).toHaveURL(/version=2/); + }); + + test("version picker search filters version list", async ({ page }) => { + // ACT + await page.goto(`/datasets/${bucket}/${datasetName}`); + await page.waitForLoadState("networkidle"); + + // Open version picker + const versionButton = page.getByRole("button", { name: /Version:.*Click to change/i }); + await versionButton.click(); + + // Type in search — filter for "v1" + const searchInput = page.getByPlaceholder("Find a version…"); + await searchInput.fill("v1"); + + // ASSERT — only v1 remains visible + const versionList = page.getByRole("listbox", { name: "Versions" }); + await expect(versionList.getByText("v1")).toBeVisible(); + // v3 and v2 should be hidden + await expect(versionList.getByText("v3")).not.toBeVisible(); + await expect(versionList.getByText("v2")).not.toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/display-mode-toggle.spec.ts b/src/ui/e2e/journeys/display-mode-toggle.spec.ts new file mode 100644 index 000000000..21c6db2b7 --- /dev/null +++ b/src/ui/e2e/journeys/display-mode-toggle.spec.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + createResourcesResponse, + BackendResourceType, +} from "@/mocks/factories"; +import { setupDefaultMocks, setupResources, setupProfile } from "@/e2e/utils/mock-setup"; + +/** + * Display Mode Toggle Tests + * + * The DisplayModeToggle component lives in the resources toolbar and allows + * users to switch between "Show Available" (free mode) and "Show Used" views. + * + * Architecture notes: + * - Component: src/components/data-table/display-mode-toggle.tsx + * - Persisted in localStorage via Zustand (shared-preferences-store) + * - Default mode is "free" (show available) + * - Toggle is a SemiStatefulButton that shows current state icon + next state label + * - aria-label reflects current state: "Currently showing available" or "Currently showing used" + */ + +test.describe("Display Mode Toggle — Resources", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + await setupResources( + page, + createResourcesResponse([ + { + hostname: "gpu-node-001.cluster.local", + resource_type: BackendResourceType.SHARED, + exposed_fields: { node: "gpu-node-001", "pool/platform": ["prod/dgx"] }, + pool_platform_labels: { prod: ["dgx"] }, + allocatable_fields: { gpu: 8, cpu: 128, memory: 512 * 1024 * 1024, storage: 2e12 }, + usage_fields: { gpu: 6, cpu: 96, memory: 384 * 1024 * 1024, storage: 1e12 }, + }, + ]), + ); + }); + + test("display mode toggle button is visible in resources toolbar", async ({ page }) => { + // ACT + await page.goto("/resources"); + await page.waitForLoadState("networkidle"); + + // ASSERT — the toggle button is visible with default "currently showing available" state + const toggleButton = page.getByRole("button", { name: /currently showing/i }); + await expect(toggleButton).toBeVisible(); + }); + + test("default mode shows 'Currently showing available' label", async ({ page }) => { + // ACT + await page.goto("/resources"); + await page.waitForLoadState("networkidle"); + + // ASSERT — default mode is free/available + const toggleButton = page.getByRole("button", { name: /currently showing available/i }); + await expect(toggleButton).toBeVisible(); + }); + + test("clicking toggle switches to 'Currently showing used' mode", async ({ page }) => { + // ACT + await page.goto("/resources"); + await page.waitForLoadState("networkidle"); + + // Click the display mode toggle + const toggleButton = page.getByRole("button", { name: /currently showing available/i }); + await toggleButton.click(); + + // ASSERT — now showing used mode + await expect(page.getByRole("button", { name: /currently showing used/i })).toBeVisible(); + }); + + test("clicking toggle twice returns to available mode", async ({ page }) => { + // ACT + await page.goto("/resources"); + await page.waitForLoadState("networkidle"); + + // Toggle to used mode + const toggleButton = page.getByRole("button", { name: /currently showing available/i }); + await toggleButton.click(); + await expect(page.getByRole("button", { name: /currently showing used/i })).toBeVisible(); + + // Toggle back to free/available mode + await page.getByRole("button", { name: /currently showing used/i }).click(); + + // ASSERT — back to available mode + await expect(page.getByRole("button", { name: /currently showing available/i })).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/empty-states.spec.ts b/src/ui/e2e/journeys/empty-states.spec.ts new file mode 100644 index 000000000..0ea20a48a --- /dev/null +++ b/src/ui/e2e/journeys/empty-states.spec.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + createWorkflowsResponse, + createPoolResponse, + createResourcesResponse, + createDatasetsResponse, + WorkflowStatus, + PoolStatus, +} from "@/mocks/factories"; +import { + setupDefaultMocks, + setupProfile, + setupWorkflows, + setupPools, + setupResources, + setupDatasets, +} from "@/e2e/utils/mock-setup"; + +/** + * Empty State Tests + * + * Verifies that tables show correct empty state messaging when no data is returned. + * This covers the TableEmptyState component and ensures graceful degradation + * when backends return empty arrays. + * + * Architecture notes: + * - TableEmptyState renders a simple "No {items} found" message + * - DataTable passes emptyContent prop which renders TableEmptyState + * - Empty states should NOT crash the page or show loading spinners indefinitely + */ + +test.describe("Empty States — Workflows Page", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("shows empty state text when no workflows returned", async ({ page }) => { + // ARRANGE — empty response with no workflows + await setupWorkflows(page, createWorkflowsResponse([])); + + // ACT + await page.goto("/workflows?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT + await expect(page.getByText(/no workflows found/i).first()).toBeVisible(); + }); + + test("shows results count of 0 when no workflows match", async ({ page }) => { + // ARRANGE + await setupWorkflows(page, createWorkflowsResponse([])); + + // ACT + await page.goto("/workflows?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — results count shows 0 + await expect(page.getByText(/0 results/).first()).toBeVisible(); + }); +}); + +test.describe("Empty States — Pools Page", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("pools page loads without crash when no pools exist", async ({ page }) => { + // ARRANGE — pool response with empty pools list + await setupPools(page, createPoolResponse([])); + + // ACT + await page.goto("/pools?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — page renders without crashing (breadcrumb is visible) + await expect(page.getByText(/pools/i).first()).toBeVisible(); + }); +}); + +test.describe("Empty States — Resources Page", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + await setupPools(page, createPoolResponse([{ name: "test-pool", status: PoolStatus.ONLINE }])); + }); + + test("resources page loads without crash when no resources exist", async ({ page }) => { + // ARRANGE — empty resources + await setupResources(page, createResourcesResponse([])); + + // ACT + await page.goto("/resources?all=true"); + await page.waitForLoadState("networkidle"); + + // ASSERT — page renders without crashing (breadcrumb is visible) + await expect(page.getByText(/resources/i).first()).toBeVisible(); + }); +}); + +test.describe("Empty States — Datasets Page", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("shows empty state when no datasets exist", async ({ page }) => { + // ARRANGE — empty datasets + await setupDatasets(page, createDatasetsResponse([])); + + // ACT + await page.goto("/datasets"); + await page.waitForLoadState("networkidle"); + + // ASSERT — empty datasets page shows 0 results + await expect(page.getByText(/0 results/).first()).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/log-viewer-content.spec.ts b/src/ui/e2e/journeys/log-viewer-content.spec.ts new file mode 100644 index 000000000..5b32ae7cc --- /dev/null +++ b/src/ui/e2e/journeys/log-viewer-content.spec.ts @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { WorkflowStatus } from "@/mocks/factories"; +import { setupDefaultMocks, setupProfile } from "@/e2e/utils/mock-setup"; + +/** + * Log Viewer Content Page Tests + * + * Tests the log viewer when a workflow is selected (/log-viewer?workflow=name). + * This complements log-viewer.spec.ts and log-viewer-selector.spec.ts which + * only test the selector UI (when no workflow param is present). + * + * - Page shows log viewer container when workflow param is provided + * - Breadcrumb shows "Log Viewer" link back to selector + * - Loading skeleton shown while workflow data loads + * - Navigating to log viewer with workflow adds to recent workflows + * - Error boundary catches and displays errors gracefully + * + * Architecture notes: + * - Route: /log-viewer?workflow={name} + * - LogViewerWithData (server component) → LogViewerPageContent (client) + * - LogViewerPageContent fetches workflow via useWorkflow({name, verbose: false}) + * - Shows LogViewerSkeleton while loading, then LogViewerContainer + * - Adds workflowId to recent workflows via addRecentWorkflow on mount + * - Page title set to workflowId via usePage() + * - Breadcrumb: Log Viewer → {workflowId} + */ + +const CT_JSON = "application/json"; + +function createWorkflowForLogs(name: string) { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + + return { + name, + uuid: `uuid-${name}`, + submitted_by: "test-user", + cancelled_by: null, + spec: "version: 1\ntasks:\n - name: train", + template_spec: "{}", + logs: `/api/workflow/${name}/logs`, + events: `/api/workflow/${name}/events`, + overview: `/api/workflow/${name}/overview`, + parent_name: null, + parent_job_id: null, + dashboard_url: null, + grafana_url: null, + tags: [], + submit_time: oneHourAgo.toISOString(), + start_time: oneHourAgo.toISOString(), + end_time: now.toISOString(), + exec_timeout: null, + queue_timeout: null, + duration: 3600, + queued_time: 5, + status: WorkflowStatus.COMPLETED, + outputs: "", + groups: [ + { + name: "train", + status: "COMPLETED", + start_time: oneHourAgo.toISOString(), + end_time: now.toISOString(), + processing_start_time: oneHourAgo.toISOString(), + scheduling_start_time: oneHourAgo.toISOString(), + initializing_start_time: oneHourAgo.toISOString(), + remaining_upstream_groups: [], + downstream_groups: [], + failure_message: null, + tasks: [ + { + name: "train-task", + retry_id: 0, + status: "COMPLETED", + failure_message: null, + exit_code: 0, + logs: `/api/workflow/${name}/task/train-task/logs`, + error_logs: null, + processing_start_time: oneHourAgo.toISOString(), + scheduling_start_time: oneHourAgo.toISOString(), + initializing_start_time: oneHourAgo.toISOString(), + start_time: oneHourAgo.toISOString(), + end_time: now.toISOString(), + duration: 3600, + }, + ], + }, + ], + pool: "test-pool", + backend: "k8s-test", + app_owner: null, + app_name: null, + app_version: null, + plugins: { rsync: false }, + }; +} + +test.describe("Log Viewer Content Page", () => { + test.describe.configure({ timeout: 30_000 }); + + const wfName = "log-viewer-content-wf"; + + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("shows log viewer container when workflow param is provided", async ({ page }) => { + // ARRANGE + const data = createWorkflowForLogs(wfName); + await page.route(`**/api/workflow/${wfName}*`, (route) => + route.fulfill({ + status: 200, + contentType: CT_JSON, + body: JSON.stringify(data), + }), + ); + // Mock the logs endpoint to return some log content + await page.route(`**/api/workflow/${wfName}/logs*`, (route) => + route.fulfill({ + status: 200, + contentType: "text/plain", + body: "2026-01-15T10:00:00Z [INFO] Training started\n2026-01-15T11:00:00Z [INFO] Training completed", + }), + ); + + // ACT + await page.goto(`/log-viewer?workflow=${wfName}`); + await page.waitForLoadState("networkidle"); + + // ASSERT — page should NOT show the selector (input for workflow ID) + await expect(page.getByPlaceholder(/enter workflow id/i)).not.toBeVisible(); + }); + + test("breadcrumb shows 'Log Viewer' link when viewing a workflow", async ({ page }) => { + // ARRANGE + const data = createWorkflowForLogs(wfName); + await page.route(`**/api/workflow/${wfName}*`, (route) => + route.fulfill({ + status: 200, + contentType: CT_JSON, + body: JSON.stringify(data), + }), + ); + await page.route(`**/api/workflow/${wfName}/logs*`, (route) => + route.fulfill({ + status: 200, + contentType: "text/plain", + body: "", + }), + ); + + // ACT + await page.goto(`/log-viewer?workflow=${wfName}`); + await page.waitForLoadState("networkidle"); + + // ASSERT — breadcrumb shows "Log Viewer" text + await expect(page.getByText("Log Viewer").first()).toBeVisible(); + }); + + test("adds workflow to recent list on visit", async ({ page }) => { + // ARRANGE + const recentWfName = "recent-log-wf"; + const data = createWorkflowForLogs(recentWfName); + await page.route(`**/api/workflow/${recentWfName}*`, (route) => + route.fulfill({ + status: 200, + contentType: CT_JSON, + body: JSON.stringify(data), + }), + ); + await page.route(`**/api/workflow/${recentWfName}/logs*`, (route) => + route.fulfill({ + status: 200, + contentType: "text/plain", + body: "", + }), + ); + + // Clear localStorage first + await page.goto("/log-viewer"); + await page.evaluate(() => { + localStorage.removeItem("osmo:recent-workflows"); + }); + + // ACT — visit log viewer with workflow param + await page.goto(`/log-viewer?workflow=${recentWfName}`); + await page.waitForLoadState("networkidle"); + + // Navigate back to selector + await page.goto("/log-viewer"); + await page.waitForLoadState("networkidle"); + + // ASSERT — recent workflows should include the visited workflow + await expect(page.getByText("Recent Workflows").first()).toBeVisible(); + await expect(page.getByText(recentWfName).first()).toBeVisible(); + }); + + test("shows workflow name in the page when loaded", async ({ page }) => { + // ARRANGE + const data = createWorkflowForLogs(wfName); + await page.route(`**/api/workflow/${wfName}*`, (route) => + route.fulfill({ + status: 200, + contentType: CT_JSON, + body: JSON.stringify(data), + }), + ); + await page.route(`**/api/workflow/${wfName}/logs*`, (route) => + route.fulfill({ + status: 200, + contentType: "text/plain", + body: "Some log output here", + }), + ); + + // ACT + await page.goto(`/log-viewer?workflow=${wfName}`); + await page.waitForLoadState("networkidle"); + + // ASSERT — workflow name appears somewhere on the page (breadcrumb or title) + await expect(page.getByText(wfName).first()).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/log-viewer-recent.spec.ts b/src/ui/e2e/journeys/log-viewer-recent.spec.ts new file mode 100644 index 000000000..444003935 --- /dev/null +++ b/src/ui/e2e/journeys/log-viewer-recent.spec.ts @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + setupDefaultMocks, + setupProfile, +} from "@/e2e/utils/mock-setup"; + +/** + * Log Viewer Recent Workflows Tests + * + * Tests the "Recent Workflows" feature on the log viewer workflow selector page. + * Recent workflows are stored in localStorage and displayed below the search input. + * + * Architecture notes: + * - WorkflowSelector component reads from localStorage key "osmo:recent-workflows" + * - Submitting a workflow ID adds it to recent workflows list + * - Each recent workflow entry has a select button and a remove (X) button + * - "Clear" button removes all recent workflows + * - Recent list shows max 10 items + */ + +test.describe("Log Viewer Recent Workflows", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("shows recent workflows section when localStorage has entries", async ({ page }) => { + // ARRANGE — seed localStorage with recent workflows before navigating + await page.goto("/log-viewer"); + await page.evaluate(() => { + localStorage.setItem( + "osmo:recent-workflows", + JSON.stringify(["workflow-alpha", "workflow-beta", "workflow-gamma"]), + ); + }); + + // ACT — reload to pick up localStorage + await page.reload(); + await page.waitForLoadState("networkidle"); + + // ASSERT — Recent Workflows section visible with entries + await expect(page.getByText("Recent Workflows").first()).toBeVisible(); + await expect(page.getByText("workflow-alpha").first()).toBeVisible(); + await expect(page.getByText("workflow-beta").first()).toBeVisible(); + await expect(page.getByText("workflow-gamma").first()).toBeVisible(); + }); + + test("does not show recent workflows section when localStorage is empty", async ({ page }) => { + // ARRANGE — ensure no recent workflows + await page.goto("/log-viewer"); + await page.evaluate(() => { + localStorage.removeItem("osmo:recent-workflows"); + }); + + // ACT + await page.reload(); + await page.waitForLoadState("networkidle"); + + // ASSERT — no Recent Workflows heading + await expect(page.getByText("Recent Workflows")).not.toBeVisible(); + }); + + test("clear button removes all recent workflows", async ({ page }) => { + // ARRANGE + await page.goto("/log-viewer"); + await page.evaluate(() => { + localStorage.setItem( + "osmo:recent-workflows", + JSON.stringify(["wf-1", "wf-2"]), + ); + }); + await page.reload(); + await page.waitForLoadState("networkidle"); + + // Verify recent section is visible first + await expect(page.getByText("Recent Workflows").first()).toBeVisible(); + + // ACT — click clear button + await page.getByRole("button", { name: /clear recent workflows/i }).click(); + + // ASSERT — recent section disappears + await expect(page.getByText("Recent Workflows")).not.toBeVisible(); + }); + + test("clicking a recent workflow navigates to log viewer with that workflow", async ({ page }) => { + // ARRANGE + await page.goto("/log-viewer"); + await page.evaluate(() => { + localStorage.setItem( + "osmo:recent-workflows", + JSON.stringify(["my-recent-workflow"]), + ); + }); + await page.reload(); + await page.waitForLoadState("networkidle"); + + // ACT — click the recent workflow entry + await page.getByText("my-recent-workflow").first().click(); + + // ASSERT — navigates to log-viewer with workflow param + await expect(page).toHaveURL(/workflow=my-recent-workflow/); + }); + + test("remove button removes individual recent workflow", async ({ page }) => { + // ARRANGE + await page.goto("/log-viewer"); + await page.evaluate(() => { + localStorage.setItem( + "osmo:recent-workflows", + JSON.stringify(["keep-this", "remove-this"]), + ); + }); + await page.reload(); + await page.waitForLoadState("networkidle"); + + // Verify both are visible + await expect(page.getByText("keep-this").first()).toBeVisible(); + await expect(page.getByText("remove-this").first()).toBeVisible(); + + // ACT — hover over "remove-this" to make remove button visible, then click it + const removeThisEntry = page.getByText("remove-this").first(); + await removeThisEntry.hover(); + await page + .getByRole("button", { name: /remove remove-this from recent workflows/i }) + .click(); + + // ASSERT — "remove-this" is gone, "keep-this" remains + await expect(page.getByText("remove-this")).not.toBeVisible(); + await expect(page.getByText("keep-this").first()).toBeVisible(); + }); + + test("submitting a workflow adds it to recent workflows on next visit", async ({ page }) => { + // ARRANGE — start with empty recent + await page.goto("/log-viewer"); + await page.evaluate(() => { + localStorage.removeItem("osmo:recent-workflows"); + }); + await page.reload(); + await page.waitForLoadState("networkidle"); + + // ACT — submit a workflow ID + const input = page.getByPlaceholder(/enter workflow id/i); + await input.fill("new-submitted-workflow"); + await input.press("Enter"); + + // Wait for navigation to complete + await expect(page).toHaveURL(/workflow=new-submitted-workflow/); + + // Navigate back to selector + await page.goto("/log-viewer"); + await page.waitForLoadState("networkidle"); + + // ASSERT — the submitted workflow should now appear in recent list + await expect(page.getByText("Recent Workflows").first()).toBeVisible(); + await expect(page.getByText("new-submitted-workflow").first()).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/log-viewer-selector.spec.ts b/src/ui/e2e/journeys/log-viewer-selector.spec.ts new file mode 100644 index 000000000..47932258d --- /dev/null +++ b/src/ui/e2e/journeys/log-viewer-selector.spec.ts @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { setupDefaultMocks, setupProfile } from "@/e2e/utils/mock-setup"; + +/** + * Log Viewer Workflow Selector Input Tests + * + * Tests the workflow selector form on the log-viewer landing page: + * - Submit button disabled when input is empty + * - Submit button enabled when input has text + * - Clear button appears when text is entered + * - Pressing Enter submits and navigates + * - Clicking submit button navigates + * + * Architecture notes: + * - WorkflowSelector component at /log-viewer (no ?workflow= param) + * - Form with input (placeholder "Enter workflow ID or name...") + * - Submit button (aria-label "Load workflow") — disabled when empty + * - Clear button (aria-label "Clear input") — only visible when input non-empty + * - On submit: navigates to /log-viewer?workflow={trimmedValue} + */ + +test.describe("Log Viewer Workflow Selector Input", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("submit button is disabled when input is empty", async ({ page }) => { + // ACT + await page.goto("/log-viewer"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Load workflow button is disabled + const submitButton = page.getByRole("button", { name: /load workflow/i }); + await expect(submitButton).toBeVisible(); + await expect(submitButton).toBeDisabled(); + }); + + test("submit button becomes enabled when text is entered", async ({ page }) => { + // ACT + await page.goto("/log-viewer"); + await page.waitForLoadState("networkidle"); + + const input = page.getByPlaceholder(/enter workflow id/i); + await input.fill("my-workflow"); + + // ASSERT — submit button is enabled + const submitButton = page.getByRole("button", { name: /load workflow/i }); + await expect(submitButton).toBeEnabled(); + }); + + test("clear button appears when input has text and clears on click", async ({ page }) => { + // ACT + await page.goto("/log-viewer"); + await page.waitForLoadState("networkidle"); + + // Initially no clear button + await expect(page.getByRole("button", { name: /clear input/i })).not.toBeVisible(); + + // Type in input + const input = page.getByPlaceholder(/enter workflow id/i); + await input.fill("some-text"); + + // Clear button appears + const clearButton = page.getByRole("button", { name: /clear input/i }); + await expect(clearButton).toBeVisible(); + + // Click clear + await clearButton.click(); + + // ASSERT — input is cleared, clear button disappears, submit disabled + await expect(input).toHaveValue(""); + await expect(clearButton).not.toBeVisible(); + await expect(page.getByRole("button", { name: /load workflow/i })).toBeDisabled(); + }); + + test("pressing Enter submits and navigates to log viewer with workflow param", async ({ page }) => { + // ACT + await page.goto("/log-viewer"); + await page.waitForLoadState("networkidle"); + + const input = page.getByPlaceholder(/enter workflow id/i); + await input.fill("test-wf-123"); + await input.press("Enter"); + + // ASSERT — navigates to log-viewer with workflow parameter + await expect(page).toHaveURL(/workflow=test-wf-123/); + }); + + test("clicking submit button navigates to log viewer with workflow param", async ({ page }) => { + // ACT + await page.goto("/log-viewer"); + await page.waitForLoadState("networkidle"); + + const input = page.getByPlaceholder(/enter workflow id/i); + await input.fill("click-submit-wf"); + + await page.getByRole("button", { name: /load workflow/i }).click(); + + // ASSERT — navigates with workflow param + await expect(page).toHaveURL(/workflow=click-submit-wf/); + }); +}); diff --git a/src/ui/e2e/journeys/not-found-page.spec.ts b/src/ui/e2e/journeys/not-found-page.spec.ts new file mode 100644 index 000000000..c29ba22bd --- /dev/null +++ b/src/ui/e2e/journeys/not-found-page.spec.ts @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { setupDefaultMocks, setupProfile } from "@/e2e/utils/mock-setup"; + +/** + * Not Found (404) Page Journey Tests + * + * Architecture notes: + * - 404 page lives at src/app/(dashboard)/not-found.tsx + * - Uses NotFoundContent component from src/components/not-found-content.tsx + * - Shows "404" heading, OSMO acronym ("Our Server Missed One..."), description + * - Has two action buttons: "Dashboard" (link to /) and "Go Back" (router.back()) + * - Decorative gradient background behind content + */ + +test.describe("Not Found Page Content", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("shows 404 heading and descriptive text", async ({ page }) => { + // ACT + await page.goto("/this-page-does-not-exist"); + + // ASSERT + await expect(page.getByRole("heading", { name: "404" })).toBeVisible(); + await expect(page.getByText(/the page you.*looking for/i)).toBeVisible(); + }); + + test("shows OSMO acronym phrase", async ({ page }) => { + // ACT + await page.goto("/nonexistent-route"); + + // ASSERT — "Our Server Missed One..." is displayed + await expect(page.getByText(/our/i).first()).toBeVisible(); + await expect(page.getByText(/server/i).first()).toBeVisible(); + await expect(page.getByText(/missed/i).first()).toBeVisible(); + }); + + test("Dashboard action links to home", async ({ page }) => { + // ACT + await page.goto("/another-nonexistent-route"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Dashboard action in main content (not sidebar) is visible + const mainContent = page.getByLabel("Main content"); + const dashboardAction = mainContent.getByRole("link", { name: /dashboard/i }); + await expect(dashboardAction).toBeVisible(); + await expect(dashboardAction).toHaveAttribute("href", "/"); + }); + + test("Go Back button is visible", async ({ page }) => { + // ACT + await page.goto("/some-missing-page"); + + // ASSERT — Go Back button is visible + const goBackButton = page.getByRole("button", { name: /go back/i }); + await expect(goBackButton).toBeVisible(); + }); + + test("Dashboard action navigates to home page", async ({ page }) => { + // ACT + await page.goto("/not-a-real-page"); + await page.waitForLoadState("networkidle"); + + // Scope to main content to avoid sidebar "Dashboard" link + const mainContent = page.getByLabel("Main content"); + const dashboardAction = mainContent.getByRole("link", { name: /dashboard/i }); + await expect(dashboardAction).toBeVisible(); + + // Click and wait for navigation + await Promise.all([ + page.waitForURL(/\/$/), + dashboardAction.click(), + ]); + + // ASSERT — navigated to home + await expect(page).toHaveURL(/\/$/); + }); + + test("Go Back button navigates to previous page", async ({ page }) => { + // ARRANGE — first navigate to a known page, then to 404 + await page.goto("/pools"); + await page.waitForLoadState("networkidle"); + await page.goto("/this-is-not-a-page"); + await page.waitForLoadState("networkidle"); + + // ACT — click Go Back + const goBackButton = page.getByRole("button", { name: /go back/i }); + await goBackButton.click(); + + // ASSERT — back to pools + await expect(page).toHaveURL(/\/pools/); + }); +}); diff --git a/src/ui/e2e/journeys/occupancy-row-actions.spec.ts b/src/ui/e2e/journeys/occupancy-row-actions.spec.ts new file mode 100644 index 000000000..6c8d2158d --- /dev/null +++ b/src/ui/e2e/journeys/occupancy-row-actions.spec.ts @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + setupDefaultMocks, + setupProfile, + setupOccupancy, +} from "@/e2e/utils/mock-setup"; + +/** + * Occupancy Row Actions & Navigation Tests + * + * Tests behavior in occupancy-column-defs.tsx and occupancy-data-table.tsx: + * - ParentRowActions dropdown menu (View Workflows, View Pool links) + * - Child row click → navigates to workflows with pool+user filter + * - Priority badges display in parent/child rows (HIGH, NORMAL, LOW) + * - Results count displayed in toolbar + */ + +function createOccupancySummaries( + entries: Array<{ + user: string; + pool: string; + gpu?: number; + cpu?: number; + memory?: number; + storage?: number; + priority?: string; + }>, +) { + return { + summaries: entries.map((e) => ({ + user: e.user, + pool: e.pool, + gpu: e.gpu ?? 4, + cpu: e.cpu ?? 32, + memory: e.memory ?? 64 * 1024 * 1024 * 1024, + storage: e.storage ?? 100 * 1024 * 1024 * 1024, + priority: e.priority ?? "NORMAL", + })), + }; +} + +test.describe("Occupancy Row Actions — Pool Grouping", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("parent row actions menu shows View Workflows link", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "production", gpu: 8 }, + { user: "bob", pool: "production", gpu: 4 }, + ]), + ); + + // ACT — navigate and hover over the parent row to reveal the actions button + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // Hover over the production row to make the actions button visible + const productionRow = page.getByText("production").first(); + await productionRow.hover(); + + // Click the actions button for the production row + const actionsButton = page.getByRole("button", { name: "Row actions production" }); + await actionsButton.click(); + + // ASSERT — "View Workflows" link is visible in the dropdown + await expect(page.getByRole("menuitem", { name: /view workflows/i })).toBeVisible(); + }); + + test("parent row actions menu shows View Pool link in pool grouping mode", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "production", gpu: 8 }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + const productionRow = page.getByText("production").first(); + await productionRow.hover(); + + await page.getByRole("button", { name: "Row actions production" }).click(); + + // ASSERT — "View Pool" link is visible (only shown in pool grouping mode) + await expect(page.getByRole("menuitem", { name: /view pool/i })).toBeVisible(); + }); + + test("View Workflows link in actions menu has correct href with pool filter", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "my-pool", gpu: 8 }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + const poolRow = page.getByText("my-pool").first(); + await poolRow.hover(); + + await page.getByRole("button", { name: "Row actions my-pool" }).click(); + + // ASSERT — View Workflows link points to workflows filtered by pool + const viewWorkflowsLink = page.getByRole("menuitem", { name: /view workflows/i }); + await expect(viewWorkflowsLink).toBeVisible(); + await expect(viewWorkflowsLink).toHaveAttribute("href", /\/workflows.*pool.*my-pool/); + }); + + test("View Pool link has correct href pointing to pools page", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "gpu-cluster", gpu: 8 }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + const poolRow = page.getByText("gpu-cluster").first(); + await poolRow.hover(); + + await page.getByRole("button", { name: "Row actions gpu-cluster" }).click(); + + // ASSERT — View Pool link points to pools page with view= query param + const viewPoolLink = page.getByRole("menuitem", { name: /view pool/i }); + await expect(viewPoolLink).toBeVisible(); + await expect(viewPoolLink).toHaveAttribute("href", /\/pools.*view=gpu-cluster/); + }); +}); + +test.describe("Occupancy Row Actions — User Grouping", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("parent row actions in user grouping does NOT show View Pool link", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "prod", gpu: 8 }, + { user: "alice", pool: "staging", gpu: 4 }, + ]), + ); + + // ACT — switch to By User grouping + await page.goto("/occupancy?groupBy=user"); + await page.waitForLoadState("networkidle"); + + const aliceRow = page.getByText("alice").first(); + await aliceRow.hover(); + + await page.getByRole("button", { name: "Row actions alice" }).click(); + + // ASSERT — only View Workflows is shown, not View Pool + await expect(page.getByRole("menuitem", { name: /view workflows/i })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: /view pool/i })).not.toBeVisible(); + }); +}); + +test.describe("Occupancy Priority Badges", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("parent row shows priority badges for aggregated HIGH and NORMAL counts", async ({ page }) => { + // ARRANGE — multiple users with different priorities in same pool + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "prod", gpu: 8, priority: "HIGH" }, + { user: "bob", pool: "prod", gpu: 4, priority: "NORMAL" }, + { user: "charlie", pool: "prod", gpu: 2, priority: "HIGH" }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // ASSERT — priority column shows badges with aggregated counts + // 2 HIGH priority tasks, 1 NORMAL priority task + await expect(page.getByLabel(/high priority: 2/i).first()).toBeVisible(); + await expect(page.getByLabel(/normal priority: 1/i).first()).toBeVisible(); + }); + + test("child rows show individual priority badges after expand", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "prod", gpu: 8, priority: "HIGH" }, + { user: "bob", pool: "prod", gpu: 4, priority: "LOW" }, + ]), + ); + + // ACT — expand all to see child rows + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: /expand all/i }).click(); + + // ASSERT — individual user rows are visible with their priority badges + await expect(page.getByText("alice").first()).toBeVisible(); + await expect(page.getByText("bob").first()).toBeVisible(); + // Each child row should show its own priority badge + await expect(page.getByLabel(/high priority: 1/i).first()).toBeVisible(); + await expect(page.getByLabel(/low priority: 1/i).first()).toBeVisible(); + }); +}); + +test.describe("Occupancy Child Row Navigation", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("clicking a child row navigates to workflows with pool+user filter", async ({ page }) => { + // ARRANGE — need at least one entry to produce a parent+child + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "production", gpu: 8 }, + { user: "bob", pool: "production", gpu: 4 }, + ]), + ); + + // Mock the workflows page so navigation succeeds + await page.route("**/api/workflow*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ workflows: [], more_entries: false }), + }), + ); + + // ACT — expand and click a child row + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: /expand all/i }).click(); + await expect(page.getByText("alice").first()).toBeVisible(); + + // Click the child row (alice under production) + await page.getByText("alice").first().click(); + + // ASSERT — navigates to workflows page with both pool and user filters + await expect(page).toHaveURL(/\/workflows/); + await expect(page).toHaveURL(/pool.*production|production.*pool/); + await expect(page).toHaveURL(/user.*alice|alice.*user/); + }); +}); diff --git a/src/ui/e2e/journeys/occupancy-summary-values.spec.ts b/src/ui/e2e/journeys/occupancy-summary-values.spec.ts new file mode 100644 index 000000000..824a14710 --- /dev/null +++ b/src/ui/e2e/journeys/occupancy-summary-values.spec.ts @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + setupDefaultMocks, + setupProfile, + setupOccupancy, +} from "@/e2e/utils/mock-setup"; + +/** + * Occupancy Summary KPI Cards Tests + * + * Tests the occupancy-summary.tsx component (KPI cards): + * - GPU, CPU, Memory, Storage cards show aggregated totals + * - Memory and Storage display formatted byte values (GiB/TiB) + * - Cards reflect actual data from the occupancy API + * - Empty data shows zero-like values + * + * Also tests the results count in occupancy-toolbar.tsx: + * - Results count reflects number of grouped rows + */ + +function createOccupancySummaries( + entries: Array<{ + user: string; + pool: string; + gpu?: number; + cpu?: number; + memory?: number; + storage?: number; + priority?: string; + }>, +) { + return { + summaries: entries.map((e) => ({ + user: e.user, + pool: e.pool, + gpu: e.gpu ?? 4, + cpu: e.cpu ?? 32, + memory: e.memory ?? 64 * 1024 * 1024 * 1024, + storage: e.storage ?? 100 * 1024 * 1024 * 1024, + priority: e.priority ?? "NORMAL", + })), + }; +} + +test.describe("Occupancy Summary KPI Values", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("GPU KPI card shows total GPU count across all entries", async ({ page }) => { + // ARRANGE — 3 entries with known GPU counts: 8 + 4 + 2 = 14 + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "prod", gpu: 8 }, + { user: "bob", pool: "prod", gpu: 4 }, + { user: "charlie", pool: "staging", gpu: 2 }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // ASSERT — GPU card shows the total "14" + await expect(page.getByText("14").first()).toBeVisible(); + }); + + test("CPU KPI card shows total CPU count", async ({ page }) => { + // ARRANGE — 2 entries with known CPU counts: 64 + 32 = 96 + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "prod", cpu: 64 }, + { user: "bob", pool: "prod", cpu: 32 }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // ASSERT — CPU card shows the total "96" + await expect(page.getByText("96").first()).toBeVisible(); + }); + + test("Memory KPI card shows formatted byte value with unit suffix", async ({ page }) => { + // ARRANGE — single entry with known memory value + await setupOccupancy( + page, + createOccupancySummaries([{ user: "alice", pool: "prod", memory: 64 * 1024 * 1024 * 1024 }]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // ASSERT — Memory card is visible with a unit suffix (Gi or Ti) + // Note: "MEMORY" appears uppercase via CSS text-transform, but DOM text is "Memory" + await expect(page.getByText("Memory").first()).toBeVisible(); + // The unit suffix "Gi" or "Ti" should be visible somewhere in the card + await expect(page.locator("text=/Gi|Ti/").first()).toBeVisible(); + }); + + test("all four KPI cards are labeled correctly", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([{ user: "alice", pool: "prod", gpu: 1, cpu: 1 }]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // ASSERT — all four card labels present (CSS uppercase, DOM text is capitalized) + await expect(page.getByText("GPU").first()).toBeVisible(); + await expect(page.getByText("CPU").first()).toBeVisible(); + await expect(page.getByText("Memory").first()).toBeVisible(); + await expect(page.getByText("Storage").first()).toBeVisible(); + }); +}); + +test.describe("Occupancy Toolbar Results Count", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("toolbar shows results count matching number of parent rows", async ({ page }) => { + // ARRANGE — 3 pools → 3 parent rows in pool grouping + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "production", gpu: 8 }, + { user: "bob", pool: "staging", gpu: 4 }, + { user: "charlie", pool: "development", gpu: 2 }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // ASSERT — results count shows "3" + await expect(page.getByText(/3 results/i).first()).toBeVisible(); + }); + + test("results count updates when switching to By User grouping", async ({ page }) => { + // ARRANGE — 2 users across pools → 2 parent rows in user grouping + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "prod", gpu: 8 }, + { user: "alice", pool: "staging", gpu: 4 }, + { user: "bob", pool: "prod", gpu: 2 }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // Switch to By User + const groupByRadioGroup = page.getByRole("radiogroup", { name: "Group by" }); + await groupByRadioGroup.getByText("By User").click(); + + // ASSERT — 2 user parent rows + await expect(page.getByText(/2 results/i).first()).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/occupancy-toolbar.spec.ts b/src/ui/e2e/journeys/occupancy-toolbar.spec.ts new file mode 100644 index 000000000..bc17b94e6 --- /dev/null +++ b/src/ui/e2e/journeys/occupancy-toolbar.spec.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { setupDefaultMocks, setupProfile, setupOccupancy } from "@/e2e/utils/mock-setup"; + +/** + * Occupancy Toolbar Interaction Tests + * + * Tests toolbar interactions on the occupancy page: + * - Expand all / collapse all toggle button + * - Search filtering creates chips + * - Group by toggle updates URL + * + * Architecture notes: + * - OccupancyToolbar has a GroupByToggle (radiogroup "Group by") + expand/collapse button + * - Expand all button label toggles: "Expand all rows" / "Collapse all rows" + * - Search uses TableToolbar with chip-based filters + * - Group by changes update ?groupBy= URL parameter + */ + +function createOccupancySummaries( + entries: Array<{ + user: string; + pool: string; + gpu?: number; + cpu?: number; + memory?: number; + storage?: number; + priority?: string; + }>, +) { + return { + summaries: entries.map((e) => ({ + user: e.user, + pool: e.pool, + gpu: e.gpu ?? 4, + cpu: e.cpu ?? 32, + memory: e.memory ?? 64 * 1024 * 1024 * 1024, + storage: e.storage ?? 100 * 1024 * 1024 * 1024, + priority: e.priority ?? "NORMAL", + })), + }; +} + +test.describe("Occupancy Expand/Collapse Toggle", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("expand all button changes to collapse all after clicking", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "prod", gpu: 8 }, + { user: "bob", pool: "staging", gpu: 4 }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // Initially shows "Expand all rows" + const expandButton = page.getByRole("button", { name: /expand all rows/i }); + await expect(expandButton).toBeVisible(); + + // Click to expand all + await expandButton.click(); + + // ASSERT — now shows "Collapse all rows" + await expect(page.getByRole("button", { name: /collapse all rows/i })).toBeVisible(); + }); + + test("collapse all button returns to expand all state", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "prod", gpu: 8 }, + { user: "bob", pool: "staging", gpu: 4 }, + ]), + ); + + // ACT — expand then collapse + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: /expand all rows/i }).click(); + await page.getByRole("button", { name: /collapse all rows/i }).click(); + + // ASSERT — back to expand state + await expect(page.getByRole("button", { name: /expand all rows/i })).toBeVisible(); + }); + + test("expanding reveals user details in child rows", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "shared-pool", gpu: 8 }, + { user: "bob", pool: "shared-pool", gpu: 4 }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: /expand all rows/i }).click(); + + // ASSERT — user names visible in expanded child rows + await expect(page.getByText("alice").first()).toBeVisible(); + await expect(page.getByText("bob").first()).toBeVisible(); + }); +}); + +test.describe("Occupancy Group By Toggle URL State", () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("clicking By User updates URL with groupBy=user", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "prod", gpu: 4 }, + ]), + ); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + const groupByRadioGroup = page.getByRole("radiogroup", { name: "Group by" }); + await groupByRadioGroup.getByText("By User").click(); + + // ASSERT — URL updates + await expect(page).toHaveURL(/groupBy=user/); + }); + + test("clicking By Pool after By User removes groupBy from URL", async ({ page }) => { + // ARRANGE + await setupOccupancy( + page, + createOccupancySummaries([ + { user: "alice", pool: "prod", gpu: 4 }, + ]), + ); + + // ACT + await page.goto("/occupancy?groupBy=user"); + await page.waitForLoadState("networkidle"); + + const groupByRadioGroup = page.getByRole("radiogroup", { name: "Group by" }); + await groupByRadioGroup.getByText("By Pool").click(); + + // ASSERT — groupBy=user removed from URL (pool is default) + await expect(page).not.toHaveURL(/groupBy=user/); + }); +}); diff --git a/src/ui/e2e/journeys/occupancy-truncation.spec.ts b/src/ui/e2e/journeys/occupancy-truncation.spec.ts new file mode 100644 index 000000000..8aacf613e --- /dev/null +++ b/src/ui/e2e/journeys/occupancy-truncation.spec.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { + setupDefaultMocks, + setupProfile, + setupOccupancy, +} from "@/e2e/utils/mock-setup"; + +/** + * Occupancy Truncation Warning Tests + * + * When the occupancy API returns more than 10,000 rows, the UI shows a warning + * banner informing users that results may be incomplete. This tests the + * truncation threshold detection in OccupancyPageContent. + * + * Architecture notes: + * - useOccupancyData hook sets `truncated: true` when response rows >= 10,000 + * - OccupancyPageContent renders a warning banner conditionally on `truncated` + * - The banner mentions "10,000 row fetch limit" + */ + +function createLargeOccupancySummaries(count: number) { + const summaries = []; + for (let i = 0; i < count; i++) { + summaries.push({ + user: `user-${i}`, + pool: `pool-${i % 5}`, + gpu: 4, + cpu: 32, + memory: 64 * 1024 * 1024 * 1024, + storage: 100 * 1024 * 1024 * 1024, + priority: "NORMAL", + }); + } + return { summaries }; +} + +function createSmallOccupancySummaries() { + return { + summaries: [ + { user: "alice", pool: "prod", gpu: 8, cpu: 64, memory: 64 * 1024 * 1024 * 1024, storage: 100 * 1024 * 1024 * 1024, priority: "NORMAL" }, + { user: "bob", pool: "staging", gpu: 4, cpu: 32, memory: 32 * 1024 * 1024 * 1024, storage: 50 * 1024 * 1024 * 1024, priority: "NORMAL" }, + ], + }; +} + +test.describe("Occupancy Truncation Warning", () => { + test.beforeEach(async ({ page }) => { + await setupDefaultMocks(page); + await setupProfile(page); + }); + + test("shows truncation warning when response has 10000+ rows", async ({ page }) => { + // ARRANGE — exactly 10,000 rows triggers the truncation banner + await setupOccupancy(page, createLargeOccupancySummaries(10000)); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // ASSERT — truncation banner is visible + await expect( + page.getByText(/results may be incomplete/i).first(), + ).toBeVisible(); + await expect( + page.getByText(/10,000 row fetch limit/i).first(), + ).toBeVisible(); + }); + + test("does not show truncation warning for small datasets", async ({ page }) => { + // ARRANGE — small dataset (2 rows) + await setupOccupancy(page, createSmallOccupancySummaries()); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // ASSERT — no truncation warning visible + await expect( + page.getByText(/results may be incomplete/i), + ).not.toBeVisible(); + }); + + test("truncation warning does not block table rendering", async ({ page }) => { + // ARRANGE — truncated response should still show grouped data + await setupOccupancy(page, createLargeOccupancySummaries(10000)); + + // ACT + await page.goto("/occupancy"); + await page.waitForLoadState("networkidle"); + + // ASSERT — both warning and data table are visible + await expect(page.getByText(/results may be incomplete/i).first()).toBeVisible(); + // Pool groups should still appear + await expect(page.getByText("pool-0").first()).toBeVisible(); + }); +}); diff --git a/src/ui/e2e/journeys/panel-keyboard-interactions.spec.ts b/src/ui/e2e/journeys/panel-keyboard-interactions.spec.ts new file mode 100644 index 000000000..ce5ffb182 --- /dev/null +++ b/src/ui/e2e/journeys/panel-keyboard-interactions.spec.ts @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from "@playwright/test"; +import { createPoolResponse, createResourcesResponse, BackendResourceType, PoolStatus } from "@/mocks/factories"; +import { setupDefaultMocks, setupPools, setupResources, setupProfile } from "@/e2e/utils/mock-setup"; + +/** + * Panel Keyboard Interaction Journey Tests + * + * Architecture notes: + * - Panels use usePanelEscape hook: ESC closes panel when focus is within it + * - Pool panel: