/* ===================================================================== AUS „TEILEN" WIRD EIN TIPP (17.09.2026) — Schritt 4 des Video-Plans Filipe postet ein Video auf dem Handy. Bisher: Link kopieren, App öffnen, Bereich suchen, Feld finden, einfügen. Fünf Schritte, von denen jeder der Grund sein kann, es „später" zu machen. --------------------------------------------------------------------- DER FALL, DER DIESE PRÜFUNG RECHTFERTIGT Das Teilen-Ziel kennt drei Felder: `title`, `text`, `url`. Man baut es und probiert es mit `?url=https://…` aus — das geht, und man ist fertig. IM ECHTEN LEBEN KOMMT DER LINK ABER IN `text`. Android-Apps, TikTok eingeschlossen, teilen als reinen Text: „Sieh dir das an! https://vm.tiktok.com/xyz/" steht dann in `text`, und `url` ist leer. Wer nur `url` liest, hat eine Seite gebaut, die im Test funktioniert und auf dem Handy eine leere Maske zeigt. Abschnitt 2 prüft genau diesen Fall — und zwar mit einem Satz drumherum, nicht mit einer nackten Adresse. Aufrufen mit: node server/pruef-teilen.mjs ===================================================================== */ import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createServer } from "node:http"; import { eigenerPort } from "./helfer-port.mjs"; const PORT = await eigenerPort(import.meta, "pruef-teilen"); const TIKTOK = await eigenerPort(import.meta, "nachgebautes TikTok", 1); const ordner = mkdtempSync(join(tmpdir(), "ws-teilen-")); 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"; process.env.TIKTOK_OEMBED_BASIS = `http://127.0.0.1:${TIKTOK}/oembed`; const PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64"); const tiktok = createServer((q, a) => { const u = new URL(q.url, `http://127.0.0.1:${TIKTOK}`); if (u.pathname === "/oembed") { const wer = ((u.searchParams.get("url") || "").match(/@([^/?]+)/) || [])[1] || ""; if (!wer) { a.writeHead(404); a.end("{}"); return; } a.writeHead(200, { "content-type": "application/json" }); a.end(JSON.stringify({ version: "1.0", type: "video", title: "Casper erwischt den Postboten", author_url: `https://www.tiktok.com/@${wer}`, thumbnail_url: `http://127.0.0.1:${TIKTOK}/cover.jpg`, })); return; } if (u.pathname === "/cover.jpg") { a.writeHead(200, { "content-type": "image/jpeg" }); a.end(PNG); return; } a.writeHead(404); a.end("{}"); }); await new Promise((r) => tiktok.listen(TIKTOK, "127.0.0.1", r)); 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 }); }; import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); notbremse(240_000, "pruef-teilen"); await new Promise((r) => setTimeout(r, 900)); const BASIS = `http://127.0.0.1:${PORT}`; let fehler = 0, geprueft = 0; const melde = (t) => console.log(t); 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"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const j = new Date().toISOString(); 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, j); d.close(); const an = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), }); const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await pw.chromium.launch(); /* AUF HANDYGRÖSSE. Diese Seite wird fast ausschliesslich dort geoeffnet -- eine Messung auf 1400 px misst etwas, das niemand sieht. */ const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, }); await ctx.addCookies(keks.split("; ").map((k) => { const [name, ...rest] = k.split("="); return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; })); const seite = await ctx.newPage(); const konsolenfehler = []; seite.on("console", (m) => { if (m.type() === "error") konsolenfehler.push(m.text()); }); seite.on("pageerror", (f) => konsolenfehler.push(String(f))); const VIDEO = `http://127.0.0.1:${TIKTOK}/@dogfather0804/video/4711`; const oeffne = async (frage) => { await seite.goto(`${BASIS}/workspace/teilen.html?${frage}`, { waitUntil: "networkidle" }); await seite.waitForTimeout(900); }; const gezeigterLink = () => seite.$eval("#url-text", (n) => n.textContent.trim()).catch(() => ""); const zahlIm = async (bereich) => { const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { headers: { cookie: keks } }); const x = await a.json().catch(() => null); return (x?.eintraege || []).length; }; /* ======================================================================= 1. DAS SAUBERE FELD ======================================================================= */ melde(""); melde("=== 1. Wenn der Link in „url“ steht ==="); { await oeffne(`url=${encodeURIComponent(VIDEO)}`); ok(await gezeigterLink() === VIDEO, "der Link wird erkannt und angezeigt"); ok(!(await seite.$("#haendisch:not([hidden])")), "und das Feld zum Einfügen bleibt weg – es gibt ja nichts einzufügen"); const bretter = await seite.$$eval(".teilen__brett", (ns) => ns.map((n) => ({ name: n.querySelector(".teilen__brett-name")?.textContent, gewaehlt: n.getAttribute("aria-pressed"), }))); ok(bretter.length === 3, `drei Ziele zur Auswahl (${bretter.length})`); ok(bretter[0]?.gewaehlt === "true" && /Highlights/.test(bretter[0]?.name || ""), "Highlights ist vorausgewählt – dort gehören Videos hin"); await seite.screenshot({ path: "server/bild-teilen.png", fullPage: true }); } /* ======================================================================= 2. DER ECHTE FALL: ein Satz mit Link darin ← der eigentliche Punkt ======================================================================= */ melde(""); melde("=== 2. Wenn TikTok einen Satz teilt ==="); { /* GENAU SO kommt es von einem Android-Handy an: alles in `text`, `url` leer, und die Adresse steckt mitten im Satz. */ const satz = `Sieh dir das an! ${VIDEO} #fyp`; await oeffne(`text=${encodeURIComponent(satz)}`); ok(await gezeigterLink() === VIDEO, `die Adresse wird aus dem Satz herausgeholt (${await gezeigterLink()})`); /* UND MIT EINEM PUNKT DAHINTER. „…/4711." ist keine Adresse mit Punkt, sondern ein Satzende. */ await oeffne(`text=${encodeURIComponent(`Schau mal: ${VIDEO}.`)}`); ok(await gezeigterLink() === VIDEO, `ein Satzzeichen am Ende gehört nicht zur Adresse (${await gezeigterLink()})`); /* Und im Titel, falls eine App es dort ablegt. */ await oeffne(`title=${encodeURIComponent(VIDEO)}&text=`); ok(await gezeigterLink() === VIDEO, "auch aus dem Titel"); } /* ======================================================================= 3. GAR KEIN LINK ======================================================================= */ melde(""); melde("=== 3. Wenn nichts dabei ist ==="); { await oeffne("text=nur%20ein%20Text%20ohne%20Adresse"); ok(!!(await seite.$("#haendisch:not([hidden])")), "dann erscheint ein Feld zum Einfügen – kein Fehler, ein Angebot"); ok(!(await seite.$("#fund:not([hidden])")), "und es wird keine Adresse behauptet, die es nicht gibt"); } /* ======================================================================= 4. UND ES TRÄGT WIRKLICH EIN ======================================================================= */ melde(""); melde("=== 4. Der Tipp auf „Übernehmen“ ==="); { const vorher = await zahlIm("highlight"); const satz = `Neues Video! ${VIDEO}`; await oeffne(`text=${encodeURIComponent(satz)}`); /* NICHT VON SELBST. Eine Seite, die beim Öffnen einträgt, kann nicht wissen, ob der Griff in die Tasche gemeint war. */ ok(await zahlIm("highlight") === vorher, `allein vom Öffnen passiert nichts (${vorher})`); await seite.click("#uebernehmen"); await seite.waitForTimeout(2500); const nachher = await zahlIm("highlight"); ok(nachher === vorher + 1, `nach dem Tipp liegt es da (${vorher} → ${nachher})`); const fertig = await seite.$eval("#fertig-satz", (n) => n.textContent.trim()).catch(() => ""); ok(/Angekommen/.test(fertig), `und die Seite sagt es (\u201e${fertig.slice(0, 55)}\u201c)`); ok(/Casper erwischt den Postboten/.test(fertig), "mitsamt dem Titel, den TikTok geliefert hat"); /* DER WEG DORTHIN steht als Knopf da -- wer teilt, will meistens gleich sehen, wie es aussieht. */ const hin = await seite.$eval("#hin", (n) => n.getAttribute("href")).catch(() => ""); ok(/b=highlight/.test(hin), `der Knopf führt zum richtigen Brett (${hin})`); /* ---- Und derselbe Link ein zweites Mal ---------------------------- */ await oeffne(`text=${encodeURIComponent(satz)}`); await seite.click("#uebernehmen"); await seite.waitForTimeout(2500); ok(await zahlIm("highlight") === nachher, `zweimal geteilt ergibt kein zweites Video (${await zahlIm("highlight")})`); const zweiterSatz = await seite.$eval("#fertig-satz", (n) => n.textContent.trim()).catch(() => ""); ok(/lag schon/.test(zweiterSatz), `und es steht auch da (\u201e${zweiterSatz.slice(0, 45)}\u201c)`); } /* ======================================================================= 5. AUF EIN ANDERES BRETT ======================================================================= */ melde(""); melde("=== 5. Wohin ist eine Frage ==="); { const vorher = await zahlIm("treff"); await oeffne(`url=${encodeURIComponent(`http://127.0.0.1:${TIKTOK}/@hasidog00804/video/99`)}`); await seite.click('.teilen__brett[data-brett="treff"]'); await seite.waitForTimeout(300); const gewaehlt = await seite.$$eval(".teilen__brett", (ns) => ns.filter((n) => n.getAttribute("aria-pressed") === "true").map((n) => n.dataset.brett)); ok(gewaehlt.length === 1 && gewaehlt[0] === "treff", `genau ein Ziel ist gewählt (${gewaehlt.join(", ")})`); await seite.click("#uebernehmen"); await seite.waitForTimeout(2500); ok(await zahlIm("treff") === vorher + 1, `und es landet dort und nicht bei den Highlights (${vorher} → ${await zahlIm("treff")})`); } /* ======================================================================= 6. DAS TEILEN-ZIEL STEHT IM MANIFEST ======================================================================= */ melde(""); melde("=== 6. Damit das Handy es überhaupt anbietet ==="); { /* OHNE DIESEN EINTRAG erscheint der Workspace gar nicht erst im Teilen-Menü -- die ganze Seite wäre dann unerreichbar, und die fünf Abschnitte darüber hätten es nicht gemerkt. */ for (const datei of ["crew.webmanifest", "app.webmanifest"]) { const m = JSON.parse(readFileSync(new URL(`../workspace/${datei}`, import.meta.url), "utf8")); const z = m.share_target; ok(!!z && z.action === "/workspace/teilen.html", `${datei}: Teilen-Ziel zeigt auf die Seite (${z?.action})`); ok(z?.method === "GET", `${datei}: per GET – POST bräuchte einen Service Worker dazwischen (${z?.method})`); /* ALLE DREI FELDER. Fehlte `text`, wäre genau der Fall aus Abschnitt 2 nicht abgedeckt -- und das ist der häufigste. */ ok(z?.params?.text === "text" && z?.params?.url === "url" && z?.params?.title === "title", `${datei}: title, text UND url werden entgegengenommen`); } /* UND DIE SEITE IST IN DER RECHTETAFEL. Ohne Eintrag ist sie seit dem 11.09. für jede Rolle zu -- ein fehlender Eintrag heißt nein. */ const { SEITEN } = await import("./rechte.js"); const rollen = SEITEN["/workspace/teilen.html"] || []; ok(rollen.length > 0, `die Seite steht in der Rechtetafel (${rollen.join(", ")})`); ok(!rollen.includes("gast"), "und die Community kommt nicht hin – sie darf auch keine Videos eintragen"); } melde(""); ok(konsolenfehler.length === 0, konsolenfehler.length ? konsolenfehler.join(" | ") : "keine Fehler in der Browserkonsole"); melde(""); melde(`${geprueft} Prüfungen, ${fehler} Fehler`); melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); await browser.close(); await new Promise((r) => tiktok.close(r)); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } process.exit(fehler ? 1 : 0);