Files
prowler/ui/tests/scans/scans-page.ts
T

214 lines
8.3 KiB
TypeScript

import { Page, Locator, expect } from "@playwright/test";
import { BasePage } from "../base-page";
// Scan page
export class ScansPage extends BasePage {
// Main content elements
readonly scanTable: Locator;
readonly scanTabs: Locator;
// Scan provider selection elements
readonly launchScanButton: Locator;
readonly launchScanDialog: Locator;
readonly scanProviderSelect: Locator;
readonly scanProviderSearchInput: Locator;
readonly scanProviderOption: Locator;
readonly scanNoteInput: Locator;
readonly startNowButton: Locator;
// Scan state elements
readonly successToast: Locator;
readonly viewScanLink: Locator;
constructor(page: Page) {
super(page);
// Scan provider selection elements
// The sidebar exposes its own icon-button labeled "Launch Scan"
// (aria-label, wrapped in a Tooltip), so scoping by accessible name
// alone hits a strict-mode duplicate. Scope to the page-shell's
// filters-and-actions group, which only contains the visible-text
// Launch Scan button.
this.launchScanButton = page
.getByRole("group", { name: /scan filters and actions/i })
.getByRole("button", { name: /^Launch Scan$/i });
this.launchScanDialog = page.getByRole("dialog");
// The modal renders the providers picker as the shared MultiSelect-based
// AccountsSelector (used in single-select mode via closeOnSelect). Scoping
// to the dialog avoids matching the search combobox that appears in the
// portalled popover when opened.
this.scanProviderSelect = this.launchScanDialog.getByRole("combobox");
this.scanProviderSearchInput = page.getByPlaceholder("Search Providers...");
// MultiSelectContent renders its items twice: once inline (CSS-hidden
// mirror used for trigger badges and selection state) and once inside
// the portalled PopoverContent. Scoping to the popover container
// ([data-slot="multiselect-content"], only mounted when open) targets
// the visible copy and skips both inline mirrors from other page-level
// MultiSelects and the "Select all Providers" pseudo-option.
this.scanProviderOption = page.locator(
'[data-slot="multiselect-content"] [data-slot="multiselect-item"]:not([hidden])',
);
this.scanNoteInput = page.getByRole("textbox", {
name: "Alias",
});
this.startNowButton = this.launchScanDialog.getByRole("button", {
name: /^Launch Scan$/i,
});
// Scan state elements
this.successToast = page.getByRole("alert", {
name: /The scan was launched successfully\.?/i,
});
// The success toast renders a ToastAction wrapping a Next.js Link whose
// visible text — and therefore its accessible name — is "View scan".
// (The longer "View scan in progress" is only the ToastAction altText,
// not the link's accessible name.)
this.viewScanLink = page.getByRole("link", {
name: /^View scan$/i,
});
// Main content elements
this.scanTable = page.locator("table");
// The scans view renders each tab with its own empty state, so a <table>
// is NOT guaranteed (an empty tab shows a NoScansEmptyState card instead).
// The tabs group is always present once providers exist, so it is the
// reliable "page loaded" signal regardless of the active tab's contents.
this.scanTabs = page.getByRole("group", { name: /scan tabs/i });
}
// Navigation methods
async goto(): Promise<void> {
await super.goto("/scans");
}
async gotoTab(tab: "active" | "completed" | "scheduled"): Promise<void> {
await super.goto(`/scans?tab=${tab}`);
}
async verifyPageLoaded(): Promise<void> {
// Verify the scans page is loaded
if (!this.page.url().includes("/scans")) {
await this.goto();
}
await expect(this.page).toHaveTitle(/Prowler/);
// Assert the always-present tabs shell rather than the table, which only
// renders when the active tab actually has scans (empty tabs show a card).
await expect(this.scanTabs).toBeVisible();
}
async selectProviderByUID(uid: string): Promise<void> {
// Open the launch scan modal and pick the provider whose UID matches.
// Provider options expose the alias (not the UID) as their accessible
// name, so we type the UID into the MultiSelect search — UIDs are part
// of each item's cmdk keywords — and click the only matching item.
await this.launchScanButton.click();
await expect(this.launchScanDialog).toBeVisible();
await this.scanProviderSelect.click();
await this.scanProviderSearchInput.fill(uid);
const providerOption = this.scanProviderOption.first();
await expect(providerOption).toBeVisible();
await expect(providerOption).toHaveAttribute("aria-disabled", "false");
await providerOption.click();
}
async fillScanNote(note: string): Promise<void> {
// Fill the scan note
await this.scanNoteInput.fill(note);
}
async clickStartNowButton(): Promise<void> {
// Click the start now button
await expect(this.startNowButton).toBeVisible();
await this.startNowButton.click();
}
async viewLaunchedScan(): Promise<void> {
// Newly launched scans land in the "In Progress" (active) tab, but /scans
// defaults to the "Completed" tab. Use the toast's "View scan" action to
// navigate to ?tab=active where the available/executing scan is listed.
await expect(this.viewScanLink).toBeVisible({ timeout: 5000 });
await this.viewScanLink.click();
await expect(this.page).toHaveURL(/\/scans\?tab=active/);
await expect(this.scanTable).toBeVisible();
}
async verifyScanLaunched(accountId: string): Promise<void> {
// Verify the scan was launched
// Verify the success toast is visible
await this.successToast
.waitFor({ state: "visible", timeout: 5000 })
.catch(() => {});
// Wait for the scans table to be visible
await expect(this.scanTable).toBeVisible();
// Find a row that contains the account ID
const rowWithAccount = this.scanTable
.locator("tbody tr")
.filter({ hasText: accountId })
.first();
// Verify the row with the account is visible
await expect(rowWithAccount).toBeVisible();
// Basic state/assertion hint: queued/available/executing (non-blocking if not present)
await rowWithAccount.textContent().then((text) => {
if (!text) return;
const hasExpectedState = /executing|available|queued/i.test(text);
if (!hasExpectedState) {
// Fall back to just ensuring alias is present in the row
// The expectation above already ensures visibility
}
});
}
async verifyScheduledScanStatus(accountId: string): Promise<void> {
// Verifies that:
// 1. The provider exists in the table (by account ID/UID)
// 2. The scan name field contains "scheduled scan"
// Adding a provider first creates a scheduled-trigger scan in the
// AVAILABLE state (the immediate first run), which the tabbed view lists
// under "In Progress" — not "Scheduled" (that tab only shows state=scheduled,
// a later run created asynchronously). /scans defaults to Completed, which
// would show an empty-state card (no table) in a fresh environment, so
// navigate to the active tab where this row actually appears.
await this.gotoTab("active");
await this.verifyPageLoaded();
// Find a row that contains the account ID (provider UID in Provider column)
// Note: Use a more specific locator strategy if possible in the future
const rowWithAccountId = this.scanTable
.locator("tbody tr")
.filter({ hasText: accountId })
.first();
try {
// Verify the row with the account ID is visible (provider exists)
// Use a short timeout first to allow for a quick check
await expect(rowWithAccountId).toBeVisible({ timeout: 5000 });
} catch {
// If not visible immediately (likely due to async backend processing),
// reload the page to fetch the latest data
await this.page.reload();
await this.verifyPageLoaded();
// Wait longer after reload
await expect(rowWithAccountId).toBeVisible({ timeout: 15000 });
}
// Verify the row contains "scheduled scan" in the Scan name column
// The scan name "Daily scheduled scan" is displayed as "scheduled scan" in the table
await expect(rowWithAccountId).toContainText("scheduled scan", {
ignoreCase: true,
});
}
}