/** * 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: () => {}, } 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)