Files
computer-playbook/roles/web-app-dashboard/files/playwright/test-tile-reachability.js
kevinveenbirkenbachandClaude Opus 5 642055a20a style(e2e): drop the timeout argument isVisible discards
Playwright declares the timeout option on locator.isVisible() and isHidden() as deprecated and ignored: both return immediately and never wait. Passing resolveTimeout(10_000) to one therefore changes nothing at runtime and only tells the reader a wait happened that never did. That reading is what let 7538b8a0b conclude two genuine call sites were fine, and what hid the false negative b97cf8888 had to fix.

Sixty call sites across fifty-eight role-local specs lose the argument and nothing else. Every added line in this commit is a bare isVisible(); a grep for added lines that are not one returns zero, so a behaviour change cannot have entered here. Files whose diff also alters waiting semantics are deliberately not part of this commit.

A repository lint added in b97cf8888 keeps the construct out. It matches isVisible and isHidden only, because isChecked, isDisabled, isEditable and isEnabled take a real timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:49:18 +02:00

204 lines
8.1 KiB
JavaScript

// One parameterised test per role declared as a dashboard consumer in
// its meta/services.yml. The role list is emitted into
// DASHBOARD_TARGET_ROLES_JSON at deploy time by the env template via the
// `roles_with_service('dashboard')` Ansible filter, so this spec — and
// ONLY this spec — owns the per-role tile assertion. Other roles'
// personas no longer drive the dashboard click; they go straight to the
// app URL.
const { test, expect } = require("./fixtures/onion-test");
const { resolveTimeout } = require("./timeouts");
const { gotoOnion } = require("./personas");
const dashboardTargetRoles = (() => {
const raw = process.env.DASHBOARD_TARGET_ROLES_JSON || "[]";
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
})();
async function findVisibleTile(page, canonicalDomain) {
const tile = page.locator(`a[href*="${canonicalDomain}"]:visible`).first();
const visible = await tile.isVisible().catch(() => false);
if (!visible) {
// Tile may be hidden inside a collapsed Bootstrap dropdown / accordion.
const triggers = page.locator(
"[data-bs-toggle='dropdown'], [data-bs-toggle='collapse'], [aria-expanded='false']"
);
const triggerCount = await triggers.count().catch(() => 0);
for (let i = 0; i < triggerCount; i++) {
const t = triggers.nth(i);
if (!(await t.isVisible().catch(() => false))) continue;
await t.click({ timeout: resolveTimeout(1_000) }).catch(() => {});
}
}
await expect(
tile,
`dashboard tile for ${canonicalDomain} MUST be visible`
).toBeVisible({ timeout: resolveTimeout(30_000) });
return tile;
}
async function assertTileLoadsInIframe(page, target) {
const tile = await findVisibleTile(page, target.canonical_domain);
const href = await tile.getAttribute("href");
expect(href, `tile for ${target.id} MUST carry an href`).toBeTruthy();
expect(href, `tile href MUST point at ${target.canonical_domain}`).toContain(
target.canonical_domain
);
await tile.click();
// The dashboard's iframe.js listener updates the outer URL's `?iframe=`
// query param via the iframeLocationChange postMessage from the embedded
// app — this confirms the click triggered an in-page embed, not a
// top-level navigation.
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(30_000),
message: `Expected dashboard URL to embed ${target.canonical_domain} via ?iframe=... after clicking the ${target.id} tile`,
})
.toContain(target.canonical_domain);
const iframe = page.locator("#main iframe").first();
await expect(
iframe,
`Expected #main iframe to be present after clicking the ${target.id} tile`
).toBeVisible({ timeout: resolveTimeout(30_000) });
const iframeSrc = await iframe.getAttribute("src");
expect(
iframeSrc || "",
`Expected #main iframe src to point at ${target.canonical_domain}, got ${iframeSrc}`
).toContain(target.canonical_domain);
}
async function assertTileNavigatesTopLevel(page, context, target) {
const tile = await findVisibleTile(page, target.canonical_domain);
const href = await tile.getAttribute("href");
expect(href, `tile for ${target.id} MUST carry an href`).toBeTruthy();
expect(href, `tile href MUST point at ${target.canonical_domain}`).toContain(
target.canonical_domain
);
const framingProbe = await page.request.get(
target.canonical_url || `https://${target.canonical_domain}`,
{ ignoreHTTPSErrors: true }
);
const xFrameOptions = (framingProbe.headers()["x-frame-options"] || "").trim();
expect(
xFrameOptions,
`iframe:false target ${target.id} must not send a framing-blocking X-Frame-Options (got "${xFrameOptions}"); fix the header or use iframe:true`
).toBe("");
const cspHeader = framingProbe.headers()["content-security-policy"] || "";
const frameAncestors = (cspHeader.match(/frame-ancestors([^;]*)/i) || ["", ""])[1].trim();
if (frameAncestors) {
expect(
frameAncestors,
`iframe:false target ${target.id} CSP frame-ancestors must permit the dashboard origin (got "${frameAncestors}")`
).toMatch(/\*|dashboard/i);
}
// iframe:false cards omit the `iframe-link` class, so clicking is a plain
// anchor activation: it either navigates the top window in place or opens a
// new tab (target=_blank). Either way the canonical domain must be reached
// WITHOUT an embedded #main iframe, since SPAs like Keycloak's admin console
// force a top-window redirect that would shatter the embed.
const popupPromise = context
.waitForEvent("page", { timeout: resolveTimeout(15_000) })
.catch(() => null);
await tile.click();
const popup = await popupPromise;
if (popup) {
await popup
.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(30_000) })
.catch(() => {});
expect(
popup.url(),
`Expected new-tab URL to contain ${target.canonical_domain}, got ${popup.url()}`
).toContain(target.canonical_domain);
await popup.close();
return;
}
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(30_000),
message: `Expected top-level navigation to ${target.canonical_domain} after clicking the ${target.id} tile`,
})
.toContain(target.canonical_domain);
await expect(
page.locator("#main iframe"),
`Expected NO #main iframe for the non-embeddable ${target.id} tile`
).toHaveCount(0);
}
async function assertTabButtonOpensNewTab(page, context, target) {
// The Tab anchor carries no href (role=generic, not link) and its
// textContent is "<icon>\n Tab\n" (a /^tab$/ RegExp never matches), so the
// stable handle is its onclick=openIframeInNewTab().
const tabButton = page
.locator('nav.menu-header a[onclick*="openIframeInNewTab"]')
.first();
// Embedding a tile calls enterFullscreen(), which adds body.fullscreen.
// default.css then collapses the header to max-height:0, so the Tab item
// (and the Toggle control next to it) exist in the DOM but have a zero box
// and read as not-visible; neither can be clicked while collapsed. Exit
// fullscreen via the globally-exposed exitFullscreen() to restore the
// header; it only clears ?fullscreen=, leaving ?iframe= set so
// openIframeInNewTab() still pops the embedded URL.
if (!(await tabButton.isVisible().catch(() => false))) {
await page.evaluate(() => window.exitFullscreen && window.exitFullscreen());
}
await expect(
tabButton,
`Expected the dashboard header "Tab" button to be visible for the ${target.id} tile`
).toBeVisible({ timeout: resolveTimeout(30_000) });
const [popup] = await Promise.all([
context.waitForEvent("page", { timeout: resolveTimeout(30_000) }),
tabButton.click(),
]);
await popup.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(30_000) }).catch(() => {});
expect(
popup.url(),
`Expected popup tab URL to contain ${target.canonical_domain}, got ${popup.url()}`
).toContain(target.canonical_domain);
await popup.close();
}
exports.register = function (shared) {
for (const target of dashboardTargetRoles) {
// iframe defaults to true so existing embeddable cards keep the original
// assertion; only targets that explicitly opt out (services.dashboard.iframe
// === false, e.g. web-app-keycloak) take the top-level-navigation branch.
const embeddable = target.iframe !== false;
if (embeddable) {
test(`dashboard tile for ${target.id} embeds ${target.canonical_domain} in iframe and opens it in a new tab`, async ({ page, context }) => {
await gotoOnion(page, "/", { waitUntil: "domcontentloaded" });
await shared.waitForDashboardReady(page);
await assertTileLoadsInIframe(page, target);
await assertTabButtonOpensNewTab(page, context, target);
});
} else {
test(`dashboard tile for ${target.id} opens ${target.canonical_domain} via top-level navigation`, async ({ page, context }) => {
await gotoOnion(page, "/", { waitUntil: "domcontentloaded" });
await shared.waitForDashboardReady(page);
await assertTileNavigatesTopLevel(page, context, target);
});
}
}
};