/* ===================================================================== "DU BEKOMMST GERADE NICHTS" -- der Hinweis auf der Startseite. WARUM ES IHN GIBT (03.10.2026). Am 03.10. am laufenden System nachgemessen: 20 aktive Personen, 8 mit einer Push-Anmeldung. Zwoelf bekamen keine einzige Benachrichtigung -- darunter die linke Hand und ein Modi. Keiner von ihnen wusste es: Die Glocke sagt es nur dem, der sie anschaut. WAS HIER WIRKLICH AUF DEM SPIEL STEHT, ist nicht "erscheint der Hinweis". Das ist der leichte Teil. Die Gefahr liegt auf der anderen Seite: Ein Hinweis, der auch denen erscheint, die laengst alles angeschaltet haben, oder der nach dem Wegklicken wiederkommt, ist eine Warnung, die immer kommt -- und die liest nach dem zweiten Mal niemand mehr. Dann haette ich die Glocke entwertet, die heute funktioniert. Deshalb misst diese Pruefung VIER Abwesenheiten und nur eine Anwesenheit: * weg, sobald jemand "Nicht jetzt" tippt * weg geblieben nach dem Neuladen (sonst ist es kein "einmalig") * gar nicht erst da auf einer anderen Seite (er haengt an 39 Seiten mit, wenn man es falsch baut) * gar nicht erst da, wenn die Benachrichtigungen AN sind Die letzte ist die teuerste -- sie braucht ein echtes Fenster und eine echte Anmeldung -- und sie ist die wichtigste. MIT FENSTER, UND ZWAR NOTWENDIG: Ein kopfloser Chromium verweigert Benachrichtigungen, egal wie man die Erlaubnis erteilt (am 02.10.2026 in fuenf Aufbauten gemessen, siehe pruef-push-zu). Ohne erteilte Erlaubnis kommt `lage()` nie auf "an", und die entscheidende Gegenprobe waere nicht messbar. DREI AUSGAENGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht nachsehen. ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-hinweis-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); import { notbremse } from "./helfer-notbremse.mjs"; const { eigenerPort } = await import("./helfer-port.mjs"); const PORT = await eigenerPort(import.meta, "pruef-push-hinweis"); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; await import("./index.js"); notbremse(300_000, "pruef-push-hinweis"); await new Promise((r) => setTimeout(r, 900)); const BASIS = `http://127.0.0.1:${PORT}`; let fehler = 0, geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "x" }), }).catch(() => {}); for (let i = 0; i < 60; i++) { try { const p = new DatabaseSync(process.env.WORKSPACE_DB); try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } } catch { await new Promise((r) => setTimeout(r, 250)); } } { const d = new DatabaseSync(process.env.WORKSPACE_DB); const salt = randomBytes(16).toString("hex"); const hash = scryptSync("CODE-DOGI-0001", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") .run("Filipe", "admin", hash, salt, 32768, new Date().toISOString()); d.close(); } const { chromium } = await import("playwright"); const browser = await chromium.launch({ headless: false }); const abbruch = async (grund) => { console.log(`\n KONNTE NICHT NACHSEHEN: ${grund}`); try { await browser.close(); } catch { /* egal */ } try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(2); }; /** Meldet sich an und landet auf der Startseite. */ async function anmelden(kontext) { const seite = await kontext.newPage(); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); await seite.click('.rolle[data-rolle="admin"]'); await seite.fill("#code", "CODE-DOGI-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 20000 }); await seite.waitForTimeout(700); return seite; } /** Was steht gerade im Hinweis -- und wie gross ist es? */ const hinweisLesen = (seite) => seite.evaluate(() => { const k = document.querySelector("#push-hinweis .push-hinweis"); if (!k) return { da: false }; const t = k.querySelector(".push-hinweis__text"); const knoepfe = [...k.querySelectorAll(".push-hinweis__knopf")].map((b) => ({ text: b.textContent.trim(), hoch: Math.round(b.getBoundingClientRect().height), breit: Math.round(b.getBoundingClientRect().width), })); const stil = getComputedStyle(t); return { da: true, text: t.textContent.replace(/\s+/g, " ").trim(), schrift: parseFloat(stil.fontSize), farbe: stil.color, knoepfe, }; }); /* ===================================================================== 1. Ohne Anmeldung: der Hinweis steht da ===================================================================== */ console.log("\n=== Wer nichts bekommt, erfaehrt es ==="); const kontext = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, }); const seite = await anmelden(kontext); { const erlaubnis = await seite.evaluate(() => Notification.permission); if (erlaubnis === "granted") { await abbruch("der Browser hat Benachrichtigungen schon erlaubt -- dann " + "laesst sich der Fall \"noch nicht angeschaltet\" hier nicht messen"); } const h = await hinweisLesen(seite); if (!h.da) { await abbruch("auf der Startseite steht gar kein Hinweis -- ohne ihn " + "ist nichts von dem unten Messbares messbar"); } ok(/keine Benachrichtigungen/.test(h.text), `er sagt, worum es geht ("${h.text.slice(0, 64)}…")`); ok(h.knoepfe.length === 2, `zwei Knoepfe: ${h.knoepfe.map((b) => b.text).join(" / ")}`); ok(h.knoepfe.some((b) => /Anschalten/.test(b.text)), "einer davon schaltet es an -- der Hinweis ist nicht nur eine Mitteilung"); /* Lesbarkeit: im Haus gilt keine Schrift unter 11,5 px, und ein Ziel, das man mit dem Daumen trifft, ist mindestens 44 px hoch. */ ok(h.schrift >= 11.5, `Schriftgroesse ${h.schrift} px (mindestens 11,5)`); const klein = h.knoepfe.filter((b) => b.hoch < 44); ok(klein.length === 0, klein.length ? `zu flache Knoepfe: ${klein.map((b) => b.text + " " + b.hoch + "px")}` : `beide Knoepfe mindestens 44 px hoch (${h.knoepfe.map((b) => b.hoch).join(", ")})`); /* Auf 390 px darf nichts ueber den Rand stehen. */ const ueber = await seite.evaluate(() => { const k = document.querySelector("#push-hinweis .push-hinweis"); return Math.round(k.getBoundingClientRect().right - document.documentElement.clientWidth); }); ok(ueber <= 0, `nichts steht ueber (${ueber} px)`); /* EIN BILD DAZU. Zahlen sagen, dass er passt -- nicht, ob er aussieht wie etwas, das man anfassen moechte. ERST HINSCROLLEN. Der erste Versuch zeigte brav die Begruessungskachel: Der Hinweis steht darunter und war im Bild gar nicht drin. Ein Beweisbild ohne das, was es beweisen soll, ist schlimmer als keins -- man glaubt ja, hingesehen zu haben. */ await seite.evaluate(() => document.querySelector("#push-hinweis") .scrollIntoView({ block: "center" })); await seite.waitForTimeout(400); await seite.screenshot({ path: new URL("./pruef-push-hinweis.png", import.meta.url).pathname .replace(/^\/([A-Za-z]:)/, "$1"), }); } /* ===================================================================== 2. Auf anderen Seiten NICHT -- er haengt an 39 Seiten mit ===================================================================== */ console.log("\n=== Nur auf der Startseite ==="); { await seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(600); const h = await hinweisLesen(seite); ok(!h.da, "auf der Aufgabenseite steht er nicht"); /* Gegenprobe zur Gegenprobe: Die Glocke ist dort sehr wohl da -- sonst wuerde oben nur gemessen, dass glocke.js gar nicht laeuft. */ const glocke = await seite.evaluate(() => !!document.getElementById("glocke")); ok(glocke, "die Glocke selbst schon -- glocke.js laeuft dort also"); await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(600); ok((await hinweisLesen(seite)).da, "und auf der Startseite wieder"); } /* ===================================================================== 3. "Nicht jetzt" heisst nicht jetzt und auch nicht morgen ===================================================================== */ console.log("\n=== Einmal weggetippt, bleibt weg ==="); { await seite.click('.push-hinweis__knopf--spaeter'); await seite.waitForTimeout(300); ok(!(await hinweisLesen(seite)).da, "nach dem Tippen ist er weg"); await seite.reload({ waitUntil: "networkidle" }); await seite.waitForTimeout(800); ok(!(await hinweisLesen(seite)).da, "und nach dem Neuladen immer noch -- sonst waere es keine einmalige Sache"); } /* ===================================================================== 4. DIE WICHTIGSTE: Wer sie AN hat, wird nicht behelligt ===================================================================== */ console.log("\n=== Wer sie an hat, sieht nichts davon ==="); { /* ==== EIN ECHTES PROFIL, KEIN INKOGNITO-FENSTER ================= Erst gemessen, dann verstanden (03.10.2026): `browser.newContext()` gibt ein Inkognito-Fenster, und dort meldete die Glocke hartnaeckig "aus". Der Browser hat es im Klartext gesagt, sobald die Pruefung ihn gefragt hat: "Chrome currently does not support the Push API in incognito mode. There is deliberately no way to feature-detect this, since incognito mode needs to be undetectable by websites." Also: kein Befund am Haus, sondern eine Eigenschaft des Fensters -- und einer, den man NICHT abfragen kann. Ohne das Mitschreiben der Browsermeldungen haette hier eine Stunde Suche im eigenen Code gestanden. `launchPersistentContext` mit einem eigenen Ordner ist ein richtiges Profil. Der Ordner wird am Ende weggeraeumt. */ const profil = mkdtempSync(join(tmpdir(), "ws-hinweis-profil-")); const k2 = await chromium.launchPersistentContext(profil, { headless: false, viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, permissions: ["notifications"], }); await k2.grantPermissions(["notifications"], { origin: BASIS }); const s2 = await anmelden(k2); const erlaubnis = await s2.evaluate(() => Notification.permission); if (erlaubnis !== "granted") { await abbruch("der Browser erlaubt keine Benachrichtigungen " + `(Notification.permission = "${erlaubnis}") -- ein kopfloser Chromium ` + "sagt immer „denied“. Diese Pruefung braucht ein Fenster."); } /* Vor dem Anschalten muss er da sein -- sonst beweist sein Fehlen danach gar nichts (er koennte aus einem ganz anderen Grund fehlen, etwa weil der neue Kontext nie fertig geladen hat). */ ok((await hinweisLesen(s2)).da, "im frischen Browser steht er zuerst da (sonst beweist sein Fehlen nichts)"); /* Mitschreiben, was der Browser sagt -- scheitert das Anmelden, steht der Grund hier und nicht in einer Vermutung. */ const klagen = []; s2.on("console", (m) => { if (m.type() === "error") klagen.push(m.text()); }); s2.on("pageerror", (f) => klagen.push(String(f?.message || f))); await s2.click(".push-hinweis__knopf--ja"); /* AUF DEN ZUSTAND WARTEN, NICHT AUF DIE UHR. Ein fester Schlaf ist entweder zu kurz (dann ist es ein Fehlalarm) oder zu lang (dann wartet jeder Lauf umsonst). `pushManager.subscribe` spricht mit einem echten Push-Dienst im Netz -- wie lange das dauert, weiss nur er. */ let lage = null; for (let i = 0; i < 60; i++) { lage = await s2.evaluate(() => document.getElementById("glocke")?.dataset.lage); if (lage === "an") break; await s2.waitForTimeout(500); } if (lage !== "an") { await abbruch(`das Anschalten hat nicht geklappt (Glocke steht auf "${lage}") ` + "-- ohne eine echte Anmeldung ist die Gegenprobe nicht messbar." + (klagen.length ? `\n Der Browser sagt dazu: ${klagen.slice(0, 3).join(" | ")}` : "\n Der Browser hat sich dazu nicht geaeussert. Moeglich ist, dass " + "pushManager.subscribe() keinen echten Push-Dienst erreicht " + "(dafuer braucht dieser Rechner eine Verbindung ins Netz).")); } ok(lage === "an", "nach dem Antippen steht die Glocke auf „an“"); ok(!(await hinweisLesen(s2)).da, "und der Hinweis ist weg -- ohne dass jemand ihn wegklicken musste"); await s2.reload({ waitUntil: "networkidle" }); await s2.waitForTimeout(1200); ok(!(await hinweisLesen(s2)).da, "auch nach dem Neuladen bleibt er weg"); /* Und das Angemeldetsein ist echt: Es steht in der Datenbank. */ const d = new DatabaseSync(process.env.WORKSPACE_DB); const n = d.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n; d.close(); ok(n === 1, `die Anmeldung steht wirklich in der Datenbank (${n})`); await k2.close(); try { rmSync(profil, { recursive: true, force: true }); } catch { /* egal */ } } await browser.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(`\n${geprueft} Pruefungen, ${fehler} Fehler`); console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); process.exit(fehler ? 1 : 0);