fix: 复制按钮在局域网页面下静默失败

明文 HTTP 的局域网页面不是安全上下文,navigator.clipboard 不存在,
旧代码落到 else 分支直接报告成功却什么都没复制;而且 .then(done, done)
连 Promise 被拒(权限拒绝)也当成功——两种情况都会谎报「已复制」。

现在:
- 只在真的写入剪贴板后才报成功;clipboard API 被拒也继续回退
- 回退到 document.execCommand('copy')(明文 HTTP 下仍然可用)
- 两者都不可用时选中链接并提示手动 Ctrl/⌘+C,不再谎报
- URL 改为可选中的只读输入框(原先是没有省略号就无法看全的 div)

新增 7 项回归测试覆盖:clipboard 缺失时确实走 execCommand、可用时走
API 且不重复复制、被拒时回退、两者都无时不抛异常。旧代码在该测试下失败。
README 中英双语排障表补充该现象与 HTTPS 说明。
This commit is contained in:
2026-09-28 23:17:12 +08:00
parent 304379c80c
commit 42859309c3
5 changed files with 290 additions and 28 deletions
+80
View File
@@ -52,6 +52,7 @@ const React = {
return [typeof initial === 'function' ? initial() : initial, () => {}]
},
useEffect: () => {},
useRef: (initial) => ({ current: initial }),
}
const requireStub = (name) => {
if (name === 'react') return React
@@ -159,6 +160,85 @@ const failedJson = JSON.stringify(failed)
check('bind failure is surfaced in the panel', failedJson.includes('EADDRINUSE'))
check('bind failure names the target', failedJson.includes('0.0.0.0:3081'))
// ── clipboard: a real copy or an honest "you must copy it yourself" ─────────
/**
* Collect every element in a rendered tree whose className matches.
* @param node - a React-element-shaped node from the fake createElement.
* @param className - the class to match.
* @returns the matching nodes.
*/
function findByClass(node, className) {
const found = []
const walk = (current) => {
if (current === null || typeof current !== 'object') return
if (Array.isArray(current)) { current.forEach(walk); return }
if (current.props?.className === className) found.push(current)
for (const child of current.children ?? []) walk(child)
}
walk(node)
return found
}
/** Render the panel with one LAN address and return its copy button. */
function renderCopyButton() {
stateFixture = {
status: 'ready',
data: {
enabled: true,
listening: true,
listen: { host: '0.0.0.0', port: 3082 },
target: { host: '127.0.0.1', port: 3080 },
lan: [{ address: '192.168.1.5', url: 'http://192.168.1.5:3082/', tokenUrl: 'http://192.168.1.5:3082/?token=tok' }],
},
}
useStateCalls = 0
const tree = registrations[0].component({})
const buttons = findByClass(tree, 'dla-btn').filter((node) => node.type === 'button')
return { tree, button: buttons[0], url: 'http://192.168.1.5:3082/?token=tok' }
}
// The URL is rendered as a selectable read-only input, not an ellipsised div.
const rendered = renderCopyButton()
const inputs = findByClass(rendered.tree, 'dla-url').filter((node) => node.type === 'input')
check('the url renders as a read-only input', inputs.length === 1 && inputs[0].props.readOnly === true)
check('the input carries the tokenized url', inputs[0]?.props?.value === rendered.url, inputs[0]?.props?.value)
// Plain HTTP over the LAN has no navigator.clipboard; the legacy path must run
// and must only claim success when execCommand actually reported it.
let execCalls = 0
sandbox.navigator = {}
sandbox.document = {
execCommand: (command) => { execCalls += 1; return command === 'copy' },
createElement: () => ({ style: {}, setAttribute() {}, select() {}, remove() {} }),
body: { appendChild() {}, removeChild() {} },
}
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
await new Promise((resolve) => setTimeout(resolve, 0))
check('falls back to execCommand when clipboard is unavailable', execCalls > 0, `execCommand calls: ${execCalls}`)
// A secure context (or any host that does provide it) uses the async API.
let written = ''
sandbox.navigator = { clipboard: { writeText: (text) => { written = text; return Promise.resolve() } } }
execCalls = 0
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
await new Promise((resolve) => setTimeout(resolve, 0))
check('uses the clipboard API when it exists', written === rendered.url, written)
check('does not double-copy through the legacy path', execCalls === 0, `execCommand calls: ${execCalls}`)
// A rejected clipboard write must fall through rather than report success.
written = ''
sandbox.navigator = { clipboard: { writeText: () => Promise.reject(new Error('denied')) } }
execCalls = 0
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
await new Promise((resolve) => setTimeout(resolve, 0))
check('a rejected clipboard write falls back', execCalls > 0, `execCommand calls: ${execCalls}`)
// Nothing available at all: it must not throw, and must not claim success.
sandbox.navigator = {}
sandbox.document = undefined
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
check('no clipboard and no document does not throw', true)
// ── the two halves agree on the route ───────────────────────────────────────
check('client fetches the host route', source.includes(SUMMARY_PATH), SUMMARY_PATH)