'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); const languages = require('../language-registry.js'); const locales = require('../locale-registry.js'); const navigation = require('../language-navigation.js'); const markdown = require('../markdown-language-preprocessor.js'); const completion = require('../workshop-completion.js'); const repoRoot = path.resolve(__dirname, '..', '..'); function extractViewerSource(html) { const match = html.match(/<${opening}>const value = 1;`), 'const value = 1;' ); } assert.throws(() => extractViewerSource(''), /must contain an inline script/); }); class Element { constructor() { this.children = []; this.attributes = new Map(); this.listeners = new Map(); this.style = { setProperty() {} }; this.classList = { toggle() {}, contains() { return false; } }; this.textContent = ''; this.innerHTML = ''; } setAttribute(name, value) { this.attributes.set(name, value); } removeAttribute(name) { this.attributes.delete(name); } toggleAttribute(name, force) { if (force) this.setAttribute(name, ''); else this.removeAttribute(name); } addEventListener(name, callback) { this.listeners.set(name, callback); } append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } } replaceChildren(...children) { this.children = []; this.append(...children); } remove() { this.parent.children = this.parent.children.filter(child => child !== this); } querySelectorAll() { return []; } } function environment(url, reducedMotion = false) { const elements = new Map(); const media = new Map(); const timers = new Map(); const events = new Map(); let timerId = 0; const document = { body: new Element(), documentElement: new Element(), createElement: () => new Element(), getElementById(id) { if (!elements.has(id)) elements.set(id, new Element()); return elements.get(id); }, querySelector: selector => document.getElementById(selector), querySelectorAll: () => [], addEventListener() {} }; const window = { location: new URL(url), localStorage: { getItem: () => null, setItem() {} }, history: { replaceState(state, unused, value) { window.location = new URL(value); } }, matchMedia(query) { if (!media.has(query)) { const listeners = new Set(); media.set(query, { matches: query.includes('reduced-motion') && reducedMotion, addEventListener(name, callback) { listeners.add(callback); }, removeEventListener(name, callback) { listeners.delete(callback); }, change(value) { this.matches = value; for (const listener of listeners) listener(); } }); } return media.get(query); }, addEventListener(name, callback) { events.set(name, callback); }, setTimeout(callback, delay) { timers.set(++timerId, { callback, delay }); return timerId; }, clearTimeout(id) { timers.delete(id); } }; return { document, window, elements, media, timers, events }; } async function viewer(step, language = 'nodejs', options = {}) { const query = navigation.lessonUrl(step, language || undefined); const env = environment(`http://localhost:8000/docs/workshop/step.html${query}`, options.reducedMotion); env.context = vm.createContext({ window: env.window, document: env.document, HTMLElement: Element, URL, URLSearchParams, console, WorkshopLanguages: languages, WorkshopLocales: locales, WorkshopLanguageNavigation: navigation, WorkshopMarkdown: markdown, WorkshopCompletion: completion, hljs: { highlightElement() {} }, marked: { parse(source) { if (options.renderFailure) throw new Error('Rendering failed.'); return source; } }, fetch: options.fetch || (async url => ({ ok: !options.fetchFailure, status: options.fetchFailure ? 404 : 200, text: async () => fs.readFileSync( path.join(repoRoot, 'workshop', new URL(url).pathname.split('/').at(-1)), 'utf8') })) }); vm.runInContext(viewerSource, env.context); await flush(); env.state = vm.runInContext('({ steps, currentIndex })', env.context); env.changeLanguage = async languageId => { const selector = env.document.getElementById('languageSelector'); selector.value = languageId; selector.listeners.get('change')(); await flush(); }; return env; } for (const [track, finalLesson, finalStep, lessonCount] of [ ['sdlc', '09-interactive-html-report', '10-complete', 9], ['museum', 'museum-08-interactive-exhibit-page', 'museum-09-complete', 7] ]) { test(`${track}: every lesson is numbered and completion is last`, async () => { const env = await viewer(finalLesson); assert.deepEqual( Array.from(env.state.steps, step => step.kind), ['preflight', ...Array(lessonCount).fill('core'), 'completion'] ); assert.deepEqual( Array.from(env.state.steps.slice(1), step => step.number), Array.from({ length: lessonCount + 1 }, (_, index) => index + 1) ); assert.equal(env.state.steps.at(-1).id, finalStep); assert.equal(env.document.getElementById('stepPosition').textContent, `Step ${lessonCount} of ${lessonCount + 1}`); assert.equal(env.document.getElementById('progressTrack').attributes.get('aria-valuemax'), String(lessonCount + 1)); assert.equal(env.document.getElementById('nextHeaderLink').href, navigation.lessonUrl(finalStep, 'nodejs')); assert.match(env.document.getElementById('markdownContent').innerHTML, new RegExp(`\\?step=${finalStep}&lang=nodejs`)); assert.doesNotMatch(env.document.getElementById('workshopNavigation').innerHTML, /Optional|\+/); assert.equal(env.document.body.children.length, 0); }); test(`${track}: completion retains each language and provides a hub exit`, async () => { for (const language of ['dotnet', 'go', 'java', 'nodejs', 'python', 'rust']) { const env = await viewer(finalStep, language); const content = env.document.getElementById('markdownContent').innerHTML; assert.match(content, /# You did it!/); assert.ok(content.includes(languages.getLanguage(language).docsUrl)); assert.ok(content.includes(`/finished/${language}/`)); for (const other of ['dotnet', 'go', 'java', 'nodejs', 'python', 'rust']) { if (other !== language) assert.ok(!content.includes(`/finished/${other}/`)); } assert.equal(env.document.getElementById('previousHeaderLink').href, navigation.lessonUrl(finalLesson, language)); assert.equal(env.document.getElementById('nextHeaderLink').hidden, true); assert.ok(env.document.getElementById('lessonFooter').innerHTML .includes(navigation.homeUrl(language, track))); assert.doesNotMatch(env.document.getElementById('lessonFooter').innerHTML, /disabled/); assert.equal(env.document.getElementById('progressFill').style.transform, 'scaleX(1)'); assert.match(env.document.getElementById('lessonStatus').textContent, /complete/); const overlay = env.document.body.children[0]; assert.equal(overlay.className, 'completion-confetti'); assert.equal(overlay.attributes.get('aria-hidden'), 'true'); assert.equal(overlay.children.length, 64); const timer = [...env.timers.values()][0]; assert.equal(timer.delay, 3500); timer.callback(); assert.equal(env.document.body.children.length, 0); assert.equal(env.timers.size, 0); } }); } test('completion does not animate when motion is reduced', async () => { const env = await viewer('10-complete', 'go', { reducedMotion: true }); assert.match(env.document.getElementById('markdownContent').innerHTML, /# You did it!/); assert.equal(env.document.body.children.length, 0); assert.equal(env.timers.size, 0); }); test('celebration stops when reduced motion changes or the page is hidden', async () => { for (const stop of [ env => env.media.get('(prefers-reduced-motion: reduce)').change(true), env => env.events.get('pagehide')() ]) { const env = await viewer('museum-09-complete'); assert.equal(env.document.body.children.length, 1); stop(env); assert.equal(env.document.body.children.length, 0); assert.equal(env.timers.size, 0); } }); test('changing language clears confetti without replaying it', async () => { const env = await viewer('10-complete'); await env.changeLanguage('rust'); assert.match(env.document.getElementById('markdownContent').innerHTML, /\/finished\/rust\//); assert.equal(env.document.body.children.length, 0); assert.equal(env.timers.size, 0); assert.ok(env.document.getElementById('lessonFooter').innerHTML.includes('lang=rust')); }); test('no celebration for missing language, failed fetch, or failed rendering', async () => { for (const [language, options, message] of [ [null, {}, 'Choose a workshop language to continue.'], ['nodejs', { fetchFailure: true }, 'Unable to load this lesson.'], ['nodejs', { renderFailure: true }, 'Unable to load this lesson.'] ]) { const env = await viewer('10-complete', language, options); assert.equal(env.document.getElementById('lessonStatus').textContent, message); assert.doesNotMatch(env.document.getElementById('stepPosition').textContent, /complete/i); assert.equal(env.document.body.children.length, 0); assert.equal(env.timers.size, 0); } }); test('stale completion requests cannot replace the selected language or stop its celebration', async () => { const requests = []; const env = await viewer('10-complete', 'nodejs', { fetch: () => new Promise(resolve => requests.push(resolve)) }); await env.changeLanguage('python'); const source = fs.readFileSync(path.join(repoRoot, 'workshop', '10-complete.md'), 'utf8'); requests[1]({ ok: true, text: async () => source }); await flush(); requests[0]({ ok: false, status: 404 }); await flush(); assert.match(env.document.getElementById('markdownContent').innerHTML, /\/finished\/python\//); assert.equal(env.document.body.children.length, 1); assert.match(env.document.getElementById('lessonStatus').textContent, /complete/); });