Skip to content

Commit 6acf1f4

Browse files
Added tests
Signed-off-by: Kristin Brown <kristin.brown@solo.io>
1 parent d668e72 commit 6acf1f4

5 files changed

Lines changed: 327 additions & 1 deletion

File tree

fixture/content/en/test/v2/everything.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
title: Everything
33
weight: 100
44
description: Every shortcode the framework cares about, in one page, with sentinel strings tests can grep for.
5+
# Title-badge front-matter flags. All five are set on this single fixture
6+
# page so static.spec.ts can assert that every badge variant renders next
7+
# to the H1. Regression guard for solo-io/docs#2414. The v1/everything
8+
# page intentionally leaves these unset to cover the negative case.
9+
enterprise: true
10+
alpha: true
11+
beta: true
12+
oss: true
13+
experimental: true
514
---
615

716
The page exists to exercise every shortcode pattern in a single place. Tests assert the rendered HTML, so sentinels of the form `MARKER_*` and `COND_*` are placed where each pattern's correctness can be verified. Sections are ordered alphabetically by heading. The body is shared across versions via the `reuse` shortcode; per-version content is gated by the `version` shortcode against the page's URL section.

tests/auto-cards.spec.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,19 @@ function extractCards(
7272
const descMatch = inner.match(
7373
/<p[^>]*class="section-card-desc"[^>]*>([\s\S]*?)<\/p>/,
7474
);
75+
// Strip any badge spans from the title: list.html appends
76+
// `<span class="section-card-badge ...">LABEL</span>` next to the
77+
// title text when the target page sets enterprise/alpha/etc. flags.
78+
// The badge presence is asserted separately; here we want just the
79+
// human-readable title text.
80+
const rawTitle = titleMatch?.[1] ?? "";
81+
const titleWithoutBadges = rawTitle.replace(
82+
/<span[^>]*class="section-card-badge[^"]*"[^>]*>[\s\S]*?<\/span>/g,
83+
"",
84+
);
7585
out.push({
7686
href: match[1],
77-
title: (titleMatch?.[1] ?? "").replace(/\s+/g, " ").trim(),
87+
title: titleWithoutBadges.replace(/\s+/g, " ").trim(),
7888
description: (descMatch?.[1] ?? "").replace(/\s+/g, " ").trim(),
7989
});
8090
}

tests/browser.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,30 @@ test.describe("dark mode flips mermaid theme", () => {
175175
expect(darkFill, "mermaid text fill did not change with dark mode").not.toBe(
176176
lightFill,
177177
);
178+
179+
// PR 2412 regression guard: at least one <text> in the dark-mode SVG
180+
// must be light-colored. The fixed bug was that actor labels and
181+
// signal text both rendered near-black on a dark background, making
182+
// the whole diagram unreadable. Looking at the *max* luminance across
183+
// all texts (not the first one) is robust to legitimate dark-on-light
184+
// text inside light-coloured node boxes — what we're catching is the
185+
// case where *every* text is dark.
186+
const maxLuminance = await darkSvg.evaluate((node) => {
187+
const texts = node.querySelectorAll("text");
188+
let max = 0;
189+
for (const t of Array.from(texts)) {
190+
const fill = window.getComputedStyle(t).fill;
191+
const m = fill.match(/(\d+),\s*(\d+),\s*(\d+)/);
192+
if (!m) continue;
193+
const lum = 0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3];
194+
if (lum > max) max = lum;
195+
}
196+
return max;
197+
});
198+
expect(
199+
maxLuminance,
200+
"no light-coloured text in dark-mode mermaid SVG — every <text> is dark on a dark background",
201+
).toBeGreaterThan(150);
178202
});
179203
});
180204

