diff --git a/AGENTS.md b/AGENTS.md index eba33b06ec..295ca673bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ # Layout & accessibility -- Build pages inside the shared `PageContainer`/`ContentPane` so you inherit the skip link, sticky footer, pagination target, and scroll restoration tied to `#scroll-container` (`app/layouts/helpers.tsx`, `app/hooks/use-scroll-restoration.ts`). +- Build pages inside the shared `PageContainer`/`ContentPane` so you inherit the skip link, sticky footer, pagination target, and scroll restoration (`app/layouts/helpers.tsx`, `app/hooks/use-scroll-restoration.ts`). - Surface page-level buttons and pagination via the `PageActions` and `Pagination` tunnels from `tunnel-rat`; anything rendered through `.In` lands in `.Target` automatically. - For global loading states, reuse `PageSkeleton`β€”it keeps the MSW banner and grid layout stable, and `skipPaths` lets you opt-out for routes with custom layouts (`app/components/PageSkeleton.tsx`). - Enforce accessibility at the type level: use `AriaLabel` type from `app/ui/util/aria.ts` which requires exactly one of `aria-label` or `aria-labelledby` on custom interactive components. diff --git a/app/components/PageSkeleton.tsx b/app/components/PageSkeleton.tsx index 55f07cd49f..c3510168d6 100644 --- a/app/components/PageSkeleton.tsx +++ b/app/components/PageSkeleton.tsx @@ -6,9 +6,15 @@ * Copyright Oxide Computer Company */ +import cn from 'classnames' import { useLocation } from 'react-router' -import { PageContainer } from '~/layouts/helpers' +import { + ContentPane, + PageContainer, + sidebarWrapperClass, + topBarWrapperClass, +} from '~/layouts/helpers' import { classed } from '~/util/classed' import { MswBanner } from './MswBanner' @@ -28,18 +34,22 @@ export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) { <> {process.env.MSW_BANNER ? : null} -
- - -
-
- -
- - + {/* TopBar */} +
+
+ + +
+
+ +
+ + +
-
+ {/* Sidebar */} +
@@ -52,7 +62,8 @@ export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) {
-
+ {/* Content */} + ) diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 96e00e8a3c..16d1b92c50 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -12,6 +12,7 @@ import { Action16Icon, Document16Icon } from '@oxide/design-system/icons/react' import { useIsActivePath } from '~/hooks/use-is-active-path' import { openQuickActions } from '~/hooks/use-quick-actions' +import { sidebarWrapperClass } from '~/layouts/helpers' import { Button } from '~/ui/lib/Button' import { Truncate } from '~/ui/lib/Truncate' @@ -62,7 +63,12 @@ const JumpToButton = () => { export function Sidebar({ children }: { children: React.ReactNode }) { return ( -
+
diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx index 5784c04432..11f277b7c9 100644 --- a/app/components/TopBar.tsx +++ b/app/components/TopBar.tsx @@ -22,6 +22,7 @@ import { import { useCrumbs } from '~/hooks/use-crumbs' import { useCurrentUser } from '~/hooks/use-current-user' +import { topBarWrapperClass } from '~/layouts/helpers' import { useThemeStore, type Theme } from '~/stores/theme' import { buttonStyle } from '~/ui/lib/Button' import * as DropdownMenu from '~/ui/lib/DropdownMenu' @@ -32,16 +33,12 @@ import { pb } from '~/util/path-builder' export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) { const { me } = useCurrentUser() - // The height of this component is governed by the `PageContainer` - // It's important that this component returns two distinct elements (wrapped in a fragment). - // Each element will occupy one of the top column slots provided by `PageContainer`. return ( - <> -
+
+
- {/* Height is governed by PageContainer grid */} -
+
@@ -50,7 +47,7 @@ export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) {
- +
) } @@ -146,7 +143,7 @@ function UserMenu() {
- + Settings logout.mutate({})} label="Sign out" /> @@ -238,7 +235,7 @@ function SiloSystemPicker({ level }: { level: 'silo' | 'system' }) {
- + diff --git a/app/hooks/use-scroll-restoration.ts b/app/hooks/use-scroll-restoration.ts index a622d18e15..cb2ff0c9e7 100644 --- a/app/hooks/use-scroll-restoration.ts +++ b/app/hooks/use-scroll-restoration.ts @@ -18,20 +18,19 @@ function setScrollPosition(key: string, pos: number) { } /** - * Given a ref to a scrolling container element, keep track of its scroll - * position before navigation and restore it on return (e.g., back/forward nav). - * Note that `location.key` is used in the cache key, not `location.pathname`, - * so the same path navigated to at different points in the history stack will - * not share the same scroll position. + * Keep track of window scroll position before navigation and restore it on + * return (e.g., back/forward nav). Note that `location.key` is used in the + * cache key, not `location.pathname`, so the same path navigated to at + * different points in the history stack will not share the same scroll position. */ -export function useScrollRestoration(container: React.RefObject) { +export function useScrollRestoration() { const key = `scroll-position-${useLocation().key}` const { state } = useNavigation() useEffect(() => { if (state === 'loading') { - setScrollPosition(key, container.current?.scrollTop ?? 0) + setScrollPosition(key, window.scrollY) } else if (state === 'idle') { - container.current?.scrollTo(0, getScrollPosition(key)) + window.scrollTo(0, getScrollPosition(key)) } - }, [key, state, container]) + }, [key, state]) } diff --git a/app/layouts/helpers.tsx b/app/layouts/helpers.tsx index 42244e9c29..ffdab7970e 100644 --- a/app/layouts/helpers.tsx +++ b/app/layouts/helpers.tsx @@ -5,7 +5,6 @@ * * Copyright Oxide Computer Company */ -import { useRef } from 'react' import { Outlet } from 'react-router' import { PageActionsTarget } from '~/components/PageActions' @@ -14,18 +13,18 @@ import { useScrollRestoration } from '~/hooks/use-scroll-restoration' import { SkipLinkTarget } from '~/ui/lib/SkipLink' import { classed } from '~/util/classed' -export const PageContainer = classed.div`grid h-screen grid-cols-[14.25rem_1fr] grid-rows-[var(--top-bar-height)_1fr]` +export const PageContainer = classed.div`min-h-full pt-(--top-bar-height)` + +// shared with PageSkeleton so the skeleton doesn't drift from the real layout +export const topBarWrapperClass = + 'bg-default border-secondary fixed top-0 right-0 left-0 z-(--z-top-bar) grid h-(--top-bar-height) grid-cols-[var(--sidebar-width)_1fr] border-b' +export const sidebarWrapperClass = + 'border-secondary fixed top-(--top-bar-height) bottom-0 left-0 w-(--sidebar-width) border-r' export function ContentPane() { - const ref = useRef(null) - useScrollRestoration(ref) + useScrollRestoration() return ( -
+
@@ -47,12 +46,10 @@ export function ContentPane() { * `
` because we don't need it. */ export const SerialConsoleContentPane = () => ( -
-
- -
- -
-
+
+ +
+ +
) diff --git a/app/table/QueryTable.tsx b/app/table/QueryTable.tsx index bf25f9c361..fdaef9786d 100644 --- a/app/table/QueryTable.tsx +++ b/app/table/QueryTable.tsx @@ -46,7 +46,7 @@ function useScrollReset(triggerDep: string | undefined) { const resetRequested = useRef(false) useEffect(() => { if (resetRequested.current) { - document.querySelector('#scroll-container')?.scrollTo(0, 0) + window.scrollTo(0, 0) resetRequested.current = false } }, [triggerDep]) diff --git a/app/table/columns/action-col.tsx b/app/table/columns/action-col.tsx index acb506a875..64cabc6fff 100644 --- a/app/table/columns/action-col.tsx +++ b/app/table/columns/action-col.tsx @@ -92,7 +92,11 @@ export const RowActions = ({ id, copyIdLabel = 'Copy ID', actions }: RowActionsP {/* offset moves menu in from the right so it doesn't align with the table border */} - + {id && } {actions?.map(({ className, ...action }) => 'to' in action ? ( diff --git a/app/ui/lib/DialogOverlay.tsx b/app/ui/lib/DialogOverlay.tsx index 960fc9ca79..b2ed5fb7e2 100644 --- a/app/ui/lib/DialogOverlay.tsx +++ b/app/ui/lib/DialogOverlay.tsx @@ -6,21 +6,40 @@ * Copyright Oxide Computer Company */ +import { Dialog as BaseDialog } from '@base-ui/react/dialog' import * as m from 'motion/react-m' import { type Ref } from 'react' -type Props = { - ref?: Ref -} +import { useIsInModal, useIsInSideModal } from './modal-context' + +type Props = { ref?: Ref } -export const DialogOverlay = ({ ref }: Props) => ( - -) +// Dialog.Backdrop registers itself with base-ui so clicks on it dismiss the +// dialog when modal={true}. A plain
here would not. +export const DialogOverlay = ({ ref }: Props) => { + const isInModal = useIsInModal() + const isInSideModal = useIsInSideModal() + // Modal scrim sits above the SideModal popup so Modal-over-SideModal is + // fully covered; SideModal scrim sits below its own popup. Modal wins when + // both contexts are set (Modal nested inside SideModal), mirroring + // usePopoverZIndex's precedence. + const zClass = + isInSideModal && !isInModal ? 'z-(--z-side-modal-overlay)' : 'z-(--z-modal-overlay)' + return ( + // forceRender so the Modal scrim still renders when nested inside a + // SideModal β€” otherwise base-ui hides it and the SideModal stays interactive. + + } + /> + ) +} diff --git a/app/ui/lib/DropdownMenu.tsx b/app/ui/lib/DropdownMenu.tsx index 6585875b2b..6e9b086f40 100644 --- a/app/ui/lib/DropdownMenu.tsx +++ b/app/ui/lib/DropdownMenu.tsx @@ -47,24 +47,43 @@ function parseAnchor( return { side, align, sideOffset, alignOffset } } +const zIndexClass = { + dropdown: 'z-(--z-content-dropdown)', + topBar: 'z-(--z-top-bar-dropdown)', + modal: 'z-(--z-modal-dropdown)', + sideModal: 'z-(--z-side-modal-dropdown)', +} as const + +type ZIndex = keyof typeof zIndexClass + type ContentProps = { className?: string children: ReactNode anchor?: AnchorProp /** Spacing in px between trigger and menu */ gap?: 8 + zIndex?: ZIndex + collisionPadding?: React.ComponentProps['collisionPadding'] } -export function Content({ className, children, anchor = 'bottom end', gap }: ContentProps) { +export function Content({ + className, + children, + anchor = 'bottom end', + gap, + zIndex = 'dropdown', + collisionPadding, +}: ContentProps) { const { side, align, sideOffset, alignOffset } = parseAnchor(anchor, gap) return ( {overlay && } @@ -93,7 +91,7 @@ export function Modal({ ) } -Modal.Body = classed.div`py-2 overflow-y-auto` +Modal.Body = classed.div`py-2 overflow-y-auto overscroll-none` Modal.Section = classed.div`p-4 space-y-4 border-b border-secondary text-default last-of-type:border-none text-sans-md` diff --git a/app/ui/lib/SideModal.tsx b/app/ui/lib/SideModal.tsx index 27622d8562..7fdf946149 100644 --- a/app/ui/lib/SideModal.tsx +++ b/app/ui/lib/SideModal.tsx @@ -59,7 +59,6 @@ export function SideModal({ onOpenChange={(open) => { if (!open) onDismiss() }} - modal={false} > @@ -129,7 +128,7 @@ function SideModalBody({ children }: { children?: ReactNode }) {
Oxide Console - + diff --git a/test/e2e/image-upload.e2e.ts b/test/e2e/image-upload.e2e.ts index cfb3f97e03..fcfd9476a3 100644 --- a/test/e2e/image-upload.e2e.ts +++ b/test/e2e/image-upload.e2e.ts @@ -193,8 +193,8 @@ test.describe('Image upload', () => { await expect(progressModal).toBeVisible() expect(confirmCount).toEqual(1) - // now let's try canceling by clicking out on the background over the side modal - await page.getByLabel('4096').click() + // now try dismissing by clicking the scrim outside the progress modal + await page.mouse.click(50, 50) await sleep(300) @@ -202,6 +202,33 @@ test.describe('Image upload', () => { expect(confirmCount).toEqual(2) }) + // regression test for the nested-dialog scrim: the progress modal's backdrop + // must cover the SideModal behind it. Without forceRender on Dialog.Backdrop, + // base-ui hides a nested backdrop by default, leaving the SideModal + // interactive through the "overlay". Without raising --z-modal-overlay above + // --z-side-modal, the overlay sits below the SideModal and doesn't cover it. + test('progress modal scrim covers the side modal underneath', async ({ + page, + browserName, + }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(browserName === 'webkit', 'safari. stop this') + + await fillForm(page, 'new-image') + + const progressModal = page.getByRole('dialog', { name: 'Image upload progress' }) + await page.getByRole('button', { name: 'Upload image' }).click() + await expect(progressModal).toBeVisible() + + // 4096 is a block-size radio in the SideModal behind the progress modal. + // Playwright's actionability check should fail here: the scrim intercepts + // pointer events, so the click can't land on the radio. Without the fix, + // nothing covers the radio and the click would succeed. + await expect(page.getByLabel('4096').click({ timeout: 2000 })).rejects.toThrow( + /intercepts pointer events/ + ) + }) + test('Image upload cancel and retry', async ({ page, browserName }) => { // eslint-disable-next-line playwright/no-skipped-test test.skip(browserName === 'webkit', 'safari. stop this') diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index 62e3953172..59406849d2 100644 --- a/test/e2e/ip-pool-silo-config.e2e.ts +++ b/test/e2e/ip-pool-silo-config.e2e.ts @@ -331,7 +331,7 @@ test.describe('IP pool configuration: pelerines silo (no defaults)', () => { // RHF required validation should show an error on the pool field await expect( - page.getByTestId('scroll-container').getByText('IPv4 pool is required') + page.locator('#create-instance-form').getByText('IPv4 pool is required') ).toBeVisible() // Should still be on the create page @@ -376,7 +376,7 @@ test.describe('IP pool configuration: pelerines silo (no defaults)', () => { // RHF required validation should show an error on the pool field await expect( - page.getByTestId('scroll-container').getByText('IPv6 pool is required') + page.locator('#create-instance-form').getByText('IPv6 pool is required') ).toBeVisible() // Should still be on the create page diff --git a/test/e2e/project-create.e2e.ts b/test/e2e/project-create.e2e.ts index 4e356439ba..db469b1c00 100644 --- a/test/e2e/project-create.e2e.ts +++ b/test/e2e/project-create.e2e.ts @@ -29,6 +29,14 @@ test.describe('Project create', () => { await expect(page).toHaveURL('/projects/mock-project-2/instances') }) + test('clicking the scrim dismisses the side modal', async ({ page }) => { + const dialog = page.getByRole('dialog', { name: /Create project/ }) + await expect(dialog).toBeVisible() + // click well to the left of the side modal panel β€” that's the scrim + await page.mouse.click(50, 50) + await expect(dialog).toBeHidden() + }) + test('shows field-level validation error and does not POST', async ({ page }) => { const expectInputError = async (text: string, error: string) => { await page.getByRole('textbox', { name: 'Name' }).fill(text) diff --git a/test/e2e/scroll-restore.e2e.ts b/test/e2e/scroll-restore.e2e.ts index 28d922e67f..c139061968 100644 --- a/test/e2e/scroll-restore.e2e.ts +++ b/test/e2e/scroll-restore.e2e.ts @@ -10,11 +10,13 @@ import { expect, test } from '@playwright/test' import { expectScrollTop, scrollTo, sleep } from './utils' test('scroll restore', async ({ page }) => { - // open small window to make scrolling easier - await page.setViewportSize({ width: 800, height: 500 }) + // use desktop-width viewport with short height to make scrolling easier + await page.setViewportSize({ width: 1280, height: 400 }) // nav to disks and scroll it await page.goto('/projects/mock-project/disks') + // wait for content to render so the page is tall enough to scroll + await page.getByRole('heading', { name: 'Disks' }).waitFor() await expectScrollTop(page, 0) await scrollTo(page, 143) @@ -32,43 +34,19 @@ test('scroll restore', async ({ page }) => { await scrollTo(page, 190) await sleep(1000) - // go forward to snapshots, now scroll it - await page.goForward() + // new nav to snapshots via click, scroll it + await page.getByRole('link', { name: 'Snapshots' }).click() await expect(page).toHaveURL('/projects/mock-project/snapshots') await expectScrollTop(page, 0) - await scrollTo(page, 30) - - // Oddly, this is required here in order for the page to have time to - // catch the 30 scroll position. This became necessary with RR v7's use of - // startTransition. Extra oddly, with a value of 500 it passes rarely, but - // with 1000 it passes every time. - await sleep(1000) - - // new nav to disks - await page.getByRole('link', { name: 'Disks' }).click() - await expectScrollTop(page, 0) - - // this is too flaky so forget it for now - // random reload in there because we use sessionStorage. note we are - // deliberately on the disks page here because there's a quirk in playwright - // that seems to reset to the disks page on reload - // await page.reload() - - // back to snapshots, scroll is restored - await page.goBack() - await expect(page).toHaveURL('/projects/mock-project/snapshots') - await expectScrollTop(page, 30) - - // back again to disks, newer scroll value is restored + // back to disks, newer scroll value is restored await page.goBack() await expect(page).toHaveURL('/projects/mock-project/disks') await sleep(1000) await expectScrollTop(page, 190) - // forward again to newest disks history entry, scroll remains 0 + // forward to snapshots, scroll is 0 (fresh nav) await page.goForward() - await page.goForward() - await expect(page).toHaveURL('/projects/mock-project/disks') + await expect(page).toHaveURL('/projects/mock-project/snapshots') await expectScrollTop(page, 0) }) diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index e573ef51b6..0ee45106ac 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -265,14 +265,12 @@ export async function chooseFile(input: Locator, size: 'large' | 'small' = 'larg } export async function expectScrollTop(page: Page, expected: number) { - const container = page.getByTestId('scroll-container') - const getScrollTop = () => container.evaluate((el: HTMLElement) => el.scrollTop) + const getScrollTop = () => page.evaluate(() => window.scrollY) await expect.poll(getScrollTop).toBe(expected) } export async function scrollTo(page: Page, to: number) { - const container = page.getByTestId('scroll-container') - await container.evaluate((el: HTMLElement, to) => el.scrollTo(0, to), to) + await page.evaluate((to) => window.scrollTo(0, to), to) } export async function addTlsCert(page: Page) { diff --git a/test/visual/regression.e2e.ts b/test/visual/regression.e2e.ts index d8a84fe2ef..a6c499a108 100644 --- a/test/visual/regression.e2e.ts +++ b/test/visual/regression.e2e.ts @@ -19,106 +19,216 @@ import { expect, test } from '../e2e/utils' // set a fixed time to avoid diffs due to irrelevant time differences test.beforeEach(async ({ page }) => { await page.clock.setFixedTime(new Date('2025-10-23T12:34:56.000Z')) + // TODO: revert to default viewport once we've confirmed no visual regressions + // from the grid layout change. The tall viewport forces all content to render + // without scrolling, so fullPage screenshots are comparable between the old + // contained-scroll layout and the new document-scroll layout. + await page.setViewportSize({ width: 1280, height: 3100 }) }) -test.describe('Visual Regression', { tag: '@visual' }, () => { - test('projects list', async ({ page }) => { - await page.goto('/projects') - await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible() - await expect(page).toHaveScreenshot('projects-list.png', { fullPage: true }) - }) - - test('instances list', async ({ page }) => { - await page.goto('/projects/mock-project/instances') - await expect(page.getByRole('heading', { name: 'Instances' })).toBeVisible() - await expect(page).toHaveScreenshot('instances-list.png', { fullPage: true }) - }) - - test('instance detail', async ({ page }) => { - await page.goto('/projects/mock-project/instances/db1') - await expect(page.getByRole('heading', { name: 'db1' })).toBeVisible() - await expect(page).toHaveScreenshot('instance-detail.png', { fullPage: true }) - }) - - test('create disk', async ({ page }) => { - await page.goto('/projects/mock-project/disks-new') - await expect(page.getByRole('heading', { name: 'Create disk' })).toBeVisible() - await expect(page).toHaveScreenshot('disks-new.png', { fullPage: true }) - }) +const fullPage = { fullPage: true } + +const p = '/projects/mock-project' + +// Standard pages: goto URL, wait for heading, take full-page screenshot +const pages = [ + // Auth + { name: 'device verify', url: '/device/verify', heading: 'Device Authentication' }, + { name: 'device success', url: '/device/success', heading: 'Device logged in' }, + + // Settings + { name: 'settings profile', url: '/settings/profile', heading: 'Profile' }, + { name: 'settings SSH keys', url: '/settings/ssh-keys', heading: 'SSH Keys' }, + { + name: 'settings access tokens', + url: '/settings/access-tokens', + heading: 'Access Tokens', + }, + + // Silo + { name: 'projects list', url: '/projects', heading: 'Projects' }, + { + name: 'silo image edit', + url: '/images/arch-2022-06-01/edit', + heading: 'Silo image', + exact: true, + }, + { name: 'silo utilization', url: '/utilization', heading: 'Utilization' }, + { name: 'silo access', url: '/access', heading: 'Silo Access' }, + + // Project - Instances + { name: 'instances list', url: `${p}/instances`, heading: 'Instances' }, + { name: 'instance create', url: `${p}/instances-new`, heading: 'Create instance' }, + { name: 'instance storage tab', url: `${p}/instances/db1/storage`, heading: 'db1' }, + { name: 'instance networking tab', url: `${p}/instances/db1/networking`, heading: 'db1' }, + { name: 'instance metrics cpu', url: `${p}/instances/db1/metrics/cpu`, heading: 'db1' }, + { name: 'instance metrics disk', url: `${p}/instances/db1/metrics/disk`, heading: 'db1' }, + { + name: 'instance metrics network', + url: `${p}/instances/db1/metrics/network`, + heading: 'db1', + }, + { name: 'instance connect tab', url: `${p}/instances/db1/connect`, heading: 'db1' }, + { name: 'instance settings tab', url: `${p}/instances/db1/settings`, heading: 'db1' }, + + // Project - Disks + { name: 'disks list', url: `${p}/disks`, heading: 'Disks' }, + { name: 'create disk', url: `${p}/disks-new`, heading: 'Create disk' }, + + // Project - Snapshots, Images + { name: 'snapshots list', url: `${p}/snapshots`, heading: 'Snapshots' }, + { name: 'images list', url: `${p}/images`, heading: 'Images' }, + { name: 'image upload', url: `${p}/images-new`, heading: 'Upload image' }, + + // Project - VPCs + { name: 'vpcs list', url: `${p}/vpcs`, heading: 'VPCs' }, + { + name: 'vpc firewall rules', + url: `${p}/vpcs/mock-vpc/firewall-rules`, + heading: 'mock-vpc', + }, + { name: 'vpc subnets', url: `${p}/vpcs/mock-vpc/subnets`, heading: 'mock-vpc' }, + { name: 'vpc routers', url: `${p}/vpcs/mock-vpc/routers`, heading: 'mock-vpc' }, + { + name: 'vpc internet gateways', + url: `${p}/vpcs/mock-vpc/internet-gateways`, + heading: 'mock-vpc', + }, + { + name: 'vpc router detail', + url: `${p}/vpcs/mock-vpc/routers/mock-custom-router`, + heading: 'mock-custom-router', + }, + + // Project - Networking + { name: 'floating IPs', url: `${p}/floating-ips`, heading: 'Floating IPs' }, + { name: 'external subnets', url: `${p}/external-subnets`, heading: 'External Subnets' }, + + // Project - Other + { name: 'project access', url: `${p}/access`, heading: 'Project Access' }, + { name: 'affinity groups', url: `${p}/affinity`, heading: 'Affinity Groups' }, + { + name: 'anti-affinity group detail', + url: `${p}/affinity/romulus-remus`, + heading: 'romulus-remus', + }, + + // System - Silos + { name: 'system silos list', url: '/system/silos', heading: 'Silos' }, + { name: 'silo detail idps', url: '/system/silos/maze-war/idps', heading: 'maze-war' }, + { + name: 'silo detail ip pools', + url: '/system/silos/maze-war/ip-pools', + heading: 'maze-war', + }, + { + name: 'silo detail subnet pools', + url: '/system/silos/maze-war/subnet-pools', + heading: 'maze-war', + }, + { name: 'silo detail quotas', url: '/system/silos/maze-war/quotas', heading: 'maze-war' }, + { + name: 'silo detail fleet roles', + url: '/system/silos/maze-war/fleet-roles', + heading: 'maze-war', + }, + { name: 'silo detail scim', url: '/system/silos/maze-war/scim', heading: 'maze-war' }, + + // System - Utilization + { name: 'system utilization', url: '/system/utilization', heading: 'Utilization' }, + { + name: 'system utilization metrics tab', + url: '/system/utilization?tab=metrics', + heading: 'Utilization', + }, + + // System - Networking + { name: 'system ip pools', url: '/system/networking/ip-pools', heading: 'IP Pools' }, + { + name: 'ip pool detail', + url: '/system/networking/ip-pools/ip-pool-1', + heading: 'ip-pool-1', + }, + { + name: 'ip pool silos tab', + url: '/system/networking/ip-pools/ip-pool-1?tab=silos', + heading: 'ip-pool-1', + }, + { + name: 'system subnet pools', + url: '/system/networking/subnet-pools', + heading: 'Subnet Pools', + }, + { + name: 'subnet pool detail', + url: '/system/networking/subnet-pools/default-v4-subnet-pool', + heading: 'default-v4-subnet-pool', + }, + { + name: 'subnet pool silos tab', + url: '/system/networking/subnet-pools/default-v4-subnet-pool?tab=silos', + heading: 'default-v4-subnet-pool', + }, + + // System - Inventory + { name: 'inventory sleds', url: '/system/inventory/sleds', heading: 'Inventory' }, + { name: 'inventory disks', url: '/system/inventory/disks', heading: 'Inventory' }, + { + name: 'sled instances', + url: '/system/inventory/sleds/c2519937-44a4-493b-9b38-5c337c597d08/instances', + heading: 'Sled', + }, + + // System - Update & Access + { name: 'system update', url: '/system/update', heading: 'System Update' }, + { name: 'fleet access', url: '/system/access', heading: 'Fleet Access' }, + + // Error + { name: 'not found', url: '/nonexistent', heading: 'Page not found' }, +] - test('disks list', async ({ page }) => { - await page.goto('/projects/mock-project/disks') - await expect(page.getByRole('heading', { name: 'Disks' })).toBeVisible() - await expect(page).toHaveScreenshot('disks-list.png', { fullPage: true }) - }) +test.describe('Visual Regression', { tag: '@visual' }, () => { + for (const { name, url, heading, exact } of pages) { + const screenshot = name.replaceAll(' ', '-') + '.png' + test(name, async ({ page }) => { + await page.goto(url, { waitUntil: 'networkidle' }) + await expect(page.getByRole('heading', { name: heading, exact })).toBeVisible() + await expect(page).toHaveScreenshot(screenshot, fullPage) + }) + } - test('vpcs list', async ({ page }) => { - await page.goto('/projects/mock-project/vpcs') - await expect(page.getByRole('heading', { name: 'VPCs' })).toBeVisible() - await expect(page).toHaveScreenshot('vpcs-list.png', { fullPage: true }) - }) + // Special cases that don't fit the standard pattern - test('snapshots list', async ({ page }) => { - await page.goto('/projects/mock-project/snapshots') - await expect(page.getByRole('heading', { name: 'Snapshots' })).toBeVisible() - await expect(page).toHaveScreenshot('snapshots-list.png', { fullPage: true }) - }) - - test('images list', async ({ page }) => { - await page.goto('/projects/mock-project/images') - await expect(page.getByRole('heading', { name: 'Images' })).toBeVisible() - await expect(page).toHaveScreenshot('images-list.png', { fullPage: true }) + test('login form', async ({ page }) => { + await page.goto('/login/default-silo/local', { waitUntil: 'networkidle' }) + await expect(page).toHaveURL(/\/login/) + await expect(page).toHaveScreenshot('login-form.png') }) - test('silo images list', async ({ page }) => { - await page.goto('/images') + test('silo images', async ({ page }) => { + await page.goto('/images', { waitUntil: 'networkidle' }) await expect(page.getByRole('heading', { name: 'Silo Images' })).toBeVisible() - await expect(page).toHaveScreenshot('silo-images.png', { fullPage: true }) - await page.click('role=button[name="Promote image"]') - await expect(page).toHaveScreenshot('silo-images-promote.png', { fullPage: true }) - }) - - test('silo image', async ({ page }) => { - await page.goto('/images/arch-2022-06-01/edit') - await expect( - page.getByRole('heading', { name: 'Silo image', exact: true }) - ).toBeVisible() - await expect(page).toHaveScreenshot('silo-image.png', { fullPage: true }) - }) - - test('system utilization', async ({ page }) => { - await page.goto('/utilization') - await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible() - await expect(page).toHaveScreenshot('system-utilization.png', { fullPage: true }) - }) - - test('system silos list', async ({ page }) => { - await page.goto('/system/silos') - await expect(page.getByRole('heading', { name: 'Silos' })).toBeVisible() - await expect(page).toHaveScreenshot('system-silos.png', { fullPage: true }) + await expect(page).toHaveScreenshot('silo-images.png', fullPage) + await page.getByRole('button', { name: 'Promote image' }).click() + await page.waitForLoadState('networkidle') + await expect(page).toHaveScreenshot('silo-images-promote.png', fullPage) }) - test('system networking ip pools', async ({ page }) => { - await page.goto('/system/networking/ip-pools') - await expect(page.getByRole('heading', { name: 'IP Pools' })).toBeVisible() - await expect(page).toHaveScreenshot('system-ip-pools.png', { fullPage: true }) - }) - - test('settings profile', async ({ page }) => { - await page.goto('/settings/profile') - await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible() - await expect(page).toHaveScreenshot('settings-profile.png', { fullPage: true }) + test('saml login', async ({ page }) => { + await page.goto('/login/default-silo/saml/mock-idp', { waitUntil: 'networkidle' }) + await expect(page).toHaveURL(/\/login/) + await expect(page).toHaveScreenshot('saml-login.png') }) - test('login form', async ({ page }) => { - await page.goto('/login/default-silo/local') - - await expect(page).toHaveURL(/\/login/) - await expect(page).toHaveScreenshot('login-form.png') + test('serial console', async ({ page }) => { + await page.goto(`${p}/instances/db1/serial-console`, { waitUntil: 'networkidle' }) + await expect(page.getByText('Serial Console')).toBeVisible() + await expect(page).toHaveScreenshot('serial-console.png', fullPage) }) test('command menu', async ({ page }) => { await page.keyboard.press(`ControlOrMeta+k`) - await expect(page).toHaveScreenshot('command-menu.png', { fullPage: true }) + await page.waitForLoadState('networkidle') + await expect(page).toHaveScreenshot('command-menu.png', fullPage) }) })