diff --git a/tests/group-link-status.test.mjs b/tests/group-link-status.test.mjs new file mode 100644 index 00000000..e1d49b1f --- /dev/null +++ b/tests/group-link-status.test.mjs @@ -0,0 +1,132 @@ +// GroupLinkStatus renders the freshness + drift note above the End User Group +// cards on docs/community/index.md. Nothing imported the module before this +// file, so none of its branches had unit coverage: the checkedAt line, the +// drift warning that names archived or unreachable upstream repositories, and +// the singular/plural wording that warning switches on. +// +// The live data file currently reports every group as healthy, so the drift +// branch is unreachable from the real module; it is driven through a fixture +// copy instead. The live-data assertions are invariants that hold for any +// shape of data/community-groups.json rather than snapshots of today's groups. + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { importWithData } from './helpers-component-data.mjs'; +import { importSource } from './helpers-jsx.mjs'; +import { textOf } from './tools/react-element-tree.mjs'; + +const DATA_SPECIFIER = '@site/data/community-groups.json'; + +const { default: GroupLinkStatus } = await importSource( + 'src/components/GroupLinkStatus/index.js', +); +const { default: groupsData } = await importSource( + 'data/community-groups.json', +); + +const group = (overrides = {}) => ({ + slug: 'research', + name: 'Research User Group', + repository: 'https://github.com/cncf/research-user-group', + archived: false, + reachable: true, + ...overrides, +}); + +async function renderWith(data) { + const module = await importWithData( + 'src/components/GroupLinkStatus/index.js', + { [DATA_SPECIFIER]: data }, + ); + try { + return module.default(); + } finally { + module.cleanup(); + } +} + +test('the live data file renders the checkedAt verification line', () => { + const expected = new Date(groupsData.checkedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const text = textOf(GroupLinkStatus()); + assert.match(text, /Upstream group links last verified on/); + assert.ok( + text.includes(expected), + `expected the rendered text to include ${expected}`, + ); +}); + +test('healthy live data raises no drift warning', () => { + const drifted = groupsData.groups.filter( + (entry) => entry.archived || entry.reachable === false, + ); + const text = textOf(GroupLinkStatus()); + if (drifted.length === 0) { + assert.doesNotMatch(text, /archived or unreachable/); + } else { + assert.match(text, /archived or unreachable/); + } +}); + +test('an unparseable checkedAt drops the verification line rather than rendering "Invalid Date"', async () => { + const text = textOf( + await renderWith({ checkedAt: 'not a timestamp', groups: [group()] }), + ); + assert.doesNotMatch(text, /last verified/); + assert.doesNotMatch(text, /Invalid Date/); +}); + +test('missing groups data renders without throwing', async () => { + const tree = await renderWith({ checkedAt: '2026-03-04T05:06:07.000Z' }); + assert.match(textOf(tree), /March 4, 2026/); + assert.doesNotMatch(textOf(tree), /archived or unreachable/); +}); + +test('an archived upstream repository is named in the warning', async () => { + const text = textOf( + await renderWith({ + checkedAt: '2026-03-04T05:06:07.000Z', + groups: [ + group(), + group({ slug: 'telecom', name: 'Telecom User Group', archived: true }), + ], + }), + ); + assert.match(text, /Telecom User Group has an archived or unreachable/); + assert.doesNotMatch(text, /Research User Group/); + assert.match(text, /upstream repository/); +}); + +test('an unreachable upstream repository is named in the warning', async () => { + const text = textOf( + await renderWith({ + checkedAt: '2026-03-04T05:06:07.000Z', + groups: [group({ reachable: false })], + }), + ); + assert.match(text, /Research User Group has an archived or unreachable/); +}); + +test('two drifted groups are listed together with plural wording', async () => { + const text = textOf( + await renderWith({ + checkedAt: '2026-03-04T05:06:07.000Z', + groups: [ + group({ archived: true }), + group({ + slug: 'telecom', + name: 'Telecom User Group', + reachable: false, + }), + ], + }), + ); + assert.match( + text, + /Research User Group, Telecom User Group have archived or unreachable upstream repositories/, + ); +}); diff --git a/tests/helpers-component-data.mjs b/tests/helpers-component-data.mjs new file mode 100644 index 00000000..d120930c --- /dev/null +++ b/tests/helpers-component-data.mjs @@ -0,0 +1,86 @@ +// Helper for unit tests that need to render a component whose input is a +// checked-in data file rather than props. +// +// Components such as src/components/PeopleFreshness/index.js read +// `@site/data/.json` at module scope and take no props, so the branches +// the live data does not happen to reach (a malformed timestamp, an archived +// upstream repo) are unreachable from a test that imports the real module. +// +// This helper copies the component source into a temp directory, rewrites its +// `@site/...` JSON imports to point at fixture files written alongside the +// copy, and imports the copy through tests/helpers-jsx.mjs so the JSX and CSS +// Modules hooks still apply. The component source itself stays unchanged. +// +// Usage: +// +// const { default: Component, cleanup } = await importWithData( +// 'src/components/PeopleFreshness/index.js', +// { '@site/data/community-people.json': { fetchedAt: 'nonsense' } }, +// ); + +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { importSource } from './helpers-jsx.mjs'; + +const REPO_ROOT = new URL('../', import.meta.url); + +/** + * Imports a copy of a component with its `@site/...` data imports replaced by + * fixtures. + * + * @param {string} repoRelativePath e.g. 'src/components/Foo/index.js' + * @param {Record} fixtures data keyed by the exact `@site/...` + * specifier the source imports; every key must appear in the source. + * @returns {Promise & {cleanup: () => void}>} the + * module namespace of the copy, plus a `cleanup` that removes the temp copy. + */ +export async function importWithData(repoRelativePath, fixtures) { + const source = readFileSync( + fileURLToPath(new URL(repoRelativePath, REPO_ROOT)), + 'utf8', + ); + + const work = mkdtempSync(join(tmpdir(), 'endusers-component-')); + let rewritten = source; + for (const [specifier, data] of Object.entries(fixtures)) { + if (!rewritten.includes(specifier)) { + rmSync(work, { recursive: true, force: true }); + throw new Error( + `${repoRelativePath} does not import ${specifier}; the fixture would be ignored`, + ); + } + const fixtureName = basename(specifier); + writeFileSync(join(work, fixtureName), JSON.stringify(data)); + rewritten = rewritten.split(specifier).join(`./${fixtureName}`); + } + + // Each import gets its own directory so Node's module cache does not hand + // back a copy built from a previous fixture. + const copy = join(work, 'index.js'); + mkdirSync(work, { recursive: true }); + writeFileSync(copy, rewritten); + // The copy still imports `react`, which Node resolves from the directory the + // module lives in; link the repository install so the temp copy sees it. + symlinkSync( + fileURLToPath(new URL('node_modules', REPO_ROOT)), + join(work, 'node_modules'), + ); + + // importSource resolves its argument against the repository root; an + // absolute path resolves to itself, which is what a temp copy needs. + const namespace = await importSource(copy); + return { + ...namespace, + cleanup: () => rmSync(work, { recursive: true, force: true }), + }; +} diff --git a/tests/people-freshness.test.mjs b/tests/people-freshness.test.mjs new file mode 100644 index 00000000..c4e95d45 --- /dev/null +++ b/tests/people-freshness.test.mjs @@ -0,0 +1,74 @@ +// PeopleFreshness renders the "last refreshed" line under the TAB/staff +// profile grids on docs/community/*. Nothing imported the module before this +// file, so its two behaviours — formatting data/community-people.json's +// fetchedAt, and rendering nothing at all when that timestamp is unusable — +// had no unit coverage and a regression in either would ship silently. +// +// The component takes no props and reads its data at module scope, so the +// live-data assertions below are invariants that hold for any shape of +// data/community-people.json; the branches the live file does not reach are +// driven through a fixture copy of the module. + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { importWithData } from './helpers-component-data.mjs'; +import { importSource } from './helpers-jsx.mjs'; +import { textOf } from './tools/react-element-tree.mjs'; + +const DATA_SPECIFIER = '@site/data/community-people.json'; + +const { default: PeopleFreshness } = await importSource( + 'src/components/PeopleFreshness/index.js', +); +const { default: peopleData } = await importSource( + 'data/community-people.json', +); + +async function renderWith(fetchedAt) { + const module = await importWithData( + 'src/components/PeopleFreshness/index.js', + { [DATA_SPECIFIER]: { fetchedAt } }, + ); + try { + return module.default(); + } finally { + module.cleanup(); + } +} + +test('the live data file renders a single freshness paragraph', () => { + const tree = PeopleFreshness(); + assert.notEqual( + tree, + null, + 'data/community-people.json has an unusable fetchedAt', + ); + assert.equal(tree.type, 'p'); +}); + +test('the rendered date is the fetchedAt of the live data file', () => { + const expected = new Date(peopleData.fetchedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + assert.match(textOf(PeopleFreshness()), /last refreshed from public GitHub/); + assert.ok( + textOf(PeopleFreshness()).includes(expected), + `expected the rendered text to include ${expected}`, + ); +}); + +test('a known timestamp is rendered as a long-form US date', async () => { + const tree = await renderWith('2026-03-04T05:06:07.000Z'); + assert.match(textOf(tree), /March 4, 2026/); +}); + +test('an unparseable fetchedAt renders nothing rather than "Invalid Date"', async () => { + assert.equal(await renderWith('not a timestamp'), null); +}); + +test('a missing fetchedAt renders nothing', async () => { + assert.equal(await renderWith(undefined), null); +});