import { readFileSync } from 'fs'; import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { spawnSync } from 'child_process'; import { evaluateThemeAccessibility, getThemeContract, validateThemeDefinition, validateThemePreference, } from '../app/core/themes/theme-contract.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const read = rel => readFileSync(join(root, rel), 'utf8'); const failures = []; const assert = (condition, message) => { if (!condition) failures.push(message); }; const expectThrow = (fn, code, message) => { try { fn(); failures.push(message); } catch (error) { if (error.code !== code) failures.push(`${message} (erhalten ${error.code || error.message})`); } }; const pkg = JSON.parse(read('package.json')); assert(pkg.version === '1.39.0', `Paketversion ist ${pkg.version}`); const source = JSON.parse(read('public/design-system/tokens/source.json')); assert(source.contractVersion === '1.1.0', `Tokenvertrag ist ${source.contractVersion}`); assert(Object.keys(source.tokens).length >= 42, `Zu wenige Tokens: ${Object.keys(source.tokens).length}`); for (const token of ['component.buttonHeight','component.inputHeight','component.cardRadius','component.focusWidth','component.touchTarget']) { assert(Boolean(source.tokens[token]), `Komponenten-Token fehlt: ${token}`); } const contract = getThemeContract(); assert(contract.tokens['color.brand'].label === 'Akzentfarbe', 'Token-Label fehlt'); assert(contract.tokens['component.buttonHeight'].group === 'Komponenten', 'Token-Gruppe fehlt'); const valid = validateThemeDefinition({ id: 'custom-markus-pro', name: 'Markus Pro', base_theme: 'dark', values: { 'color.brand': '#ff6600', 'component.buttonHeight': '44px' } }); assert(valid.id === 'custom-markus-pro', 'Gültiges Theme wurde nicht akzeptiert'); expectThrow(() => validateThemeDefinition({ id: 'unsafe', name: 'Unsafe', values: {} }), 'THEME_ID_PREFIX', 'Theme ohne custom-Präfix wurde akzeptiert'); expectThrow(() => validateThemeDefinition({ id: 'custom-unsafe', name: 'Unsafe', values: { 'color.brand': 'url(javascript:alert(1))' } }), 'THEME_VALUE_INVALID', 'CSS-Injektion wurde akzeptiert'); expectThrow(() => validateThemeDefinition({ id: 'custom-unsafe', name: 'Unsafe', values: { 'shadow.float': '0 0 1px red' } }), 'THEME_TOKEN_FORBIDDEN', 'Nicht anpassbarer Token wurde akzeptiert'); expectThrow(() => validateThemePreference({ theme_id: 'missing', density: 'comfortable', reduced_motion: 'system' }), 'THEME_NOT_FOUND', 'Unbekanntes Theme wurde akzeptiert'); const poorContrast = evaluateThemeAccessibility({ baseTheme: 'light', values: { 'color.ink': '#ffffff', 'color.canvas': '#ffffff', 'color.surface': '#ffffff' } }); assert(!poorContrast.ok && poorContrast.failures.length >= 2, 'Kontrastfehler wurde nicht erkannt'); const before = read('public/design-system/tokens.css'); const generated = spawnSync(process.execPath, [join(root, 'tools/generate-design-tokens.mjs')], { cwd: root, encoding: 'utf8' }); assert(generated.status === 0, `Token-Generator fehlgeschlagen: ${generated.stderr || generated.stdout}`); assert(before === read('public/design-system/tokens.css'), 'Token-Generator ist nicht deterministisch'); const manifest = JSON.parse(read('app/modules/themes/module.json')); assert(manifest.status === 'native', 'Theme-Modul ist nicht nativ'); assert(manifest.provides.includes('themes.accessibility'), 'Accessibility-Capability fehlt'); for (const file of [ 'app/modules/themes/index.mjs','app/modules/themes/service.mjs','app/modules/themes/repository.mjs', 'app/modules/themes/migrations/001_theme_profiles.sql','public/modules/theme-manager/theme-manager.js', 'public/modules/theme-manager/theme-manager.css', ]) assert(read(file).length > 50, `Theme-Pflichtdatei fehlt oder ist leer: ${file}`); const server = read('server.mjs'); for (const marker of [ "id: 'themes.catalog.read'", "id: 'themes.preference.update'", "id: 'themes.profile.create'", "id: 'themes.profile.update'", "id: 'themes.profile.delete'", "id: 'themes.system-default.update'", "policy: 'themes.manage'", "policy: 'themes.use'", ]) assert(server.includes(marker), `Theme-Route fehlt: ${marker}`); assert(server.indexOf("path: '/api/themes/system-default'") < server.indexOf("path: '/api/themes/:id'"), 'Systemstandard-Route wird von :id überschattet'); const html = read('public/index.html'); for (const marker of ['id="theme-manager-root"','id="theme-choice-grid"','id="theme-token-groups"','id="theme-contrast-list"','theme-manager.js?v=1.39.0']) assert(html.includes(marker), `Theme-Manager UI fehlt: ${marker}`); const manager = read('public/modules/theme-manager/theme-manager.js'); assert(!manager.includes('.innerHTML ='), 'Theme Manager verwendet unsicheres innerHTML'); for (const marker of ['/api/themes/validate','/api/themes/preference','/api/themes/system-default','window.VendooTheme?.previewTheme']) assert(manager.includes(marker), `Theme Manager Logik fehlt: ${marker}`); const runtime = read('public/design-system/theme-runtime.js'); for (const marker of ['vendoo-theme-state-v2','dataset.reducedMotion','clearCustomProperties','previewTheme']) assert(runtime.includes(marker), `Theme Runtime fehlt: ${marker}`); const schema = read('db/schema.sql'); for (const marker of ['CREATE TABLE IF NOT EXISTS theme_profiles','CREATE TABLE IF NOT EXISTS user_theme_preferences']) assert(schema.includes(marker), `Theme-Schema fehlt: ${marker}`); if (failures.length) { console.error(`Theme-Manager-Gate 1.39.0 fehlgeschlagen (${failures.length}):`); failures.forEach(item => console.error(`- ${item}`)); process.exit(1); } console.log(`Theme-Manager-Gate 1.39.0 erfolgreich: ${Object.keys(source.tokens).length} Tokens, natives Theme-Modul, sichere Profile, Benutzerpräferenzen, Kontrastprüfung und tokenisierte Komponenten geprüft.`);