feat: dsh-lan-access — 局域网访问 dsh Web UI
用独立反向代理把 dsh Web UI 发布到局域网。官方 CLI 主动拒绝 `dsh web --host 0.0.0.0`(会把宿主机远程代码执行暴露到网络), 因此本插件让官方服务器保持只绑回环,另开监听并转发。 宿主端(index.js) - 在 0.0.0.0:3082 监听,把 HTTP 与 WebSocket 转发到 127.0.0.1:<webServer 端口> - 上游 Host/Origin 重写为回环,使官方 /api 信任围栏「通过」而不是被绕过 - 不改动 Set-Cookie:浏览器按请求 URL 建立 host-only cookie,转发层无需干预 - 监听端 DNS 重绑定防护;targetHost 仅接受回环地址(不会变成开放代理) - /api/dsh-lan-access/summary 复用官方 connection.admit() 做围栏与浏览器认证 - 启动横幅打印带 launch token 的局域网链接 - 端口被占用时只告警不抛出,并在面板上报 listening:false,不展示不可用的二维码 - ownsHostCompat(默认关):仅为非回环页面声明 ownsHost,恢复官方设置界面 客户端(src/ 经 scripts/build-client.mjs 生成 client.js) - Settings → 局域网访问:局域网地址列表、复制/打开、选中地址的二维码 - 零依赖二维码编码器(byte 模式 / ECC M / version 1–10) 组合层 - 只 insert 一行,不覆盖任何 shipped row:停用本插件只会移除局域网监听, 回环 Web UI 不受影响 验证 - 宿主转发契约 28/28;客户端契约 25/25 - 二维码编码器与 npm qrcode 参考实现在全版本 × 全 8 掩码下逐模块比对 240/240 一致 - 端到端:局域网 token 首访 303 并铸 cookie → 应用 200;无凭证 401; WebSocket 升级 101,且与直连回环逐项行为一致
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Emit the single browser artifact `client.js` from `src/panel.js` and
|
||||
* `src/qrcode.js`.
|
||||
*
|
||||
* A bundle's client half is served as one self-contained module; it cannot
|
||||
* import a sibling file or a package dependency at runtime. This script inlines
|
||||
* both sources into the `window.__ModuleLoader__.load` shell, which is the same
|
||||
* shape the reference plugins ship.
|
||||
*
|
||||
* Run: `node scripts/build-client.mjs`
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(here, '..')
|
||||
|
||||
/** Read a source file and turn its ESM exports into plain declarations. */
|
||||
function inline(relativePath) {
|
||||
const source = readFileSync(join(root, relativePath), 'utf8')
|
||||
return source
|
||||
.replace(/^import .*$/gm, '')
|
||||
.replace(/^export (?=(?:function|const|let|class)\b)/gm, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const qrcode = inline('src/qrcode.js')
|
||||
const panel = inline('src/panel.js')
|
||||
|
||||
const artifact = `/**
|
||||
* @sutong/dsh-lan-access — browser half (generated by scripts/build-client.mjs).
|
||||
*
|
||||
* Do not edit this file: change src/panel.js or src/qrcode.js and rebuild.
|
||||
*/
|
||||
window.__ModuleLoader__.load({
|
||||
id: "@sutong/dsh-lan-access",
|
||||
factory: function (require) {
|
||||
"use strict";
|
||||
var React = require("react");
|
||||
|
||||
// ── inlined src/qrcode.js ────────────────────────────────────────────────
|
||||
var __qrcode = (function () {
|
||||
${qrcode.replace(/^/gm, ' ')}
|
||||
return { encodeQR: encodeQR, MAX_VERSION: MAX_VERSION };
|
||||
})();
|
||||
var encodeQR = __qrcode.encodeQR;
|
||||
|
||||
// ── inlined src/panel.js ─────────────────────────────────────────────────
|
||||
${panel.replace(/^/gm, ' ')}
|
||||
|
||||
return { inject: inject, apply: apply };
|
||||
},
|
||||
});
|
||||
`
|
||||
|
||||
const out = join(root, 'client.js')
|
||||
writeFileSync(out, artifact)
|
||||
console.log(`wrote ${out} (${artifact.length} bytes)`)
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Standalone smoke test for the LAN listener. It mounts the plugin against a
|
||||
* fake Cordis context, drives a real upstream HTTP + WebSocket server, and
|
||||
* asserts the forwarding contract. Run: `node scripts/smoke.mjs`.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import { connect as netConnect } from 'node:net'
|
||||
import vm from 'node:vm'
|
||||
import { apply, internals, OWNS_HOST_COMPAT_SCRIPT } from '../index.js'
|
||||
|
||||
const results = []
|
||||
function check(label, condition, detail) {
|
||||
results.push({ label, ok: Boolean(condition), detail })
|
||||
console.log(`${condition ? 'PASS' : 'FAIL'} ${label}${detail === undefined || condition ? '' : ` — ${detail}`}`)
|
||||
}
|
||||
|
||||
/** Bind a throwaway server on an OS-assigned loopback port. */
|
||||
function listen(server, host = '127.0.0.1') {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, host, () => resolve(server.address().port))
|
||||
})
|
||||
}
|
||||
|
||||
const seen = []
|
||||
const upstream = createServer((req, res) => {
|
||||
seen.push({ url: req.url, host: req.headers.host, origin: req.headers.origin, cookie: req.headers.cookie })
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'set-cookie': 'dsh-auth-test=abc123; Max-Age=60; Path=/; HttpOnly; SameSite=Strict',
|
||||
'x-upstream': 'yes',
|
||||
})
|
||||
res.end(JSON.stringify({ url: req.url, host: req.headers.host, origin: req.headers.origin ?? null }))
|
||||
})
|
||||
|
||||
// DSH serves HTTP and WebSocket on the same port, so the upstream carries both.
|
||||
upstream.on('upgrade', (req, socket) => {
|
||||
seen.push({ upgrade: true, url: req.url, host: req.headers.host, origin: req.headers.origin })
|
||||
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n')
|
||||
socket.write('hello-from-upstream')
|
||||
})
|
||||
|
||||
const upstreamPort = await listen(upstream)
|
||||
|
||||
// ── fake Cordis context ─────────────────────────────────────────────────────
|
||||
// Cordis exposes injected services both as `ctx.<name>` and through `ctx.get`.
|
||||
const routes = []
|
||||
let admitted = 0
|
||||
const connection = {
|
||||
admit: () => {
|
||||
admitted += 1
|
||||
return { peer: {} }
|
||||
},
|
||||
authenticatedUrl: (base) => `${base}?token=TESTTOKEN`,
|
||||
}
|
||||
const webServer = {
|
||||
port: upstreamPort,
|
||||
register: (route) => {
|
||||
routes.push(route)
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
const ctx = {
|
||||
webServer,
|
||||
connection,
|
||||
get(name) {
|
||||
if (name === 'connection') return connection
|
||||
if (name === 'webServer') return webServer
|
||||
return undefined
|
||||
},
|
||||
inject(deps, callback) {
|
||||
if (deps.every((d) => ctx.get(d) !== undefined)) callback(ctx)
|
||||
return () => {}
|
||||
},
|
||||
effect(callback) {
|
||||
callback()
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
|
||||
const LAN_PORT = 39_991
|
||||
apply(ctx, {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: LAN_PORT,
|
||||
targetHost: '127.0.0.1',
|
||||
targetPort: upstreamPort,
|
||||
printBanner: false,
|
||||
})
|
||||
|
||||
// Wait for the listener to bind.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
|
||||
/** Issue a request through the LAN listener. */
|
||||
function viaLan(path, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = netConnect(LAN_PORT, '127.0.0.1', () => {
|
||||
req.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${LAN_PORT}\r\nOrigin: http://127.0.0.1:${LAN_PORT}\r\nCookie: dsh-auth-test=abc123\r\nConnection: close\r\n\r\n`)
|
||||
})
|
||||
let raw = ''
|
||||
req.on('data', (chunk) => { raw += chunk })
|
||||
req.on('end', () => resolve(raw))
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
const response = await viaLan('/api/remote.mux')
|
||||
check('LAN listener returns the upstream status', response.startsWith('HTTP/1.1 200'), response.split('\r\n')[0])
|
||||
check('upstream sees a loopback Host', seen[0]?.host === `127.0.0.1:${upstreamPort}`, seen[0]?.host)
|
||||
check('upstream sees a matching loopback Origin', seen[0]?.origin === `http://127.0.0.1:${upstreamPort}`, seen[0]?.origin)
|
||||
check('upstream receives the browser cookie', seen[0]?.cookie === 'dsh-auth-test=abc123', seen[0]?.cookie)
|
||||
check('Set-Cookie is forwarded untouched', /set-cookie: dsh-auth-test=abc123/i.test(response), 'no set-cookie')
|
||||
check('custom upstream header survives', /x-upstream: yes/i.test(response))
|
||||
|
||||
// ── WebSocket upgrade ───────────────────────────────────────────────────────
|
||||
const wsRaw = await new Promise((resolve, reject) => {
|
||||
const socket = netConnect(LAN_PORT, '127.0.0.1', () => {
|
||||
socket.write(
|
||||
`GET /api/remote.mux HTTP/1.1\r\nHost: 127.0.0.1:${LAN_PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nOrigin: http://127.0.0.1:${LAN_PORT}\r\n\r\n`,
|
||||
)
|
||||
})
|
||||
let raw = ''
|
||||
socket.on('data', (chunk) => { raw += chunk })
|
||||
socket.on('close', () => resolve(raw))
|
||||
socket.on('error', reject)
|
||||
setTimeout(() => { socket.destroy(); resolve(raw) }, 900)
|
||||
})
|
||||
const upgrade = seen.find((entry) => entry.upgrade)
|
||||
check('WebSocket upgrade reached the upstream', upgrade !== undefined)
|
||||
check('upgraded request kept a loopback Host', upgrade?.host === `127.0.0.1:${upstreamPort}`, upgrade?.host)
|
||||
check('upgrade response flows back to the client', /hello-from-upstream/.test(wsRaw))
|
||||
|
||||
// ── the panel data route ────────────────────────────────────────────────────
|
||||
const route = routes.find((entry) => entry.path === '/api/dsh-lan-access/summary')
|
||||
check('summary route registered as exact', route?.kind === 'exact', route?.kind)
|
||||
let body = ''
|
||||
const fakeRes = {
|
||||
writeHead(status, headers) { this.status = status; this.headers = headers },
|
||||
end(value) { body = value ?? '' },
|
||||
}
|
||||
route?.handler({ headers: { host: `127.0.0.1:${upstreamPort}` } }, fakeRes)
|
||||
check('summary route requires admission', admitted === 1, `admit calls: ${admitted}`)
|
||||
check('summary reports the listen port', JSON.parse(body).listen.port === LAN_PORT, body)
|
||||
check('summary carries a tokenized LAN url', /token=TESTTOKEN/.test(body), body)
|
||||
|
||||
// ── pure helpers ────────────────────────────────────────────────────────────
|
||||
check('isLoopbackHost accepts 127.0.0.1', internals.isLoopbackHost('127.0.0.1') === true)
|
||||
check('isLoopbackHost accepts ::1', internals.isLoopbackHost('::1') === true)
|
||||
check('isLoopbackHost rejects 192.168.1.5', internals.isLoopbackHost('192.168.1.5') === false)
|
||||
check('hostnameOf strips the port', internals.hostnameOf('192.168.1.5:3081') === '192.168.1.5')
|
||||
check('upstreamHeaders drops hop-by-hop', internals.upstreamHeaders({ connection: 'keep-alive', 'x-a': '1' }, 'h:1').connection === undefined)
|
||||
|
||||
// ── bind failure is reported, never thrown ──────────────────────────────────
|
||||
const blocker = createServer(() => {})
|
||||
const blockerPort = await listen(blocker)
|
||||
const routes2 = []
|
||||
const connection2 = { admit: () => ({ peer: {} }), authenticatedUrl: (base) => `${base}?token=T` }
|
||||
const webServer2 = { port: upstreamPort, register: (route) => { routes2.push(route); return () => {} } }
|
||||
const ctx2 = {
|
||||
webServer: webServer2,
|
||||
connection: connection2,
|
||||
get: (name) => (name === 'connection' ? connection2 : name === 'webServer' ? webServer2 : undefined),
|
||||
inject: (deps, callback) => {
|
||||
if (deps.every((d) => ctx2.get(d) !== undefined)) callback(ctx2)
|
||||
return () => {}
|
||||
},
|
||||
effect: (callback) => { callback(); return () => {} },
|
||||
}
|
||||
apply(ctx2, {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: blockerPort,
|
||||
targetHost: '127.0.0.1',
|
||||
targetPort: upstreamPort,
|
||||
printBanner: false,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
const route2 = routes2.find((entry) => entry.path === '/api/dsh-lan-access/summary')
|
||||
let body2 = ''
|
||||
route2?.handler({ headers: { host: `127.0.0.1:${upstreamPort}` } }, {
|
||||
writeHead(status) { this.status = status },
|
||||
end(value) { body2 = value ?? '' },
|
||||
})
|
||||
const payload2 = JSON.parse(body2)
|
||||
check('taken port is reported as listening:false', payload2.listening === false, body2)
|
||||
check('taken port carries the bind error', String(payload2.bindError ?? '').includes('EADDRINUSE'), payload2.bindError)
|
||||
blocker.close()
|
||||
|
||||
// ── ownsHost compat is opt-in and page-conditional ──────────────────────────
|
||||
/**
|
||||
* Mount the plugin with a given config and return the registered index taps.
|
||||
* @param config - extra config over merged onto a loopback test binding.
|
||||
* @returns the tap functions the plugin registered.
|
||||
*/
|
||||
let compatPort = 39_990
|
||||
async function tapsFor(config) {
|
||||
compatPort += 1
|
||||
const taps = []
|
||||
const records = []
|
||||
const c = {
|
||||
get: () => undefined,
|
||||
inject: (deps, callback) => {
|
||||
callback({
|
||||
webServer: {
|
||||
port: upstreamPort,
|
||||
register: (route) => { records.push(route); return () => {} },
|
||||
tapIndex: (transform) => { taps.push(transform); return () => {} },
|
||||
},
|
||||
effect: (callback2) => { callback2(); return () => {} },
|
||||
})
|
||||
return () => {}
|
||||
},
|
||||
effect: (callback2) => { callback2(); return () => {} },
|
||||
}
|
||||
apply(c, {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: compatPort,
|
||||
targetHost: '127.0.0.1',
|
||||
targetPort: upstreamPort,
|
||||
printBanner: false,
|
||||
...config,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||
return taps
|
||||
}
|
||||
|
||||
const offTaps = await tapsFor({ ownsHostCompat: false })
|
||||
check('ownsHost compat is off by default', offTaps.length === 0, `taps: ${offTaps.length}`)
|
||||
|
||||
const onTaps = await tapsFor({ ownsHostCompat: true })
|
||||
check('ownsHost compat registers one index tap', onTaps.length === 1, `taps: ${onTaps.length}`)
|
||||
const html = '<html><head><title>t</title></head><body><script type="module" src="x.js"></script></body></html>'
|
||||
const tapped = onTaps[0]?.(html) ?? html
|
||||
const scriptAt = tapped.indexOf('ownsHost = true')
|
||||
const bundleAt = tapped.indexOf('x.js')
|
||||
check('compat script lands in the head', scriptAt !== -1 && tapped.indexOf('</head>') > scriptAt, tapped.slice(0, 80))
|
||||
check('compat script precedes the app bundle', scriptAt !== -1 && scriptAt < bundleAt)
|
||||
|
||||
/** Run the compat script against a stubbed page location. */
|
||||
function runCompat(hostname) {
|
||||
const sandbox = { globalThis: undefined, location: hostname === undefined ? undefined : { hostname } }
|
||||
sandbox.globalThis = sandbox
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(OWNS_HOST_COMPAT_SCRIPT, sandbox)
|
||||
return sandbox.__DSH_TRANSPORT__
|
||||
}
|
||||
check('compat forges ownsHost on a LAN page', runCompat('192.168.1.5')?.ownsHost === true)
|
||||
check('compat leaves a loopback page untouched', runCompat('127.0.0.1') === undefined)
|
||||
check('compat leaves localhost untouched', runCompat('localhost') === undefined)
|
||||
check('compat survives a missing location', runCompat(undefined) === undefined)
|
||||
|
||||
upstream.close()
|
||||
const failed = results.filter((entry) => !entry.ok)
|
||||
console.log(`\n${results.length - failed.length}/${results.length} checks passed`)
|
||||
process.exit(failed.length === 0 ? 0 : 1)
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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 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 === '@sutong/dsh-lan-access', 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: () => {},
|
||||
}
|
||||
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'))
|
||||
|
||||
// ── 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)
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Cross-check `src/qrcode.js` against the reference `qrcode` npm package.
|
||||
*
|
||||
* The reference is only a test oracle — it is never a runtime dependency. Run
|
||||
* `node scripts/test-qr.mjs`; set `QR_ORACLE_DIR` to the directory holding the
|
||||
* installed `qrcode` package (default `/tmp/qr-oracle`).
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { encodeQR } from '../src/qrcode.js'
|
||||
|
||||
const oracleDir = process.env.QR_ORACLE_DIR ?? '/tmp/qr-oracle'
|
||||
const requireFromOracle = createRequire(`${oracleDir}/package.json`)
|
||||
let oracle
|
||||
try {
|
||||
oracle = requireFromOracle('qrcode')
|
||||
} catch (error) {
|
||||
console.error(`cannot load the qrcode oracle from ${oracleDir}: ${error.message}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
/** Byte-mode capacity at error-correction level M, independent of the encoder under test. */
|
||||
const CAPACITY_M = { 1: 14, 2: 26, 3: 42, 4: 62, 5: 84, 6: 106, 7: 122, 8: 152, 9: 180, 10: 213 }
|
||||
|
||||
/** Deterministic pseudo-random byte source. */
|
||||
function makeRandom(seed) {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state * 1_664_525 + 1_013_904_223) >>> 0
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic payload of exactly `length` bytes.
|
||||
*
|
||||
* A lowercase letter is always present on purpose: the reference implementation
|
||||
* auto-selects the most compact mode, and QR alphanumeric mode covers only
|
||||
* digits, uppercase, space and `$%*+-./:`. This encoder implements byte mode
|
||||
* (what a URL needs), so the payload must rule numeric and alphanumeric modes
|
||||
* out to make the two comparable.
|
||||
*/
|
||||
function payload(length, seed) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~:/?#[]@!$&()*+,;=%'
|
||||
const random = makeRandom(seed)
|
||||
let text = ''
|
||||
while (text.length < length) text += alphabet[random() % alphabet.length]
|
||||
text = text.slice(0, length)
|
||||
if (!/[a-z]/.test(text)) text = `${text.slice(0, -1)}a`
|
||||
return text
|
||||
}
|
||||
|
||||
let comparisons = 0
|
||||
let failures = 0
|
||||
const failureSamples = []
|
||||
|
||||
for (let version = 1; version <= 10; version += 1) {
|
||||
for (const length of [1, Math.floor(CAPACITY_M[version] / 2), CAPACITY_M[version]]) {
|
||||
const text = payload(length, version * 1000 + length)
|
||||
for (let mask = 0; mask < 8; mask += 1) {
|
||||
const mine = encodeQR(text, { version, mask })
|
||||
const reference = oracle.create(text, {
|
||||
errorCorrectionLevel: 'M',
|
||||
version,
|
||||
maskPattern: mask,
|
||||
})
|
||||
comparisons += 1
|
||||
|
||||
const size = reference.modules.size
|
||||
const expected = reference.modules.data
|
||||
let mismatch = -1
|
||||
if (mine.size !== size) {
|
||||
mismatch = -2
|
||||
} else {
|
||||
for (let i = 0; i < expected.length; i += 1) {
|
||||
if ((mine.modules[i] ? 1 : 0) !== (expected[i] ? 1 : 0)) {
|
||||
mismatch = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mismatch !== -1) {
|
||||
failures += 1
|
||||
if (failureSamples.length < 5) {
|
||||
failureSamples.push({ version, length, mask, mismatch })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`forced version+mask comparisons: ${comparisons - failures}/${comparisons} identical`)
|
||||
|
||||
// The format-information and mask choice must match too: compare the chosen
|
||||
// mask for payloads where both implementations score penalties the same way.
|
||||
let maskAgreements = 0
|
||||
let maskCases = 0
|
||||
for (let version = 1; version <= 10; version += 1) {
|
||||
const text = payload(Math.min(20, CAPACITY_M[version]), version * 7 + 3)
|
||||
const mine = encodeQR(text, { version })
|
||||
const reference = oracle.create(text, { errorCorrectionLevel: 'M', version })
|
||||
maskCases += 1
|
||||
if (mine.mask === reference.maskPattern) maskAgreements += 1
|
||||
else console.log(` mask differs at v${version}: mine=${mine.mask} reference=${reference.maskPattern}`)
|
||||
}
|
||||
console.log(`auto-mask agreement: ${maskAgreements}/${maskCases}`)
|
||||
|
||||
// UTF-8. The reference re-segments mixed text into Byte+Alphanumeric runs for
|
||||
// compactness; this encoder deliberately emits one Byte segment (there is no
|
||||
// correctness difference, only size). Compare strictly against a payload the
|
||||
// reference also encodes as a single Byte segment.
|
||||
const byteOnly = '局域网_def~令牌~abc'
|
||||
const byteOnlyAuto = encodeQR(byteOnly)
|
||||
const byteOnlyReference = oracle.create(byteOnly, {
|
||||
errorCorrectionLevel: 'M',
|
||||
version: byteOnlyAuto.version,
|
||||
maskPattern: byteOnlyAuto.mask,
|
||||
})
|
||||
const byteOnlySame =
|
||||
byteOnlyAuto.size === byteOnlyReference.modules.size &&
|
||||
byteOnlyAuto.modules.every((value, index) => (value ? 1 : 0) === (byteOnlyReference.modules.data[index] ? 1 : 0))
|
||||
console.log(
|
||||
`utf8 single-byte-segment (v${byteOnlyAuto.version}, mask ${byteOnlyAuto.mask}) byte-identical: ${byteOnlySame}`,
|
||||
)
|
||||
if (byteOnlyReference.segments.length !== 1) console.log(' note: reference did not emit one segment')
|
||||
|
||||
// A realistic URL is where the reference re-segments. Report the divergence
|
||||
// rather than treating a valid-but-larger symbol as a failure.
|
||||
const url = 'http://192.168.1.5:3081/?token=aBcD1234EfGh5678'
|
||||
const urlSymbol = encodeQR(url)
|
||||
const urlReference = oracle.create(url, { errorCorrectionLevel: 'M' })
|
||||
console.log(
|
||||
`realistic URL fits v${urlSymbol.version} (size ${urlSymbol.size}); reference used v${urlReference.version} in ${urlReference.segments.length} segment(s)`,
|
||||
)
|
||||
const urlStructurallySound = urlSymbol.size === urlSymbol.version * 4 + 17 && urlSymbol.modules.length === urlSymbol.size * urlSymbol.size
|
||||
|
||||
const ok = failures === 0 && byteOnlySame && urlStructurallySound
|
||||
if (failureSamples.length > 0) console.log('failure samples:', JSON.stringify(failureSamples))
|
||||
console.log(ok ? '\nQR encoder matches the reference implementation' : '\nQR encoder MISMATCH')
|
||||
process.exit(ok ? 0 : 1)
|
||||
Reference in New Issue
Block a user