extroonie.com/scripts/test.mjs
2026-09-18 15:30:20 -07:00

193 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import { mkdtempSync, cpSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { checkSite } from './check.mjs';
import { siteBaseURL } from './config.mjs';
const temp = mkdtempSync(join(tmpdir(), 'extroonie-test-'));
try {
cpSync('content', join(temp, 'content'), { recursive: true });
cpSync('tests/fixtures/math.md', join(temp, 'content/writing/math-test.md'));
cpSync('tests/fixtures/post.md', join(temp, 'content/writing/post-test.md'));
for (const [name, headings] of Object.entries({
'one-section': '# An in-body title\n\n## One section\n\n#### A minor heading',
'two-sections': '## First section\n\n### Second section',
'abstract-only': '',
'abstract-and-section': '## One section',
})) {
writeFileSync(
join(temp, `content/writing/${name}.md`),
`---\ntitle: ${name}\ndate: 2026-01-01\nformat: post\n${name.startsWith('abstract-') ? 'abstract: A short abstract.\n' : ''}---\n${headings}\n`,
);
}
const hugo = process.env.HUGO_BIN || 'hugo';
const build = (...args) =>
spawnSync(
hugo,
[
'--contentDir',
join(temp, 'content'),
'--destination',
join(temp, 'out'),
'--baseURL',
siteBaseURL().href,
...args,
],
{ encoding: 'utf8' },
);
const result = build();
assert.equal(result.status, 0, result.stdout + result.stderr);
checkSite(join(temp, 'out'));
const page = readFileSync(join(temp, 'out/writing/math-test/index.html'), 'utf8');
assert(page.includes('katex.min.css'), 'Math stylesheet missing');
assert(page.includes('katex-mathml'), 'Accessible build-time math missing');
assert(page.includes('eq-entropy'), 'Equation anchor missing');
assert(page.includes('href="#eq-entropy"'), 'Equation reference missing');
assert(!page.includes('>Published'), 'Publication label should not appear');
assert(!page.includes('>Updated'), 'Update label should not appear');
assert(page.includes('January 1, 2026'), 'Published date missing');
assert(page.includes('“quoted”'), 'Smart quotes missing');
assert(
page.includes('writer’s') || page.includes('writer’s') || page.includes('writer's'),
'Smart apostrophe missing',
);
assert(
page.includes('<code>&quot;code&quot;</code>') ||
page.includes('<code>&#34;code&#34;</code>') ||
page.includes('<code>"code"</code>'),
'Code quotes changed: ' + page.match(/<code>[\s\S]*?<\/code>/g),
);
assert(!page.includes('katex.min.js'), 'Math should require no client JavaScript');
const home = readFileSync(join(temp, 'out/index.html'), 'utf8');
assert(home.includes('class="biography"'), 'Expandable biography missing');
assert(!/<details[^>]*\bopen\b/.test(home), 'Biography must start collapsed');
assert(home.includes('<a href="/">Ishmaam Khan</a>'), 'Homepage name must reset the URL');
new HTMLRewriter()
.on('nav nav', {
element() {
assert.fail('Contents must not nest navigation landmarks');
},
})
.transform(home);
assert(home.includes('/writing/posts/rss.xml'), 'Posts RSS missing');
assert(home.includes('/writing/essays/rss.xml'), 'Essays RSS missing');
const essays = readFileSync(join(temp, 'out/writing/essays/rss.xml'), 'utf8');
assert(essays.includes('/writing/math-test/'), 'Essay filtering failed');
assert(!essays.includes('/writing/nodejs-workspaces/'), 'Post leaked into essay feed');
assert(page.includes('/js/contents.'), 'Sectioned essay should load navigation');
assert(!home.includes('/js/contents.'), 'Homepage should not load article navigation');
assert(page.includes('aria-controls="article-navigation"'), 'Contents control missing');
assert(page.includes('aria-label="Close contents"'), 'Accessible sidebar close button missing');
assert(page.includes('class="contents-reopen"'), 'Sidebar reopening control missing');
assert(!home.includes('contents-reopen'), 'Homepage should not have sidebar controls');
assert(page.includes('class="subtitle"'), 'Essay subtitle missing');
assert(page.includes('href="#abstract">Abstract</a>'), 'Abstract missing from Contents');
for (const [name, expected] of [
['one-section', false],
['two-sections', true],
['abstract-only', false],
['abstract-and-section', true],
]) {
const article = readFileSync(join(temp, `out/writing/${name}/index.html`), 'utf8');
for (const marker of ['has-contents', 'class="article-contents"', 'class="contents-reopen"', '/js/contents.'])
assert.equal(
article.includes(marker),
expected,
`${name}: inconsistent Contents eligibility for ${marker}`,
);
assert(!/<details[^>]*\bopen\b/.test(article), 'Inline Contents must start collapsed');
if (name === 'abstract-and-section') assert(article.includes('href="#abstract">Abstract</a>'));
}
const postFixture = readFileSync(join(temp, 'out/writing/post-test/index.html'), 'utf8');
assert(!postFixture.includes('/js/contents.'), 'Unsectioned article loads unused navigation');
assert(postFixture.includes('An italic subtitle for a post'), 'Post subtitle missing');
assert(postFixture.includes('katex-mathml'), 'Post math missing');
for (const article of [page, postFixture]) {
assert(article.includes('class="footnote-ref"'), 'Footnote reference missing');
assert(article.includes('class="footnote-backref"'), 'Footnote return link missing');
assert(article.includes('/js/footnotes.'), 'Footnote previews missing');
}
assert(postFixture.includes('A second paragraph'), 'Multi-paragraph footnote missing');
assert(postFixture.match(/class="footnote-ref"/g).length === 2, 'Repeated footnote references missing');
assert(!home.includes('/js/footnotes.'), 'Homepage loads unused footnote script');
const post = readFileSync(join(temp, 'out/writing/nodejs-workspaces/index.html'), 'utf8');
assert(!post.includes('katex.min.css'), 'Non-math post loads math assets');
const rss = readFileSync(join(temp, 'out/writing/rss.xml'), 'utf8');
assert(rss.includes('A “quoted” title'), 'RSS typography inconsistent');
for (const section of ['writing', 'writing/posts', 'writing/essays']) {
const description = readFileSync(`content/${section}/_index.md`, 'utf8').match(/^description: "(.*)"$/m)[1];
assert(
readFileSync(join(temp, `out/${section}/rss.xml`), 'utf8').includes(
`<description>${description}</description>`,
),
`${section}: RSS description ignored`,
);
}
assert(page.includes('<pre><code class="language-js">'), 'Fenced code should use plain semantic markup');
assert(!page.includes('class="chroma"'), 'Unused syntax-token markup should not be generated');
for (const kind of ['posts', 'essays']) {
const archive = readFileSync(join(temp, `out/writing/${kind}/index.html`), 'utf8');
assert(archive.includes(`url=/#${kind}`), `Populated ${kind} archive should target its homepage section`);
}
for (const [shipped, upstream] of [
['static/licenses/katex.txt', 'node_modules/katex/LICENSE'],
['static/licenses/newsreader.txt', 'node_modules/@fontsource-variable/newsreader/LICENSE'],
])
assert.equal(readFileSync(shipped, 'utf8'), readFileSync(upstream, 'utf8'), `Outdated license: ${shipped}`);
const checks = join(temp, 'checks');
mkdirSync(checks);
const checkFile = join(checks, 'index.html');
writeFileSync(checkFile, '<h2 id="café">Heading</h2><a href="#caf%C3%A9">Link</a>');
checkSite(checks);
for (const [html, expected] of [
['<a href="#missing">Broken fragment</a>', /missing fragment/],
['<img src="missing.png">', /broken asset or link/],
['<h2 id="duplicate"></h2><h2 id="duplicate"></h2>', /duplicate id/],
['<button aria-controls="missing">Contents</button>', /missing accessible control target/],
]) {
writeFileSync(checkFile, html);
assert.throws(() => checkSite(checks), expected);
}
const originalURL = process.env.SITE_BASE_URL;
try {
process.env.SITE_BASE_URL = 'https://alternate.example/';
assert.equal(siteBaseURL().href, process.env.SITE_BASE_URL);
writeFileSync(checkFile, '<a href="https://alternate.example/missing">Broken absolute link</a>');
assert.throws(() => checkSite(checks), /broken asset or link/);
for (const url of [
'https://example.test/subpath/',
'file:///tmp/',
'https://example.test/?q=1',
'https://example.test/#hash',
'https://user:pass@example.test/',
]) {
process.env.SITE_BASE_URL = url;
assert.throws(siteBaseURL, /must be an HTTP\(S\) origin/);
}
} finally {
if (originalURL === undefined) delete process.env.SITE_BASE_URL;
else process.env.SITE_BASE_URL = originalURL;
}
const invalidFile = join(temp, 'content/writing/invalid.md');
for (const format of ['', 'format: typo\n']) {
writeFileSync(invalidFile, `---\ntitle: Invalid\ndate: 2026-01-01\n${format}---\nText.\n`);
const invalid = build();
assert.notEqual(invalid.status, 0, 'Invalid writing format must fail the build');
assert((invalid.stdout + invalid.stderr).includes('format must be post or essay'));
}
rmSync(invalidFile);
writeFileSync(
join(temp, 'content/writing/draft.md'),
'---\ntitle: Draft\ndate: 2026-01-01\nformat: post\ndraft: true\n---\nDraft text.\n',
);
assert.equal(build().status, 0);
assert(!existsSync(join(temp, 'out/writing/draft/index.html')), 'Draft leaked into the production build');
assert.equal(build('--buildDrafts').status, 0);
assert(existsSync(join(temp, 'out/writing/draft/index.html')), 'Draft preview omitted draft content');
console.log(
'Passed: math, equations, smart quotes, literal code, dates, subtitles, biography, filtered RSS, and conditional assets.',
);
} finally {
rmSync(temp, { recursive: true, force: true });
}