Files
dogfather-universe/server/pruef-installieren.mjs
T
DogFatherGitandClaude Opus 5 bf3c7bd718 Einen Aushang loest jeder fuer sich -- fuer alle nur DogFather und die rechte Hand
Filipe: "jeder soll das fixierte individuel für sich lösen können aber
niemals so dass es sich für alle löst. außer dogfather macht es oder die
rechte hand dan ist es bei jedem weg ansonsten sollen alle anderen
rollen es individuell für sich lösen können. dogfather und die rechte
hand sollen die option haben für sich selbst oder für alle zu lösen."

BIS HEUTE GAB ES NUR EIN LOESEN, UND DAS GALT FUER ALLE. Wer den Knopf
sah, nahm damit jedem im Raum den Aushang weg; wer ihn nicht sah, musste
die Ansage vom Montag bis Freitag ueber jedem Gespraech stehen lassen.

JETZT ZWEI KNOEPFE AM AUSHANG:

  "lösen"      nimmt ihn nur bei MIR weg -- jeder darf das, ohne
               Rueckfrage. Umkehrbar: Das Menue an der Nachricht holt
               ihn mit "wieder oben" zurueck. Eine Rueckfrage vor etwas
               Umkehrbarem lernt man wegzuklicken, und danach klickt man
               auch die weg, die zaehlt.

  "bei allen"  nimmt ihn jedem weg -- nur fuer DogFather und die rechte
               Hand, und MIT Rueckfrage. Er traegt die Warnfarbe des
               Hauses: Zwei gleich aussehende Knoepfe nebeneinander
               waeren die schlechteste Loesung, man traefe den falschen
               und merkte es erst, wenn jemand fragt, wo die Ansage
               hin ist.

EIN EINZIGER KNOPF MIT AUSWAHLFENSTER waere kuerzer und schlechter: Der
haeufige Fall ("weg damit, kenne ich") braeuchte dann zwei Klicks, und
der seltene, folgenreiche waere genauso weit entfernt wie der harmlose.

WAS NICHT IN FILIPES SATZ STEHT UND TROTZDEM NOETIG IST: Wer einen
Aushang SELBST angeheftet hat, darf ihn auch selbst wieder fuer alle
loesen. Sonst entsteht eine Sackgasse -- es haengen hoechstens drei,
und eine Gruppenleitung, die drei angeheftet hat und keinen abnehmen
darf, koennte nie wieder etwas anheften. Sie nimmt damit nur zurueck,
was sie selbst getan hat; das ist die Kehrseite derselben Erlaubnis,
keine neue.

TECHNISCH: eine Tabelle `chat_pin_aus` (Nachricht, Person). Kein
Eintrag heisst sichtbar -- nicht umgekehrt, sonst muesste beim Anheften
fuer jeden Teilnehmer eine Zeile entstehen und wer spaeter dazukommt,
saehe den Aushang nie. Der Verbund steht in der Abfrage und nicht im
Browser: Eine Liste, die alles schickt und im Browser gefiltert wird,
ist eine Liste, die alles schickt.

GEMESSEN -- server/pruef-pin-fuer-mich.mjs (neu), 37 Pruefungen, 0 Fehler:
  - Der Modi nimmt sie bei sich weg. BEI DOGFATHER UND BEIM ZWEITEN
    MODI HAENGT SIE WEITER -- das ist der Kern des Auftrags, und er
    laesst sich nur mit mehreren Anmeldungen messen.
  - Er holt sie zurueck; zweimal wegnehmen ist kein Fehler.
  - Er kann NICHT fuer alle loesen (403), und die Absage sagt, was
    stattdessen geht.
  - Die rechte Hand loest fuer alle -- danach ist sie bei jedem weg.
  - Wer selbst angeheftet hat, loest seinen eigenen (200) und den von
    DogFather nicht (403).
  - Das Nachruecken stimmt: Wer einen von dreien weggenommen hat, sieht
    zwei, waehrend DogFather drei sieht.
  - Drei Gegenproben: fremder Raum 404, ohne Anmeldung 401, erfundene
    Nummer 404.

