import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
DOCS_DIR, IMAGES_DIR, adaptMarkdown, approvedPages, planImport,
} from './app-for-beginners-sync.mjs';
const sha = 'a'.repeat(40);
const nextSha = 'b'.repeat(40);
const date = '2026-10-04';
const directories = [
'00-setup', '01-tour-the-app', '02-sessions-worktrees-context', '03-development-workflows',
'04-skills-custom-agents', '05-mcp-plugins', '06-canvases', '07-automations',
];
function fixture() {
const source = new Map([
['README.md', Buffer.from('# GitHub Copilot app for Beginners\n\n[Start](00-setup/)\n')],
['LICENSE', Buffer.from('MIT License\nCopyright GitHub, Inc.\n')],
['assets/assignment.webp', Buffer.from([0, 1, 2, 255])],
['GLOSSARY.md', Buffer.from('## Worktree\n')],
['samples/book-app-web/README.md', Buffer.from('# Sample\n')],
['.github/scripts/setup-training-scenarios.js', Buffer.from('// source only\n')],
]);
for (const directory of directories) {
source.set(`${directory}/README.md`, Buffer.from(
'\n\n[Home](../README.md)\n',
));
}
return source;
}
function plan(source, local = new Map(), commit = sha, importDate = date) {
return planImport({
sha: commit, date: importDate, paths: new Set(source.keys()),
readSource: async (filename) => {
assert.ok(source.has(filename), `Missing source: ${filename}`);
return source.get(filename);
},
readLocal: async (filename) => local.get(filename),
});
}
function applyToMap(result, previous = new Map()) {
const local = new Map(previous);
for (const filename of result.changed) {
if (result.outputs.has(filename)) local.set(filename, result.outputs.get(filename));
else local.delete(filename);
}
return local;
}
test('imports only nine pages and referenced binary assets; repeat and unrelated commits are noops', async () => {
const source = fixture();
const initial = await plan(source);
assert.equal(initial.pages, 9);
assert.equal(initial.assets, 1);
assert.deepEqual(initial.outputs.get(`${IMAGES_DIR}/overview/assignment.webp`), source.get('assets/assignment.webp'));
assert.ok(![...initial.outputs.keys()].some((filename) => filename.includes('samples/')));
const local = applyToMap(initial);
assert.deepEqual((await plan(source, local, sha, '2026-10-05')).changed, []);
source.set('samples/book-app-web/src/App.tsx', Buffer.from('// unrelated\n'));
assert.deepEqual((await plan(source, local, nextSha)).changed, []);
assert.equal(JSON.parse(local.get(`${DOCS_DIR}/source.json`)).sourceCommit, sha);
});
test('cache loss and pending PR do not advance the main baseline or miss older changes', async () => {
const source = fixture();
const main = applyToMap(await plan(source));
source.set('00-setup/README.md', Buffer.from('A visible typo fix.\n'));
const pendingPlan = await plan(source, main, nextSha, '2026-11-04');
assert.equal(pendingPlan.baseline, sha);
assert.ok(pendingPlan.changed.includes(`${DOCS_DIR}/00-setup.md`));
const pending = applyToMap(pendingPlan, main);
assert.deepEqual((await plan(source, pending, nextSha)).changed, []);
assert.equal(JSON.parse(main.get(`${DOCS_DIR}/source.json`)).sourceCommit, sha);
assert.ok((await plan(source, main, nextSha)).changed.length > 0);
source.set('01-tour-the-app/README.md', Buffer.from('A later change while the PR is open.\n'));
assert.ok((await plan(source, pending, 'c'.repeat(40))).changed.includes(`${DOCS_DIR}/01-tour-the-app.md`));
});
test('asset-only updates change bytes and the dates of pages that use them', async () => {
const source = fixture();
const local = applyToMap(await plan(source));
source.set('assets/assignment.webp', Buffer.from([0, 3, 4, 255]));
const result = await plan(source, local, nextSha, '2026-10-05');
assert.ok(result.changed.includes(`${IMAGES_DIR}/overview/assignment.webp`));
assert.match(result.outputs.get(`${DOCS_DIR}/06-canvases.md`).toString(), /lastUpdated: 2026-10-05/);
});
test('renames keep chapter routes stable; deletions prune only manifest-owned files; additions stay upstream', async () => {
const source = fixture();
const local = applyToMap(await plan(source));
source.set('01-renamed/README.md', source.get('01-tour-the-app/README.md'));
source.delete('01-tour-the-app/README.md');
source.delete('07-automations/README.md');
source.set('08-extra/README.md', Buffer.from('New out-of-scope chapter\n'));
const result = await plan(source, local, nextSha);
assert.equal(result.pages, 8);
assert.ok(result.outputs.has(`${DOCS_DIR}/01-tour-the-app.md`));
assert.ok(result.deleted.includes(`${DOCS_DIR}/07-automations.md`));
assert.ok(!result.outputs.has(`${DOCS_DIR}/08-extra.md`));
assert.throws(() => approvedPages(new Set(['00-a/README.md', '00-b/README.md'])), /Ambiguous/);
});
test('rewrites links and images without changing prose, code, prompts, or inline sample paths', () => {
const paths = new Set(fixture().keys());
const pages = approvedPages(paths);
const markdown = [
'[Chapter](../00-setup/README.md#seed-the-repository)',
'[Glossary](../GLOSSARY.md#worktree)',
'[`setup` script](../.github/scripts/setup-training-scenarios.js)',
'[Sample](../samples/book-app-web/)',
'[](../LICENSE)',
'[home]: ../README.md',
'
',
'\nOptional
\n\n## Nested heading\n\n ',
'> [!NOTE]\n> Keep this note.',
'Use `samples/book-app-web` and `@samples/book-app-web`.',
'```text\n[do not rewrite](../README.md)\ncd samples/book-app-web\n```',
].join('\n');
const { body } = adaptMarkdown(markdown, '06-canvases/README.md', pages, paths);
assert.match(body, /\/learning-hub\/app-for-beginners\/00-setup\/#seed-the-repository/);
assert.match(body, /\/blob\/main\/GLOSSARY.md#worktree/);
assert.match(body, /\/blob\/main\/.github\/scripts\/setup-training-scenarios.js/);
assert.match(body, /\/tree\/main\/samples\/book-app-web/);
assert.match(body, /\]\(https:\/\/github.com\/github\/copilot-app-for-beginners\/blob\/main\/LICENSE\)/);
assert.match(body, /\[home\]: \/learning-hub\/app-for-beginners\//);
assert.match(body, /\/images\/learning-hub\/copilot-app-for-beginners\/overview\/assignment.webp/);
for (const unchanged of [
'\nOptional
\n\n## Nested heading\n\n ',
'> [!NOTE]\n> Keep this note.',
'Use `samples/book-app-web` and `@samples/book-app-web`.',
'```text\n[do not rewrite](../README.md)\ncd samples/book-app-web\n```',
]) assert.ok(body.includes(unchanged));
});
test('source failures, invalid commits, missing links and unsafe deletions remain errors', async () => {
const source = fixture();
await assert.rejects(plan(source, new Map(), 'main'), /full source commit/);
const local = applyToMap(await plan(source));
source.set('README.md', Buffer.from('[Broken](missing.md)\n'));
await assert.rejects(plan(source, local), /Missing source link/);
source.set('README.md', Buffer.from('[Outside](../../other)\n'));
await assert.rejects(plan(source, local), /leaves the repository/);
const valid = fixture();
const manifest = JSON.parse(local.get(`${DOCS_DIR}/source.json`));
manifest.files.push({ local: 'CODEOWNERS' });
local.set(`${DOCS_DIR}/source.json`, Buffer.from(JSON.stringify(manifest)));
await assert.rejects(plan(valid, local), /Unsafe deletion/);
});