Files
sutong 42859309c3 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 说明。
2026-09-28 23:17:12 +08:00

248 lines
11 KiB
JavaScript

/**
* Contract test for the generated `client.js`.
*
* Loads the artifact in a sandbox with a stub `window.__ModuleLoader__` and a
* stub React, then checks the slot registration, the localization wiring, the
* loading render, the ready render, and that the route the panel fetches is the
* one the host half exports.
*
* Run: `node scripts/test-client.mjs`
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import vm from 'node:vm'
import { SUMMARY_PATH } from '../index.js'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const source = readFileSync(join(root, 'client.js'), 'utf8')
const packageName = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name
const results = []
function check(label, condition, detail) {
results.push({ label, ok: Boolean(condition) })
console.log(`${condition ? 'PASS' : 'FAIL'} ${label}${condition || detail === undefined ? '' : ` — ${detail}`}`)
}
// ── load the artifact the way the browser kernel does ───────────────────────
let captured
const sandbox = {
window: { __ModuleLoader__: { load: (options) => { captured = options } } },
console,
TextEncoder,
setTimeout,
navigator: {},
}
vm.createContext(sandbox)
vm.runInContext(source, sandbox)
check('artifact registers exactly once', captured !== undefined)
check('module id equals the package name', captured?.id === packageName, captured?.id)
check('factory is a function', typeof captured?.factory === 'function')
// ── stub React ──────────────────────────────────────────────────────────────
let useStateCalls = 0
let stateFixture
const React = {
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
useState: (initial) => {
useStateCalls += 1
if (useStateCalls === 1 && stateFixture !== undefined) return [stateFixture, () => {}]
return [typeof initial === 'function' ? initial() : initial, () => {}]
},
useEffect: () => {},
useRef: (initial) => ({ current: initial }),
}
const requireStub = (name) => {
if (name === 'react') return React
throw new Error(`unexpected require(${JSON.stringify(name)})`)
}
const plugin = captured.factory(requireStub)
check('plugin exposes an inject list', Array.isArray(plugin?.inject), JSON.stringify(plugin?.inject))
check('plugin injects slots and locale', plugin.inject?.includes('slots') && plugin.inject?.includes('locale'))
check('plugin exposes apply', typeof plugin?.apply === 'function')
// ── drive apply against a fake client context ───────────────────────────────
let dictionaries
const registrations = []
let injectedOwner
const slots = {
inject(owner, callback) {
injectedOwner = owner
callback()
return () => {}
},
register(options, component) {
registrations.push({ options, component })
return () => {}
},
}
const locale = {
register(ns, dicts) {
dictionaries = { ns, dicts }
return () => {}
},
bind: () => (key) => dictionaries?.dicts.zh?.[key] ?? key,
subscribe: () => () => {},
}
const effects = []
const ctx = {
get: (name) => (name === 'slots' ? slots : name === 'locale' ? locale : undefined),
effect: (callback, label) => {
effects.push(label)
const disposer = callback()
return () => {
if (typeof disposer === 'function') disposer()
}
},
}
plugin.apply(ctx)
check('locale namespace registered', dictionaries?.ns === 'dsh-lan-access', dictionaries?.ns)
check('locale carries zh and en', dictionaries?.dicts?.zh !== undefined && dictionaries?.dicts?.en !== undefined)
check(
'zh and en cover the same keys',
JSON.stringify(Object.keys(dictionaries.dicts.zh).sort()) === JSON.stringify(Object.keys(dictionaries.dicts.en).sort()),
'dictionary key sets differ',
)
check('registered into the Settings nav seat', injectedOwner === 'settings.section', injectedOwner)
check('exactly one slot registration', registrations.length === 1, `count ${registrations.length}`)
check('registration id is the section key', registrations[0]?.options?.id === 'lan-access', registrations[0]?.options?.id)
check('registration names its slot', registrations[0]?.options?.name === 'settings.section')
check('label thunk projects localized text', registrations[0]?.options?.label?.() === '局域网访问', registrations[0]?.options?.label?.())
// ── loading render ──────────────────────────────────────────────────────────
useStateCalls = 0
const loading = registrations[0].component({})
check('loading render returns an element', loading?.type === 'div' && loading.props.className === 'dla-root')
check('loading render mentions reading', JSON.stringify(loading.children).includes('正在读取'))
// ── ready render with the host's payload shape ──────────────────────────────
stateFixture = {
status: 'ready',
data: {
enabled: true,
listen: { host: '0.0.0.0', port: 3081 },
target: { host: '127.0.0.1', port: 3080 },
lan: [
{ address: '192.168.1.5', url: 'http://192.168.1.5:3081/', tokenUrl: 'http://192.168.1.5:3081/?token=abc' },
{ address: '10.0.0.7', url: 'http://10.0.0.7:3081/', tokenUrl: 'http://10.0.0.7:3081/?token=abc' },
],
},
}
useStateCalls = 0
const ready = registrations[0].component({})
const readyJson = JSON.stringify(ready)
check('ready render returns the panel', ready?.type === 'div' && ready.props.className === 'dla-root')
check('ready render lists both addresses', readyJson.includes('192.168.1.5') && readyJson.includes('10.0.0.7'))
check('ready render embeds a QR svg', readyJson.includes('"svg"'))
check('ready render shows the token url', readyJson.includes('token=abc'))
check('ready render warns about the token', readyJson.includes('launch token'))
check('qr caption targets the selected address', readyJson.includes('192.168.1.5 的二维码'))
// ── a failed bind must be visible, not dressed up as a live QR ──────────────
stateFixture = {
status: 'ready',
data: {
enabled: true,
listening: false,
bindError: 'listen EADDRINUSE: address already in use 0.0.0.0:3081',
listen: { host: '0.0.0.0', port: 3081 },
target: { host: '127.0.0.1', port: 3080 },
lan: [{ address: '192.168.1.5', url: 'http://192.168.1.5:3081/', tokenUrl: 'http://192.168.1.5:3081/?token=abc' }],
},
}
useStateCalls = 0
const failed = registrations[0].component({})
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)
const failures = results.filter((entry) => !entry.ok)
console.log(`\n${results.length - failures.length}/${results.length} checks passed`)
process.exit(failures.length === 0 ? 0 : 1)