DIE PRUEFUNG MUSSTE AUF node:http UMGEBAUT WERDEN. Sie braucht
Team-Dogi-Rollen, die es nur auf der Crew-Adresse gibt -- und den
`Host`-Kopf laesst `fetch` nicht setzen (verbotener Kopf, undici
verwirft ihn stumm). Die Anfrage kam auf 127.0.0.1 an, waehrend
`Origin` die Crew-Adresse nannte; jede schreibende Anfrage bekam 403
"fremde_herkunft". Im ersten Lauf sah das aus, als sei die neue Route
kaputt.

AUSSERDEM IN DIESER RUNDE: Die drei Faecher im Chat (Personen, Gruppen,
Kanaele) waren 38 px hoch statt 44. Gefunden vom Handy-Rundgang bei
jeder Rolle -- aber erst, seit der Sammellauf auch die Zeile UNTER dem
Befund mitschreibt. Vorher stand dort nur "1 Befund".

Die Schemaaenderung auf einer Kopie der echten Datenbank durchgespielt:
72 Tabellen, keine Zeile und keine Spalte verloren, chat_pin_aus da.
pruef-chat 63/0, pruef-chat-optik 62/0, pruef-chat-kanaele 81/0,
pruef-treffchat 110/0, pruef-chat-neu 36/0, pruef-handy-teamdogi
0 Befunde, pruef-css-klassen gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 14:13:05 +02:00

403 lines
19 KiB
JavaScript

