Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions tests/group-link-status.test.mjs
Original file line number Diff line number Diff line change
@@ -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/,
);
});
86 changes: 86 additions & 0 deletions tests/helpers-component-data.mjs
Original file line number Diff line number Diff line change
@@ -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/<file>.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<string, unknown>} fixtures data keyed by the exact `@site/...`
* specifier the source imports; every key must appear in the source.
* @returns {Promise<Record<string, unknown> & {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 }),
};
}
74 changes: 74 additions & 0 deletions tests/people-freshness.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading