import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import vm from 'node:vm' // Statische Pruefung und Funktionstests des Website-Skripts public/embed/zeiten.js (Oeffnungszeiten live, Aufgabe 25; // Spezifikation 4.2 und 4.3 Punkt 4, Rulings R6 und Skript-Regeln der Gesamtvorgaben): // - Das Skript rechnet nie mit Datum oder Zeitzone des Browsers: "jetzt" ist Date.now() (bei grosser Uhrabweichung die Serverzeit aus // dem Date-Kopf), Wochentage und Kalendertage kommen als Zeichenketten vom Server und werden rein rechnerisch ausgewertet. // - Kein Browser-Speicher, keine Cookies, keine Fremdadressen im Code; Kundentext nur ueber textContent. // - Die reinen Funktionen (Marken @rein-anfang / @rein-ende) laufen hier einzeln in einem leeren vm-Kontext. // Task 26 haengt Hinweis-Leiste, Kontakt-Leiste und JSON-LD an dieselbe Datei und ergaenzt diese Datei um deren Pruefungen. const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8') const QUELLE = lies('public/embed/zeiten.js') /** Quelltext ohne Kommentare (die Kopfzeile darf Beispiele nennen, der Code nicht). */ const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1') /** Ergebnisse aus dem vm-Kontext haben andere Prototypen (deepStrictEqual wuerde scheitern): ueber JSON angleichen. */ const norm = (x) => JSON.parse(JSON.stringify(x)) test('Quelltext ist gueltiges JavaScript (kompiliert, wird nicht ausgefuehrt)', () => { assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'zeiten.js' })) }) test('Quelltext: keine Cookies, kein Storage, kein fremder Host', () => { for (const verboten of ['localStorage', 'sessionStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent', 'resolvedOptions']) { assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Skript nicht vorkommen`) } // auch nicht in Kommentaren oder Texten: nirgends ein https://-Literal (die Adresse kommt aus data-api oder der Herkunft des Skripts) assert.doesNotMatch(QUELLE, /https?:\/\//, 'Adressen gehoeren in data-api, nicht in die Datei') assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/) }) test('Quelltext: Date nur als Date.now( und Date.parse(, keine Zeitzonen- und Kalenderrechnung des Browsers', () => { // erlaubt: Date.now(, Date.parse( und der Kopfname 'Date' (headers.get('Date')) const rest = OHNE_KOMMENTARE.replace(/\bDate\.now\(/g, '').replace(/\bDate\.parse\(/g, '').replace(/headers\.get\('Date'\)/g, '') assert.ok(!/\bDate\b/.test(rest), 'Date darf nur als Date.now( oder Date.parse( vorkommen') assert.ok(OHNE_KOMMENTARE.includes('Date.now('), 'Date.now( fehlt') assert.ok(OHNE_KOMMENTARE.includes('Date.parse('), 'Date.parse( fehlt') for (const verboten of ['getHours', 'getMinutes', 'getDay', 'getDate', 'getFullYear', 'getMonth', 'getTime(', 'toLocale', 'Intl.', 'getTimezoneOffset', 'toISOString', 'toUTCString', 'new Date']) { assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Skript nicht vorkommen`) } }) test('Quelltext: Kundentexte nie per innerHTML (kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, srcdoc)', () => { for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'createContextualFragment', 'srcdoc', 'DOMParser']) { assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Skript nicht vorkommen`) } assert.ok(OHNE_KOMMENTARE.includes('textContent'), 'Texte kommen ueber textContent') }) test('Quelltext: setTimeout nur mit Funktionsargument, in genau einer Stelle, jede Wartezeit auf 0 bis 600000 ms begrenzt (R6)', () => { const stellen = [...OHNE_KOMMENTARE.matchAll(/\bsetTimeout\(/g)] assert.equal(stellen.length, 1, 'genau eine Stelle ruft setTimeout auf (Hilfsfunktion timer)') assert.match(OHNE_KOMMENTARE, /setTimeout\(function \(\) \{/, 'setTimeout bekommt eine Funktion, nie eine Zeichenkette') assert.doesNotMatch(OHNE_KOMMENTARE, /\bsetInterval\(/, 'kein setInterval') assert.match(OHNE_KOMMENTARE, /var TIMER_MAX_MS = 600000\b/, 'Obergrenze 10 Minuten (setTimeout laeuft ab 2^31-1 ms ueber)') const zeile = OHNE_KOMMENTARE.split('\n').find((z) => z.includes('setTimeout(')) const hilfe = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function timer ('), OHNE_KOMMENTARE.indexOf(zeile) + zeile.length + 80) assert.match(hilfe, /Math\.max\(0, Math\.min\(TIMER_MAX_MS,/, 'die Wartezeit wird auf [0, TIMER_MAX_MS] begrenzt') assert.match(OHNE_KOMMENTARE, /var RELOAD_MS = 600000\b/, 'Neuladen hoechstens alle 10 Minuten') assert.ok(OHNE_KOMMENTARE.includes('visibilitychange'), 'Neuladen auch beim Zurueckkehren in den Tab') }) test('Doppel-Laden-Schutz __wkZeiten', () => { assert.match(OHNE_KOMMENTARE, /if \(window\.__wkZeiten\) return/) assert.match(OHNE_KOMMENTARE, /window\.__wkZeiten = true/) assert.ok(OHNE_KOMMENTARE.indexOf('window.__wkZeiten = true') < OHNE_KOMMENTARE.indexOf("var STYLE_ID"), 'der Schutz steht vor allem anderen') }) test('Abruf: genau ein fetch, ohne Cookies, nur an die Zeiten-Schnittstelle; letzter erfolgreicher Abruf im Speicher der Seite', () => { assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch (ein Abruf je Seite fuer alle Teile)') assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'") assert.ok(OHNE_KOMMENTARE.includes('/api/public/zeiten/v1/')) assert.match(OHNE_KOMMENTARE, /var ALTER_MAX_MS = 24 \* 60 \* 60 \* 1000\b/, 'Daten aelter als 24 Stunden gelten nicht mehr') assert.match(OHNE_KOMMENTARE, /geladenMs = /, 'Zeitpunkt des letzten erfolgreichen Abrufs wird gemerkt (nicht body.stand)') assert.ok(!OHNE_KOMMENTARE.includes('.stand'), 'body.stand zaehlt nicht fuer das Alter der Daten') assert.ok(OHNE_KOMMENTARE.includes("headers.get('Date')"), 'Serverzeit aus dem Date-Kopf') }) test('Pflichtbausteine der Spezifikation (Platzhalter, Stile, Tabelle, Plaene, Texte)', () => { for (const noetig of [ 'data-webklar-zeiten', 'data-site', 'data-api', 'data-modus', 'data-plan', 'currentScript', "'wkz-style'", '@layer wkz', 'wkz-', '--wkz-', "'caption'", "'scope'", "'row'", "'th'", 'aria-current', "'date'", 'tabelle', 'status', 'badge', 'haupt', 'console.warn', 'Abweichungen in den nächsten 14 Tagen', 'rund um die Uhr', 'nach Vereinbarung', 'geschlossen', 'Montag', 'Sonntag', 'DOMContentLoaded', 'removeChild', ]) { assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`) } assert.doesNotMatch(OHNE_KOMMENTARE, /aria-live/, 'der Status meldet sich nicht von selbst (kein aria-live)') }) test('Stile stehen einmal in