CI / Run tests (pull_request) Has been cancelled
All reading list links point at external http(s) URLs, so each rendered anchor now carries rel="noopener noreferrer" as a hardening best practice. Renderer test updated for the new attribute and asserts every rendered link carries it.
329 lines
12 KiB
JavaScript
329 lines
12 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
import { READING_LIST } from "../data/reading-list.js";
|
|
import {
|
|
escapeHtml,
|
|
groupByCategory,
|
|
isValidEntry,
|
|
isValidUrl,
|
|
renderReadingList,
|
|
} from "../js/reading-list.js";
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const readingHtml = readFileSync(join(root, "reading.html"), "utf8");
|
|
const indexHtml = readFileSync(join(root, "index.html"), "utf8");
|
|
const contactHtml = readFileSync(join(root, "contact.html"), "utf8");
|
|
const rendererSource = readFileSync(join(root, "js/reading-list.js"), "utf8");
|
|
|
|
const ANCHOR_RE = /<a class="reading-link" href="([^"]*)"[^>]*>([^<]*)<\/a>/g;
|
|
const ENTRY_LI_RE = /<li class="reading-entry">/g;
|
|
const SECTION_RE = /<section class="reading-category"/g;
|
|
|
|
/** Build a deterministic fixture with `count` entries across `categories`. */
|
|
function makeFixture(count, categories) {
|
|
const entries = [];
|
|
for (let i = 1; i <= count; i++) {
|
|
entries.push({
|
|
category: categories[i % categories.length],
|
|
title: `Entry ${String(i).padStart(3, "0")}`,
|
|
url: `https://example.com/articles/${i}`,
|
|
...(i % 3 === 0 ? { note: `Note ${i}` } : {}),
|
|
});
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component tests: the reading list page is wired up and reachable
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("reading page renders a reading list container and loads the renderer module", () => {
|
|
assert.match(readingHtml, /<div[^>]*id="reading-list"/);
|
|
assert.match(
|
|
readingHtml,
|
|
/<script[^>]*type="module"[^>]*src="js\/reading-list\.js"/
|
|
);
|
|
});
|
|
|
|
test("home and contact pages link to the reading list page from the nav", () => {
|
|
assert.match(indexHtml, /<a href="reading\.html">Reading<\/a>/);
|
|
assert.match(contactHtml, /<a href="reading\.html">Reading<\/a>/);
|
|
});
|
|
|
|
test("reading page nav marks the reading link as the current page", () => {
|
|
assert.match(
|
|
readingHtml,
|
|
/<a href="reading\.html"[^>]*aria-current="page"[^>]*>Reading<\/a>/
|
|
);
|
|
});
|
|
|
|
test("renderer module loads the reading list data file", () => {
|
|
assert.match(
|
|
rendererSource,
|
|
/import\s*\{\s*READING_LIST\s*\}\s*from\s*"\.\.\/data\/reading-list\.js"/
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Data file tests: every entry is renderable, note stays optional
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("every entry in the data file has a title, a category, and a valid http(s) url", () => {
|
|
assert.ok(Array.isArray(READING_LIST), "data file exports an array");
|
|
assert.ok(READING_LIST.length > 0, "data file has at least one entry");
|
|
for (const entry of READING_LIST) {
|
|
assert.ok(
|
|
typeof entry.title === "string" && entry.title.trim().length > 0,
|
|
`entry needs a title: ${JSON.stringify(entry)}`
|
|
);
|
|
assert.ok(
|
|
typeof entry.category === "string" && entry.category.trim().length > 0,
|
|
`entry needs a category: ${JSON.stringify(entry)}`
|
|
);
|
|
assert.ok(
|
|
isValidUrl(entry.url),
|
|
`entry url must be an absolute http(s) url: ${entry.url}`
|
|
);
|
|
assert.ok(
|
|
entry.note === undefined ||
|
|
(typeof entry.note === "string" && entry.note.length > 0),
|
|
`note must be a non-empty string or absent: ${JSON.stringify(entry)}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test("seed data includes an entry without a note so the optional path is exercised", () => {
|
|
assert.ok(
|
|
READING_LIST.some((entry) => !entry.note),
|
|
"expected at least one entry with no note"
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Renderer tests: grouping, links, notes, and robustness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("renders links grouped by category from the data file", () => {
|
|
const html = renderReadingList(READING_LIST);
|
|
const categories = [...new Set(READING_LIST.map((entry) => entry.category))];
|
|
|
|
assert.equal((html.match(SECTION_RE) || []).length, categories.length);
|
|
for (const category of categories) {
|
|
assert.ok(
|
|
html.includes(`>${escapeHtml(category)}</h2>`),
|
|
`expected a heading for category ${category}`
|
|
);
|
|
}
|
|
for (const entry of READING_LIST) {
|
|
assert.ok(
|
|
html.includes(escapeHtml(entry.title)),
|
|
`expected title ${entry.title}`
|
|
);
|
|
assert.ok(html.includes(entry.url), `expected url ${entry.url}`);
|
|
}
|
|
});
|
|
|
|
test("each rendered link has a valid href and shows its title, with the note shown only when present", () => {
|
|
const fixture = [
|
|
{
|
|
category: "Testing",
|
|
title: "With a note",
|
|
url: "https://example.com/with-note",
|
|
note: "One line of context.",
|
|
},
|
|
{
|
|
category: "Testing",
|
|
title: "Without a note",
|
|
url: "https://example.com/without-note",
|
|
},
|
|
];
|
|
const html = renderReadingList(fixture);
|
|
|
|
const anchors = [...html.matchAll(ANCHOR_RE)];
|
|
assert.equal(anchors.length, 2);
|
|
for (const [, href, title] of anchors) {
|
|
assert.ok(isValidUrl(href), `href must be a valid http(s) url: ${href}`);
|
|
assert.ok(
|
|
fixture.some((entry) => entry.title === title && entry.url === href),
|
|
`rendered link ${title} -> ${href} must match a data entry`
|
|
);
|
|
}
|
|
|
|
// All reading links point at external sites, so each carries the
|
|
// rel="noopener noreferrer" hardening attribute.
|
|
assert.equal(
|
|
(html.match(/rel="noopener noreferrer"/g) || []).length,
|
|
anchors.length,
|
|
"every rendered link carries rel=\"noopener noreferrer\""
|
|
);
|
|
|
|
assert.ok(html.includes("One line of context."), "note is rendered when present");
|
|
assert.equal(
|
|
(html.match(/<p class="reading-note">/g) || []).length,
|
|
1,
|
|
"no note paragraph for the entry without a note"
|
|
);
|
|
});
|
|
|
|
test("renders correctly against a 500+ entry fixture", () => {
|
|
const fixture = makeFixture(500, ["A", "B", "C", "D", "E"]);
|
|
|
|
const start = performance.now();
|
|
const html = renderReadingList(fixture);
|
|
const elapsed = performance.now() - start;
|
|
|
|
assert.ok(
|
|
elapsed < 5000,
|
|
`rendering 500 entries should stay fast, took ${elapsed.toFixed(1)}ms`
|
|
);
|
|
|
|
// Every entry rendered exactly once.
|
|
assert.equal((html.match(ENTRY_LI_RE) || []).length, fixture.length);
|
|
assert.equal((html.match(ANCHOR_RE) || []).length, fixture.length);
|
|
assert.ok(html.includes("https://example.com/articles/1"));
|
|
assert.ok(html.includes("https://example.com/articles/500"));
|
|
|
|
// Notes rendered only for entries that have them.
|
|
assert.equal(
|
|
(html.match(/<p class="reading-note">/g) || []).length,
|
|
fixture.filter((entry) => entry.note).length
|
|
);
|
|
|
|
// Grouped by category: one section per category, containing exactly that
|
|
// category's entries, in the first-seen category order.
|
|
const expectedOrder = [...new Set(fixture.map((entry) => entry.category))];
|
|
const expectedCounts = new Map();
|
|
for (const entry of fixture) {
|
|
expectedCounts.set(entry.category, (expectedCounts.get(entry.category) || 0) + 1);
|
|
}
|
|
|
|
const sections = html.split(/<section class="reading-category"[^>]*>/).slice(1);
|
|
assert.equal(sections.length, expectedOrder.length);
|
|
sections.forEach((section, index) => {
|
|
const category = expectedOrder[index];
|
|
const heading = section.match(/<h2[^>]*>([^<]*)<\/h2>/);
|
|
assert.ok(heading, `section ${index} should have a heading`);
|
|
assert.equal(heading[1], category);
|
|
assert.equal(
|
|
(section.match(ENTRY_LI_RE) || []).length,
|
|
expectedCounts.get(category),
|
|
`section for ${category} should contain exactly its own entries`
|
|
);
|
|
});
|
|
});
|
|
|
|
test("escapes HTML in titles, notes, and categories", () => {
|
|
const nasty = [
|
|
{
|
|
category: 'Design <img src=x onerror=alert(1)>',
|
|
title: '<script>alert("x")</script>',
|
|
url: "https://example.com/?a=1&b=2",
|
|
note: 'note "quoted" & <b>bold</b>',
|
|
},
|
|
];
|
|
const html = renderReadingList(nasty);
|
|
|
|
assert.ok(!html.includes("<script>"), "raw script tag must not appear");
|
|
assert.ok(html.includes("<script>"), "title is escaped");
|
|
assert.ok(!html.includes("<img"), "raw img tag must not appear");
|
|
assert.ok(html.includes("<img"), "category is escaped");
|
|
assert.ok(html.includes('"quoted" & <b>bold</b>'));
|
|
});
|
|
|
|
test("skips invalid entries and shows an empty-state message when nothing renders", () => {
|
|
const mixed = [
|
|
{ category: "Good", title: "Fine", url: "https://example.com/fine" },
|
|
{ category: "Good", title: "Bad protocol", url: "javascript:alert(1)" },
|
|
{ category: "Good", title: "Empty url", url: "" },
|
|
{ category: "Good", title: "", url: "https://example.com/empty-title" },
|
|
{ category: "Good", title: "Ftp url", url: "ftp://example.com/x" },
|
|
null,
|
|
];
|
|
const html = renderReadingList(mixed);
|
|
assert.ok(html.includes("Fine"));
|
|
assert.ok(!html.includes("Bad protocol"));
|
|
assert.ok(!html.includes("Empty url"));
|
|
assert.ok(!html.includes("Ftp url"));
|
|
assert.ok(!html.includes("javascript:"));
|
|
assert.equal((html.match(ENTRY_LI_RE) || []).length, 1);
|
|
|
|
assert.equal(
|
|
renderReadingList([]),
|
|
'<p class="reading-empty">The reading list is empty.</p>'
|
|
);
|
|
assert.equal(
|
|
renderReadingList([{ title: "No url" }]),
|
|
'<p class="reading-empty">The reading list is empty.</p>'
|
|
);
|
|
});
|
|
|
|
test("a new link can be added by editing only the data file", () => {
|
|
const original = renderReadingList(READING_LIST);
|
|
assert.ok(!original.includes("https://example.com/brand-new"));
|
|
|
|
// Simulate an author adding a link to the data file — nothing else changes.
|
|
const extended = [
|
|
...READING_LIST,
|
|
{
|
|
category: "Engineering",
|
|
title: "A brand new link",
|
|
url: "https://example.com/brand-new",
|
|
note: "added later, no code changes",
|
|
},
|
|
];
|
|
const html = renderReadingList(extended);
|
|
assert.ok(html.includes("https://example.com/brand-new"));
|
|
assert.ok(html.includes("A brand new link"));
|
|
assert.ok(html.includes("added later, no code changes"));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unit tests: helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("escapeHtml escapes the five HTML-sensitive characters", () => {
|
|
assert.equal(escapeHtml(`&<>"'`), "&<>"'");
|
|
assert.equal(escapeHtml("plain text"), "plain text");
|
|
});
|
|
|
|
test("isValidUrl accepts http(s) and rejects everything else", () => {
|
|
assert.ok(isValidUrl("https://example.com"));
|
|
assert.ok(isValidUrl("http://example.com/path?q=1"));
|
|
assert.ok(!isValidUrl("javascript:alert(1)"));
|
|
assert.ok(!isValidUrl("ftp://example.com"));
|
|
assert.ok(!isValidUrl("/relative/path"));
|
|
assert.ok(!isValidUrl(""));
|
|
});
|
|
|
|
test("isValidEntry requires a non-empty title and a valid url", () => {
|
|
assert.ok(isValidEntry({ title: "T", url: "https://example.com" }));
|
|
assert.ok(!isValidEntry(null));
|
|
assert.ok(!isValidEntry({}));
|
|
assert.ok(!isValidEntry({ title: "T" }));
|
|
assert.ok(!isValidEntry({ url: "https://example.com" }));
|
|
assert.ok(!isValidEntry({ title: "", url: "https://example.com" }));
|
|
assert.ok(!isValidEntry({ title: "T", url: "javascript:alert(1)" }));
|
|
});
|
|
|
|
test("groupByCategory preserves first-seen order and falls back to Uncategorized", () => {
|
|
const groups = groupByCategory([
|
|
{ title: "A", url: "https://example.com/a", category: "Zebra" },
|
|
{ title: "B", url: "https://example.com/b", category: "Alpha" },
|
|
{ title: "C", url: "https://example.com/c" },
|
|
{ title: "D", url: "https://example.com/d", category: "Zebra" },
|
|
{ title: "Broken", url: "javascript:alert(1)", category: "Zebra" },
|
|
]);
|
|
assert.deepEqual([...groups.keys()], ["Zebra", "Alpha", "Uncategorized"]);
|
|
assert.deepEqual(
|
|
groups.get("Zebra").map((entry) => entry.title),
|
|
["A", "D"]
|
|
);
|
|
assert.deepEqual(
|
|
groups.get("Uncategorized").map((entry) => entry.title),
|
|
["C"]
|
|
);
|
|
});
|