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() {