tests/static.spec.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,217 @@ test.describe("conditional-text excludes content correctly", () => {
266266
});
267267
}
268268
});
269+
270+
// Regression guard for solo-io/docs#2389: the module's pager partial overrides
271+
// hextra's default to include section index pages (`_index.md`) as navigable
272+
// siblings. If anyone reverts to hextra's default, section landings silently
273+
// stop rendering prev/next links.
274+
test.describe("section-index pager (PR 2389 regression guard)", () => {
275+
const sectionRel = path.join(
276+
target.baseURL.replace(/^\/+|\/+$/g, "") || "",
277+
"v2",
278+
"index.html",
279+
);
280+
const sectionIndex = path.join(target.builtRoot, sectionRel);
281+
282+
test.skip(
283+
!fs.existsSync(sectionIndex),
284+
"section index /v2/ not present in builtRoot (fixture-only check)",
285+
);
286+
287+
test("v2 section landing renders a pager linking to a sibling section", () => {
288+
const html = readFixture(sectionIndex);
289+
// The pager wrapper has a unique Tailwind class signature emitted by
290+
// layouts/partials/components/pager.html. If the partial is dropped or
291+
// hextra's default takes over (which skips _index pages), this match
292+
// fails and the section landing silently loses navigation.
293+
const pagerRe =
294+
/<div class="hx:mb-8 hx:flex hx:items-center hx:border-t[^"]*"[^>]*>([\s\S]*?)<\/div>/;
295+
const pagerMatch = html.match(pagerRe);
296+
expect(pagerMatch, "no pager rendered on /v2/").not.toBeNull();
297+
const inner = pagerMatch![1];
298+
const hrefs = [...inner.matchAll(/<a[^>]+href="([^"]+)"/g)].map((m) => m[1]);
299+
expect(hrefs.length, "pager has no <a> children").toBeGreaterThan(0);
300+
// Every pager href on /v2/ should point at a sibling section landing
301+
// (v1, main, etc.) — never at a non-existent path. Strip baseURL and
302+
// expect it to match one of the configured sibling versions.
303+
const baseAbs = "/" + target.baseURL.replace(/^\/+|\/+$/g, "");
304+
const siblingVersions = target.versions.filter((v) => v !== "v2");
305+
for (const href of hrefs) {
306+
const rel = href.replace(baseAbs, "").replace(/\/$/, "").replace(/^\/+/, "");
307+
expect(
308+
siblingVersions,
309+
`pager href ${href} should point at a sibling section`,
310+
).toContain(rel);
311+
}
312+
});
313+
});
314+
315+
// Regression guard for solo-io/docs#2416: numbered-list counter integrity.
316+
// The bug was triggered by a `{{% version %}}` shortcode wrapping a sub-list
317+
// item, which broke CSS counter increments and produced gaps in step numbers.
318+
// We don't reproduce the exact pattern here, but the simpler check — that
319+
// the fixture's 3-level ordered list renders with the expected <ol>/<li>
320+
// structure — catches any regression that mangles list nesting.
321+
test.describe("ordered-list structure (PR 2416 regression guard)", () => {
322+
const everythingV2 = path.join(TEST_PRODUCT_ROOT, "v2", "everything", "index.html");
323+
test.skip(
324+
!fs.existsSync(everythingV2),
325+
"v2/everything not present in builtRoot (fixture-only check)",
326+
);
327+
328+
test("3-level ordered list preserves nesting: outer 2 items, each with nested <ol>", () => {
329+
const html = readFixture(everythingV2);
330+
// Locate the "Ordered (3 levels)" section heading, then capture the
331+
// first <ol> that follows it. This is the fixture's canonical
332+
// three-level list from everything.md ("Ordered (3 levels)" subsection).
333+
const headingIdx = html.indexOf('id="ordered-3-levels"');
334+
expect(headingIdx, "ordered-3-levels heading not found").toBeGreaterThan(-1);
335+
const tail = html.slice(headingIdx);
336+
const olStart = tail.indexOf("<ol");
337+
expect(olStart, "no <ol> follows the heading").toBeGreaterThan(-1);
338+
// Extract the balanced top <ol> ... </ol> (with one level of nesting).
339+
// Hugo emits this as well-formed HTML so a simple depth counter works.
340+
let depth = 0;
341+
let i = olStart;
342+
let endIdx = -1;
343+
while (i < tail.length) {
344+
if (tail.startsWith("<ol", i)) {
345+
depth++;
346+
i += 3;
347+
} else if (tail.startsWith("</ol>", i)) {
348+
depth--;
349+
if (depth === 0) {
350+
endIdx = i + 5;
351+
break;
352+
}
353+
i += 5;
354+
} else {
355+
i++;
356+
}
357+
}
358+
expect(endIdx, "unbalanced <ol> tags").toBeGreaterThan(-1);
359+
const outerOl = tail.slice(olStart, endIdx);
360+
// Count direct-child <li> elements at the outer level. A naive count
361+
// would include nested <li>; instead, count <li> *after* slicing out
362+
// any nested <ol>...</ol> blocks first.
363+
const outerWithoutNested = stripNested(outerOl, "<ol", "</ol>");
364+
const outerLiCount = (outerWithoutNested.match(/<li\b/g) ?? []).length;
365+
expect(
366+
outerLiCount,
367+
"top-level ordered list should have exactly 2 items",
368+
).toBe(2);
369+
// The list MUST contain at least one nested <ol> (the bug pattern is
370+
// that nesting collapses). Two <ol> total: outer + one nested.
371+
const nestedOlCount = (outerOl.match(/<ol\b/g) ?? []).length;
372+
expect(
373+
nestedOlCount,
374+
"list lost its nesting — expected outer + at least one nested <ol>",
375+
).toBeGreaterThan(1);
376+
});
377+
});
378+
379+
// Helper: remove every balanced `<openTag...</closeTag>` block from a string.
380+
// Used to count direct children at a given nesting depth.
381+
function stripNested(s: string, openTag: string, closeTag: string): string {
382+
let out = "";
383+
let i = 0;
384+
// Skip the very first opening tag (we want to keep it as the outer wrapper).
385+
const first = s.indexOf(openTag);
386+
if (first < 0) return s;
387+
out += s.slice(0, first + openTag.length);
388+
i = first + openTag.length;
389+
while (i < s.length) {
390+
const nextOpen = s.indexOf(openTag, i);
391+
const nextClose = s.indexOf(closeTag, i);
392+
if (nextOpen < 0 || nextClose < 0 || nextClose < nextOpen) {
393+
out += s.slice(i);
394+
break;
395+
}
396+
// Found a nested open before the next close — skip everything between
397+
// the nested open and its matching close.
398+
out += s.slice(i, nextOpen);
399+
let depth = 1;
400+
let j = nextOpen + openTag.length;
401+
while (j < s.length && depth > 0) {
402+
if (s.startsWith(openTag, j)) {
403+
depth++;
404+
j += openTag.length;
405+
} else if (s.startsWith(closeTag, j)) {
406+
depth--;
407+
j += closeTag.length;
408+
} else {
409+
j++;
410+
}
411+
}
412+
i = j;
413+
}
414+
return out;
415+
}
416+
417+
// Regression guard for solo-io/docs#2414: title badges. The fixture sets
418+
// every badge flag (enterprise/alpha/beta/oss/experimental) on v2/everything.md
419+
// so a single page exercises all five variants. v1/everything.md has none
420+
// of them, covering the negative case.
421+
test.describe("title badges (PR 2414 regression guard)", () => {
422+
const v2Everything = path.join(
423+
TEST_PRODUCT_ROOT,
424+
"v2",
425+
"everything",
426+
"index.html",
427+
);
428+
const v1Everything = path.join(
429+
TEST_PRODUCT_ROOT,
430+
"v1",
431+
"everything",
432+
"index.html",
433+
);
434+
435+
test.skip(
436+
!fs.existsSync(v2Everything),
437+
"v2/everything not present in builtRoot (fixture-only check)",
438+
);
439+
440+
test("v2/everything renders .page-badges with all five badge variants", () => {
441+
const html = readFixture(v2Everything);
442+
// Pull out the .page-badges block. The module's docs/single.html only
443+
// emits this wrapper when at least one flag is truthy.
444+
const badgesMatch = html.match(
445+
/<div class="page-badges">([\s\S]*?)<\/div>/,
446+
);
447+
expect(
448+
badgesMatch,
449+
".page-badges container missing — badge front-matter not honored",
450+
).not.toBeNull();
451+
const inner = badgesMatch![1];
452+
// Each badge is a <span class="section-card-badge ...">LABEL</span>.
453+
// Assert every label appears.
454+
const expectedLabels = ["Enterprise", "Alpha", "Beta", "Open Source", "Experimental"];
455+
for (const label of expectedLabels) {
456+
expect(
457+
inner,
458+
`badge "${label}" should render when its front-matter flag is true`,
459+
).toContain(label);
460+
}
461+
// Variant classes: the alpha/beta/experimental badges should carry
462+
// badge-tag, the oss badge carries badge-oss. Catches silent CSS-class
463+
// regressions that would render the right text in the wrong color.
464+
expect(inner, ".badge-tag should apply to alpha/beta/experimental").toMatch(
465+
/section-card-badge badge-tag/,
466+
);
467+
expect(inner, ".badge-oss should apply to the Open Source badge").toMatch(
468+
/section-card-badge badge-oss/,
469+
);
470+
});
471+
472+
test("v1/everything (no badge flags) does NOT render .page-badges", () => {
473+
if (!fs.existsSync(v1Everything)) {
474+
test.skip(true, "v1/everything not present in builtRoot");
475+
}
476+
const html = readFixture(v1Everything);
477+
expect(
478+
html,
479+
"page-badges container leaked onto a page with no badge front-matter",
480+
).not.toContain('class="page-badges"');
481+
});
482+
});

