diff --git a/build/tasks/verify/verify-unit.mts b/build/tasks/verify/verify-unit.mts new file mode 100644 index 000000000..79352fab0 --- /dev/null +++ b/build/tasks/verify/verify-unit.mts @@ -0,0 +1,28 @@ +/** + * @file Verify the build task helpers behave as their callers assume. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-unit + */ + +import { exec, glob } from '@openinf/portal/build/utils'; + +const testFiles = await glob(['**/*.test.mts', '!_site/', '!node_modules/']); + +// `node --test` handed a pattern that matches nothing exits 0, so a task that +// only forwarded the pattern would report success having run no tests. The +// count is the guard against that -- and against `glob` itself finding +// nothing, which is among the failures these very tests exist to catch. +if (testFiles.length === 0) { + console.error('No test files matched `**/*.test.mts`.'); + process.exitCode = 1; +} else { + let exitCode = 0; + const scripts = [`node --test ${testFiles.join(' ')}`]; + + for (const element of scripts) { + exitCode = await exec(element); + + if (exitCode !== 0) process.exitCode = exitCode; + } +} diff --git a/build/utils.test.mts b/build/utils.test.mts new file mode 100644 index 000000000..d3ad601d2 --- /dev/null +++ b/build/utils.test.mts @@ -0,0 +1,115 @@ +/** + * @file Tests for the common build task utilities. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/utils.test + */ + +import { deepStrictEqual, ok } from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join as pathJoin } from 'node:path'; +import { after, before, describe, test } from 'node:test'; +import { glob } from '@openinf/portal/build/utils'; + +// Every pattern a build task writes is relative to the directory the task +// runs in, so the fixture has to become that directory. +const cwd = process.cwd(); + +/** Files laid out to cover what the tasks actually ask of `glob`. */ +const FIXTURE = [ + 'a.md', + '.hidden.md', // a dot file beside ordinary ones + 'sub/b.md', + 'sub/.hidden-too.md', // a dot file under a directory named outright + 'sub/nested/c.md', + '.dotdir/d.md', // a dot directory to descend through + '.dotdir/deep/e.md', + 'skipped/f.md', + 'skipped/.g.md', // a dot file inside an excluded directory + '.git/h.md', // git's own directory, never any task's business + 'a.txt', // a different extension, to prove patterns discriminate +]; + +const sorted = (paths: string[]) => [...paths].sort(); + +describe('glob', () => { + before(async () => { + const root = await mkdtemp(pathJoin(tmpdir(), 'openinf-glob-')); + + for (const path of FIXTURE) { + const full = pathJoin(root, path); + + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, ''); + } + + process.chdir(root); + }); + + after(() => { + process.chdir(cwd); + }); + + test('returns paths relative to the working directory', async () => { + deepStrictEqual(await glob('a.md'), ['a.md']); + }); + + test('takes a lone pattern as well as a list', async () => { + deepStrictEqual(await glob(['a.md']), await glob('a.md')); + }); + + test('discriminates by extension', async () => { + deepStrictEqual(await glob('*.txt'), ['a.txt']); + }); + + test('excludes what a `!` pattern names', async () => { + const files = await glob(['**/*.md', '!skipped/']); + + ok(!files.some((file) => file.startsWith('skipped/'))); + ok(files.includes('sub/b.md')); + }); + + test('a trailing slash covers a whole subtree, not one entry', async () => { + // `sub/` on its own matches the directory and nothing in it, which is + // never what naming a directory is meant to mean. Dot files included: + // the pattern this expands to has no basename for the dot alternative to + // attach to, so they went missing until it was given one of its own. + deepStrictEqual(sorted(await glob('sub/')), [ + 'sub/.hidden-too.md', + 'sub/b.md', + 'sub/nested/c.md', + ]); + }); + + test('never returns a directory', async () => { + // Callers paste the result into shell commands, where a directory + // argument makes the tool recurse and quietly undo the exclusions. + const files = await glob(['**/*', '!.git/']); + + ok(!files.includes('sub')); + ok(!files.includes('.dotdir')); + ok(files.includes('sub/b.md')); + }); + + test('matches a dot file that a bare wildcard would skip', async () => { + ok((await glob('**/*.md')).includes('.hidden.md')); + }); + + test('descends into a dot directory', async () => { + const files = await glob('**/*.md'); + + ok(files.includes('.dotdir/d.md')); + ok(files.includes('.dotdir/deep/e.md')); + }); + + test('leaves .git alone without being asked', async () => { + ok(!(await glob('**/*.md')).some((file) => file.startsWith('.git/'))); + }); + + test('prunes dot files inside an excluded directory', async () => { + // The exclusion is written without regard for dot entries, so pruning + // has to cover them or matching dot names would reopen what it closed. + ok(!(await glob(['**/*.md', '!skipped/'])).includes('skipped/.g.md')); + }); +}); diff --git a/package-scripts.yml b/package-scripts.yml index fc0d4b500..4b855f3da 100644 --- a/package-scripts.yml +++ b/package-scripts.yml @@ -19,6 +19,7 @@ scripts: svg: node build/tasks/verify/verify-svg.mts toml: node build/tasks/verify/verify-toml.mts ts: node build/tasks/verify/verify-ts.mts + unit: node build/tasks/verify/verify-unit.mts validForEC: node build/tasks/verify/verify-valid-for-ec.mts yaml: node build/tasks/verify/verify-yaml.mts format: