From 2c9ca2a1192b2e71bb89b2b5d0757780fd4b8bec Mon Sep 17 00:00:00 2001 From: "kubestellar-hive[bot]" Date: Sat, 19 Sep 2026 03:31:44 -0400 Subject: [PATCH] test: cover the .github issue-form and PR-template contract No test reads .github/ISSUE_TEMPLATE/ or .github/PULL_REQUEST_TEMPLATE.md, so a malformed issue form is reported only to the contributor trying to file, and a blog-post author option that does not resolve in blog/authors.yml produces frontmatter Docusaurus cannot resolve. Adds tests/issue-template-contract.test.mjs asserting issue-form schema validity, unique element ids, non-empty dropdown options, the blog-post field set, author-option resolution against blog/authors.yml, and a non-empty pull request template. Closes #310 Signed-off-by: kubestellar-hive[bot] --- tests/issue-template-contract.test.mjs | 187 +++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/issue-template-contract.test.mjs diff --git a/tests/issue-template-contract.test.mjs b/tests/issue-template-contract.test.mjs new file mode 100644 index 00000000..c1637109 --- /dev/null +++ b/tests/issue-template-contract.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; + +const repoRoot = new URL('..', import.meta.url); + +function repoPath(relativePath) { + return fileURLToPath(new URL(relativePath, repoRoot)); +} + +function readYaml(relativePath) { + return parse(readFileSync(repoPath(relativePath), 'utf8')); +} + +const TEMPLATE_DIR = '.github/ISSUE_TEMPLATE/'; + +const templateFiles = readdirSync(repoPath(TEMPLATE_DIR)) + .filter((name) => /\.ya?ml$/.test(name)) + .filter((name) => name !== 'config.yml') + .sort(); + +const templates = templateFiles.map((name) => ({ + name, + path: `${TEMPLATE_DIR}${name}`, + form: readYaml(`${TEMPLATE_DIR}${name}`), +})); + +// GitHub rejects an issue form outright when these are absent or when a body +// element uses an unknown type, and the rejection only surfaces to a +// contributor trying to open the issue. +const FORM_ELEMENT_TYPES = new Set([ + 'markdown', + 'input', + 'textarea', + 'dropdown', + 'checkboxes', +]); + +test('the repository ships at least one issue form', () => { + assert.ok(templates.length > 0, `expected issue forms under ${TEMPLATE_DIR}`); +}); + +test('every issue form declares name, description and a body', () => { + for (const { path, form } of templates) { + assert.equal(typeof form?.name, 'string', `${path}: missing name`); + assert.notEqual(form.name.trim(), '', `${path}: empty name`); + assert.equal( + typeof form.description, + 'string', + `${path}: missing description`, + ); + assert.notEqual(form.description.trim(), '', `${path}: empty description`); + assert.ok(Array.isArray(form.body), `${path}: body must be a list`); + assert.ok(form.body.length > 0, `${path}: body must not be empty`); + } +}); + +test('every issue form labels field is a list of non-empty strings', () => { + for (const { path, form } of templates) { + if (form.labels === undefined) continue; + assert.ok(Array.isArray(form.labels), `${path}: labels must be a list`); + for (const label of form.labels) { + assert.equal(typeof label, 'string', `${path}: non-string label`); + assert.notEqual(label.trim(), '', `${path}: empty label`); + } + } +}); + +test('every form element uses a supported type', () => { + for (const { path, form } of templates) { + for (const [index, element] of form.body.entries()) { + assert.ok( + FORM_ELEMENT_TYPES.has(element?.type), + `${path}: body[${index}] has unsupported type ${JSON.stringify(element?.type)}`, + ); + } + } +}); + +test('every non-markdown form element has a unique id and a label', () => { + for (const { path, form } of templates) { + const seen = new Set(); + for (const [index, element] of form.body.entries()) { + if (element.type === 'markdown') { + assert.equal( + typeof element.attributes?.value, + 'string', + `${path}: body[${index}] markdown element needs attributes.value`, + ); + continue; + } + assert.equal( + typeof element.id, + 'string', + `${path}: body[${index}] (${element.type}) needs an id`, + ); + assert.ok( + !seen.has(element.id), + `${path}: duplicate element id "${element.id}"`, + ); + seen.add(element.id); + assert.equal( + typeof element.attributes?.label, + 'string', + `${path}: element "${element.id}" needs attributes.label`, + ); + assert.notEqual( + element.attributes.label.trim(), + '', + `${path}: element "${element.id}" has an empty label`, + ); + } + } +}); + +test('every dropdown offers a non-empty list of unique options', () => { + for (const { path, form } of templates) { + for (const element of form.body) { + if (element.type !== 'dropdown') continue; + const options = element.attributes?.options; + assert.ok( + Array.isArray(options) && options.length > 0, + `${path}: dropdown "${element.id}" needs attributes.options`, + ); + assert.equal( + new Set(options).size, + options.length, + `${path}: dropdown "${element.id}" repeats an option`, + ); + } + } +}); + +test('the blog-post form exists and collects title, content and author', () => { + const blogPost = templates.find(({ name }) => name === 'blog-post.yml'); + assert.ok(blogPost, `${TEMPLATE_DIR}blog-post.yml is missing`); + const ids = blogPost.form.body + .filter((element) => element.type !== 'markdown') + .map((element) => element.id); + for (const required of ['title', 'content', 'author']) { + assert.ok( + ids.includes(required), + `${blogPost.path}: expected a "${required}" field, got ${ids.join(', ')}`, + ); + } +}); + +// A dropdown option that does not resolve in blog/authors.yml becomes a post +// frontmatter author key Docusaurus cannot resolve, which fails `npm run +// build`. This asserts only the option -> authors.yml direction; the field +// contract of blog/authors.yml entries themselves is not asserted here. +test('every blog-post author option resolves in blog/authors.yml', () => { + const blogPost = templates.find(({ name }) => name === 'blog-post.yml'); + assert.ok(blogPost, `${TEMPLATE_DIR}blog-post.yml is missing`); + const authorField = blogPost.form.body.find( + (element) => element.id === 'author', + ); + assert.equal( + authorField?.type, + 'dropdown', + `${blogPost.path}: author field must be a dropdown`, + ); + + const authorKeys = new Set(Object.keys(readYaml('blog/authors.yml'))); + const unknown = authorField.attributes.options.filter( + (option) => !authorKeys.has(option), + ); + assert.deepEqual( + unknown, + [], + `${blogPost.path}: author options absent from blog/authors.yml: ${unknown.join(', ')}`, + ); +}); + +test('the pull request template is present and non-empty', () => { + const body = readFileSync( + repoPath('.github/PULL_REQUEST_TEMPLATE.md'), + 'utf8', + ); + assert.notEqual( + body.trim(), + '', + '.github/PULL_REQUEST_TEMPLATE.md must not be empty', + ); +});