Files
sencho/e2e/sidebar-stack-truncate.spec.ts
Anso 0daddfde00 fix: reconcile sticky update indicators with Anatomy preview (#1698)
* fix: reconcile sticky update indicators with Anatomy preview

Sidebar, Updates filter, and Fleet treated retained partial/failed
scanner has_update as confirmed. Keep raw state for retention/notifications,
project confirmed-only to APIs, show distinct incomplete indicators, and
clear sticky rows only after an authoritative-negative preview.

Closes #1685

* test: align sidebar truncate E2E with failed-over-retained precedence

Purple update indicators are confirmed-only; hasUpdate with a failed
check correctly shows the failed trailing icon.

* fix: clear confirmed update rows on authoritative-negative preview

Address audit SF-1/SF-2/SF-3: observation-watermark clears for older
ok+has_update rows (DB + memory gens), Fleet checkability parity with
backend not_checkable, and Updates chip confirmed-only regressions.

* fix: tombstone equal-generation writers on preview clear

Advance the per-stack write generation when clearing at the observation
watermark so a scanner reserved before preview cannot recreate the row
after an authoritative-negative reconcile.

* fix: clear sticky updates with digest and tag preview parity

Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear.

* test: set digestUpdate on auto-update checkImage mocks

Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path.

* fix: clear unused lint errors on sticky update branch

Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green.

* fix: use inflight holder for CacheService prefer-const

Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
2026-07-25 15:42:19 -04:00

327 lines
12 KiB
TypeScript

/**
* 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<void> {
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<void> {
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${encodeURIComponent(name)}`, {
method: 'DELETE',
credentials: 'include',
}).catch(() => undefined);
}, stackName);
}
async function deleteAllTestStacks(page: Page): Promise<void> {
for (const name of TEST_STACKS) {
await deleteStack(page, name);
}
}
async function ensureTestStacks(page: Page): Promise<void> {
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<RowLayoutMetrics> {
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<RowLayoutMetrics> {
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);
});
});