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 <noreply@anthropic.com>
168 lines
7.7 KiB
JavaScript
168 lines
7.7 KiB
JavaScript
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() }
|
|
})
|