diff --git a/tests/static-assets.test.mjs b/tests/static-assets.test.mjs index 58663e14..ac12988d 100644 --- a/tests/static-assets.test.mjs +++ b/tests/static-assets.test.mjs @@ -1,17 +1,20 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative, resolve } from 'node:path'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + collectCardLogos, + collectReferences, + findBrokenCardLogos, + findCaseMismatches, + findEmptyAssets, + findEscapingReferences, + findMissingAssets, +} from './tools/static-references.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); const staticRoot = join(repoRoot, 'static'); -// Top-level names under static/ are the only absolute paths that resolve to a -// file on disk. Everything else beginning with "/" is a page route, which -// Docusaurus already enforces via onBrokenLinks: 'throw'. -const staticTopLevel = new Set(readdirSync(staticRoot)); - // data/architectures/ assets are validated by scripts/validate-architecture-assets.mjs, // and docusaurus.config.js branding assets are asserted in tests/site-config.test.mjs. const SOURCES = [ @@ -20,76 +23,21 @@ const SOURCES = [ { dir: 'src', extensions: ['.js', '.jsx', '.css'] }, ]; const DATA_FILES = [ - 'awards.json', - 'community-people.json', - 'community-roster.json', - 'members.json', - 'metrics.json', - 'milestones.json', - 'projects-born.json', + 'data/awards.json', + 'data/community-people.json', + 'data/community-roster.json', + 'data/members.json', + 'data/metrics.json', + 'data/milestones.json', + 'data/projects-born.json', ]; -function walk(dir, extensions, found = []) { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) walk(full, extensions, found); - else if (extensions.some((ext) => entry.name.endsWith(ext))) - found.push(full); - } - return found; -} - -function sourceFiles() { - const files = []; - for (const { dir, extensions } of SOURCES) { - files.push(...walk(join(repoRoot, dir), extensions)); - } - for (const name of DATA_FILES) files.push(join(repoRoot, 'data', name)); - return files; -} - -// Absolute and protocol-relative URLs contain path segments that look exactly -// like local references (".../main/images/foo.jpg"). Remove them before -// scanning so a remote URL is never mistaken for a missing local asset. -function stripUrls(text) { - return text - .replace(/[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"'`)\]<>]*/g, ' ') - .replace(/(^|[\s"'`(=])\/\/[^\s"'`)\]<>]*/g, ' '); -} - -const PATH_TOKEN = /\/[A-Za-z0-9._%-]+(?:\/[A-Za-z0-9._%~-]+)*/g; - -function staticReferences(file) { - const text = stripUrls(readFileSync(file, 'utf8')); - const refs = new Map(); - for (const match of text.match(PATH_TOKEN) || []) { - const ref = match.replace(/[.]+$/, ''); - const [, first] = ref.split('/'); - if (!staticTopLevel.has(first)) continue; - // A bare "/img" with no file after it is not an asset reference. - if (ref === `/${first}` && statSync(join(staticRoot, first)).isDirectory()) - continue; - if (!refs.has(ref)) refs.set(ref, relative(repoRoot, file)); - } - return refs; -} - -function allStaticReferences() { - const all = new Map(); - for (const file of sourceFiles()) { - for (const [ref, origin] of staticReferences(file)) { - if (!all.has(ref)) all.set(ref, []); - all.get(ref).push(origin); - } - } - return all; -} - -const REFERENCES = allStaticReferences(); - -function describe(ref) { - return `${ref} (referenced by ${REFERENCES.get(ref).join(', ')})`; -} +const REFERENCES = collectReferences({ + repoRoot, + staticRoot, + sources: SOURCES, + dataFiles: DATA_FILES, +}); test('the scan finds references to check', () => { assert.ok( @@ -100,16 +48,7 @@ test('the scan finds references to check', () => { }); test('every referenced static asset exists on disk', () => { - const missing = []; - for (const ref of REFERENCES.keys()) { - const target = join(staticRoot, decodeURIComponent(ref)); - try { - if (!statSync(target).isFile()) - missing.push(`${describe(ref)} is not a file`); - } catch { - missing.push(describe(ref)); - } - } + const missing = findMissingAssets(REFERENCES, staticRoot); assert.deepEqual( missing, [], @@ -118,46 +57,12 @@ test('every referenced static asset exists on disk', () => { }); test('referenced static assets are non-empty', () => { - const empty = []; - for (const ref of REFERENCES.keys()) { - const target = join(staticRoot, decodeURIComponent(ref)); - let stats; - try { - stats = statSync(target); - } catch { - continue; // absence is reported by the test above - } - if (stats.isFile() && stats.size === 0) empty.push(describe(ref)); - } + const empty = findEmptyAssets(REFERENCES, staticRoot); assert.deepEqual(empty, [], `zero-byte static assets:\n${empty.join('\n')}`); }); test('referenced static assets match the on-disk name exactly', () => { - // GitHub Pages serves from a case-sensitive filesystem. A reference whose - // case differs from the file on disk resolves locally on macOS and 404s in - // production, so statSync() alone is not enough. - const mismatched = []; - for (const ref of REFERENCES.keys()) { - const segments = decodeURIComponent(ref).split('/').filter(Boolean); - let dir = staticRoot; - for (const segment of segments) { - let entries; - try { - entries = readdirSync(dir); - } catch { - break; - } - if (!entries.includes(segment)) { - const insensitive = entries.find( - (entry) => entry.toLowerCase() === segment.toLowerCase(), - ); - if (insensitive) - mismatched.push(`${describe(ref)} — on disk as "${insensitive}"`); - break; - } - dir = join(dir, segment); - } - } + const mismatched = findCaseMismatches(REFERENCES, staticRoot); assert.deepEqual( mismatched, [], @@ -166,12 +71,7 @@ test('referenced static assets match the on-disk name exactly', () => { }); test('no static reference escapes static/ via path traversal', () => { - const escaping = []; - for (const ref of REFERENCES.keys()) { - const target = resolve(staticRoot, `.${decodeURIComponent(ref)}`); - if (target !== staticRoot && !target.startsWith(`${staticRoot}/`)) - escaping.push(describe(ref)); - } + const escaping = findEscapingReferences(REFERENCES, staticRoot); assert.deepEqual( escaping, [], @@ -180,27 +80,8 @@ test('no static reference escapes static/ via path traversal', () => { }); test('every local CNCFProjectCard logo prop resolves under static/', () => { - // is authored by hand in the - // architecture docs. The component renders the value straight into , - // so a typo produces a broken image with no build-time error. - const cardLogos = new Map(); - for (const file of walk(join(repoRoot, 'docs'), ['.md', '.mdx'])) { - const text = readFileSync(file, 'utf8'); - for (const [, value] of text.matchAll(/\blogo=["']([^"']+)["']/g)) { - if (!value.startsWith('/')) continue; - if (!cardLogos.has(value)) cardLogos.set(value, relative(repoRoot, file)); - } - } - const broken = []; - for (const [value, origin] of cardLogos) { - const target = join(staticRoot, decodeURIComponent(value)); - try { - if (!statSync(target).isFile()) - broken.push(`${value} (${origin}) is not a file`); - } catch { - broken.push(`${value} (${origin})`); - } - } + const cardLogos = collectCardLogos(join(repoRoot, 'docs'), repoRoot); + const broken = findBrokenCardLogos(cardLogos, staticRoot); assert.deepEqual( broken, [], diff --git a/tests/static-references.test.mjs b/tests/static-references.test.mjs new file mode 100644 index 00000000..b72f885c --- /dev/null +++ b/tests/static-references.test.mjs @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { + collectCardLogos, + collectReferences, + describeRef, + findBrokenCardLogos, + findCaseMismatches, + findEmptyAssets, + findEscapingReferences, + findMissingAssets, + stripUrls, + walk, +} from './tools/static-references.mjs'; + +const SOURCES = [{ dir: 'docs', extensions: ['.md', '.mdx'] }]; +const DATA_FILES = ['data/members.json']; + +// Mirrors the layout tests/static-assets.test.mjs scans: docs/ and a data file +// holding the references, static/ holding the assets they point at. `files` +// maps repo-relative paths to contents; a directory is created for every +// entry, and an empty string produces the zero-byte file the emptiness check +// looks for. +function withFixture(files, run) { + const root = mkdtempSync(join(tmpdir(), 'endusers-static-refs-')); + try { + mkdirSync(join(root, 'docs'), { recursive: true }); + mkdirSync(join(root, 'static'), { recursive: true }); + mkdirSync(join(root, 'data'), { recursive: true }); + writeFileSync(join(root, 'data', 'members.json'), '{}'); + for (const [relativePath, content] of Object.entries(files)) { + const target = join(root, relativePath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content); + } + const staticRoot = join(root, 'static'); + const references = collectReferences({ + repoRoot: root, + staticRoot, + sources: SOURCES, + dataFiles: DATA_FILES, + }); + return run({ root, staticRoot, references }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test('stripUrls removes absolute and protocol-relative URLs', () => { + assert.equal( + stripUrls('see https://example.com/img/logo.png now').includes('/img/'), + false, + ); + assert.equal( + stripUrls('src="//cdn.example.com/img/logo.png"').includes('/img/'), + false, + ); + assert.equal(stripUrls('![](/img/logo.png)'), '![](/img/logo.png)'); +}); + +test('walk finds matching files recursively and skips other extensions', () => { + withFixture( + { + 'docs/a.md': '', + 'docs/nested/b.mdx': '', + 'docs/nested/c.txt': '', + }, + ({ root }) => { + const found = walk(join(root, 'docs'), ['.md', '.mdx']).map((file) => + file.slice(root.length + 1), + ); + assert.deepEqual(found.sort(), ['docs/a.md', 'docs/nested/b.mdx']); + }, + ); +}); + +test('the scan records every referencing file and ignores non-static paths', () => { + withFixture( + { + 'static/img/logo.png': 'x', + 'docs/a.md': '![](/img/logo.png) and [route](/community/members)', + 'docs/b.md': '![](/img/logo.png)', + 'data/members.json': '{"logo":"/img/logo.png"}', + }, + ({ root, references }) => { + assert.deepEqual([...references.keys()], ['/img/logo.png']); + assert.deepEqual(references.get('/img/logo.png'), [ + 'docs/a.md', + 'docs/b.md', + 'data/members.json', + ]); + assert.equal( + describeRef(references, '/img/logo.png'), + '/img/logo.png (referenced by docs/a.md, docs/b.md, data/members.json)', + ); + assert.ok(root); + }, + ); +}); + +test('a bare top-level directory reference is not treated as an asset', () => { + withFixture( + { 'static/img/logo.png': 'x', 'docs/a.md': 'files live under /img' }, + ({ references }) => { + assert.deepEqual([...references.keys()], []); + }, + ); +}); + +test('a remote URL is never mistaken for a local asset reference', () => { + withFixture( + { + 'static/img/logo.png': 'x', + 'docs/a.md': '![](https://cdn.example.com/img/absent.png)', + }, + ({ references }) => { + assert.deepEqual([...references.keys()], []); + }, + ); +}); + +test('a reference to an absent file is reported as missing', () => { + withFixture( + { 'static/img/logo.png': 'x', 'docs/a.md': '![](/img/absent.png)' }, + ({ staticRoot, references }) => { + assert.deepEqual(findMissingAssets(references, staticRoot), [ + '/img/absent.png (referenced by docs/a.md)', + ]); + }, + ); +}); + +test('a reference resolving to a directory is reported as not a file', () => { + withFixture( + { + 'static/img/logo.png': 'x', + 'static/img/icons/inner.png': 'x', + 'docs/a.md': '![](/img/icons)', + }, + ({ staticRoot, references }) => { + assert.deepEqual(findMissingAssets(references, staticRoot), [ + '/img/icons (referenced by docs/a.md) is not a file', + ]); + }, + ); +}); + +test('a resolvable reference is not reported as missing', () => { + withFixture( + { 'static/img/logo.png': 'x', 'docs/a.md': '![](/img/logo.png)' }, + ({ staticRoot, references }) => { + assert.deepEqual(findMissingAssets(references, staticRoot), []); + }, + ); +}); + +test('a zero-byte asset is reported, and an absent one is left to the missing check', () => { + withFixture( + { + 'static/img/empty.png': '', + 'static/img/logo.png': 'x', + 'docs/a.md': + '![](/img/empty.png) ![](/img/absent.png) ![](/img/logo.png)', + }, + ({ staticRoot, references }) => { + assert.deepEqual(findEmptyAssets(references, staticRoot), [ + '/img/empty.png (referenced by docs/a.md)', + ]); + }, + ); +}); + +test('a reference whose case differs from the file on disk is reported', () => { + withFixture( + { 'static/img/Logo.png': 'x', 'docs/a.md': '![](/img/logo.png)' }, + ({ staticRoot, references }) => { + assert.deepEqual(findCaseMismatches(references, staticRoot), [ + '/img/logo.png (referenced by docs/a.md) — on disk as "Logo.png"', + ]); + }, + ); +}); + +test('a case mismatch in an intermediate directory is reported', () => { + withFixture( + { + 'static/img/Icons/logo.png': 'x', + 'docs/a.md': '![](/img/icons/logo.png)', + }, + ({ staticRoot, references }) => { + assert.deepEqual(findCaseMismatches(references, staticRoot), [ + '/img/icons/logo.png (referenced by docs/a.md) — on disk as "Icons"', + ]); + }, + ); +}); + +test('an exactly-named reference raises no case mismatch', () => { + withFixture( + { 'static/img/logo.png': 'x', 'docs/a.md': '![](/img/logo.png)' }, + ({ staticRoot, references }) => { + assert.deepEqual(findCaseMismatches(references, staticRoot), []); + }, + ); +}); + +test('a reference with no counterpart at any case stops without reporting', () => { + withFixture( + { 'static/img/logo.png': 'x', 'docs/a.md': '![](/img/absent.png)' }, + ({ staticRoot, references }) => { + assert.deepEqual(findCaseMismatches(references, staticRoot), []); + }, + ); +}); + +test('a reference descending through a file rather than a directory stops', () => { + withFixture( + { 'static/img/logo.png': 'x', 'docs/a.md': '![](/img/logo.png/inner.png)' }, + ({ staticRoot, references }) => { + assert.deepEqual(findCaseMismatches(references, staticRoot), []); + }, + ); +}); + +test('a reference escaping static/ via traversal is reported', () => { + withFixture( + { + 'static/img/logo.png': 'x', + 'docs/a.md': '![](/img/../../secrets.env) ![](/img/logo.png)', + }, + ({ staticRoot, references }) => { + assert.deepEqual(findEscapingReferences(references, staticRoot), [ + '/img/../../secrets.env (referenced by docs/a.md)', + ]); + }, + ); +}); + +test('CNCFProjectCard logo props are collected from docs and deduplicated', () => { + withFixture( + { + 'static/img/logo.png': 'x', + 'docs/a.md': + '\n' + + '\n' + + '', + 'docs/b.mdx': "", + }, + ({ root }) => { + const logos = collectCardLogos(join(root, 'docs'), root); + assert.deepEqual( + [...logos], + [ + ['/img/logo.png', 'docs/a.md'], + ['/img/other.png', 'docs/b.mdx'], + ], + ); + }, + ); +}); + +test('an unresolvable CNCFProjectCard logo prop is reported', () => { + withFixture( + { + 'static/img/logo.png': 'x', + 'static/img/icons/inner.png': 'x', + 'docs/a.md': + '\n' + + '\n' + + '', + }, + ({ root, staticRoot }) => { + const logos = collectCardLogos(join(root, 'docs'), root); + assert.deepEqual(findBrokenCardLogos(logos, staticRoot), [ + '/img/absent.png (docs/a.md)', + '/img/icons (docs/a.md) is not a file', + ]); + }, + ); +}); diff --git a/tests/tools/static-references.mjs b/tests/tools/static-references.mjs new file mode 100644 index 00000000..e0ee7c9b --- /dev/null +++ b/tests/tools/static-references.mjs @@ -0,0 +1,182 @@ +// The static-asset contract in tests/static-assets.test.mjs is only as good as +// its ability to spot a broken reference. Keeping the scan and the checkers +// here, parameterised by root, lets tests/static-references.test.mjs point them +// at a fixture tree that really does contain a missing, zero-byte, +// case-mismatched, escaping or unresolvable reference and confirm each one is +// reported. +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +export function walk(dir, extensions, found = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full, extensions, found); + else if (extensions.some((ext) => entry.name.endsWith(ext))) + found.push(full); + } + return found; +} + +// Absolute and protocol-relative URLs contain path segments that look exactly +// like local references (".../main/images/foo.jpg"). Remove them before +// scanning so a remote URL is never mistaken for a missing local asset. +export function stripUrls(text) { + return text + .replace(/[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"'`)\]<>]*/g, ' ') + .replace(/(^|[\s"'`(=])\/\/[^\s"'`)\]<>]*/g, ' '); +} + +const PATH_TOKEN = /\/[A-Za-z0-9._%-]+(?:\/[A-Za-z0-9._%~-]+)*/g; + +function sourceFiles({ repoRoot, sources, dataFiles }) { + const files = []; + for (const { dir, extensions } of sources) { + files.push(...walk(join(repoRoot, dir), extensions)); + } + for (const name of dataFiles) files.push(join(repoRoot, name)); + return files; +} + +// Top-level names under static/ are the only absolute paths that resolve to a +// file on disk. Everything else beginning with "/" is a page route, which +// Docusaurus already enforces via onBrokenLinks: 'throw'. +function staticReferences(file, { repoRoot, staticRoot, staticTopLevel }) { + const text = stripUrls(readFileSync(file, 'utf8')); + const refs = new Map(); + for (const match of text.match(PATH_TOKEN) || []) { + const ref = match.replace(/[.]+$/, ''); + const [, first] = ref.split('/'); + if (!staticTopLevel.has(first)) continue; + // A bare "/img" with no file after it is not an asset reference. + if (ref === `/${first}` && statSync(join(staticRoot, first)).isDirectory()) + continue; + if (!refs.has(ref)) refs.set(ref, relative(repoRoot, file)); + } + return refs; +} + +export function collectReferences({ + repoRoot, + staticRoot, + sources, + dataFiles, +}) { + const staticTopLevel = new Set(readdirSync(staticRoot)); + const all = new Map(); + for (const file of sourceFiles({ repoRoot, sources, dataFiles })) { + const found = staticReferences(file, { + repoRoot, + staticRoot, + staticTopLevel, + }); + for (const [ref, origin] of found) { + if (!all.has(ref)) all.set(ref, []); + all.get(ref).push(origin); + } + } + return all; +} + +export function describeRef(references, ref) { + return `${ref} (referenced by ${references.get(ref).join(', ')})`; +} + +export function findMissingAssets(references, staticRoot) { + const missing = []; + for (const ref of references.keys()) { + const target = join(staticRoot, decodeURIComponent(ref)); + try { + if (!statSync(target).isFile()) + missing.push(`${describeRef(references, ref)} is not a file`); + } catch { + missing.push(describeRef(references, ref)); + } + } + return missing; +} + +export function findEmptyAssets(references, staticRoot) { + const empty = []; + for (const ref of references.keys()) { + const target = join(staticRoot, decodeURIComponent(ref)); + let stats; + try { + stats = statSync(target); + } catch { + continue; // absence is reported by findMissingAssets() + } + if (stats.isFile() && stats.size === 0) + empty.push(describeRef(references, ref)); + } + return empty; +} + +// GitHub Pages serves from a case-sensitive filesystem. A reference whose case +// differs from the file on disk resolves locally on macOS and 404s in +// production, so statSync() alone is not enough. +export function findCaseMismatches(references, staticRoot) { + const mismatched = []; + for (const ref of references.keys()) { + const segments = decodeURIComponent(ref).split('/').filter(Boolean); + let dir = staticRoot; + for (const segment of segments) { + let entries; + try { + entries = readdirSync(dir); + } catch { + break; + } + if (!entries.includes(segment)) { + const insensitive = entries.find( + (entry) => entry.toLowerCase() === segment.toLowerCase(), + ); + if (insensitive) + mismatched.push( + `${describeRef(references, ref)} — on disk as "${insensitive}"`, + ); + break; + } + dir = join(dir, segment); + } + } + return mismatched; +} + +export function findEscapingReferences(references, staticRoot) { + const escaping = []; + for (const ref of references.keys()) { + const target = resolve(staticRoot, `.${decodeURIComponent(ref)}`); + if (target !== staticRoot && !target.startsWith(`${staticRoot}/`)) + escaping.push(describeRef(references, ref)); + } + return escaping; +} + +// is authored by hand in the +// architecture docs. The component renders the value straight into , +// so a typo produces a broken image with no build-time error. +export function collectCardLogos(docsDir, repoRoot) { + const cardLogos = new Map(); + for (const file of walk(docsDir, ['.md', '.mdx'])) { + const text = readFileSync(file, 'utf8'); + for (const [, value] of text.matchAll(/\blogo=["']([^"']+)["']/g)) { + if (!value.startsWith('/')) continue; + if (!cardLogos.has(value)) cardLogos.set(value, relative(repoRoot, file)); + } + } + return cardLogos; +} + +export function findBrokenCardLogos(cardLogos, staticRoot) { + const broken = []; + for (const [value, origin] of cardLogos) { + const target = join(staticRoot, decodeURIComponent(value)); + try { + if (!statSync(target).isFile()) + broken.push(`${value} (${origin}) is not a file`); + } catch { + broken.push(`${value} (${origin})`); + } + } + return broken; +}