tests/viewport.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,75 @@ test.describe("viewport responsive layout", () => {
106106
expect(box, "version dropdown has no box").not.toBeNull();
107107
expect(box!.width, "version dropdown collapsed to zero width").toBeGreaterThan(0);
108108
});
109+
110+
// PR 2394 regression guard: on mobile the version dropdown elided
111+
// the product-name prefix so the button fits one line in the
112+
// narrow nav. On tablet and up, the product name is visible again.
113+
test("version dropdown product-name visibility matches breakpoint", async ({
114+
page,
115+
}) => {
116+
await page.goto(REPRESENTATIVE_PAGE!);
117+
const productName = page
118+
.locator(".version-dropdown-btn .version-product-name")
119+
.first();
120+
if (await productName.count() === 0) {
121+
test.skip(
122+
true,
123+
"no .version-product-name in this build (consumer didn't set product name)",
124+
);
125+
}
126+
if (vp.width < 768) {
127+
await expect(
128+
productName,
129+
"product-name should be hidden under 768px to keep the dropdown narrow",
130+
).toBeHidden();
131+
} else {
132+
await expect(
133+
productName,
134+
"product-name should be visible at >=768px",
135+
).toBeVisible();
136+
}
137+
});
138+
139+
// PR 2388 regression guard: on mobile, cards must stack (one per row)
140+
// and stay within the viewport width. The bug was a grid layout that
141+
// produced multi-column cards on narrow viewports, which overflowed.
142+
test("cards stack and stay within the viewport", async ({ page }) => {
143+
await page.goto(REPRESENTATIVE_PAGE!);
144+
const cards = page.locator(".hextra-cards .hextra-card");
145+
const count = await cards.count();
146+
if (count < 2) {
147+
test.skip(true, "fewer than 2 cards on the sample page");
148+
}
149+
const viewportWidth = vp.width;
150+
// Collect every card's bounding box, then assert none extends
151+
// past the viewport edge (overflow bug from PR 2388).
152+
const boxes = await cards.evaluateAll((els) =>
153+
els.map((el) => el.getBoundingClientRect()).map((r) => ({
154+
left: r.left,
155+
right: r.right,
156+
top: r.top,
157+
width: r.width,
158+
})),
159+
);
160+
for (const b of boxes) {
161+
expect(
162+
b.right,
163+
`card extends past viewport right edge (${b.right} > ${viewportWidth})`,
164+
).toBeLessThanOrEqual(viewportWidth + 1);
165+
}
166+
// Mobile-specific: cards should stack vertically. A heuristic that
167+
// works without coupling to CSS grid internals: no two cards share
168+
// the same `top` (within a small tolerance) on mobile.
169+
if (vp.width < 768 && boxes.length >= 2) {
170+
const tops = boxes.map((b) => Math.round(b.top));
171+
const uniqueTops = new Set(tops);
172+
expect(
173+
uniqueTops.size,
174+
"cards should stack vertically on mobile (each on its own row)",
175+
).toBe(boxes.length);
176+
}
177+
});
109178
});
110179
}
111180
});

0 commit comments

Comments
 (0)