/** * Sidebar stack-row truncation E2E tests. * * Verifies long stack names ellipsize instead of pushing trailing indicators * (update dot, check-failed icon, git-pending icon) past the sidebar edge. */ import { test, expect, type Page } from '@playwright/test'; import { loginAs, waitForStacksLoaded } from './helpers'; const LONG_STACK = 'e2e-tick-grafana-docker-observability-monitoring'; const SHORT_STACK = 'e2e-z'; const UPDATE_STACK = 'e2e-long-stack-update-dot-indicator'; const FAILED_STACK = 'e2e-long-stack-check-failed-indicator'; const GIT_STACK = 'e2e-long-stack-git-pending-indicator'; const TEST_STACKS = [LONG_STACK, SHORT_STACK, UPDATE_STACK, FAILED_STACK, GIT_STACK]; interface RowLayoutMetrics { stackName: string; sidebarRight: number; rowRight: number; rowWithinSidebar: boolean; nameTruncated: boolean; nameOverflowHidden: boolean; rowHasMinWidthZero: boolean; trailingRight: number | null; trailingWithinSidebar: boolean; trailingKind: 'update' | 'failed' | 'git' | 'none'; } async function createStack(page: Page, stackName: string): Promise { const res = await page.evaluate(async (name) => { const response = await fetch('/api/stacks', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stackName: name }), }); return { ok: response.ok, status: response.status }; }, stackName); expect(res.ok || res.status === 409, `create ${stackName} failed: ${res.status}`).toBeTruthy(); } async function deleteStack(page: Page, stackName: string): Promise { await page.evaluate(async (name) => { await fetch(`/api/stacks/${encodeURIComponent(name)}`, { method: 'DELETE', credentials: 'include', }).catch(() => undefined); }, stackName); } async function deleteAllTestStacks(page: Page): Promise { for (const name of TEST_STACKS) { await deleteStack(page, name); } } async function ensureTestStacks(page: Page): Promise { await deleteAllTestStacks(page); for (const name of TEST_STACKS) { await createStack(page, name); } await page.reload(); await waitForStacksLoaded(page); } function sidebarLocator(page: Page) { return page.locator('.w-64.border-r').first(); } async function measureRow(page: Page, stackName: string): Promise { const row = page.locator('[data-testid="stack-row"]').filter({ hasText: stackName }); await expect(row).toBeVisible({ timeout: 10_000 }); return row.evaluate((rowEl) => { const sidebar = document.querySelector('.bg-sidebar.border-r, .w-64.border-r, .bg-sidebar'); const sidebarRect = sidebar?.getBoundingClientRect(); if (!sidebarRect) { throw new Error('Could not locate sidebar container'); } const nameEl = rowEl.querySelector('.truncate') as HTMLElement | null; const updateDot = rowEl.querySelector('[data-testid="stack-trailing-update"]'); const failedIcon = rowEl.querySelector('[data-testid="stack-trailing-check-failed"]'); const gitIcon = rowEl.querySelector('[data-testid="stack-trailing-git-pending"]'); const trailing = updateDot ?? failedIcon ?? gitIcon; let trailingKind: RowLayoutMetrics['trailingKind'] = 'none'; if (updateDot) trailingKind = 'update'; else if (failedIcon) trailingKind = 'failed'; else if (gitIcon) trailingKind = 'git'; const rowRect = rowEl.getBoundingClientRect(); const trailingRect = trailing?.getBoundingClientRect() ?? null; return { stackName: nameEl?.textContent ?? '', sidebarRight: sidebarRect.right, rowRight: rowRect.right, rowWithinSidebar: rowRect.right <= sidebarRect.right + 0.5, nameTruncated: nameEl ? nameEl.scrollWidth > nameEl.clientWidth + 1 : false, nameOverflowHidden: nameEl ? getComputedStyle(nameEl).overflow === 'hidden' : false, rowHasMinWidthZero: rowEl.classList.contains('min-w-0'), trailingRight: trailingRect?.right ?? null, trailingWithinSidebar: trailingRect ? trailingRect.right <= sidebarRect.right + 0.5 : true, trailingKind, }; }); } async function assertRowLayout( page: Page, stackName: string, opts: { expectTruncated: boolean; trailingKind?: RowLayoutMetrics['trailingKind'] }, ): Promise { const metrics = await measureRow(page, stackName); expect(metrics.rowWithinSidebar, `${stackName} row clips past sidebar`).toBe(true); expect(metrics.nameOverflowHidden, `${stackName} name missing overflow hidden`).toBe(true); expect(metrics.rowHasMinWidthZero, `${stackName} row missing min-w-0`).toBe(true); expect(metrics.nameTruncated, `${stackName} truncation state`).toBe(opts.expectTruncated); if (opts.trailingKind) { expect(metrics.trailingKind, `${stackName} trailing indicator`).toBe(opts.trailingKind); expect(metrics.trailingWithinSidebar, `${stackName} trailing indicator clips`).toBe(true); } return metrics; } test.describe('Sidebar stack name truncation', () => { test.beforeEach(async ({ page }) => { await loginAs(page); await waitForStacksLoaded(page); await ensureTestStacks(page); }); test.afterEach(async ({ page }) => { await deleteAllTestStacks(page); }); test('long stack names truncate and stay within the sidebar', async ({ page }) => { await assertRowLayout(page, LONG_STACK, { expectTruncated: true }); }); test('short stack names do not truncate unnecessarily', async ({ page }) => { await assertRowLayout(page, SHORT_STACK, { expectTruncated: false }); }); test('update dot stays visible on a long stack name', async ({ page }) => { await page.route('**/api/image-updates/detail', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ [UPDATE_STACK]: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: Date.now(), }, }), }); }); const detailResponse = page.waitForResponse( (res) => res.url().includes('/api/image-updates/detail') && res.ok(), ); await page.reload(); await waitForStacksLoaded(page); await detailResponse; await expect( page.locator('[data-testid="stack-row"]').filter({ hasText: UPDATE_STACK }).locator('[data-testid="stack-trailing-update"]'), ).toBeVisible({ timeout: 10_000 }); await assertRowLayout(page, UPDATE_STACK, { expectTruncated: true, trailingKind: 'update', }); }); test('check-failed icon stays visible on a long stack name', async ({ page }) => { await page.route('**/api/image-updates/detail', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ [FAILED_STACK]: { hasUpdate: false, checkStatus: 'failed', lastError: 'Registry unreachable', checkedAt: Date.now(), }, }), }); }); const detailResponse = page.waitForResponse( (res) => res.url().includes('/api/image-updates/detail') && res.ok(), ); await page.reload(); await waitForStacksLoaded(page); await detailResponse; await expect( page.locator('[data-testid="stack-row"]').filter({ hasText: FAILED_STACK }).locator('[data-testid="stack-trailing-check-failed"]'), ).toBeVisible({ timeout: 10_000 }); await assertRowLayout(page, FAILED_STACK, { expectTruncated: true, trailingKind: 'failed', }); }); test('failed icon wins over retained hasUpdate on a long stack name', async ({ page }) => { await page.route('**/api/image-updates/detail', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ [UPDATE_STACK]: { hasUpdate: true, checkStatus: 'failed', lastError: 'Registry unreachable', checkedAt: Date.now(), }, }), }); }); await page.reload(); await waitForStacksLoaded(page); await assertRowLayout(page, UPDATE_STACK, { expectTruncated: true, trailingKind: 'failed', }); }); test('git-pending icon stays visible on a long stack name', async ({ page }) => { await page.route('**/api/git-sources', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { stack_name: GIT_STACK, pending_commit_sha: 'abc123def456' }, ]), }); }); const gitResponse = page.waitForResponse( (res) => res.url().includes('/api/git-sources') && res.ok(), ); await page.reload(); await waitForStacksLoaded(page); await gitResponse; await expect( page.locator('[data-testid="stack-row"]').filter({ hasText: GIT_STACK }).locator('[data-testid="stack-trailing-git-pending"]'), ).toBeVisible({ timeout: 10_000 }); await assertRowLayout(page, GIT_STACK, { expectTruncated: true, trailingKind: 'git', }); }); test('active row with a long name still truncates', async ({ page }) => { const row = page.locator('[data-testid="stack-row"]').filter({ hasText: LONG_STACK }); const slug = LONG_STACK.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); await Promise.all([ page.waitForURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)), row.click(), ]); await expect(row).toHaveClass(/bg-accent/, { timeout: 10_000 }); await assertRowLayout(page, LONG_STACK, { expectTruncated: true }); }); test('bulk mode checkbox does not break truncation on long names', async ({ page }) => { const bulkToggle = page.locator('button[aria-pressed]').filter({ has: page.locator('.lucide-layout-list') }); await bulkToggle.click(); await expect(bulkToggle).toHaveAttribute('aria-pressed', 'true'); const row = page.locator('[data-testid="stack-row"][data-bulk="true"]').filter({ hasText: LONG_STACK }); await expect(row).toBeVisible(); await expect(row.locator('[role="checkbox"]')).toBeVisible(); await assertRowLayout(page, LONG_STACK, { expectTruncated: true }); }); test('all stack rows stay within the sidebar width', async ({ page }) => { const metrics = await page.evaluate(() => { const sidebar = document.querySelector('.bg-sidebar.border-r, .w-64.border-r'); const sidebarRect = sidebar?.getBoundingClientRect(); if (!sidebarRect) return { ok: false, offenders: ['sidebar-missing'] }; const rows = Array.from(document.querySelectorAll('[data-testid="stack-row"]')); const offenders: string[] = []; for (const row of rows) { const rect = row.getBoundingClientRect(); if (rect.right > sidebarRect.right + 0.5) { offenders.push(row.textContent?.trim().slice(0, 40) ?? 'unknown-row'); } } return { ok: offenders.length === 0, offenders }; }); expect(metrics.ok, `rows overflow sidebar: ${metrics.offenders.join(', ')}`).toBe(true); }); test('ScrollArea block mode constrains list width', async ({ page }) => { const viewportUsesBlock = await page.evaluate(() => { const viewport = document.querySelector('[data-radix-scroll-area-viewport]'); const inner = viewport?.firstElementChild as HTMLElement | null; if (!inner) return false; return getComputedStyle(inner).display === 'block'; }); expect(viewportUsesBlock).toBe(true); }); test('mobile viewport keeps long names truncated', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/nodes/local/stacks'); await waitForStacksLoaded(page); const metrics = await measureRow(page, LONG_STACK); expect(metrics.nameTruncated).toBe(true); expect(metrics.rowWithinSidebar).toBe(true); }); });