import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import vm from 'node:vm'; import { fileURLToPath } from 'node:url'; import { ChromeVisualBrowser, findChrome } from '../bin/visual-check.mjs'; import { DIAGRAM_TYPES, DIAGRAM_TYPE_LABELS } from '../../scripts/site-copy.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '../..'); const runtimePath = path.join(repoRoot, 'docs/assets/site-language.js'); const navigationPath = path.join(repoRoot, 'docs/assets/site-navigation.css'); const integrationEnabled = process.env.ARCHIFY_SITE_INTEGRATION === '1'; const chromePath = integrationEnabled && process.env.ARCHIFY_CHROME ? findChrome() : null; function loadRuntime({ url = 'https://example.test/', values = new Map(), storageError = false, historyError = false, source = runtimePath, } = {}) { const localStorage = { getItem(key) { if (storageError) throw new Error('storage unavailable'); return values.has(key) ? values.get(key) : null; }, setItem(key, value) { if (storageError) throw new Error('storage unavailable'); values.set(key, String(value)); }, }; let currentUrl = new URL(url); const location = {}; function syncLocation() { location.href = currentUrl.href; location.search = currentUrl.search; location.pathname = currentUrl.pathname; location.hash = currentUrl.hash; } syncLocation(); const window = { location, history: { replaceState(_state, _title, next) { if (historyError) throw new Error('history unavailable'); currentUrl = new URL(next, currentUrl); syncLocation(); }, }, localStorage, }; vm.runInNewContext(fs.readFileSync(source, 'utf8'), { window, URL, URLSearchParams }); return { language: window.ArchifySiteLanguage, values, url: () => new URL(currentUrl) }; } async function evaluate(browser, sessionId, expression) { const response = await browser.cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, }, sessionId); if (response.exceptionDetails) { throw new Error(response.exceptionDetails.exception?.description || response.exceptionDetails.text || 'Runtime.evaluate failed'); } return response.result?.value; } async function navigate(browser, sessionId, url) { const loaded = browser.cdp.waitFor('Page.loadEventFired', sessionId); const navigation = await browser.cdp.send('Page.navigate', { url }, sessionId); if (navigation.errorText) throw new Error(`Chrome navigation failed: ${navigation.errorText}`); await loaded; } async function clickAndNavigate(browser, sessionId, selector) { const loaded = browser.cdp.waitFor('Page.loadEventFired', sessionId); await evaluate(browser, sessionId, `(function () { var link = document.querySelector(${JSON.stringify(selector)}); if (!link) throw new Error('Missing navigation link: ' + ${JSON.stringify(selector)}); link.click(); })()`); await loaded; } function startStaticServer(root) { const server = http.createServer((request, response) => { const requestUrl = new URL(request.url || '/', 'http://127.0.0.1'); const relative = decodeURIComponent(requestUrl.pathname).replace(/^\/+/, '') || 'index.html'; const requestedPath = path.resolve(root, relative); if (!requestedPath.startsWith(`${path.resolve(root)}${path.sep}`)) { response.writeHead(403).end('Forbidden'); return; } try { const body = fs.readFileSync(requestedPath); const contentType = requestedPath.endsWith('.css') ? 'text/css' : requestedPath.endsWith('.js') ? 'text/javascript' : requestedPath.endsWith('.json') ? 'application/json' : 'text/html'; response.writeHead(200, { 'content-type': `${contentType}; charset=utf-8` }); response.end(body); } catch (_) { response.writeHead(404).end('Not found'); } }); return server; } test('site language runtime normalizes one entry parameter into one durable preference', () => { const canonical = loadRuntime({ values: new Map([['archify-lang', 'zh']]) }); assert.equal(canonical.language.read(), 'zh'); for (const legacyKey of ['archify-gallery-language', 'archify-guide-language']) { const legacy = loadRuntime({ values: new Map([[legacyKey, 'zh']]) }); assert.equal(legacy.language.read(), 'zh', `${legacyKey} must remain readable during migration`); assert.equal(legacy.values.get('archify-lang'), 'zh', `${legacyKey} must migrate to the canonical key`); } const canonicalWins = loadRuntime({ values: new Map([ ['archify-lang', 'en'], ['archify-gallery-language', 'zh'], ['archify-guide-language', 'zh'], ]), }); assert.equal(canonicalWins.language.read(), 'en'); const secondLegacyFallback = loadRuntime({ values: new Map([ ['archify-gallery-language', 'fr'], ['archify-guide-language', 'zh'], ]), }); assert.equal(secondLegacyFallback.language.read(), 'zh'); assert.equal(secondLegacyFallback.values.get('archify-lang'), 'zh'); const conflictingLegacy = loadRuntime({ values: new Map([ ['archify-gallery-language', 'zh'], ['archify-guide-language', 'en'], ]), }); assert.equal(conflictingLegacy.language.read(), 'zh'); assert.equal(conflictingLegacy.values.get('archify-lang'), 'zh'); conflictingLegacy.values.set('archify-gallery-language', 'en'); const migrated = loadRuntime({ values: conflictingLegacy.values }); assert.equal(migrated.language.read(), 'zh', 'the canonical migration must win on later page loads'); const explicit = loadRuntime({ url: 'https://example.test/guide.html?lang=en&type=workflow#chooser', values: new Map([['archify-lang', 'zh']]), }); assert.equal(explicit.language.read(), 'en'); assert.equal(explicit.values.get('archify-lang'), 'en'); assert.equal(explicit.url().searchParams.has('lang'), false); assert.equal(explicit.url().searchParams.get('type'), 'workflow'); assert.equal(explicit.url().hash, '#chooser'); const historyBlocked = loadRuntime({ url: 'https://example.test/guide.html?lang=zh&type=workflow#chooser', values: new Map([['archify-lang', 'en']]), historyError: true, }); assert.equal(historyBlocked.language.read(), 'zh'); assert.equal(historyBlocked.values.get('archify-lang'), 'zh'); assert.equal(historyBlocked.url().searchParams.get('lang'), 'zh'); assert.equal(explicit.language.write('zh'), 'zh'); const refreshed = loadRuntime({ url: explicit.url().href, values: explicit.values }); assert.equal(refreshed.language.read(), 'zh'); const unsupported = loadRuntime({ url: 'https://example.test/?lang=fr', values: new Map([['archify-lang', 'zh']]), }); assert.equal(unsupported.language.read(), 'zh'); assert.equal(unsupported.url().searchParams.has('lang'), false); const defaultLanguage = loadRuntime(); assert.equal(defaultLanguage.language.read(), 'en'); const blocked = loadRuntime({ storageError: true }); assert.equal(blocked.language.read(), 'en'); assert.equal(blocked.language.write('zh'), 'zh'); const source = fs.readFileSync(runtimePath, 'utf8'); assert.match(source, /archify-gallery-language/); assert.match(source, /archify-guide-language/); assert.doesNotMatch(source, /navigator\.language|detectBrowserLanguage|select\s*:/); }); test('custom site builders emit every shared site asset and preserve entry, navigation, selection, and refresh state', { skip: integrationEnabled ? false : 'Run through the serialized site integration gate.', }, () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-site-language-')); try { const builds = [ { script: 'build-start.mjs', args: [path.join(tmp, 'start-site/start.html')], root: 'start-site' }, { script: 'build-guide.mjs', args: [path.join(tmp, 'guide-site/guide.html')], root: 'guide-site' }, { script: 'build-gallery.mjs', args: [path.join(tmp, 'gallery-site')], root: 'gallery-site' }, ]; for (const build of builds) { execFileSync(process.execPath, [path.join(repoRoot, 'scripts', build.script), ...build.args]); for (const asset of ['site-language.js', 'site-navigation.css']) { const emitted = path.join(tmp, build.root, 'assets', asset); const canonicalAsset = path.join(repoRoot, 'docs/assets', asset); assert.ok(fs.existsSync(emitted), `${build.script}: ${asset} missing from custom output`); assert.equal(fs.readFileSync(emitted, 'utf8'), fs.readFileSync(canonicalAsset, 'utf8')); } } const emittedRuntime = path.join(tmp, 'start-site/assets/site-language.js'); const values = new Map(); const landing = loadRuntime({ url: 'https://example.test/?lang=zh&utm_source=readme#proof', values, source: emittedRuntime, }); assert.equal(landing.language.read(), 'zh'); assert.equal(values.get('archify-lang'), 'zh'); assert.equal(landing.url().searchParams.has('lang'), false); assert.equal(landing.url().searchParams.get('utm_source'), 'readme'); assert.equal(landing.url().hash, '#proof'); for (const page of ['gallery.html', 'guide.html', 'start.html']) { const navigation = loadRuntime({ url: `https://example.test/${page}`, values, source: emittedRuntime }); assert.equal(navigation.language.read(), 'zh', page); } const explicitEnglish = loadRuntime({ url: 'https://example.test/guide.html?lang=en#recipes', values, source: emittedRuntime, }); assert.equal(explicitEnglish.language.read(), 'en'); assert.equal(values.get('archify-lang'), 'en'); assert.equal(explicitEnglish.url().searchParams.has('lang'), false); explicitEnglish.language.write('zh'); assert.equal(explicitEnglish.url().searchParams.has('lang'), false); assert.equal(explicitEnglish.url().hash, '#recipes'); const refreshed = loadRuntime({ url: explicitEnglish.url().href, values, source: emittedRuntime }); assert.equal(refreshed.language.read(), 'zh'); const nextPage = loadRuntime({ url: 'https://example.test/gallery.html', values, source: emittedRuntime }); assert.equal(nextPage.language.read(), 'zh'); nextPage.language.write('en'); const refreshedEnglish = loadRuntime({ url: nextPage.url().href, values, source: emittedRuntime }); assert.equal(refreshedEnglish.language.read(), 'en'); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); test('all site pages consume one language runtime and one navigation contract', () => { const pages = [ 'docs/index.html', 'scripts/gallery-template.html', 'scripts/guide-template.html', 'scripts/start-template.html', 'docs/gallery.html', 'docs/guide.html', 'docs/start.html', ]; for (const relative of pages) { const html = fs.readFileSync(path.join(repoRoot, relative), 'utf8'); assert.match(html, /