384 lines
16 KiB
JavaScript
384 lines
16 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { cp, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import test from 'node:test';
|
|
import { auditContent } from '../scripts/audit-content.mjs';
|
|
import { loadManifest } from '../scripts/lib/migration.mjs';
|
|
|
|
async function withAuditFixture(run) {
|
|
const root = await mkdtemp(join(tmpdir(), 'incognito-audit-'));
|
|
const docsRoot = join(root, 'src/content/docs');
|
|
try {
|
|
await mkdir(join(root, 'src/content'), { recursive: true });
|
|
await cp('src/content/docs', docsRoot, { recursive: true });
|
|
await mkdir(join(root, 'docs'), { recursive: true });
|
|
await cp('docs/migration-report.md', join(root, 'docs/migration-report.md'));
|
|
await mkdir(join(root, 'public'), { recursive: true });
|
|
const manifest = structuredClone(await loadManifest('docs/migration-manifest.json'));
|
|
await run({ root, docsRoot, manifest });
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function expectAuditFailure(mutate, pattern) {
|
|
await withAuditFixture(async ({ root, docsRoot, manifest }) => {
|
|
await mutate({ root, docsRoot, manifest });
|
|
await assert.rejects(
|
|
auditContent({ docsRoot, manifest }),
|
|
pattern,
|
|
);
|
|
});
|
|
}
|
|
|
|
test('the full migration audit accounts for every source and destination', async () => {
|
|
const manifest = await loadManifest('docs/migration-manifest.json');
|
|
const result = await auditContent({
|
|
docsRoot: 'src/content/docs',
|
|
manifest,
|
|
});
|
|
|
|
assert.equal(result.sourceCount, 29);
|
|
assert.equal(result.destinationCount, 28);
|
|
assert.equal(result.pageCount, 28);
|
|
});
|
|
|
|
test('published source pages leave the sole H1 to Starlight frontmatter rendering', async () => {
|
|
const manifest = await loadManifest('docs/migration-manifest.json');
|
|
const destinations = [...new Set(manifest.entries.map(({ destination }) => destination))];
|
|
|
|
for (const destination of destinations) {
|
|
const content = await readFile(destination, 'utf8');
|
|
assert.doesNotMatch(content, /^#\s+/m, `${destination} contains a body H1`);
|
|
}
|
|
});
|
|
|
|
test('present-day association introductions use the current DACS name', async () => {
|
|
for (const destination of [
|
|
'src/content/docs/index.mdx',
|
|
'src/content/docs/about-incognito.md',
|
|
]) {
|
|
const content = await readFile(destination, 'utf8');
|
|
assert.match(content, /Department of Advanced Computing Sciences/);
|
|
assert.doesNotMatch(content, /study association for the Department of Data Science & Artificial Intelligence/);
|
|
}
|
|
});
|
|
|
|
test('programme overviews disclose that promised summaries and exams were absent from the export', async () => {
|
|
for (const destination of [
|
|
'src/content/docs/bachelor/index.md',
|
|
'src/content/docs/master-ai/index.md',
|
|
'src/content/docs/master-dsdm/index.md',
|
|
]) {
|
|
const content = await readFile(destination, 'utf8');
|
|
assert.match(content, /not present in the export/i);
|
|
assert.doesNotMatch(content, /Course summaries and old exams can be found in their respective folders\./);
|
|
}
|
|
});
|
|
|
|
test('the home historical warning uses the standard Starlight caution', async () => {
|
|
const content = await readFile('src/content/docs/index.mdx', 'utf8');
|
|
assert.match(content, /:::caution\[Historical information\]\nThis information originated in the previous wiki and may be outdated\.[\s\S]*?\n:::/);
|
|
});
|
|
|
|
test('Useful Information scopes contribution guidance to repository maintainers', async () => {
|
|
const content = await readFile('src/content/docs/useful-information/index.md', 'utf8');
|
|
assert.match(content, /Repository maintainers/);
|
|
assert.doesNotMatch(content, /please create a new page/i);
|
|
});
|
|
|
|
test('DSDM year pages rely on their page-level caution without redundant inner notes', async () => {
|
|
for (const destination of [
|
|
'src/content/docs/master-dsdm/year-1/index.md',
|
|
'src/content/docs/master-dsdm/year-2/index.md',
|
|
]) {
|
|
const content = await readFile(destination, 'utf8');
|
|
assert.doesNotMatch(content, /:::note\[Historical (?:course list|study structure)\]/);
|
|
}
|
|
});
|
|
|
|
test('all 29 migration-report rows classify link handling truthfully', async () => {
|
|
const report = await readFile('docs/migration-report.md', 'utf8');
|
|
const expectedPrefixes = new Map([
|
|
['pages/start.txt', '**Converted source links:**'],
|
|
['pages/study.txt', '**Converted source links:**'],
|
|
['pages/study/msv_incognito.txt', '**Converted source link:**'],
|
|
['pages/study/bachelor.txt', '**Converted source link:**'],
|
|
['pages/study/bachelor/year_1.txt', '**Newly verified links:**'],
|
|
['pages/study/bachelor/year_1/project_1-1.txt', '**None.**'],
|
|
['pages/study/bachelor/year_1/project_1-2.txt', '**None.**'],
|
|
['pages/study/bachelor/year_2.txt', '**Newly verified links:**'],
|
|
['pages/study/bachelor/year_2/honours_programme.txt', '**None.**'],
|
|
['pages/study/bachelor/year_2/project_2-1.txt', '**Converted plain-text reference:**'],
|
|
['pages/study/bachelor/year_2/project_2-2.txt', '**Converted plain-text reference:**'],
|
|
['pages/study/bachelor/year_3.txt', '**Newly verified links:**'],
|
|
['pages/study/bachelor/year_3/bachelors_thesis.txt', '**None.**'],
|
|
['pages/study/bachelor/year_3/honours_programme.txt', '**None.**'],
|
|
['pages/study/bachelor/year_3/project_3-1.txt', '**Converted plain-text reference:**'],
|
|
['pages/study/bachelor/year_3/study_abroad.txt', '**Newly verified link:**'],
|
|
['pages/study/master_ai.txt', '**Converted source link:**'],
|
|
['pages/study/master_ai/year_1.txt', '**Newly verified links:**'],
|
|
['pages/study/master_ai/year_1/project_mai_1.txt', '**None.**'],
|
|
['pages/study/master_ai/year_1/project_mai_2.txt', '**None.**'],
|
|
['pages/study/master_ai/year_2.txt', '**None.**'],
|
|
['pages/study/master_dsdm.txt', '**Converted source link:**'],
|
|
['pages/study/master_dsdm/year_1.txt', '**None.**'],
|
|
['pages/study/master_dsdm/year_2.txt', '**None.**'],
|
|
['pages/study/useful_information.txt', '**Converted source link:**'],
|
|
['pages/study/useful_information/pages/dke_locations.txt', '**Converted source links:**'],
|
|
['pages/study/useful_information/pages/handy_locations.txt', '**None.**'],
|
|
['pages/study/useful_information/pages/it_services.txt', '**Converted source links:**'],
|
|
['pages/study/useful_information/pages/laptop_buy_advice.txt', '**None.**'],
|
|
]);
|
|
const rows = report.split('\n').filter((line) => line.startsWith('| `pages/'));
|
|
|
|
assert.equal(rows.length, expectedPrefixes.size);
|
|
for (const row of rows) {
|
|
const cells = row.split(' | ');
|
|
const source = cells[0].match(/^\| `([^`]+)`$/)?.[1];
|
|
assert.ok(source, `could not parse report source from ${row}`);
|
|
assert.ok(expectedPrefixes.has(source), `unexpected report source ${source}`);
|
|
assert.ok(
|
|
cells[4].startsWith(expectedPrefixes.get(source)),
|
|
`${source} link handling must start with ${expectedPrefixes.get(source)}`,
|
|
);
|
|
}
|
|
|
|
assert.match(report, /\| Link handling \|/);
|
|
assert.doesNotMatch(report, /Surfgroepen|DKE file server|old namespace link/i);
|
|
assert.match(
|
|
report,
|
|
/pages\/study\/bachelor\/year_3\/study_abroad\.txt[^\n]*\*\*Newly verified link:\*\* the official UM study-abroad page/,
|
|
);
|
|
});
|
|
|
|
test('audit rejects a published page with missing frontmatter', async () => {
|
|
await expectAuditFailure(async ({ docsRoot }) => {
|
|
await writeFile(join(docsRoot, 'about-incognito.md'), 'Missing frontmatter.\n');
|
|
}, /missing YAML frontmatter/);
|
|
});
|
|
|
|
test('audit rejects frontmatter with an empty required field', async () => {
|
|
await expectAuditFailure(async ({ docsRoot }) => {
|
|
await writeFile(join(docsRoot, 'about-incognito.md'), [
|
|
'---',
|
|
'title:',
|
|
'description: Still described.',
|
|
'---',
|
|
'',
|
|
'Body.',
|
|
].join('\n'));
|
|
}, /missing non-empty title frontmatter/);
|
|
});
|
|
|
|
test('audit rejects syntactically malformed YAML frontmatter', async () => {
|
|
await expectAuditFailure(async ({ docsRoot }) => {
|
|
await writeFile(join(docsRoot, 'about-incognito.md'), [
|
|
'---',
|
|
'title: "unterminated',
|
|
'description: Still described.',
|
|
'---',
|
|
'',
|
|
'Body.',
|
|
].join('\n'));
|
|
}, /malformed YAML frontmatter/);
|
|
});
|
|
|
|
test('audit rejects forbidden DokuWiki syntax in published content', async () => {
|
|
await expectAuditFailure(async ({ docsRoot }) => {
|
|
const page = join(docsRoot, 'about-incognito.md');
|
|
await writeFile(page, `${await readFile(page, 'utf8')}\n[[forbidden:link]]\n`);
|
|
}, /contains forbidden DokuWiki token/);
|
|
});
|
|
|
|
test('audit rejects a missing published document', async () => {
|
|
await expectAuditFailure(async ({ docsRoot }) => {
|
|
await rm(join(docsRoot, 'about-incognito.md'));
|
|
}, /missing manifest destination: src\/content\/docs\/about-incognito\.md/);
|
|
});
|
|
|
|
test('audit rejects an extra Markdown document outside the manifest destination set', async () => {
|
|
await expectAuditFailure(async ({ docsRoot }) => {
|
|
await writeFile(join(docsRoot, 'extra.md'), [
|
|
'---',
|
|
'title: Extra',
|
|
'description: Not in the migration contract.',
|
|
'---',
|
|
].join('\n'));
|
|
}, /published content file is absent from manifest: src\/content\/docs\/extra\.md/);
|
|
});
|
|
|
|
test('audit rejects a directory where the manifest requires a regular file', async () => {
|
|
await expectAuditFailure(async ({ docsRoot }) => {
|
|
const destination = join(docsRoot, 'about-incognito.md');
|
|
await rm(destination);
|
|
await mkdir(destination);
|
|
}, /manifest destination is not a regular file: src\/content\/docs\/about-incognito\.md/);
|
|
});
|
|
|
|
test('audit rejects coordinated manifest and report drift from the canonical sources', async () => {
|
|
await expectAuditFailure(async ({ root, manifest }) => {
|
|
const original = 'pages/study/msv_incognito.txt';
|
|
const fabricated = 'pages/study/fabricated.txt';
|
|
manifest.entries[2].source = fabricated;
|
|
const reportPath = join(root, 'docs/migration-report.md');
|
|
const report = await readFile(reportPath, 'utf8');
|
|
await writeFile(reportPath, report.replace(original, fabricated));
|
|
}, /canonical migration contract/);
|
|
});
|
|
|
|
test('audit rejects coordinated destination, report, and published-file drift', async () => {
|
|
await expectAuditFailure(async ({ root, docsRoot, manifest }) => {
|
|
const original = 'src/content/docs/about-incognito.md';
|
|
const drifted = 'src/content/docs/association.md';
|
|
manifest.entries[2].destination = drifted;
|
|
await rename(
|
|
join(docsRoot, 'about-incognito.md'),
|
|
join(docsRoot, 'association.md'),
|
|
);
|
|
const reportPath = join(root, 'docs/migration-report.md');
|
|
const report = await readFile(reportPath, 'utf8');
|
|
await writeFile(reportPath, report.replace(original, drifted));
|
|
}, /canonical migration contract/);
|
|
});
|
|
|
|
test('audit rejects a report row whose mapping differs from the manifest', async () => {
|
|
await expectAuditFailure(async ({ root }) => {
|
|
const reportPath = join(root, 'docs/migration-report.md');
|
|
const report = await readFile(reportPath, 'utf8');
|
|
await writeFile(
|
|
reportPath,
|
|
report.replace(
|
|
'`src/content/docs/about-incognito.md` | page',
|
|
'`src/content/docs/not-about-incognito.md` | page',
|
|
),
|
|
);
|
|
}, /migration report mapping differs from manifest: pages\/study\/msv_incognito\.txt/);
|
|
});
|
|
|
|
test('audit rejects docs roots that merely end with a false src/content/docs suffix', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'incognito-false-root-'));
|
|
try {
|
|
const docsRoot = join(root, 'not-src/content/docs');
|
|
await mkdir(docsRoot, { recursive: true });
|
|
const manifest = await loadManifest('docs/migration-manifest.json');
|
|
await assert.rejects(
|
|
auditContent({ docsRoot, manifest }),
|
|
/docsRoot must identify src\/content\/docs/,
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('audit rejects to-be-studied directories on either publication path', async () => {
|
|
await expectAuditFailure(async ({ root, docsRoot }) => {
|
|
await mkdir(join(docsRoot, 'to-be-studied'), { recursive: true });
|
|
await mkdir(join(root, 'public/to-be-studied'), { recursive: true });
|
|
}, /to-be-studied must not appear under src\/content\/docs/);
|
|
});
|
|
|
|
const forbidden = [/\[\[/, /\{\{/, /~~NOCACHE~~/, /NEWPAGE>/, /indexmenu>/];
|
|
|
|
for (const file of [
|
|
'src/content/docs/bachelor/index.md',
|
|
'src/content/docs/bachelor/year-1/index.md',
|
|
'src/content/docs/bachelor/year-1/project-1-1.md',
|
|
'src/content/docs/bachelor/year-1/project-1-2.md',
|
|
'src/content/docs/bachelor/year-2/index.md',
|
|
'src/content/docs/bachelor/year-2/honours-programme.md',
|
|
'src/content/docs/bachelor/year-2/project-2-1.md',
|
|
'src/content/docs/bachelor/year-2/project-2-2.md',
|
|
'src/content/docs/bachelor/year-3/index.md',
|
|
'src/content/docs/bachelor/year-3/bachelors-thesis.md',
|
|
'src/content/docs/bachelor/year-3/honours-programme.md',
|
|
'src/content/docs/bachelor/year-3/project-3-1.md',
|
|
'src/content/docs/bachelor/year-3/study-abroad.md',
|
|
'src/content/docs/master-ai/index.md',
|
|
'src/content/docs/master-ai/year-1/index.md',
|
|
'src/content/docs/master-ai/year-1/research-project-1.md',
|
|
'src/content/docs/master-ai/year-1/research-project-2.md',
|
|
'src/content/docs/master-ai/year-2/index.md',
|
|
'src/content/docs/master-dsdm/index.md',
|
|
'src/content/docs/master-dsdm/year-1/index.md',
|
|
'src/content/docs/master-dsdm/year-2/index.md',
|
|
'src/content/docs/useful-information/index.md',
|
|
'src/content/docs/useful-information/dke-locations.md',
|
|
'src/content/docs/useful-information/handy-locations.md',
|
|
'src/content/docs/useful-information/it-services.md',
|
|
'src/content/docs/useful-information/laptop-buying-advice.md',
|
|
]) {
|
|
test(`${file} contains clean migrated Markdown`, async () => {
|
|
const content = await readFile(file, 'utf8');
|
|
assert.match(content, /^---\n[\s\S]*title:/);
|
|
assert.match(content, /This information originated in the previous wiki and may be outdated\./);
|
|
for (const pattern of forbidden) assert.doesNotMatch(content, pattern);
|
|
});
|
|
}
|
|
|
|
for (const file of [
|
|
'src/content/docs/useful-information/it-services.md',
|
|
'src/content/docs/useful-information/laptop-buying-advice.md',
|
|
]) {
|
|
test(`${file} prominently identifies historical guidance`, async () => {
|
|
const content = await readFile(file, 'utf8');
|
|
assert.match(content, /:::caution\[Historical information\]/);
|
|
assert.match(content, /This information originated in the previous wiki and may be outdated\./);
|
|
});
|
|
}
|
|
|
|
test('Handy Locations retains its awaiting-content notices', async () => {
|
|
const content = await readFile(
|
|
'src/content/docs/useful-information/handy-locations.md',
|
|
'utf8',
|
|
);
|
|
assert.match(content, /:::note\[Awaiting content\]/);
|
|
assert.match(content, /This section is awaiting content\./);
|
|
});
|
|
|
|
test('Year 2 honours programme retains its awaiting-content notice', async () => {
|
|
const content = await readFile(
|
|
'src/content/docs/bachelor/year-2/honours-programme.md',
|
|
'utf8',
|
|
);
|
|
assert.match(content, /:::note\[Awaiting content\]/);
|
|
assert.match(content, /This page is awaiting content\./);
|
|
});
|
|
|
|
test('Year 3 honours programme retains its awaiting-content notice', async () => {
|
|
const content = await readFile(
|
|
'src/content/docs/bachelor/year-3/honours-programme.md',
|
|
'utf8',
|
|
);
|
|
assert.match(content, /:::note\[Awaiting content\]/);
|
|
assert.match(content, /This page is awaiting content\./);
|
|
});
|
|
|
|
test('Year 3 study abroad page identifies 2022 advice as historical', async () => {
|
|
const content = await readFile(
|
|
'src/content/docs/bachelor/year-3/study-abroad.md',
|
|
'utf8',
|
|
);
|
|
assert.match(content, /2022/);
|
|
assert.match(content, /must be verified/i);
|
|
});
|
|
|
|
for (const { file, correctHref, shallowHref } of [
|
|
{
|
|
file: 'src/content/docs/bachelor/year-2/project-2-1.md',
|
|
correctHref: '../../year-1/project-1-1/',
|
|
shallowHref: '../year-1/project-1-1/',
|
|
},
|
|
{
|
|
file: 'src/content/docs/bachelor/year-2/project-2-2.md',
|
|
correctHref: '../../year-1/project-1-2/',
|
|
shallowHref: '../year-1/project-1-2/',
|
|
},
|
|
]) {
|
|
test(`${file} links to its Year 1 prerequisite from the nested route`, async () => {
|
|
const content = await readFile(file, 'utf8');
|
|
assert.match(content, new RegExp(`\\]\\(${correctHref.replaceAll('/', '\\/')}\\)`));
|
|
assert.doesNotMatch(content, new RegExp(`\\]\\(${shallowHref.replaceAll('/', '\\/')}\\)`));
|
|
});
|
|
}
|