From f6099938d81b7dae533e84fb3a0703142c7e56f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:41:24 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P3-Endpr=C3=BCfung=20W2:=20Kopieren?= =?UTF-8?q?=20&=20=C3=B6ffnen=20meldet=20ein=20gescheitertes=20Kopieren=20?= =?UTF-8?q?ehrlich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copyTextChecked erfüllt nur, wenn der Text wirklich in der Zwischenablage liegt (Clipboard-API, sonst Notweg mit document.execCommand, dessen Rückgabewert jetzt geprüft wird; das Textfeld wird immer entfernt). copyText bleibt für die Kundenansicht die verschluckende Hülle. Das Pult nimmt die strenge Fassung, sodass die Zeile statt "Kopiert und geöffnet." den Hinweis zum Kopieren von Hand zeigt. Die Reihenfolge Kopieren vor Öffnen bleibt (Nutzer-Geste). Co-Authored-By: Claude Sonnet 5.5 --- src/components/CopyableCredential.jsx | 37 ++++-- src/components/workers/PultList.jsx | 6 +- tests/unit/copyableCredential.test.mjs | 167 +++++++++++++++++++++++++ tests/unit/workersPultUi.test.mjs | 4 +- 4 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 tests/unit/copyableCredential.test.mjs diff --git a/src/components/CopyableCredential.jsx b/src/components/CopyableCredential.jsx index 994c10a..98a77fc 100644 --- a/src/components/CopyableCredential.jsx +++ b/src/components/CopyableCredential.jsx @@ -1,18 +1,39 @@ import { useEffect, useRef, useState } from 'react' -export async function copyText(text) { +// Strenge Fassung: erfüllt nur, wenn der Text wirklich in der Zwischenablage liegt, sonst Error('Kopieren fehlgeschlagen'). +// Erst die Clipboard-API, bei Ablehnung (kein Fokus, keine Berechtigung, unsicherer Kontext) der Notweg über ein Textfeld und +// document.execCommand('copy'), das bei Misserfolg false liefert statt zu werfen. Der Aufruf von writeText geschieht synchron beim +// Aufruf der Funktion (kein await davor): wer gleich danach window.open ruft, bleibt im Zug der Nutzer-Geste. +export async function copyTextChecked(text) { try { await navigator.clipboard.writeText(text) + return } catch { - const textarea = document.createElement('textarea') - textarea.value = text - textarea.setAttribute('readonly', '') - textarea.style.position = 'fixed' - textarea.style.left = '-9999px' + // weiter mit dem Notweg + } + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.left = '-9999px' + let ok = false + try { document.body.appendChild(textarea) textarea.select() - document.execCommand('copy') - document.body.removeChild(textarea) + ok = document.execCommand('copy') === true + } finally { + textarea.parentNode?.removeChild(textarea) + } + if (!ok) throw new Error('Kopieren fehlgeschlagen') +} + +// Bisheriges Verhalten für die Kundenansicht: ein Fehlschlag wird verschluckt (die Aufrufer zeigen „Kopiert“ trotzdem). +// Wer dem Menschen ehrlich melden muss, ob etwas in der Zwischenablage liegt (Pult: „Kopieren & öffnen“), nimmt copyTextChecked. +export async function copyText(text) { + try { + await copyTextChecked(text) + } catch { + // bewusst still } } diff --git a/src/components/workers/PultList.jsx b/src/components/workers/PultList.jsx index 34c578f..80d45f5 100644 --- a/src/components/workers/PultList.jsx +++ b/src/components/workers/PultList.jsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import Badge from '../ui/Badge' import Button from '../ui/Button' import EmptyState from '../ui/EmptyState' -import { copyText } from '../CopyableCredential' +import { copyTextChecked } from '../CopyableCredential' import { useEmployees } from '../../hooks/useEmployees' import { usePultFeed } from '../../hooks/usePultFeed' import { PULT_FILTERS, copyAndOpen, mailtoHref, rowModel } from '../../lib/workersPult' @@ -84,10 +84,12 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails } // Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen. + // Kopiert wird mit der strengen Fassung (copyTextChecked): scheitern Clipboard-API und Notweg, meldet die Zeile das ehrlich, + // statt „Kopiert“ zu zeigen, während noch der alte Inhalt der Zwischenablage drinliegt. // Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis. const copyOpen = async () => { if (!begin()) return - const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyText, open: openTab }) + const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab }) let next = copyNote(result, row.sourceHref !== null) if (!row.claimedByMe) { try { diff --git a/tests/unit/copyableCredential.test.mjs b/tests/unit/copyableCredential.test.mjs new file mode 100644 index 0000000..fe41cb3 --- /dev/null +++ b/tests/unit/copyableCredential.test.mjs @@ -0,0 +1,167 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import esbuild from 'esbuild' + +// Zwischenablage: copyTextChecked (streng, für das Pult) und copyText (verschluckt Fehler, für die Kundenansicht). +// CopyableCredential.jsx wird mit einer Attrappe für react gebündelt; navigator und document sind Attrappen, die jeden Aufruf mitschreiben. +// Endprüfung P3 (W2): scheitern Clipboard-API und Notweg, darf „Kopieren & öffnen“ nicht „Kopiert“ melden. + +async function load() { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') + const STUB = { + react: 'export const useEffect = () => {}; export const useRef = () => ({ current: null }); export const useState = (v) => [v, () => {}]', + 'react/jsx-runtime': 'export const jsx = () => null; export const jsxs = () => null; export const Fragment = null', + } + const reactStub = { + name: 'react-stub', + setup(build) { + build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, (a) => ({ path: a.path, namespace: 'react-stub' })) + build.onLoad({ filter: /.*/, namespace: 'react-stub' }, (a) => ({ contents: STUB[a.path], loader: 'js' })) + }, + } + const out = await esbuild.build({ + stdin: { + contents: "export { copyText, copyTextChecked } from './src/components/CopyableCredential.jsx'\nexport { copyAndOpen } from './src/lib/workersPult.js'", + resolveDir: root, + loader: 'js', + }, + bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [reactStub], + }) + return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) +} + +const { copyText, copyTextChecked, copyAndOpen } = await load() + +// Umgebung mit Protokoll. writeText: 'ok' | 'reject' | 'missing' (navigator.clipboard fehlt, z. B. unsicherer Kontext); +// execCommand: true | false | 'throw' +function setup({ writeText = 'ok', execCommand = true } = {}) { + const log = [] + const textareas = [] + const clipboard = writeText === 'missing' ? undefined : { + writeText: (text) => { + log.push(`writeText:${text}`) + return writeText === 'ok' ? Promise.resolve() : Promise.reject(new DOMException('Document is not focused.', 'NotAllowedError')) + }, + } + const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + Object.defineProperty(globalThis, 'navigator', { value: { clipboard }, configurable: true, writable: true }) + const body = { + appendChild: (el) => { el.parentNode = body; log.push('append') }, + removeChild: (el) => { el.parentNode = null; log.push('remove') }, + } + globalThis.document = { + body, + createElement: (tag) => { + const el = { tag, value: '', style: {}, attrs: {}, parentNode: null, setAttribute(k, v) { this.attrs[k] = v }, select() { log.push('select') } } + textareas.push(el) + return el + }, + execCommand: (cmd) => { + log.push(`exec:${cmd}`) + if (execCommand === 'throw') throw new Error('execCommand gesperrt') + return execCommand + }, + } + return { + log, + textareas, + restore() { + if (original) Object.defineProperty(globalThis, 'navigator', original) + else delete globalThis.navigator + delete globalThis.document + }, + } +} + +test('copyTextChecked: Clipboard-API klappt ⇒ erfüllt, kein Notweg', async () => { + const env = setup({ writeText: 'ok' }) + try { + await copyTextChecked('Hallo') + assert.deepEqual(env.log, ['writeText:Hallo']) + } finally { env.restore() } +}) + +test('copyTextChecked: Clipboard-API abgelehnt, Notweg liefert true ⇒ erfüllt; das Textfeld ist danach wieder entfernt', async () => { + const env = setup({ writeText: 'reject', execCommand: true }) + try { + await copyTextChecked('Hallo') + assert.deepEqual(env.log, ['writeText:Hallo', 'append', 'select', 'exec:copy', 'remove']) + assert.equal(env.textareas.length, 1) + assert.equal(env.textareas[0].value, 'Hallo') + assert.equal(env.textareas[0].parentNode, null) + } finally { env.restore() } +}) + +test('copyTextChecked: kein navigator.clipboard (unsicherer Kontext) ⇒ Notweg wird benutzt', async () => { + const env = setup({ writeText: 'missing', execCommand: true }) + try { + await copyTextChecked('Hallo') + assert.deepEqual(env.log, ['append', 'select', 'exec:copy', 'remove']) + } finally { env.restore() } +}) + +test('copyTextChecked: Clipboard-API abgelehnt und execCommand liefert false ⇒ lehnt mit „Kopieren fehlgeschlagen“ ab, Textfeld entfernt', async () => { + const env = setup({ writeText: 'reject', execCommand: false }) + try { + await assert.rejects(copyTextChecked('Hallo'), { message: 'Kopieren fehlgeschlagen' }) + assert.deepEqual(env.log, ['writeText:Hallo', 'append', 'select', 'exec:copy', 'remove']) + assert.equal(env.textareas[0].parentNode, null) + } finally { env.restore() } +}) + +test('copyTextChecked: wirft der Notweg selbst, wird das Textfeld trotzdem entfernt und der Fehler bleibt sichtbar', async () => { + const env = setup({ writeText: 'reject', execCommand: 'throw' }) + try { + await assert.rejects(copyTextChecked('Hallo'), { message: 'execCommand gesperrt' }) + assert.equal(env.log.at(-1), 'remove') + assert.equal(env.textareas[0].parentNode, null) + } finally { env.restore() } +}) + +test('copyText (Kundenansicht): verschluckt jeden Fehlschlag wie bisher und wirft nie', async () => { + for (const options of [{ writeText: 'reject', execCommand: false }, { writeText: 'reject', execCommand: 'throw' }, { writeText: 'missing', execCommand: false }]) { + const env = setup(options) + try { + assert.equal(await copyText('Hallo'), undefined) + } finally { env.restore() } + } + const env = setup({ writeText: 'ok' }) + try { + await copyText('Hallo') + assert.deepEqual(env.log, ['writeText:Hallo']) + } finally { env.restore() } +}) + +test('Pult „Kopieren & öffnen“: scheitern beide Kopierwege, meldet copyAndOpen copied:false (die Quelle wird trotzdem geöffnet)', async () => { + const env = setup({ writeText: 'reject', execCommand: false }) + try { + const opened = [] + const result = await copyAndOpen({ text: 'Antwort', url: 'https://www.kleinanzeigen.de/s-anzeige/x/1', copy: copyTextChecked, open: (u) => { opened.push(u); env.log.push('open') } }) + assert.deepEqual(result, { copied: false, opened: true }) + assert.deepEqual(opened, ['https://www.kleinanzeigen.de/s-anzeige/x/1']) + // writeText steht vor dem Öffnen (Nutzer-Geste), die Ablehnung wird danach abgewartet + assert.ok(env.log.indexOf('writeText:Antwort') < env.log.indexOf('open')) + } finally { env.restore() } +}) + +test('Pult „Kopieren & öffnen“: Notweg klappt ⇒ copied:true; Clipboard-API klappt ⇒ copied:true, writeText vor open', async () => { + let env = setup({ writeText: 'reject', execCommand: true }) + try { + assert.deepEqual(await copyAndOpen({ text: 'A', url: 'https://example.org/x', copy: copyTextChecked, open: () => {} }), { copied: true, opened: true }) + } finally { env.restore() } + env = setup({ writeText: 'ok' }) + try { + const result = await copyAndOpen({ text: 'B', url: 'https://example.org/x', copy: copyTextChecked, open: () => env.log.push('open') }) + assert.deepEqual(result, { copied: true, opened: true }) + assert.deepEqual(env.log, ['writeText:B', 'open']) + } finally { env.restore() } +}) + +test('Pult „Kopieren & öffnen“ mit der verschluckenden Fassung würde fälschlich copied:true melden (darum nimmt PultList copyTextChecked)', async () => { + const env = setup({ writeText: 'reject', execCommand: false }) + try { + assert.deepEqual(await copyAndOpen({ text: 'C', url: 'https://example.org/x', copy: copyText, open: () => {} }), { copied: true, opened: true }) + } finally { env.restore() } +}) diff --git a/tests/unit/workersPultUi.test.mjs b/tests/unit/workersPultUi.test.mjs index 6881979..1486dcd 100644 --- a/tests/unit/workersPultUi.test.mjs +++ b/tests/unit/workersPultUi.test.mjs @@ -34,7 +34,7 @@ test('PultList: Aktionen über pultAction, "Als gesendet" sendet den bearbeitete assert.match(s, /api\.pultAction\(row\.id, 'claim'\)/); assert.match(s, /api\.pultAction\(row\.id, 'sent', \{ text \}\)/) assert.match(s, /api\.pultAction\(row\.id, 'discard', \{\}\)/); assert.match(s, /api\.pultAction\(row\.id, 'done'\)/) // Kopieren & öffnen: Text ist der Inhalt des Textfelds (nicht row.copyText), das Öffnen geschieht ohne Referrer und ohne Rückbezug - assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyText, open: openTab \}\)/) + assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyTextChecked, open: openTab \}\)/) assert.match(s, /window\.open\(url, '_blank', 'noopener,noreferrer'\)/) assert.match(s, /Wirklich verwerfen\?/); assert.match(s, /CONFIRM_MS = 4000/) for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Details']) assert.ok(s.includes(t), t) @@ -277,7 +277,7 @@ test('usePultFeed: nach dem Abbau setzt eine späte Antwort nichts mehr', async const STUBS = { usePultFeed: 'export function usePultFeed() { return globalThis.__pultFeed }', useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }', - CopyableCredential: 'export async function copyText() {}', + CopyableCredential: 'export async function copyTextChecked() {}', } async function loadList() {