/* =====================================================================
pruef-installieren.mjs — Die Seite als App auf dem Gerät
Filipe, 20.09.2026: „kannst du da dazwischen in der starseite oben,
ein button reinsetzen damit die leute die seite installieren koennen
auf dem handy oder pc. das soll auch verschwinden wenn die leute es
installiert haben."
---------------------------------------------------------------------
WARUM DAS MEHR IST ALS EIN KNOPF
Ohne installierte App gibt es auf dem Handy keine verlässlichen
Benachrichtigungen — daran hing im September das Telefonieren. Das
Manifest war die ganze Zeit vollständig, die App ließ sich
installieren. Nur bot das ausschließlich der Browser an, versteckt
in seinem Dreipunktemenü.
DREI ZUSTÄNDE, DREI ANTWORTEN — und jede wird hier geprüft:
1. schon installiert -> gar kein Knopf
2. Browser bietet an -> Knopf fragt ihn
3. Safari/iPhone -> Knopf erklärt den Weg (dort gibt es
`beforeinstallprompt` nicht und wird es
nicht geben)
Der gefährlichste Fall ist der dritte: Ein Knopf, der auf dem iPhone
nichts tut, ist schlimmer als keiner — man drückt ihn und sucht den
Fehler bei sich.
AUFRUF: node server/pruef-installieren.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eigenerPort } from "./helfer-port.mjs";
import { notbremse } from "./helfer-notbremse.mjs";
const PORT = await eigenerPort(import.meta, "pruef-installieren");
const ordner = mkdtempSync(join(tmpdir(), "ws-install-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(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");
/* 220 -> 360 s: Abschnitt 0 startet einen weiteren Browser
und wartet auf den Service Worker. */
notbremse(360_000, "pruef-installieren");
await new Promise((r) => setTimeout(r, 900));
const BASIS = `http://127.0.0.1:${PORT}`;
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
console.log("\n=== Die Seite als App installieren ===\n");
/* ---- 1. Das Manifest, ohne Browser -------------------------------- */
{
const { readFileSync } = await import("node:fs");
const { fileURLToPath } = await import("node:url");
const { dirname } = await import("node:path");
const HIER = dirname(fileURLToPath(import.meta.url));
const m = JSON.parse(readFileSync(join(HIER, "..", "workspace", "crew.webmanifest"), "utf8"));
/* Ohne diese vier Angaben bietet kein Browser das Installieren an --
der schoenste Knopf haette dann nichts zu fragen. */
ok(!!m.name && !!m.short_name, `Name und Kurzname stehen (${m.short_name})`);
ok(m.display === "standalone", `display ist "${m.display}"`);
ok(!!m.start_url && m.start_url.startsWith("/workspace/"),
`Startadresse zeigt in den Workspace (${m.start_url})`);
const gross = (m.icons || []).filter((i) => /512|384/.test(i.sizes || ""));
ok(gross.length >= 1, `mindestens ein grosses Symbol (${gross.length})`);
}
/* ---- 2. Am echten Bildschirm -------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
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 pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
try {
async function seiteAuf(extra = {}) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 860 }, ...extra });
const s = await ctx.newPage();
await s.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await s.click('.rolle[data-rolle="admin"]');
await s.fill("#code", "CODE-DOGI-0001");
await s.click("button[type=submit]");
await s.waitForURL("**/start.html", { timeout: 25000 });
await s.waitForTimeout(1300);
return { ctx, s };
}
/* =====================================================================
0. BIETET DER BROWSER ES UEBERHAUPT AN? (neu am 22.09.2026)
=====================================================================
DAS HIER IST DIE FRAGE, DIE ZWEI TAGE LANG NIEMAND GESTELLT HAT.
Die Pruefungen darunter stellen `beforeinstallprompt` SELBST zu und
messen, was die Seite damit macht. Das war richtig gedacht und
vollstaendig wertlos: Sie waren gruen, waehrend der Knopf auf
Filipes Rechner seit dem 20.09. unsichtbar blieb, weil das echte
Ereignis nie kam. Er hat dreimal danach gefragt.
ZWEI URSACHEN, beide ausserhalb dessen, was gemessen wurde:
1. Der Service Worker wurde nur in `glocke.js` angemeldet, also
erst beim ERLAUBEN von Benachrichtigungen. Auf Filipes
Rechner stehen sie auf "Vom Browser blockiert".
2. Der Service Worker hatte keinen `fetch`-Zuhoerer. Chrome
verlangt ihn fuer sein Installationsangebot.
Ohne Service Worker kein Angebot, ohne Angebot kein Knopf.
GEMESSEN WIRD JETZT AN DER QUELLE: `Page.getInstallabilityErrors`
ist Chromes eigenes Urteil ueber genau diese Frage. Eine leere
Liste heisst, dass der Browser es anbietet. Das kann keine
nachgestellte Veranstaltung vortaeuschen.
`beforeinstallprompt` selbst wird hier bewusst NICHT verlangt: In
dieser Umgebung (kein Profil, kein Nutzerverhalten, kein Fenster)
loest Chromium es nicht aus. Das Urteil oben ist die Aussage, auf
die es ankommt.
===================================================================== */
{
const ctx0 = await browser.newContext({ viewport: { width: 1280, height: 860 } });
const s0 = await ctx0.newPage();
const cdp = await ctx0.newCDPSession(s0);
await cdp.send("Page.enable");
/* OHNE ANMELDUNG UND OHNE BENACHRICHTIGUNGSRECHT -- genau die Lage,
in der es bisher nicht ging. Die Anmeldewand ist ausserdem die
Seite, auf der jeder zuerst landet. */
await s0.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await s0.waitForTimeout(2200);
const sw = await s0.evaluate(async () => {
if (!("serviceWorker" in navigator)) return { da: false };
const r = await navigator.serviceWorker.getRegistration("/workspace/");
return { da: !!r, aktiv: !!r?.active, stand: r?.active?.state || null,
recht: Notification.permission };
});
ok(sw.recht !== "granted",
`Benachrichtigungen sind NICHT erlaubt (${sw.recht}) -- genau Filipes Lage`);
ok(sw.da === true && sw.aktiv === true,
`und trotzdem laeuft ein Service Worker (${sw.stand}) `
+ `-- vorher haette hier "keiner" gestanden`);
/* Der `fetch`-Zuhoerer: ohne ihn bietet Chrome nichts an. Gemessen
an der Datei, nicht am Gedaechtnis. */
const { readFileSync: lies } = await import("node:fs");
const { fileURLToPath: alsPfad } = await import("node:url");
const { dirname: ordnerVon } = await import("node:path");
const wurzel = join(ordnerVon(alsPfad(import.meta.url)), "..");
const swText = lies(join(wurzel, "workspace", "sw.js"), "utf8");
ok(/addEventListener\(\s*["']fetch["']/.test(swText),
"sw.js hat einen fetch-Zuhoerer -- Chromes Bedingung fuers Anbieten");
/* UND ER LEGT NICHTS AB. Das Versprechen im Kopf der Datei ist
keine Absichtserklaerung, sondern wird hier nachgesehen: Wer
`caches.open` einbaut, hebt Antworten auf -- hinter einer
Anmeldung waeren das persoenliche Daten auf fremden Geraeten. */
ok(!/caches\.(open|match)|cache\.put/.test(swText),
"und er legt nichts ab -- keine persoenlichen Daten auf dem Geraet");
let urteil = null;
try {
urteil = (await cdp.send("Page.getInstallabilityErrors")).installabilityErrors;
} catch (f) { urteil = "unlesbar: " + f.message; }
/* DRITTER AUSGANG: Kann die Frage gar nicht gestellt werden, ist
das kein Bestehen. Eine Pruefung, die ihre Voraussetzung nicht
hat, muss das sagen. */
ok(Array.isArray(urteil),
Array.isArray(urteil) ? "Chrome laesst sich nach der Installierbarkeit fragen"
: `KONNTE NICHT NACHSEHEN (${urteil})`);
ok(Array.isArray(urteil) && urteil.length === 0,
Array.isArray(urteil) && urteil.length
? `Chrome nennt Hindernisse: ${urteil.map((f) => f.errorId).join(", ")}`
: "und nennt kein einziges Hindernis -- die Seite ist installierbar");
let manifest = null;
try { manifest = await cdp.send("Page.getAppManifest"); } catch { /* s.o. */ }
const mFehler = (manifest?.errors || []).filter((f) => f.critical !== 0);
ok(!!manifest?.url, `Chrome findet das Manifest (${manifest?.url || "nicht"})`);
ok(mFehler.length === 0 && (manifest?.errors || []).length === 0,
(manifest?.errors || []).length
? `es meldet: ${(manifest.errors || []).map((f) => f.message).join(" | ").slice(0, 120)}`
: "und beanstandet nichts daran");
/* DER KNOPF STEHT AUF DER ANMELDEWAND. Sie hat keine Kopfleiste und
hatte deshalb frueher gar kein Angebot -- dabei landet dort
jeder zuerst. */
const aufWand = await s0.evaluate(() => {
const k = document.querySelector(".install-knopf");
if (!k) return { da: false };
const r = k.getBoundingClientRect();
return { da: true, sichtbar: !k.hidden, breit: Math.round(r.width),
hoch: Math.round(r.height), imPlatz: !!k.closest("[data-install-platz]"),
wort: k.textContent.trim() };
});
ok(aufWand.da && aufWand.imPlatz,
`der Knopf steht auf der Anmeldewand, an seinem vorgesehenen Platz`);
ok(aufWand.sichtbar === true && aufWand.hoch >= 44,
`und ist zu sehen und zu treffen (${aufWand.breit}x${aufWand.hoch} px, "${aufWand.wort}")`);
ok(/installieren/i.test(aufWand.wort || ""),
`und sagt, was er tut ("${aufWand.wort}")`);
/* GEGENPROBE: nur EIN Knopf. Seit der Aufruf nicht mehr an der
Reihenfolge der Skriptzeilen haengt, kann er aus zwei Richtungen
kommen. */
const anzahl = await s0.locator(".install-knopf").count();
ok(anzahl === 1, `und zwar genau einer (${anzahl})`);
/* UND ER TUT ETWAS, WENN MAN IHN DRUECKT.
Das ist hier keine Formalie, sondern der Fall, an dem der erste
Entwurf gescheitert waere: Der Knopf rief `window.frageNach?.()`
auf. Diese Seite laedt `nachfrage.js` NICHT -- also war die
Funktion `undefined`, und `?.()` verschluckt das lautlos. Auf
dem iPhone steht der Knopf hier von Anfang an da. Er haette
stumm dagestanden, und wer ihn drueckt, sucht den Fehler bei
sich.
Gegenprobe gleich dahinter: ein zweiter Druck macht die
Erklaerung wieder zu. Sonst sammeln sich Absaetze. */
const ohneDialog = await s0.evaluate(() => typeof window.frageNach !== "function");
ok(ohneDialog, "auf der Anmeldewand gibt es den Dialogbaustein nicht (nachfrage.js fehlt dort)");
await s0.click(".install-knopf");
await s0.waitForTimeout(400);
const hinweis = await s0.evaluate(() => {
const h = document.querySelector(".install-hinweis");
return h ? h.textContent.replace(/\s+/g, " ").trim() : null;
});
ok(!!hinweis && /Menü|Teilen|Home-Bildschirm/i.test(hinweis),
`er erklaert trotzdem den Weg ("${(hinweis || "nichts").slice(0, 60)}…")`);
await s0.click(".install-knopf");
await s0.waitForTimeout(300);
const zu = await s0.locator(".install-hinweis").count();
ok(zu === 0, `und ein zweiter Druck macht sie wieder zu (${zu})`);
await ctx0.close();
}
/* ---- Zustand 2: der Browser bietet es an ----------------------- */
const a = await seiteAuf();
/* Chromium loest `beforeinstallprompt` in dieser Umgebung nicht von
selbst aus (kein echtes Profil, kein Nutzerverhalten). Das
Ereignis wird deshalb nachgestellt -- geprueft wird, was DIE SEITE
damit macht, und genau das ist hier die Frage. */
const vorher = await a.s.locator(".install-knopf").count();
const sichtbarVorher = await a.s.evaluate(() =>
!!document.querySelector(".install-knopf:not([hidden])"));
ok(vorher === 1, `der Knopf steht im Dokument (${vorher})`);
/* HIER STAND: "bleibt versteckt, solange der Browser nichts
anbietet". Das war bis zum 22.09.2026 richtig -- und genau der
Grund, warum Filipe ihn nie gesehen hat. `beforeinstallprompt`
kommt in Chrome nicht verlaesslich sofort und gar nicht mehr,
wenn es kurz vorher weggeklickt wurde.
Jetzt zeigt er sich, sobald der Browser das Installieren
ueberhaupt KANN (`'onbeforeinstallprompt' in window`, wahr bei
allen Chromium-Browsern) -- und faellt auf die Erklaerung
zurueck, falls das Angebot ausbleibt. Wo auch das nicht zutrifft
und kein Apple-Geraet vorliegt, bleibt er weg. */
ok(sichtbarVorher === true,
"und ist in einem Browser, der installieren kann, von Anfang an zu sehen");
await a.s.evaluate(() => {
const e = new Event("beforeinstallprompt");
e.prompt = () => {};
e.userChoice = Promise.resolve({ outcome: "accepted" });
window.dispatchEvent(e);
});
await a.s.waitForTimeout(300);
const nachher = await a.s.evaluate(() => {
const k = document.querySelector(".install-knopf");
if (!k) return null;
const r = k.getBoundingClientRect();
return { sichtbar: !k.hidden, text: k.textContent.trim(),
satz: k.getAttribute("aria-label"),
breit: Math.round(r.width), hoch: Math.round(r.height) };
});
ok(nachher?.sichtbar === true, `sobald der Browser anbietet, steht er da ("${nachher?.text}")`);
ok((nachher?.hoch || 0) >= 44, ` und ist am Finger treffbar (${nachher?.hoch} px)`);
ok(/installieren/i.test(nachher?.satz || ""),
` mit einem ganzen Satz fuer Vorleseprogramme ("${nachher?.satz}")`);
/* Und ein Druck macht ihn fertig. */
await a.s.click(".install-knopf");
await a.s.waitForTimeout(500);
const weg = await a.s.locator(".install-knopf").count();
ok(weg === 0, `nach dem Installieren ist er weg (${weg})`);
/* ---- Zustand 1: schon installiert ------------------------------ */
const b = await browser.newContext({ viewport: { width: 1280, height: 860 } });
const bs = await b.newPage();
/* `display-mode: standalone` laesst sich nicht direkt setzen --
`matchMedia` wird deshalb vor dem Laden ueberschrieben. Damit
antwortet die Seite genau so, wie sie es in einer installierten
App taete. */
await bs.addInitScript(() => {
const echt = window.matchMedia.bind(window);
window.matchMedia = (q) => (q.includes("display-mode: standalone")
? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} }
: echt(q));
});
await bs.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await bs.click('.rolle[data-rolle="admin"]');
await bs.fill("#code", "CODE-DOGI-0001");
await bs.click("button[type=submit]");
await bs.waitForURL("**/start.html", { timeout: 25000 });
await bs.waitForTimeout(1300);
const inApp = await bs.locator(".install-knopf").count();
ok(inApp === 0,
`Gegenprobe: in der installierten App gibt es den Knopf gar nicht erst (${inApp})`);
/* ---- Zustand 3: Safari auf dem iPhone -------------------------- */
const c = await browser.newContext({
viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true,
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
});
const cs = await c.newPage();
await cs.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await cs.click('.rolle[data-rolle="admin"]');
await cs.fill("#code", "CODE-DOGI-0001");
await cs.click("button[type=submit]");
await cs.waitForURL("**/start.html", { timeout: 25000 });
await cs.waitForTimeout(1300);
/* ==== ER KANN SEIT DEM 25.09.2026 IM MEHR-MENUE STEHEN ==========
Die Kopfleiste haelt sich seither selbst einreihig: Passt sie
nicht in eine Zeile, wandert der naechste Kandidat in ein
Mehr-Menue, und der Installieren-Knopf ist der zweite auf dieser
Liste. Auf einem iPhone mit 390 px ist er deshalb zu finden --
nur eben hinter einem Knopf.
GEMESSEN WIRD DAMIT, WAS ZAEHLT: Ist er ERREICHBAR und, wenn man
ihn sieht, treffbar? Nicht: Steht er an einer bestimmten Stelle.
Die alte Fassung verlangte das Zweite und war deshalb rot,
obwohl der Knopf zwei Fingertipps entfernt stand.
ZUERST WIRD AUFGEMACHT, DANN GEMESSEN -- und wenn es kein Menue
gibt, bleibt alles wie vorher. */
await cs.evaluate(() => {
const m = document.getElementById("kopf-mehr");
if (m && !m.hidden) m.click();
});
await cs.waitForTimeout(300);
const beiApple = await cs.evaluate(() => {
const k = document.querySelector(".install-knopf");
if (!k) return null;
const r = k.getBoundingClientRect();
return {
sichtbar: !k.hidden,
breit: Math.round(r.width), hoch: Math.round(r.height),
imMenue: !!k.closest(".kopf-mehr__fach"),
};
});
/* DER WICHTIGSTE FALL: Dort gibt es `beforeinstallprompt` nicht --
der Knopf muss trotzdem dastehen und erklaeren. */
ok(beiApple?.sichtbar === true,
"auf dem iPhone steht er von Anfang an da (dort fragt der Browser nie)"
+ (beiApple?.imMenue ? " -- im Mehr-Menue" : ""));
ok((beiApple?.breit || 0) >= 44 && (beiApple?.hoch || 0) >= 44,
` und ist auch dort treffbar (${beiApple?.breit} x ${beiApple?.hoch} px)`);
await cs.click(".install-knopf");
await cs.waitForTimeout(700);
const erklaerung = await cs.evaluate(() => {
const d2 = document.querySelector("dialog.nachfrage[open]");
return d2 ? d2.textContent.replace(/\s+/g, " ").trim() : null;
});
ok(/Teilen|Home-Bildschirm/i.test(erklaerung || ""),
` und erklaert den Weg statt es zu versuchen ("${(erklaerung || "").slice(0, 70)}…")`);
await a.ctx.close(); await b.close(); await c.close();
} finally {
await browser.close();
}
/* Die Zahl gehoert in die Bedingung. Faellt sie, ist still ein
Abschnitt ausgefallen -- und genau der neue Abschnitt 0 ist der,
auf den es ankommt. */
if (geprueft < 29) {
console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 29+`);
fehler++;
}
console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler ? 1 : 0);