mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
Integrate supplied upstream frontier without rewriting reviewed source
Change-source: pulse-maintainer
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "410d84c69444ce4b59d00432b0b5510ce3016d0e",
|
||||
"verified_at": "2026-10-08T07:54:48Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "a6c8fe542971abd4938326622957ddf7b7dc3b8432010261e8a3342c48095ec8"
|
||||
},
|
||||
"routes": [
|
||||
"/docker",
|
||||
"/kubernetes/overview",
|
||||
"/kubernetes/workloads",
|
||||
"/proxmox/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"height": 1024
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private Vite dev server from this change's tree (origin/main d2ac0c5112 plus the change; the base then moved to 410d84c694 across 883 commits, none of which touch ResourceDetailSummary.tsx, DetailSectionTable, MetadataBadge, Table or index.css, and the only dependency changes are an icon library and a patch-level prettier bump) against a private mock-mode backend built from the same base (4 PVE nodes, 2 Docker hosts with 12 containers, 1 Kubernetes cluster with 3 nodes, 4 deployments and pods)",
|
||||
"Every inline drawer on the four routes opened in turn (83 drawers per width: 12 Docker containers, 22 Kubernetes overview rows, 19 Kubernetes workload rows, 4 Proxmox nodes and 26 guests) and every value and label cell measured for clipping (scrollWidth over clientWidth with hidden overflow, split into ellipsis and silent cuts)",
|
||||
"Before, on main's ResourceDetailSummary.tsx: 390px had 148 cut values in Docker and Kubernetes drawers (Primary ID ellipsized in all 12 containers and 40 Kubernetes rows, Parent 40, Hostname 16, Discovery 23, Machine ID 2, Image 3, Labels chips silently cut in all 12 containers with no ellipsis, 161px shown of up to 343px); 768px had 28 (Labels 2, Kubernetes Primary ID 26); 1440px had 7 (Labels 5, Kubernetes Primary ID 2)",
|
||||
"After: zero cut values in Docker and Kubernetes drawers at 390, 768 and 1440; pod names such as checkout-api-6c746d5bcf-c7z2p, Primary IDs, parent cluster IDs, image tags and every label chip read in full, wrapping inside the drawer",
|
||||
"Proxmox node and guest drawers (NodeDrawerOverview, GuestDrawerOverview, not touched) show the same residual cuts before and after: node Pulse coverage and CPU model, guest System and the Last completed backup label",
|
||||
"1440x900 A/B by swapping main's file under the same Vite session: 12 of 15 sampled drawers have identical row heights; the 3 that changed were the ones clipped before (two Kubernetes node Primary ID rows 20px to 34.3px; the customer-portal Labels row 288px to 320px with its two cut chips, 274px of 300px and of 279px, now whole); section cards keep the same three-across 424px layout"
|
||||
],
|
||||
"interactions": [
|
||||
"Opened and closed each row's detail disclosure on /docker, /kubernetes/overview, /kubernetes/workloads and /proxmox/overview",
|
||||
"Resized to 390x844, 768x1024 and 1440x900 and re-ran the clip sweep at each width, before and after the change",
|
||||
"Swapped main's ResourceDetailSummary.tsx in and out under the running Vite server and compared drawer row heights at 1440x900"
|
||||
]
|
||||
}
|
||||
@@ -80,6 +80,14 @@ const richRow = (
|
||||
options: Pick<DetailRow, 'title' | 'tone' | 'wrap' | 'valueClass'> = {},
|
||||
): DetailRow => ({ label, value, valueContent, ...options });
|
||||
|
||||
// The drawer is where a truncated table cell sends the operator for the full
|
||||
// value, so identifying values (IDs, names, image references, addresses) wrap
|
||||
// here instead of ending in an ellipsis. Their distinguishing part is often at
|
||||
// the end (a pod suffix, a hash, an image tag), and a phone cannot hover the
|
||||
// title to read it.
|
||||
const identityRow = (label: string, value?: string | null) =>
|
||||
makeDetailRow(label, value, { wrap: true });
|
||||
|
||||
// A Docker container's runtime rows match its table row: Docker's own state,
|
||||
// the health check on a row of its own while it runs, and uptime only for the
|
||||
// current run (Docker keeps the last value on a stopped container).
|
||||
@@ -126,7 +134,7 @@ const dockerSection = (docker: NonNullable<Resource['docker']>, now: number): De
|
||||
: null;
|
||||
|
||||
const rows = compactDetailRows([
|
||||
makeDetailRow('Image', image, { title: image }),
|
||||
identityRow('Image', image),
|
||||
makeDetailRow('Image update', updateState, {
|
||||
title: update?.error,
|
||||
tone: update?.updateAvailable ? 'accent' : update?.error ? 'danger' : 'default',
|
||||
@@ -173,13 +181,13 @@ const dockerSection = (docker: NonNullable<Resource['docker']>, now: number): De
|
||||
title: new Date(finishedAt).toLocaleString(),
|
||||
})
|
||||
: null,
|
||||
makeDetailRow('Podman pod', podmanPodName),
|
||||
makeDetailRow('Podman pod ID', podmanPodId),
|
||||
identityRow('Podman pod', podmanPodName),
|
||||
identityRow('Podman pod ID', podmanPodId),
|
||||
typeof docker.podman?.infra === 'boolean'
|
||||
? makeDetailRow('Podman infra', docker.podman.infra ? 'Yes' : 'No')
|
||||
: null,
|
||||
makeDetailRow('Compose project', composeProject),
|
||||
makeDetailRow('Compose service', composeService),
|
||||
identityRow('Compose project', composeProject),
|
||||
identityRow('Compose service', composeService),
|
||||
makeDetailRow('Auto-update', autoUpdatePolicy),
|
||||
makeDetailRow('User namespace', userNamespace),
|
||||
blockReadBytes !== null ? makeDetailRow('Block I/O read', formatBytes(blockReadBytes)) : null,
|
||||
@@ -193,13 +201,15 @@ const dockerSection = (docker: NonNullable<Resource['docker']>, now: number): De
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={labelEntries}>
|
||||
{([key, value]) => (
|
||||
<span
|
||||
class="inline-flex max-w-full items-center truncate rounded-sm bg-surface-alt px-1.5 py-0.5 text-[10px]"
|
||||
<MetadataBadge
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
wrap
|
||||
title={value ? `${key}: ${value}` : key}
|
||||
>
|
||||
{key}
|
||||
<Show when={value}>: {value}</Show>
|
||||
</span>
|
||||
</MetadataBadge>
|
||||
)}
|
||||
</For>
|
||||
</div>,
|
||||
@@ -226,8 +236,8 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
|
||||
? undefined
|
||||
: props.resource.uptime;
|
||||
const identityRows = compactDetailRows([
|
||||
...props.drawer.primaryIdentityRows().map((row) => makeDetailRow(row.label, row.value)),
|
||||
props.showPlatformId ? makeDetailRow('Platform ID', props.resource.platformId) : null,
|
||||
...props.drawer.primaryIdentityRows().map((row) => identityRow(row.label, row.value)),
|
||||
props.showPlatformId ? identityRow('Platform ID', props.resource.platformId) : null,
|
||||
props.drawer.identityIpValues().length > 0
|
||||
? richRow(
|
||||
'IP Addresses',
|
||||
@@ -289,7 +299,7 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
|
||||
valueClass: props.drawer.sourceSummary()?.className,
|
||||
})
|
||||
: null,
|
||||
makeDetailRow('Primary IP', props.drawer.identityIpValues()[0]),
|
||||
identityRow('Primary IP', props.drawer.identityIpValues()[0]),
|
||||
]),
|
||||
}
|
||||
: null,
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { cleanup, render, screen, within } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { InlineResourceSummaryTables } from '../ResourceDetailSummary';
|
||||
import type { UseResourceDetailDrawerStateResult } from '../useResourceDetailDrawerState';
|
||||
|
||||
const drawerState = (
|
||||
overrides: Partial<Record<keyof UseResourceDetailDrawerStateResult, unknown>> = {},
|
||||
) =>
|
||||
({
|
||||
primaryIdentityRows: () => [],
|
||||
identityIpValues: () => [],
|
||||
identityAliasValues: () => [],
|
||||
aliasPreviewValues: () => [],
|
||||
hasAliasOverflow: () => false,
|
||||
identityCardHasRichData: () => true,
|
||||
sourceSummary: () => null,
|
||||
lastSeen: () => '',
|
||||
lastSeenAbsolute: () => '',
|
||||
...overrides,
|
||||
}) as unknown as UseResourceDetailDrawerStateResult;
|
||||
|
||||
const pod: Resource = {
|
||||
id: 'pod-1',
|
||||
name: 'checkout-api-6c746d5bcf-c7z2p',
|
||||
displayName: 'checkout-api-6c746d5bcf-c7z2p',
|
||||
platformId: 'k8s-production-1-agent',
|
||||
platformType: 'kubernetes',
|
||||
sourceType: 'agent',
|
||||
type: 'pod',
|
||||
status: 'online',
|
||||
uptime: 3_600,
|
||||
lastSeen: Date.parse('2026-10-08T07:00:00Z'),
|
||||
};
|
||||
|
||||
const container: Resource = {
|
||||
id: 'container-1',
|
||||
name: 'customer-portal',
|
||||
displayName: 'customer-portal',
|
||||
platformId: 'docker-host-1',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
type: 'app-container',
|
||||
status: 'running',
|
||||
lastSeen: Date.parse('2026-10-08T07:00:00Z'),
|
||||
docker: {
|
||||
image: 'ghcr.io/pulse-demo/customer-portal:2026.04',
|
||||
labels: {
|
||||
'com.docker.compose.project': 'auth-service-01',
|
||||
'io.containers.capabilities': 'CHOWN,DAC_OVERRIDE,SETUID,SETGID,NET_BIND_SERVICE',
|
||||
},
|
||||
podman: {
|
||||
podName: 'customer-portal-pod',
|
||||
podId: '5ff108b94376a2c1d7e0f4b9c3a85e6d1f2b7c94a0e3d6f8b1c5a7e9d2f4b6a8',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// The span that holds a plain detail row's value, found by its full text.
|
||||
const valueSpan = (section: HTMLElement, value: string): HTMLElement => {
|
||||
const span = within(section).getByText(value);
|
||||
expect(span.tagName).toBe('SPAN');
|
||||
return span;
|
||||
};
|
||||
|
||||
const expectWrapped = (span: HTMLElement) => {
|
||||
expect(span.className).toContain('whitespace-normal');
|
||||
expect(span.className).not.toContain('truncate');
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('resource drawer identity values', () => {
|
||||
it('shows identity rows whole, since their distinguishing part is at the end', () => {
|
||||
render(() => (
|
||||
<InlineResourceSummaryTables
|
||||
resource={pod}
|
||||
drawer={drawerState({
|
||||
primaryIdentityRows: () => [
|
||||
{ label: 'Hostname', value: 'checkout-api-6c746d5bcf-c7z2p' },
|
||||
{
|
||||
label: 'Primary ID',
|
||||
value: 'pod:k8s:k8s-production-1:pod:nginx-d2541-3-ec73ea11c6',
|
||||
},
|
||||
{ label: 'Parent', value: 'k8s-cluster-afcb623d93ff2ae7' },
|
||||
{ label: 'Discovery', value: 'pod:nginx-d2541-3-ec73ea11c6' },
|
||||
],
|
||||
identityIpValues: () => ['2001:db8:85a3:0:0:8a2e:370:7334'],
|
||||
})}
|
||||
showPlatformId
|
||||
/>
|
||||
));
|
||||
|
||||
const identity = screen.getByTestId('resource-identity-section');
|
||||
for (const value of [
|
||||
'checkout-api-6c746d5bcf-c7z2p',
|
||||
'pod:k8s:k8s-production-1:pod:nginx-d2541-3-ec73ea11c6',
|
||||
'k8s-cluster-afcb623d93ff2ae7',
|
||||
'pod:nginx-d2541-3-ec73ea11c6',
|
||||
'k8s-production-1-agent',
|
||||
]) {
|
||||
expectWrapped(valueSpan(identity, value));
|
||||
}
|
||||
expectWrapped(
|
||||
valueSpan(
|
||||
screen.getByTestId('resource-current-state-section'),
|
||||
'2001:db8:85a3:0:0:8a2e:370:7334',
|
||||
),
|
||||
);
|
||||
|
||||
// Descriptive rows keep the single-line default.
|
||||
const runtime = screen.getByTestId('resource-runtime-context-section');
|
||||
expect(valueSpan(runtime, 'online').className).toContain('truncate');
|
||||
});
|
||||
|
||||
it('wraps container image references, pod and compose names, and label chips', () => {
|
||||
render(() => (
|
||||
<InlineResourceSummaryTables
|
||||
resource={container}
|
||||
drawer={drawerState()}
|
||||
showPlatformId={false}
|
||||
/>
|
||||
));
|
||||
|
||||
const section = screen.getByTestId('resource-docker-container-section');
|
||||
for (const value of [
|
||||
'ghcr.io/pulse-demo/customer-portal:2026.04',
|
||||
'customer-portal-pod',
|
||||
'5ff108b94376a2c1d7e0f4b9c3a85e6d1f2b7c94a0e3d6f8b1c5a7e9d2f4b6a8',
|
||||
'auth-service-01',
|
||||
]) {
|
||||
expectWrapped(valueSpan(section, value));
|
||||
}
|
||||
|
||||
// An inline-flex chip draws no ellipsis, so a truncating one would cut a
|
||||
// long label silently; the shared badge wraps it instead.
|
||||
const chip = within(section).getByTitle(
|
||||
'io.containers.capabilities: CHOWN,DAC_OVERRIDE,SETUID,SETGID,NET_BIND_SERVICE',
|
||||
);
|
||||
expect(chip.textContent).toBe(
|
||||
'io.containers.capabilities: CHOWN,DAC_OVERRIDE,SETUID,SETGID,NET_BIND_SERVICE',
|
||||
);
|
||||
expect(chip.className).toContain('wrap-anywhere');
|
||||
expect(chip.className).toContain('max-w-full');
|
||||
expect(chip.className).not.toContain('truncate');
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,106 @@ const gotoMobileRoute = async (
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
// Runs in the page against one inline drawer section and lists every value
|
||||
// that is cut: content crossing a clipping box, or a value cell that ends in
|
||||
// an ellipsis. The drawer is where a truncated table cell sends the reader
|
||||
// for the full value, so neither is allowed there.
|
||||
const findCutDrawerValues = (section: Element): string[] => {
|
||||
// Below lg the inline drawer's content shell clips anything past it.
|
||||
let shell: HTMLElement | null = section.parentElement;
|
||||
while (shell && window.getComputedStyle(shell).overflowX !== "clip") {
|
||||
shell = shell.parentElement;
|
||||
}
|
||||
if (!shell) return ["no clipping drawer content shell"];
|
||||
|
||||
const offenders: string[] = [];
|
||||
const walker = document.createTreeWalker(
|
||||
section,
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
let rect: DOMRect;
|
||||
let inlineLevel: boolean;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
rect = range.getBoundingClientRect();
|
||||
inlineLevel = true;
|
||||
} else {
|
||||
rect = (node as Element).getBoundingClientRect();
|
||||
inlineLevel = window
|
||||
.getComputedStyle(node as Element)
|
||||
.display.startsWith("inline");
|
||||
}
|
||||
const left = rect.left;
|
||||
let right = rect.right;
|
||||
// Inline content that truncates with an ellipsis ends inside the block
|
||||
// whose line it sits on; the value-cell check below reports that case.
|
||||
// The fixed-layout detail cell also declares an ellipsis, but that only
|
||||
// marks its own line: a block child such as a chip row crosses the cell
|
||||
// edge with no ellipsis and is silently cut.
|
||||
if (inlineLevel) {
|
||||
let line = node.parentElement;
|
||||
while (
|
||||
line &&
|
||||
line !== shell &&
|
||||
["inline", "contents"].includes(window.getComputedStyle(line).display)
|
||||
) {
|
||||
line = line.parentElement;
|
||||
}
|
||||
const lineStyle = line ? window.getComputedStyle(line) : null;
|
||||
if (
|
||||
line &&
|
||||
lineStyle?.textOverflow === "ellipsis" &&
|
||||
lineStyle.overflowX !== "visible" &&
|
||||
["block", "inline-block", "table-cell", "list-item", "flow-root"].includes(
|
||||
lineStyle.display,
|
||||
)
|
||||
) {
|
||||
right = Math.min(right, line.getBoundingClientRect().right);
|
||||
}
|
||||
}
|
||||
// Any clipping box on the way up (a chip, the detail cell, the table
|
||||
// wrapper, the drawer shell) hides whatever still crosses either edge.
|
||||
for (
|
||||
let box: HTMLElement | null = node.parentElement;
|
||||
box;
|
||||
box = box === shell ? null : box.parentElement
|
||||
) {
|
||||
const style = window.getComputedStyle(box);
|
||||
if (style.overflowX === "visible" || style.display === "contents") {
|
||||
continue;
|
||||
}
|
||||
const edges = box.getBoundingClientRect();
|
||||
const past = Math.max(right - edges.right, edges.left - left);
|
||||
if (past > 1) {
|
||||
offenders.push(
|
||||
`${node.textContent?.trim().slice(0, 60)} (${Math.round(past)}px past ${box.tagName.toLowerCase()})`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const cell of Array.from(section.querySelectorAll("tr > td:last-child"))) {
|
||||
for (const element of [cell, ...Array.from(cell.querySelectorAll("*"))]) {
|
||||
const style = window.getComputedStyle(element);
|
||||
if (
|
||||
style.textOverflow === "ellipsis" &&
|
||||
style.overflowX !== "visible" &&
|
||||
element.scrollWidth > element.clientWidth + 1
|
||||
) {
|
||||
offenders.push(
|
||||
`${element.textContent?.trim().slice(0, 60)} (ends in an ellipsis)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
};
|
||||
|
||||
test.describe("Mobile viewport flows", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await ensureAuthenticated(page);
|
||||
@@ -401,129 +501,80 @@ test.describe("Mobile viewport flows", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("Drawer identity chips wrap inside the drawer content instead of being clipped", async ({
|
||||
test("Drawer identity values show in full inside the drawer instead of being cut", async ({
|
||||
page,
|
||||
}) => {
|
||||
await waitForDefaultMockRuntimeReady(page);
|
||||
await page.goto("/docker");
|
||||
const requireRows = ["1", "true", "yes", "on"].includes(
|
||||
String(process.env.PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY || "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
|
||||
// Container drawers carry long generated aliases such as
|
||||
// app-container-<hash> that are wider than the identity value cell.
|
||||
const containerRow = page.locator("tr[data-docker-container-row]").first();
|
||||
if (
|
||||
["1", "true", "yes", "on"].includes(
|
||||
String(process.env.PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY || "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
// CI runs the default mock estate, which always has Docker containers.
|
||||
await expect(containerRow).toBeVisible({ timeout: 15_000 });
|
||||
} else {
|
||||
const containerRowVisible = await containerRow
|
||||
.waitFor({ state: "visible", timeout: 15_000 })
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!containerRowVisible) {
|
||||
test.skip(true, "No Docker container rows available to expand");
|
||||
// Identity values differ at their ends. Container drawers carry generated
|
||||
// IDs (app-container:<host>-<hash>), image tags and long label chips;
|
||||
// pods from one ReplicaSet differ only in their name suffix, primary ID
|
||||
// hash and the parent cluster ID. A phone cannot hover a title to read
|
||||
// the rest, so the drawer must show each value whole.
|
||||
const drawers = [
|
||||
{
|
||||
route: "/docker",
|
||||
row: "tr[data-docker-container-row]",
|
||||
sections: [
|
||||
"resource-identity-section",
|
||||
"resource-docker-container-section",
|
||||
],
|
||||
identityLabel: "Aliases",
|
||||
},
|
||||
{
|
||||
route: "/kubernetes",
|
||||
row: "tr[data-kubernetes-pod-row]",
|
||||
sections: ["resource-identity-section"],
|
||||
identityLabel: "Parent",
|
||||
},
|
||||
];
|
||||
let checked = 0;
|
||||
for (const drawer of drawers) {
|
||||
await page.goto(drawer.route);
|
||||
const row = page.locator(drawer.row).first();
|
||||
if (requireRows) {
|
||||
// CI runs the default mock estate, which always has containers and pods.
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
} else {
|
||||
const rowVisible = await row
|
||||
.waitFor({ state: "visible", timeout: 15_000 })
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!rowVisible) continue;
|
||||
}
|
||||
// On phones the whole row is the disclosure target.
|
||||
await row.locator("td").first().click();
|
||||
await expect(
|
||||
row.getByRole("button", { name: /^Collapse details for / }),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
if (requireRows) {
|
||||
// The default mock's first container and pod carry these long rows.
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="resource-identity-section"]')
|
||||
.first()
|
||||
.getByText(drawer.identityLabel, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
for (const testId of drawer.sections) {
|
||||
const section = page.locator(`[data-testid="${testId}"]`).first();
|
||||
await expect(section).toBeVisible();
|
||||
const cutOff = await section.evaluate(findCutDrawerValues);
|
||||
expect(cutOff, `${drawer.route} ${testId}`).toEqual([]);
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
if (checked === 0) {
|
||||
test.skip(true, "No container or pod rows available to expand");
|
||||
}
|
||||
// On phones the whole row is the disclosure target.
|
||||
await containerRow.locator("td").first().click();
|
||||
await expect(
|
||||
containerRow.getByRole("button", { name: /^Collapse details for / }),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
const identity = page
|
||||
.locator('[data-testid="resource-identity-section"]')
|
||||
.first();
|
||||
await expect(identity).toBeVisible();
|
||||
await expect(identity.getByText("Aliases")).toBeVisible();
|
||||
|
||||
const cutOff = await identity.evaluate((section) => {
|
||||
// Below lg the inline drawer's content shell clips anything past it.
|
||||
let shell: HTMLElement | null = section.parentElement;
|
||||
while (shell && window.getComputedStyle(shell).overflowX !== "clip") {
|
||||
shell = shell.parentElement;
|
||||
}
|
||||
if (!shell) return ["no clipping drawer content shell"];
|
||||
|
||||
const offenders: string[] = [];
|
||||
const walker = document.createTreeWalker(
|
||||
section,
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
let rect: DOMRect;
|
||||
let inlineLevel: boolean;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
rect = range.getBoundingClientRect();
|
||||
inlineLevel = true;
|
||||
} else {
|
||||
rect = (node as Element).getBoundingClientRect();
|
||||
inlineLevel = window
|
||||
.getComputedStyle(node as Element)
|
||||
.display.startsWith("inline");
|
||||
}
|
||||
const left = rect.left;
|
||||
let right = rect.right;
|
||||
// Inline content that truncates with an ellipsis is meant to end
|
||||
// inside the block whose line it sits on (a truncating value span or
|
||||
// label cell). The fixed-layout detail cell also declares an ellipsis,
|
||||
// but that only marks its own line: a block child such as the chip
|
||||
// row crosses the cell edge with no ellipsis and is silently cut.
|
||||
if (inlineLevel) {
|
||||
let line = node.parentElement;
|
||||
while (
|
||||
line &&
|
||||
line !== shell &&
|
||||
["inline", "contents"].includes(
|
||||
window.getComputedStyle(line).display,
|
||||
)
|
||||
) {
|
||||
line = line.parentElement;
|
||||
}
|
||||
const lineStyle = line ? window.getComputedStyle(line) : null;
|
||||
if (
|
||||
line &&
|
||||
lineStyle?.textOverflow === "ellipsis" &&
|
||||
lineStyle.overflowX !== "visible" &&
|
||||
["block", "inline-block", "table-cell", "list-item", "flow-root"].includes(
|
||||
lineStyle.display,
|
||||
)
|
||||
) {
|
||||
right = Math.min(right, line.getBoundingClientRect().right);
|
||||
}
|
||||
}
|
||||
// Any clipping box on the way up (the detail cell, the table wrapper,
|
||||
// the drawer shell) hides whatever still crosses either of its edges.
|
||||
for (
|
||||
let box: HTMLElement | null = node.parentElement;
|
||||
box;
|
||||
box = box === shell ? null : box.parentElement
|
||||
) {
|
||||
const style = window.getComputedStyle(box);
|
||||
if (style.overflowX === "visible" || style.display === "contents") {
|
||||
continue;
|
||||
}
|
||||
const edges = box.getBoundingClientRect();
|
||||
const past = Math.max(right - edges.right, edges.left - left);
|
||||
if (past > 1) {
|
||||
offenders.push(
|
||||
`${node.textContent?.trim().slice(0, 60)} (${Math.round(past)}px past ${box.tagName.toLowerCase()})`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
});
|
||||
expect(cutOff).toEqual([]);
|
||||
});
|
||||
|
||||
test("Infrastructure landing loads without horizontal overflow at mobile viewport", async ({
|
||||
|
||||
Reference in New Issue
Block a user