533 lines
17 KiB
TypeScript
533 lines
17 KiB
TypeScript
import type { PortfolioContent } from "@/content/types";
|
|
import { expect, expectInsetFocusIndicator, test } from "../helpers/serve-dist";
|
|
|
|
test.use({ javaScriptEnabled: false });
|
|
|
|
const sectionIds = [
|
|
"start",
|
|
"profile",
|
|
"experience",
|
|
"infrastructure",
|
|
"selected-builds",
|
|
"additional-work",
|
|
"toolkit",
|
|
"contact",
|
|
] as const;
|
|
|
|
const sectionHeadings = [
|
|
"Profile",
|
|
"Experience",
|
|
"Infrastructure",
|
|
"Selected builds",
|
|
"Additional work",
|
|
"Toolkit",
|
|
"Contact",
|
|
] as const;
|
|
|
|
const navigationLabels: Readonly<Record<(typeof sectionIds)[number], string>> =
|
|
{
|
|
start: "Start",
|
|
profile: "Profile",
|
|
experience: "Career",
|
|
infrastructure: "Infra",
|
|
"selected-builds": "Builds",
|
|
"additional-work": "More work",
|
|
toolkit: "Toolkit",
|
|
contact: "Contact",
|
|
};
|
|
|
|
const visibleTextLeafSelector =
|
|
"h1,h2,h3,h4,p,dt,dd,li,figcaption,caption,th,td,a,svg text";
|
|
const visibleTextSelector = [
|
|
".site-header :is(h1,h2,h3,h4,p,dt,dd,li,figcaption,caption,th,td,a)",
|
|
".portfolio-main :is(h1,h2,h3,h4,p,dt,dd,li,figcaption,caption,th,td,a)",
|
|
".portfolio-main svg text",
|
|
].join(",");
|
|
|
|
const normalizedText = (value: string): string =>
|
|
value.replace(/\s+/gu, " ").trim();
|
|
|
|
type TextMultiset = Array<[text: string, count: number]>;
|
|
|
|
const textMultiset = (values: readonly string[]): TextMultiset => {
|
|
const counts = new Map<string, number>();
|
|
for (const value of values.map(normalizedText).filter(Boolean)) {
|
|
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
}
|
|
return [...counts].sort(([left], [right]) =>
|
|
left < right ? -1 : left > right ? 1 : 0,
|
|
);
|
|
};
|
|
|
|
const missingText = (
|
|
expected: TextMultiset,
|
|
actual: TextMultiset,
|
|
): TextMultiset => {
|
|
const actualCounts = new Map(actual);
|
|
return expected.flatMap(([text, count]) => {
|
|
const missing = count - (actualCounts.get(text) ?? 0);
|
|
return missing > 0 ? [[text, missing] as [string, number]] : [];
|
|
});
|
|
};
|
|
|
|
const portfolioContentFromSnapshot = async (
|
|
page: import("@playwright/test").Page,
|
|
rawHtml: string,
|
|
): Promise<PortfolioContent> =>
|
|
page.evaluate((html) => {
|
|
const document = new DOMParser().parseFromString(html, "text/html");
|
|
const snapshot = document.querySelector("#portfolio-inputs");
|
|
if (snapshot?.textContent === null || snapshot?.textContent === undefined) {
|
|
throw new Error("Missing #portfolio-inputs snapshot");
|
|
}
|
|
return (JSON.parse(snapshot.textContent) as { content: PortfolioContent })
|
|
.content;
|
|
}, rawHtml);
|
|
|
|
const expectedVisibleTextSegments = (
|
|
content: Readonly<PortfolioContent>,
|
|
): string[] => {
|
|
const navigationById = new Map(
|
|
content.navigation.map((item) => [item.id, item.label]),
|
|
);
|
|
const section = (
|
|
id: Exclude<(typeof sectionIds)[number], "start">,
|
|
body: readonly string[],
|
|
eyebrow?: string,
|
|
): string[] => {
|
|
const label = navigationById.get(id);
|
|
if (label === undefined) throw new Error(`Missing navigation label: ${id}`);
|
|
return [eyebrow, label, ...body].filter(
|
|
(segment): segment is string => segment !== undefined,
|
|
);
|
|
};
|
|
const projectLinkText = (
|
|
project: PortfolioContent["projects"]["selected"][number],
|
|
): string[] =>
|
|
project.visibility === "public" && project.url ? [project.title] : [];
|
|
const projectMediaText = (
|
|
project: PortfolioContent["projects"]["selected"][number],
|
|
): string[] =>
|
|
(project.media ?? []).flatMap((presentation) => [
|
|
presentation.caption,
|
|
presentation.longDescription,
|
|
...(presentation.kind === "chart"
|
|
? [
|
|
"Variables",
|
|
presentation.chart.variables,
|
|
"Trend",
|
|
presentation.chart.trend,
|
|
"Conclusion",
|
|
presentation.chart.conclusion,
|
|
...(presentation.chart.table === undefined
|
|
? []
|
|
: [
|
|
presentation.chart.table.caption,
|
|
...presentation.chart.table.columns,
|
|
...presentation.chart.table.rows.flat(),
|
|
]),
|
|
]
|
|
: []),
|
|
].filter((segment): segment is string => segment !== undefined));
|
|
|
|
return [
|
|
...sectionIds.map((id) => navigationLabels[id]),
|
|
content.identity.name,
|
|
content.identity.role,
|
|
content.identity.valueProposition,
|
|
content.identity.availability,
|
|
"Email Luca",
|
|
"Explore work",
|
|
...section("profile", [
|
|
content.profile.introduction,
|
|
...content.profile.screeningFacts.flatMap((fact) => [
|
|
fact.label,
|
|
fact.value,
|
|
]),
|
|
]),
|
|
...section("experience", [
|
|
...content.experience.flatMap((record) => [
|
|
record.role,
|
|
`${record.organization} · ${record.dates}${record.location === undefined ? "" : ` · ${record.location}`}`,
|
|
...record.highlights,
|
|
]),
|
|
content.systemsOperations.title,
|
|
content.systemsOperations.summary,
|
|
...content.systemsOperations.evidence,
|
|
]),
|
|
...section("infrastructure", [
|
|
content.infrastructure.title,
|
|
content.infrastructure.summary,
|
|
content.infrastructure.title,
|
|
"Trust boundary",
|
|
"Where trust or access rules change",
|
|
"Request flow",
|
|
"The direction traffic moves through the system",
|
|
"Legend: Boundary labels mark changes in trust or access rules. Connectors show request flow direction.",
|
|
...content.infrastructure.bands.map(
|
|
(band) => `${band.title}: ${band.summary}`,
|
|
),
|
|
content.infrastructure.featuredOperation.title,
|
|
content.infrastructure.featuredOperation.context,
|
|
...content.infrastructure.featuredOperation.actions,
|
|
`Outcome: ${content.infrastructure.featuredOperation.outcome}`,
|
|
]),
|
|
...section(
|
|
"selected-builds",
|
|
content.projects.selected.flatMap((project) => [
|
|
project.title,
|
|
project.summary,
|
|
...projectMediaText(project),
|
|
"Problem",
|
|
project.problem,
|
|
"Contribution",
|
|
project.contribution,
|
|
"Technical decisions",
|
|
...project.technicalDecisions,
|
|
"Outcome",
|
|
project.outcome,
|
|
...projectLinkText(project),
|
|
]),
|
|
"Selected work",
|
|
),
|
|
...section(
|
|
"additional-work",
|
|
content.projects.additional.flatMap((project) => [
|
|
project.title,
|
|
project.summary,
|
|
...projectMediaText(project),
|
|
project.contribution,
|
|
project.outcome,
|
|
...projectLinkText(project),
|
|
]),
|
|
),
|
|
...section(
|
|
"toolkit",
|
|
content.toolkit.flatMap((group) => [
|
|
group.title,
|
|
...group.technologies.map((technology) => technology.name),
|
|
]),
|
|
),
|
|
...section(
|
|
"contact",
|
|
[
|
|
content.contact.introduction,
|
|
"Email Luca",
|
|
content.contact.email,
|
|
content.contact.cvMessage,
|
|
...content.contact.social.map((link) => link.service),
|
|
],
|
|
"Destination",
|
|
),
|
|
];
|
|
};
|
|
|
|
const rawVisibleTextSegments = async (
|
|
page: import("@playwright/test").Page,
|
|
rawHtml: string,
|
|
): Promise<string[]> =>
|
|
page.evaluate(
|
|
({ html, leafSelector, rootSelector }) => {
|
|
const document = new DOMParser().parseFromString(html, "text/html");
|
|
const isHidden = (element: Element): boolean => {
|
|
for (
|
|
let current: Element | null = element;
|
|
current;
|
|
current = current.parentElement
|
|
) {
|
|
if (
|
|
current.matches("template,script,style,noscript") ||
|
|
current.hasAttribute("hidden") ||
|
|
current.hasAttribute("inert")
|
|
) {
|
|
return true;
|
|
}
|
|
const ariaHidden = current.getAttribute("aria-hidden");
|
|
if (
|
|
ariaHidden !== null &&
|
|
ariaHidden.trim().toLowerCase() !== "false"
|
|
) {
|
|
return true;
|
|
}
|
|
const hiddenClass =
|
|
current.getAttribute("class")?.toLowerCase() ?? "";
|
|
if (
|
|
/(?:^|\s|-)print-only(?:\s|-|$)|print-portfolio-label|visually-hidden|sr-only|screen-reader-only/u.test(
|
|
hiddenClass,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
const inlineStyle =
|
|
current.getAttribute("style")?.toLowerCase() ?? "";
|
|
if (
|
|
/(?:^|;)\s*display\s*:\s*none\b/u.test(inlineStyle) ||
|
|
/(?:^|;)\s*visibility\s*:\s*hidden\b/u.test(inlineStyle) ||
|
|
/(?:^|;)\s*opacity\s*:\s*0(?:[;\s]|$)/u.test(inlineStyle)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
return [...document.querySelectorAll<Element>(rootSelector)]
|
|
.filter(
|
|
(element) =>
|
|
!isHidden(element) && element.querySelector(leafSelector) === null,
|
|
)
|
|
.map((element) =>
|
|
(element.textContent ?? "").replace(/\s+/gu, " ").trim(),
|
|
)
|
|
.filter((text) => text.length > 0);
|
|
},
|
|
{
|
|
html: rawHtml,
|
|
leafSelector: visibleTextLeafSelector,
|
|
rootSelector: visibleTextSelector,
|
|
},
|
|
);
|
|
|
|
const browserVisibleTextSegments = async (
|
|
page: import("@playwright/test").Page,
|
|
): Promise<string[]> =>
|
|
page.locator(visibleTextSelector).evaluateAll(
|
|
(elements, leafSelector) =>
|
|
elements
|
|
.filter((element) => {
|
|
for (
|
|
let current: Element | null = element;
|
|
current;
|
|
current = current.parentElement
|
|
) {
|
|
if (
|
|
current.matches("template,script,style,noscript") ||
|
|
current.hasAttribute("hidden") ||
|
|
current.hasAttribute("inert")
|
|
) {
|
|
return false;
|
|
}
|
|
const ariaHidden = current.getAttribute("aria-hidden");
|
|
if (
|
|
ariaHidden !== null &&
|
|
ariaHidden.trim().toLowerCase() !== "false"
|
|
) {
|
|
return false;
|
|
}
|
|
const hiddenClass =
|
|
current.getAttribute("class")?.toLowerCase() ?? "";
|
|
if (
|
|
/(?:^|\s|-)print-only(?:\s|-|$)|print-portfolio-label|visually-hidden|sr-only|screen-reader-only/u.test(
|
|
hiddenClass,
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
const style = getComputedStyle(current);
|
|
if (
|
|
style.display === "none" ||
|
|
style.visibility === "hidden" ||
|
|
style.opacity === "0"
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return (
|
|
element.querySelector(leafSelector) === null &&
|
|
element.getClientRects().length > 0
|
|
);
|
|
})
|
|
.map((element) =>
|
|
(element.textContent ?? "").replace(/\s+/gu, " ").trim(),
|
|
)
|
|
.filter((text) => text.length > 0),
|
|
visibleTextLeafSelector,
|
|
);
|
|
|
|
test("keeps every raw SSR reading-path segment visible without JavaScript", async ({
|
|
page,
|
|
distUrl,
|
|
request,
|
|
}) => {
|
|
const pageErrors: string[] = [];
|
|
const consoleErrors: string[] = [];
|
|
const failedRequests: string[] = [];
|
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") consoleErrors.push(message.text());
|
|
});
|
|
page.on("requestfailed", (request) => {
|
|
if (request.resourceType() !== "script") {
|
|
failedRequests.push(
|
|
`${request.url()}: ${request.failure()?.errorText ?? "failed"}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
const rawResponse = await request.get(`${distUrl}/`);
|
|
expect(rawResponse.status()).toBe(200);
|
|
const rawHtml = await rawResponse.text();
|
|
const content = await portfolioContentFromSnapshot(page, rawHtml);
|
|
const expectedVisibleText = textMultiset(
|
|
expectedVisibleTextSegments(content),
|
|
);
|
|
const rawVisibleText = textMultiset(
|
|
await rawVisibleTextSegments(page, rawHtml),
|
|
);
|
|
|
|
await page.goto(distUrl, { waitUntil: "load" });
|
|
const actualVisibleText = textMultiset(
|
|
await browserVisibleTextSegments(page),
|
|
);
|
|
|
|
expect(expectedVisibleText.length).toBeGreaterThan(80);
|
|
expect(rawVisibleText).toEqual(expectedVisibleText);
|
|
expect(actualVisibleText).toEqual(expectedVisibleText);
|
|
expect(normalizedText(JSON.stringify(actualVisibleText))).not.toContain(
|
|
"preview-semantic-v1",
|
|
);
|
|
|
|
await expect(page.locator("h1:visible")).toHaveCount(1);
|
|
await expect(page.locator("h1")).toHaveText("Luca Pîslaru");
|
|
await expect(page.locator(".hero-journey__role")).toHaveText(
|
|
"System Administrator & Full-Stack Developer",
|
|
);
|
|
await expect(page.locator("main")).toBeVisible();
|
|
await expect(
|
|
page.locator('nav[aria-label="Portfolio sections"]'),
|
|
).toBeVisible();
|
|
await expect(page.locator("footer")).toBeVisible();
|
|
expect(
|
|
await page
|
|
.locator("main > section")
|
|
.evaluateAll((sections) => sections.map((section) => section.id)),
|
|
).toEqual(sectionIds);
|
|
expect(await page.locator("main > section > h2").allTextContents()).toEqual(
|
|
sectionHeadings,
|
|
);
|
|
|
|
for (const evidence of [
|
|
"Professional systems operations",
|
|
"Secure self-hosted platform and lifecycle",
|
|
"Private self-hosted medication tracker",
|
|
"Footy Tracker",
|
|
"Linux",
|
|
"Docker",
|
|
"Python",
|
|
"Vue.js",
|
|
]) {
|
|
await expect(
|
|
page.getByText(evidence, { exact: true }).first(),
|
|
).toBeVisible();
|
|
}
|
|
|
|
const email = page.locator('a[href="mailto:luca.pislaru@gmail.com"]');
|
|
await expect(email).toHaveCount(2);
|
|
await expect(email).toHaveText(["Email Luca", "Email Luca"]);
|
|
await expect(
|
|
page.getByText(
|
|
"For a current CV or role-specific information, contact me directly.",
|
|
{ exact: true },
|
|
),
|
|
).toBeVisible();
|
|
|
|
for (const title of [
|
|
"Private self-hosted medication tracker",
|
|
"Private healthcare platform",
|
|
"Private game prototype",
|
|
]) {
|
|
await expect(
|
|
page.locator("article").filter({ hasText: title }).locator("a"),
|
|
).toHaveCount(0);
|
|
}
|
|
await expect(page.locator("a[download]")).toHaveCount(0);
|
|
expect(pageErrors).toEqual([]);
|
|
expect(consoleErrors).toEqual([]);
|
|
expect(failedRequests).toEqual([]);
|
|
});
|
|
|
|
test("content oracle detects a removed highlight and an inert reading path", async ({
|
|
page,
|
|
distUrl,
|
|
request,
|
|
}) => {
|
|
const rawResponse = await request.get(`${distUrl}/`);
|
|
expect(rawResponse.status()).toBe(200);
|
|
const rawHtml = await rawResponse.text();
|
|
const content = await portfolioContentFromSnapshot(page, rawHtml);
|
|
const expected = textMultiset(expectedVisibleTextSegments(content));
|
|
const firstHighlight = content.experience[0]?.highlights[0];
|
|
expect(firstHighlight).toBeTruthy();
|
|
|
|
const withoutHighlight = await page.evaluate(
|
|
({ html, highlight }) => {
|
|
const document = new DOMParser().parseFromString(html, "text/html");
|
|
const target = [...document.querySelectorAll("li")].find(
|
|
(element) =>
|
|
(element.textContent ?? "").replace(/\s+/gu, " ").trim() ===
|
|
highlight,
|
|
);
|
|
if (target === undefined) throw new Error("Expected highlight is absent");
|
|
target.remove();
|
|
return `<!doctype html>${document.documentElement.outerHTML}`;
|
|
},
|
|
{ html: rawHtml, highlight: firstHighlight ?? "" },
|
|
);
|
|
const withoutHighlightText = textMultiset(
|
|
await rawVisibleTextSegments(page, withoutHighlight),
|
|
);
|
|
expect(withoutHighlightText).not.toEqual(expected);
|
|
const missingHighlight = missingText(expected, withoutHighlightText);
|
|
expect(missingHighlight).toEqual([[normalizedText(firstHighlight ?? ""), 1]]);
|
|
expect(missingText(withoutHighlightText, expected)).toEqual([]);
|
|
|
|
const inertMain = await page.evaluate((html) => {
|
|
const document = new DOMParser().parseFromString(html, "text/html");
|
|
const main = document.querySelector("main");
|
|
if (main === null) throw new Error("Expected main is absent");
|
|
main.setAttribute("inert", "");
|
|
return `<!doctype html>${document.documentElement.outerHTML}`;
|
|
}, rawHtml);
|
|
const inertMainText = textMultiset(
|
|
await rawVisibleTextSegments(page, inertMain),
|
|
);
|
|
expect(inertMainText).not.toEqual(expected);
|
|
const missingWhenInert = missingText(expected, inertMainText);
|
|
expect(missingWhenInert.length).toBeGreaterThan(80);
|
|
expect(missingWhenInert).toContainEqual([
|
|
normalizedText(content.identity.valueProposition),
|
|
1,
|
|
]);
|
|
expect(missingText(inertMainText, expected)).toEqual([]);
|
|
});
|
|
|
|
test("activating the skip link focuses visible portfolio content without JavaScript", async ({
|
|
page,
|
|
distUrl,
|
|
}) => {
|
|
await page.goto(distUrl, { waitUntil: "load" });
|
|
await page.keyboard.press("Tab");
|
|
await expect(page.locator(".skip-link")).toBeFocused();
|
|
|
|
await page.keyboard.press("Enter");
|
|
|
|
await expect(page).toHaveURL(`${distUrl}/#portfolio-content`);
|
|
await expect(page.locator("#portfolio-content")).toBeFocused();
|
|
await expect(page.locator("#portfolio-content")).toHaveAttribute(
|
|
"tabindex",
|
|
"-1",
|
|
);
|
|
await expectInsetFocusIndicator(page, "#portfolio-content");
|
|
});
|
|
|
|
test("retains native section navigation when scripts are disabled", async ({
|
|
page,
|
|
distUrl,
|
|
}) => {
|
|
await page.goto(distUrl, { waitUntil: "load" });
|
|
await page.locator('nav a[href="#contact"]').click();
|
|
|
|
await expect(page).toHaveURL(`${distUrl}/#contact`);
|
|
await expect(page.locator("#contact")).toBeInViewport();
|
|
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
|
});
|