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}
-
-
+ {/* 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 */}
-
-
+
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)
})
})