test(dashboard): select rail internals through stable test ids

The dashboard contract makes data-test-id hooks the supported Electron
E2E selector surface. The rail now exposes -viewport, -track and
-card-link hooks next to its existing ones, and the focus E2E selects
those (and the rail heading by role) instead of internal class names.
The dashboard doc lists the new hooks and records the focus-reveal
contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-03 08:53:53 +02:00
1 parent f305e68bec
commit c544d4645b
3 files changed
+73 -54

No files matched your search

@@ -11,6 +11,13 @@ import {
const sourcesRailId = 'dashboard-recent-sources-rail';
const sourceCount = 4;
/** The rail's stable `data-test-id` hooks, as CSS selectors. */
const hooks = {
viewport: `[data-test-id="${sourcesRailId}-viewport"]`,
track: `[data-test-id="${sourcesRailId}-track"]`,
card: `[data-test-id="${sourcesRailId}-card"]`,
cardLink: `[data-test-id="${sourcesRailId}-card-link"]`,
};
/**
* Sizes the sources rail's cards so the last one is half visible: the rail
@@ -18,21 +25,24 @@ const sourceCount = 4;
* an element that already shows 32px or more. Returns the card width.
*/
async function makeLastCardHalfVisible(rail: Locator): Promise<number> {
const width = await rail.evaluate((section, count) => {
const host = section.parentElement as HTMLElement;
const viewport = section.querySelector(
'.rail__viewport'
) as HTMLElement;
const track = section.querySelector('.rail__track') as HTMLElement;
const gap = parseFloat(getComputedStyle(track).columnGap) || 0;
// count cards + (count - 1) gaps = viewport + half a card.
const cardWidth = Math.floor(
(viewport.clientWidth - gap * (count - 1)) / (count - 0.5)
);
host.style.setProperty('--cover-rail-width', `${cardWidth}px`);
track.scrollTo({ left: 0, behavior: 'auto' });
return cardWidth;
}, sourceCount);
const width = await rail.evaluate(
(section, { count, selectors }) => {
const host = section.parentElement as HTMLElement;
const viewport = section.querySelector(
selectors.viewport
) as HTMLElement;
const track = section.querySelector(selectors.track) as HTMLElement;
const gap = parseFloat(getComputedStyle(track).columnGap) || 0;
// count cards + (count - 1) gaps = viewport + half a card.
const cardWidth = Math.floor(
(viewport.clientWidth - gap * (count - 1)) / (count - 0.5)
);
host.style.setProperty('--cover-rail-width', `${cardWidth}px`);
track.scrollTo({ left: 0, behavior: 'auto' });
return cardWidth;
},
{ count: sourceCount, selectors: hooks }
);
await expect
.poll(() => lastCardVisibleWidth(rail))
.toBeGreaterThan(Math.min(64, width / 4));
@@ -41,41 +51,39 @@ async function makeLastCardHalfVisible(rail: Locator): Promise<number> {
/** Pixels of the last card inside the rail's visible viewport. */
function lastCardVisibleWidth(rail: Locator): Promise<number> {
return rail.evaluate((section) => {
return rail.evaluate((section, selectors) => {
const viewport = section
.querySelector('.rail__viewport')
.querySelector(selectors.viewport)
?.getBoundingClientRect();
const cards = section.querySelectorAll('.rail__card');
const cards = section.querySelectorAll(selectors.card);
const card = cards[cards.length - 1]?.getBoundingClientRect();
if (!viewport || !card) return 0;
return (
Math.min(card.right, viewport.right) -
Math.max(card.left, viewport.left)
);
});
}, hooks);
}
async function lastCardFullyVisible(rail: Locator): Promise<boolean> {
return rail.evaluate((section) => {
return rail.evaluate((section, selectors) => {
const viewport = section
.querySelector('.rail__viewport')
.querySelector(selectors.viewport)
?.getBoundingClientRect();
const cards = section.querySelectorAll('.rail__card');
const cards = section.querySelectorAll(selectors.card);
const card = cards[cards.length - 1]?.getBoundingClientRect();
if (!viewport || !card) return false;
return (
card.left >= viewport.left - 1 && card.right <= viewport.right + 1
);
});
}, hooks);
}
function lastCardLinkFocused(page: Page): Promise<boolean> {
return page.evaluate((railId) => {
const links = document.querySelectorAll(
`[data-test-id="${railId}"] .rail__card-link`
);
return page.evaluate((selector) => {
const links = document.querySelectorAll(selector);
return document.activeElement === links[links.length - 1];
}, sourcesRailId);
}, hooks.cardLink);
}
test.describe('Dashboard rail focus', () => {
@@ -102,13 +110,12 @@ test.describe('Dashboard rail focus', () => {
await goToDashboard(app.mainWindow);
const rail = app.mainWindow.getByTestId(sourcesRailId);
await expect(rail.getByTestId(`${sourcesRailId}-card`)).toHaveCount(
sourceCount
);
await expect(rail.locator(hooks.card)).toHaveCount(sourceCount);
const cardLinks = rail.locator(hooks.cardLink);
// Tab from the first card to the last card's link.
await makeLastCardHalfVisible(rail);
await rail.locator('.rail__card-link').first().focus();
await cardLinks.first().focus();
for (
let presses = 0;
presses < sourceCount * 3 &&
@@ -122,35 +129,29 @@ test.describe('Dashboard rail focus', () => {
// `focus()` from script after a mouse click elsewhere, starting
// at the rail's start again.
await rail.locator('.rail__label').click();
await rail.evaluate((section) => {
await rail.getByRole('heading').click();
await rail.evaluate((section, selector) => {
(document.activeElement as HTMLElement | null)?.blur();
section
.querySelector('.rail__track')
.querySelector(selector)
?.scrollTo({ left: 0, behavior: 'auto' });
});
}, hooks.track);
await expect.poll(() => lastCardFullyVisible(rail)).toBe(false);
await rail
.locator('.rail__card-link')
.last()
.evaluate((link) => {
(link as HTMLElement).focus();
});
await cardLinks.last().evaluate((link) => {
(link as HTMLElement).focus();
});
await expect.poll(() => lastCardFullyVisible(rail)).toBe(true);
// A mouse press focuses the link too; the rail must not slide it
// away from under the pointer before the click lands.
await rail.evaluate((section) => {
await rail.evaluate((section, selector) => {
(document.activeElement as HTMLElement | null)?.blur();
section
.querySelector('.rail__track')
.querySelector(selector)
?.scrollTo({ left: 0, behavior: 'auto' });
});
}, hooks.track);
await expect.poll(() => lastCardFullyVisible(rail)).toBe(false);
const box = await rail
.locator('.rail__card-link')
.last()
.boundingBox();
const box = await cardLinks.last().boundingBox();
expect(box).not.toBeNull();
await app.mainWindow.mouse.move(
(box?.x ?? 0) + 24,
@@ -160,7 +161,7 @@ test.describe('Dashboard rail focus', () => {
expect(await lastCardLinkFocused(app.mainWindow)).toBe(true);
expect(
await rail
.locator('.rail__track')
.locator(hooks.track)
.evaluate((track) => track.scrollLeft)
).toBe(0);
await app.mainWindow.mouse.up();
+10 -3
View File
@@ -158,11 +158,18 @@ and that pause holds the slide.
hover lift are not clipped; the fades are offset by the same variable so
they reach the track's clipping edge and no card strip shows beyond them.
4. Cards are keyboard-focusable router links; `scroll-snap-align: start`
means arrow-key nav lands on card boundaries.
means arrow-key nav lands on card boundaries. A card that receives
keyboard or script focus scrolls fully into the viewport: Chromium skips
its own focus scroll once 32px of an element shows, so the track's
`focusin` handler moves to the first card-start snap position revealing
the whole card (a card wider than the viewport aligns at its own start).
Focus caused by a press inside the track (within 100ms of `pointerdown`,
650ms for touch) leaves the rail still, so the card does not slide from
under the pointer before the click.
5. Image handling: `loading="lazy"`, `decoding="async"`, fallback icon tile
when `imageUrl` is missing or `error` fires.
6. Dashboard hero, rail containers, rail cards, and "Manage all" links expose
stable `data-test-id` hooks. Treat these as the supported Electron E2E
6. Dashboard hero, rail containers, rail viewports and tracks, rail cards and
their links, and "Manage all" links expose stable `data-test-id` hooks. Treat these as the supported Electron E2E
selector surface; do not target internal CSS class names.
## Data Flow
@@ -33,7 +33,11 @@
}
</header>
<div #viewport class="rail__viewport">
<div
#viewport
class="rail__viewport"
[attr.data-test-id]="testId() ? testId() + '-viewport' : null"
>
<button
type="button"
class="rail__chev rail__chev--left"
@@ -49,6 +53,7 @@
#track
class="rail__track"
[attr.data-layout]="layout()"
[attr.data-test-id]="testId() ? testId() + '-track' : null"
(scroll)="onScroll()"
(focusin)="onTrackFocusIn($event)"
(pointerdown)="onTrackPointerDown($event)"
@@ -69,6 +74,9 @@
<a
class="rail__channel-link"
[routerLink]="card.link"
[attr.data-test-id]="
testId() ? testId() + '-card-link' : null
"
[queryParams]="card.queryParams ?? null"
[state]="card.state"
>
@@ -155,6 +163,9 @@
<a
class="rail__card-link"
[routerLink]="card.link"
[attr.data-test-id]="
testId() ? testId() + '-card-link' : null
"
[queryParams]="card.queryParams ?? null"
[state]="card.state"
>