mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-08-05 20:19:45 +00:00
refactor(web): use generated proto network types (#2373)
* refactor(web): use generated proto network types * fix(core): preserve dumped config flags * test(web): cover config flag save paths * fix(ci): use system protoc before frontend codegen * fix(ci): serialize frontend-lib builds
This commit is contained in:
@@ -13,12 +13,18 @@
|
||||
"./*.css": "./dist/*.css"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"codegen:proto": "node scripts/codegen-proto.mjs",
|
||||
"dev": "pnpm codegen:proto && vite",
|
||||
"build": "pnpm codegen:proto && vue-tsc -b && vite build",
|
||||
"test": "pnpm test:config-ui && pnpm test:network-config",
|
||||
"test:config-ui": "pnpm codegen:proto && vitest run --config vitest.config.ts",
|
||||
"test:network-config": "pnpm build && node scripts/test-network-config.mjs",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@protobuf-ts/runtime": "2.11.1",
|
||||
"@protobuf-ts/runtime-rpc": "2.11.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"chart.js": "^4.5.0",
|
||||
@@ -33,9 +39,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||
"@protobuf-ts/plugin": "2.11.1",
|
||||
"@protobuf-ts/protoc": "2.11.1",
|
||||
"@types/node": "^22.8.6",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"happy-dom": "16.8.1",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-nested": "^7.0.2",
|
||||
@@ -43,10 +53,11 @@
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vitest": "^2.1.9",
|
||||
"vue-tsc": "^2.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.12",
|
||||
"primevue": "^4.3.9"
|
||||
"primevue": "^4.3.9",
|
||||
"vue": "^3.5.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2482
-10
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { delimiter, dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const protoRoot = resolve(root, '../../easytier/src/proto')
|
||||
const generatedRoot = resolve(root, 'src/generated')
|
||||
const outDir = resolve(generatedRoot, 'proto')
|
||||
const nodeBinDir = resolve(root, 'node_modules/.bin')
|
||||
|
||||
const protocWrapper = require.resolve('@protobuf-ts/protoc/protoc.js')
|
||||
const protobufTsPluginRoot = dirname(require.resolve('@protobuf-ts/plugin/package.json'))
|
||||
|
||||
const protoFiles = [
|
||||
'common.proto',
|
||||
'acl.proto',
|
||||
'api_instance.proto',
|
||||
'api_manage.proto',
|
||||
'peer_rpc.proto',
|
||||
'error.proto',
|
||||
]
|
||||
|
||||
function installGeneratedFiles(fromDir, toDir) {
|
||||
mkdirSync(toDir, { recursive: true })
|
||||
|
||||
for (const entry of readdirSync(fromDir)) {
|
||||
const source = resolve(fromDir, entry)
|
||||
const target = resolve(toDir, entry)
|
||||
|
||||
if (statSync(source).isDirectory()) {
|
||||
installGeneratedFiles(source, target)
|
||||
continue
|
||||
}
|
||||
|
||||
renameSync(source, target)
|
||||
}
|
||||
}
|
||||
|
||||
function findExecutableInPath(command, extensions = ['']) {
|
||||
const envPath = process.env[pathEnvKey()]
|
||||
if (typeof envPath !== 'string') return undefined
|
||||
|
||||
const nodeBinSuffix = ['node_modules/.bin', 'node_modules\\.bin']
|
||||
for (const entry of envPath.split(delimiter)) {
|
||||
if (!entry || nodeBinSuffix.some((suffix) => entry.endsWith(suffix))) continue
|
||||
|
||||
for (const extension of extensions) {
|
||||
const candidate = resolve(entry, `${command}${extension}`)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pathEnvKey() {
|
||||
return Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'
|
||||
}
|
||||
|
||||
function withNodeBinPath() {
|
||||
const key = pathEnvKey()
|
||||
const currentPath = process.env[key]
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
[key]: currentPath ? `${nodeBinDir}${delimiter}${currentPath}` : nodeBinDir,
|
||||
}
|
||||
}
|
||||
|
||||
function getProtocCommand() {
|
||||
const extensions = process.platform === 'win32' ? ['.exe'] : ['']
|
||||
const systemProtoc = findExecutableInPath('protoc', extensions)
|
||||
|
||||
if (systemProtoc) {
|
||||
return {
|
||||
command: systemProtoc,
|
||||
argsPrefix: ['--proto_path', protobufTsPluginRoot],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command: process.execPath,
|
||||
argsPrefix: [protocWrapper],
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(generatedRoot, { recursive: true })
|
||||
const tmpDir = mkdtempSync(resolve(generatedRoot, '.proto-'))
|
||||
const protocCommand = getProtocCommand()
|
||||
|
||||
try {
|
||||
const result = spawnSync(protocCommand.command, [
|
||||
...protocCommand.argsPrefix,
|
||||
'-I',
|
||||
protoRoot,
|
||||
`--ts_out=${tmpDir}`,
|
||||
'--ts_opt=use_proto_field_name,server_none,client_none,ts_nocheck',
|
||||
...protoFiles.map((file) => resolve(protoRoot, file)),
|
||||
], {
|
||||
cwd: root,
|
||||
env: withNodeBinPath(),
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
const status = result.status ?? 1
|
||||
if (status === 0) {
|
||||
installGeneratedFiles(tmpDir, outDir)
|
||||
}
|
||||
|
||||
process.exit(status)
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
import ts from 'typescript'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const generatedApiManagePath = path.join(projectRoot, 'src/generated/proto/api_manage.ts')
|
||||
const distPath = path.join(projectRoot, 'dist/easytier-frontend-lib.js')
|
||||
|
||||
const { NetworkTypes } = await import(pathToFileURL(distPath))
|
||||
|
||||
const {
|
||||
AclAction,
|
||||
AclChainType,
|
||||
AclProtocol,
|
||||
CompressionAlgoPb,
|
||||
DEFAULT_NETWORK_CONFIG,
|
||||
NetworkingMethod,
|
||||
normalizeNetworkConfig,
|
||||
toBackendNetworkConfig,
|
||||
} = NetworkTypes
|
||||
|
||||
const BOOLEAN_CONFIG_FIELDS = [
|
||||
'dhcp',
|
||||
'enable_vpn_portal',
|
||||
'advanced_settings',
|
||||
'latency_first',
|
||||
'use_smoltcp',
|
||||
'disable_ipv6',
|
||||
'enable_kcp_proxy',
|
||||
'disable_kcp_input',
|
||||
'disable_p2p',
|
||||
'bind_device',
|
||||
'no_tun',
|
||||
'enable_exit_node',
|
||||
'relay_all_peer_rpc',
|
||||
'multi_thread',
|
||||
'enable_relay_network_whitelist',
|
||||
'enable_manual_routes',
|
||||
'proxy_forward_by_system',
|
||||
'disable_encryption',
|
||||
'enable_socks5',
|
||||
'disable_udp_hole_punching',
|
||||
'enable_magic_dns',
|
||||
'enable_private_mode',
|
||||
'enable_quic_proxy',
|
||||
'disable_quic_input',
|
||||
'disable_sym_hole_punching',
|
||||
'p2p_only',
|
||||
'lazy_p2p',
|
||||
'need_p2p',
|
||||
'disable_upnp',
|
||||
'ipv6_public_addr_provider',
|
||||
'ipv6_public_addr_auto',
|
||||
'disable_relay_data',
|
||||
'enable_udp_broadcast_relay',
|
||||
'disable_tcp_hole_punching',
|
||||
]
|
||||
|
||||
function readGeneratedNetworkConfigFields() {
|
||||
const source = ts.createSourceFile(
|
||||
generatedApiManagePath,
|
||||
fs.readFileSync(generatedApiManagePath, 'utf8'),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
)
|
||||
|
||||
for (const statement of source.statements) {
|
||||
if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'NetworkConfig') {
|
||||
continue
|
||||
}
|
||||
|
||||
return statement.members
|
||||
.filter(ts.isPropertySignature)
|
||||
.map((member) => member.name.getText(source).replace(/^['"]|['"]$/g, ''))
|
||||
}
|
||||
|
||||
throw new Error(`NetworkConfig interface not found in ${generatedApiManagePath}`)
|
||||
}
|
||||
|
||||
function expectNoCamelCaseKeys(value, pathSegments = []) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => expectNoCamelCaseKeys(item, [...pathSegments, String(index)]))
|
||||
return
|
||||
}
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
assert.equal(
|
||||
/[A-Z]/.test(key),
|
||||
false,
|
||||
`JSON key should use proto field name: ${[...pathSegments, key].join('.')}`,
|
||||
)
|
||||
expectNoCamelCaseKeys(child, [...pathSegments, key])
|
||||
}
|
||||
}
|
||||
|
||||
function allFieldFixture() {
|
||||
return {
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
instance_id: '11111111-2222-3333-4444-555555555555',
|
||||
dhcp: false,
|
||||
virtual_ipv4: '10.9.8.7',
|
||||
network_length: 25,
|
||||
hostname: 'frontend-e2e',
|
||||
network_name: 'full-field-network',
|
||||
network_secret: 'full-field-secret',
|
||||
networking_method: NetworkingMethod.Manual,
|
||||
public_server_url: 'tcp://public.example:11010',
|
||||
peer_urls: [' tcp://peer-a:11010 ', '', 'udp://peer-b:11010'],
|
||||
proxy_cidrs: ['10.10.0.0/16', '192.168.2.0/24->10.99.0.0/24'],
|
||||
enable_vpn_portal: true,
|
||||
vpn_portal_listen_port: 23000,
|
||||
vpn_portal_client_network_addr: '10.88.0.0',
|
||||
vpn_portal_client_network_len: 24,
|
||||
advanced_settings: true,
|
||||
listener_urls: ['tcp://0.0.0.0:12010', 'udp://0.0.0.0:12010'],
|
||||
latency_first: true,
|
||||
dev_name: 'et-full',
|
||||
use_smoltcp: true,
|
||||
disable_ipv6: true,
|
||||
enable_kcp_proxy: true,
|
||||
disable_kcp_input: true,
|
||||
disable_p2p: true,
|
||||
bind_device: false,
|
||||
no_tun: true,
|
||||
enable_exit_node: true,
|
||||
relay_all_peer_rpc: true,
|
||||
multi_thread: false,
|
||||
enable_relay_network_whitelist: true,
|
||||
relay_network_whitelist: ['10.0.0.0/8', 'fd00::/8'],
|
||||
enable_manual_routes: true,
|
||||
routes: ['10.20.0.0/16', 'fd00:20::/64'],
|
||||
exit_nodes: ['10.9.8.1', 'fd00::1'],
|
||||
proxy_forward_by_system: true,
|
||||
disable_encryption: true,
|
||||
enable_socks5: true,
|
||||
socks5_port: 1081,
|
||||
disable_udp_hole_punching: true,
|
||||
mtu: 1280,
|
||||
mapped_listeners: ['tcp://127.0.0.1:13010'],
|
||||
enable_magic_dns: true,
|
||||
enable_private_mode: true,
|
||||
enable_quic_proxy: true,
|
||||
disable_quic_input: true,
|
||||
quic_listen_port: 14010,
|
||||
port_forwards: [
|
||||
{
|
||||
proto: 'tcp',
|
||||
bind_ip: '127.0.0.1',
|
||||
bind_port: 8080,
|
||||
dst_ip: '10.9.8.7',
|
||||
dst_port: 80,
|
||||
},
|
||||
{
|
||||
proto: 'udp',
|
||||
bind_ip: '0.0.0.0',
|
||||
bind_port: 5353,
|
||||
dst_ip: '10.9.8.8',
|
||||
dst_port: 53,
|
||||
},
|
||||
],
|
||||
disable_sym_hole_punching: true,
|
||||
p2p_only: true,
|
||||
data_compress_algo: CompressionAlgoPb.Zstd,
|
||||
encryption_algorithm: 'aes-gcm',
|
||||
disable_tcp_hole_punching: true,
|
||||
secure_mode: {
|
||||
enabled: true,
|
||||
local_private_key: 'private-key',
|
||||
local_public_key: 'public-key',
|
||||
},
|
||||
acl: {
|
||||
acl_v1: {
|
||||
group: {
|
||||
declares: [
|
||||
{
|
||||
group_name: 'ops',
|
||||
group_secret: 'ops-secret',
|
||||
},
|
||||
],
|
||||
members: ['node-a', 'node-b'],
|
||||
},
|
||||
chains: [
|
||||
{
|
||||
name: 'forward-chain',
|
||||
chain_type: AclChainType.Forward,
|
||||
description: 'forward traffic',
|
||||
enabled: true,
|
||||
default_action: AclAction.Drop,
|
||||
rules: [
|
||||
{
|
||||
name: 'allow-web',
|
||||
description: 'allow web traffic',
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
protocol: AclProtocol.TCP,
|
||||
ports: ['80', '443'],
|
||||
source_ips: ['10.0.0.0/8'],
|
||||
destination_ips: ['10.9.8.7/32'],
|
||||
source_ports: ['1024-65535'],
|
||||
action: AclAction.Allow,
|
||||
rate_limit: 1000,
|
||||
burst_limit: 2000,
|
||||
stateful: true,
|
||||
source_groups: ['ops'],
|
||||
destination_groups: ['web'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
credential_file: '/tmp/easytier-credential.toml',
|
||||
lazy_p2p: true,
|
||||
need_p2p: true,
|
||||
instance_recv_bps_limit: '9007199254740993',
|
||||
disable_upnp: true,
|
||||
ipv6_public_addr_provider: true,
|
||||
ipv6_public_addr_auto: true,
|
||||
ipv6_public_addr_prefix: '2001:db8:1::/64',
|
||||
disable_relay_data: true,
|
||||
enable_udp_broadcast_relay: true,
|
||||
socket_mark: 1234,
|
||||
}
|
||||
}
|
||||
|
||||
function assertFixtureCoversGeneratedFields() {
|
||||
const generatedFields = readGeneratedNetworkConfigFields()
|
||||
const fixtureFields = new Set(Object.keys(allFieldFixture()))
|
||||
const missing = generatedFields.filter((field) => !fixtureFields.has(field))
|
||||
|
||||
assert.deepEqual(missing, [], 'all generated NetworkConfig fields should be represented in the fixture')
|
||||
}
|
||||
|
||||
function assertFullFieldRoundTrip() {
|
||||
const input = allFieldFixture()
|
||||
const normalized = normalizeNetworkConfig(input)
|
||||
|
||||
assert.equal(normalized.peer_urls.join(','), 'tcp://peer-a:11010,udp://peer-b:11010')
|
||||
assert.equal(normalized.instance_recv_bps_limit, '9007199254740993')
|
||||
assert.equal(normalized.data_compress_algo, CompressionAlgoPb.Zstd)
|
||||
assert.equal(normalized.acl.acl_v1.chains[0].chain_type, AclChainType.Forward)
|
||||
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].protocol, AclProtocol.TCP)
|
||||
|
||||
const backend = toBackendNetworkConfig(normalized)
|
||||
expectNoCamelCaseKeys(backend)
|
||||
|
||||
for (const field of readGeneratedNetworkConfigFields()) {
|
||||
assert.ok(field in backend, `backend JSON should include fixture field ${field}`)
|
||||
}
|
||||
|
||||
assert.equal(backend.networking_method, 'Manual')
|
||||
assert.equal(backend.public_server_url, '')
|
||||
assert.deepEqual(backend.peer_urls, ['tcp://peer-a:11010', 'udp://peer-b:11010'])
|
||||
assert.equal(backend.data_compress_algo, 'Zstd')
|
||||
assert.equal(backend.instance_recv_bps_limit, '9007199254740993')
|
||||
assert.equal(backend.secure_mode.enabled, true)
|
||||
assert.equal(backend.secure_mode.local_private_key, 'private-key')
|
||||
assert.equal(backend.acl.acl_v1.chains[0].chain_type, 'Forward')
|
||||
assert.equal(backend.acl.acl_v1.chains[0].default_action, 'Drop')
|
||||
assert.equal(backend.acl.acl_v1.chains[0].rules[0].protocol, 'TCP')
|
||||
assert.equal(backend.acl.acl_v1.chains[0].rules[0].action, 'Allow')
|
||||
assert.equal(backend.port_forwards[1].proto, 'udp')
|
||||
assert.equal(backend.socket_mark, 1234)
|
||||
}
|
||||
|
||||
function assertBooleanFieldValuesPreserved() {
|
||||
const input = allFieldFixture()
|
||||
const normalized = normalizeNetworkConfig(input)
|
||||
const backend = toBackendNetworkConfig(normalized)
|
||||
|
||||
for (const field of BOOLEAN_CONFIG_FIELDS) {
|
||||
assert.equal(
|
||||
normalized[field],
|
||||
input[field],
|
||||
`normalized config should preserve boolean field ${field}`,
|
||||
)
|
||||
assert.equal(
|
||||
backend[field],
|
||||
input[field],
|
||||
`backend JSON should preserve boolean field ${field}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertEnumCompatibility() {
|
||||
const normalized = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
networking_method: 'Manual',
|
||||
data_compress_algo: 'Zstd',
|
||||
acl: {
|
||||
acl_v1: {
|
||||
group: { declares: [], members: [] },
|
||||
chains: [
|
||||
{
|
||||
chain_type: 'Forward',
|
||||
default_action: 'Drop',
|
||||
rules: [
|
||||
{
|
||||
protocol: 'TCP',
|
||||
action: 'Allow',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(normalized.data_compress_algo, CompressionAlgoPb.Zstd)
|
||||
assert.equal(normalized.acl.acl_v1.chains[0].chain_type, AclChainType.Forward)
|
||||
assert.equal(normalized.acl.acl_v1.chains[0].default_action, AclAction.Drop)
|
||||
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].protocol, AclProtocol.TCP)
|
||||
assert.equal(normalized.acl.acl_v1.chains[0].rules[0].action, AclAction.Allow)
|
||||
|
||||
const backend = toBackendNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
data_compress_algo: 'Zstd',
|
||||
acl: {
|
||||
acl_v1: {
|
||||
group: { declares: [], members: [] },
|
||||
chains: [
|
||||
{
|
||||
chain_type: 'Forward',
|
||||
default_action: 'Drop',
|
||||
rules: [
|
||||
{
|
||||
protocol: 'TCP',
|
||||
action: 'Allow',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(backend.data_compress_algo, 'Zstd')
|
||||
assert.equal(backend.acl.acl_v1.chains[0].chain_type, 'Forward')
|
||||
assert.equal(backend.acl.acl_v1.chains[0].rules[0].protocol, 'TCP')
|
||||
}
|
||||
|
||||
function assertAclDefaultsAndExplicitZero() {
|
||||
const partialAcl = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
acl: {
|
||||
acl_v1: {
|
||||
group: { declares: [], members: [] },
|
||||
chains: [{ rules: [{}] }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const defaultedChain = partialAcl.acl.acl_v1.chains[0]
|
||||
|
||||
assert.equal(defaultedChain.chain_type, AclChainType.UnspecifiedChain)
|
||||
assert.equal(defaultedChain.default_action, AclAction.Allow)
|
||||
assert.equal(defaultedChain.rules[0].protocol, AclProtocol.Any)
|
||||
assert.equal(defaultedChain.rules[0].action, AclAction.Allow)
|
||||
|
||||
const explicitZero = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
acl: {
|
||||
acl_v1: {
|
||||
group: { declares: [], members: [] },
|
||||
chains: [
|
||||
{
|
||||
chain_type: 0,
|
||||
default_action: 0,
|
||||
rules: [{ protocol: 0, action: 0 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const zeroChain = explicitZero.acl.acl_v1.chains[0]
|
||||
|
||||
assert.equal(zeroChain.chain_type, AclChainType.UnspecifiedChain)
|
||||
assert.equal(zeroChain.default_action, AclAction.Noop)
|
||||
assert.equal(zeroChain.rules[0].protocol, AclProtocol.Unspecified)
|
||||
assert.equal(zeroChain.rules[0].action, AclAction.Noop)
|
||||
}
|
||||
|
||||
function assertNetworkingMethodNormalization() {
|
||||
const publicServer = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
networking_method: 'PublicServer',
|
||||
public_server_url: ' tcp://public.example:11010 ',
|
||||
peer_urls: ['tcp://manual.example:11010'],
|
||||
})
|
||||
|
||||
assert.equal(publicServer.networking_method, NetworkingMethod.Manual)
|
||||
assert.equal(publicServer.public_server_url, '')
|
||||
assert.deepEqual(publicServer.peer_urls, ['tcp://public.example:11010'])
|
||||
|
||||
const standalone = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
networking_method: 'Standalone',
|
||||
peer_urls: ['tcp://manual.example:11010'],
|
||||
})
|
||||
|
||||
assert.equal(standalone.networking_method, NetworkingMethod.Manual)
|
||||
assert.deepEqual(standalone.peer_urls, [])
|
||||
|
||||
const missing = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
networking_method: undefined,
|
||||
peer_urls: [' tcp://one ', '', 'udp://two '],
|
||||
})
|
||||
|
||||
assert.deepEqual(missing.peer_urls, ['tcp://one', 'udp://two'])
|
||||
}
|
||||
|
||||
function assertNumberBoundaries() {
|
||||
const safeLimit = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
instance_recv_bps_limit: '12345',
|
||||
})
|
||||
assert.equal(safeLimit.instance_recv_bps_limit, 12345)
|
||||
|
||||
const largeLimit = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
instance_recv_bps_limit: '9007199254740993',
|
||||
})
|
||||
assert.equal(largeLimit.instance_recv_bps_limit, '9007199254740993')
|
||||
assert.equal(toBackendNetworkConfig(largeLimit).instance_recv_bps_limit, '9007199254740993')
|
||||
|
||||
const invalidNumbers = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
mtu: Number.NaN,
|
||||
instance_recv_bps_limit: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
assert.equal(invalidNumbers.mtu, null)
|
||||
assert.equal(invalidNumbers.instance_recv_bps_limit, null)
|
||||
|
||||
const emptyLimit = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
instance_recv_bps_limit: '',
|
||||
})
|
||||
assert.equal(emptyLimit.instance_recv_bps_limit, null)
|
||||
|
||||
const zeroLimit = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
instance_recv_bps_limit: '0',
|
||||
})
|
||||
assert.equal(zeroLimit.instance_recv_bps_limit, null)
|
||||
assert.equal(toBackendNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
instance_recv_bps_limit: 0,
|
||||
}).instance_recv_bps_limit, undefined)
|
||||
|
||||
const oversizedLimit = normalizeNetworkConfig({
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
instance_recv_bps_limit: '18446744073709551616',
|
||||
})
|
||||
assert.equal(oversizedLimit.instance_recv_bps_limit, null)
|
||||
}
|
||||
|
||||
const tests = [
|
||||
assertFixtureCoversGeneratedFields,
|
||||
assertFullFieldRoundTrip,
|
||||
assertBooleanFieldValuesPreserved,
|
||||
assertEnumCompatibility,
|
||||
assertAclDefaultsAndExplicitZero,
|
||||
assertNetworkingMethodNormalization,
|
||||
assertNumberBoundaries,
|
||||
]
|
||||
|
||||
for (const test of tests) {
|
||||
test()
|
||||
console.log(`ok ${test.name}`)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
normalizeNetworkConfig,
|
||||
removeRow
|
||||
} from '../types/network'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AclManager from './acl/AclManager.vue'
|
||||
import UrlListInput from './UrlListInput.vue'
|
||||
@@ -134,6 +134,7 @@ function savePortForward() {
|
||||
const portForwardContainer = ref<HTMLElement | null>(null);
|
||||
const isCompact = ref(false);
|
||||
|
||||
const UINT64_MAX = (1n << 64n) - 1n
|
||||
|
||||
onMounted(() => {
|
||||
if (portForwardContainer.value) {
|
||||
@@ -161,6 +162,39 @@ function syncNormalizedNetwork(network: NetworkConfig | undefined): void {
|
||||
}
|
||||
|
||||
watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: false })
|
||||
|
||||
function parseInstanceRecvBpsLimitInput(value: string): number | string | null | undefined {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const limit = BigInt(trimmed)
|
||||
if (limit === 0n) {
|
||||
return null
|
||||
}
|
||||
if (limit > UINT64_MAX) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return limit <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(limit) : limit.toString()
|
||||
}
|
||||
|
||||
const instanceRecvBpsLimitInput = computed<string>({
|
||||
get: () => {
|
||||
const limit = curNetwork.value.instance_recv_bps_limit
|
||||
return limit == null ? '' : String(limit)
|
||||
},
|
||||
set: (value) => {
|
||||
const limit = parseInstanceRecvBpsLimitInput(value)
|
||||
if (limit !== undefined) {
|
||||
curNetwork.value.instance_recv_bps_limit = limit
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -317,9 +351,9 @@ watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: fa
|
||||
<span class="pi pi-question-circle ml-2 self-center"
|
||||
v-tooltip="t('instance_recv_bps_limit_help')"></span>
|
||||
</div>
|
||||
<InputNumber id="instance_recv_bps_limit" v-model="curNetwork.instance_recv_bps_limit"
|
||||
aria-describedby="instance_recv_bps_limit-help" :format="false"
|
||||
:placeholder="t('instance_recv_bps_limit_placeholder')" :min="1" fluid />
|
||||
<InputText id="instance_recv_bps_limit" v-model="instanceRecvBpsLimitInput"
|
||||
aria-describedby="instance_recv_bps_limit-help" inputmode="numeric" pattern="[0-9]*"
|
||||
:placeholder="t('instance_recv_bps_limit_placeholder')" fluid />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,166 +1,49 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import {
|
||||
NetworkConfig as NetworkConfigPb,
|
||||
NetworkingMethod,
|
||||
type NetworkConfig as ProtoNetworkConfig,
|
||||
type PortForwardConfig,
|
||||
} from '../generated/proto/api_manage'
|
||||
import {
|
||||
Action as AclAction,
|
||||
ChainType as AclChainType,
|
||||
Protocol as AclProtocol,
|
||||
type Acl,
|
||||
type AclV1,
|
||||
type Chain as AclChain,
|
||||
type GroupIdentity,
|
||||
type GroupInfo,
|
||||
type Rule as AclRule,
|
||||
} from '../generated/proto/acl'
|
||||
import { CompressionAlgoPb, NatType, type SecureModeConfig } from '../generated/proto/common'
|
||||
import { prepareNetworkConfigForProtoJson } from './networkCompat'
|
||||
|
||||
export enum NetworkingMethod {
|
||||
PublicServer = 0,
|
||||
Manual = 1,
|
||||
Standalone = 2,
|
||||
}
|
||||
export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod }
|
||||
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, PortForwardConfig, SecureModeConfig }
|
||||
|
||||
export interface SecureModeConfig {
|
||||
enabled: boolean
|
||||
// Keep protocol compatibility with backend/import-export flows even though the GUI
|
||||
// does not render secure-mode or credential inputs.
|
||||
local_private_key?: string
|
||||
local_public_key?: string
|
||||
}
|
||||
|
||||
export enum AclProtocol {
|
||||
Unspecified = 0,
|
||||
TCP = 1,
|
||||
UDP = 2,
|
||||
ICMP = 3,
|
||||
ICMPv6 = 4,
|
||||
Any = 5,
|
||||
}
|
||||
|
||||
export enum AclAction {
|
||||
Noop = 0,
|
||||
Allow = 1,
|
||||
Drop = 2,
|
||||
}
|
||||
|
||||
export enum AclChainType {
|
||||
UnspecifiedChain = 0,
|
||||
Inbound = 1,
|
||||
Outbound = 2,
|
||||
Forward = 3,
|
||||
}
|
||||
|
||||
export interface AclRule {
|
||||
name: string
|
||||
description: string
|
||||
priority: number
|
||||
enabled: boolean
|
||||
protocol: AclProtocol
|
||||
ports: string[]
|
||||
source_ips: string[]
|
||||
destination_ips: string[]
|
||||
source_ports: string[]
|
||||
action: AclAction
|
||||
rate_limit: number
|
||||
burst_limit: number
|
||||
stateful: boolean
|
||||
source_groups: string[]
|
||||
destination_groups: string[]
|
||||
}
|
||||
|
||||
export interface AclChain {
|
||||
name: string
|
||||
chain_type: AclChainType
|
||||
description: string
|
||||
enabled: boolean
|
||||
rules: AclRule[]
|
||||
default_action: AclAction
|
||||
}
|
||||
|
||||
export interface GroupIdentity {
|
||||
group_name: string
|
||||
group_secret: string
|
||||
}
|
||||
|
||||
export interface GroupInfo {
|
||||
declares: GroupIdentity[]
|
||||
members: string[]
|
||||
}
|
||||
|
||||
export interface AclV1 {
|
||||
chains: AclChain[]
|
||||
group?: GroupInfo
|
||||
}
|
||||
|
||||
export interface Acl {
|
||||
acl_v1?: AclV1
|
||||
}
|
||||
|
||||
export interface NetworkConfig {
|
||||
export type NetworkConfig = Omit<
|
||||
ProtoNetworkConfig,
|
||||
'instance_id' | 'instance_recv_bps_limit' | 'mtu' | 'networking_method'
|
||||
> & {
|
||||
instance_id: string
|
||||
|
||||
dhcp: boolean
|
||||
virtual_ipv4: string
|
||||
network_length: number
|
||||
hostname?: string
|
||||
network_name: string
|
||||
network_secret?: string
|
||||
credential_file?: string
|
||||
secure_mode?: SecureModeConfig
|
||||
|
||||
networking_method: NetworkingMethod
|
||||
|
||||
public_server_url: string
|
||||
peer_urls: string[]
|
||||
|
||||
proxy_cidrs: string[]
|
||||
|
||||
enable_vpn_portal: boolean
|
||||
vpn_portal_listen_port: number
|
||||
vpn_portal_client_network_addr: string
|
||||
vpn_portal_client_network_len: number
|
||||
|
||||
advanced_settings: boolean
|
||||
|
||||
listener_urls: string[]
|
||||
latency_first: boolean
|
||||
|
||||
dev_name: string
|
||||
|
||||
use_smoltcp?: boolean
|
||||
disable_ipv6?: boolean
|
||||
ipv6_public_addr_auto?: boolean
|
||||
enable_kcp_proxy?: boolean
|
||||
disable_kcp_input?: boolean
|
||||
enable_quic_proxy?: boolean
|
||||
disable_quic_input?: boolean
|
||||
disable_p2p?: boolean
|
||||
p2p_only?: boolean
|
||||
lazy_p2p?: boolean
|
||||
bind_device?: boolean
|
||||
no_tun?: boolean
|
||||
enable_exit_node?: boolean
|
||||
relay_all_peer_rpc?: boolean
|
||||
need_p2p?: boolean
|
||||
multi_thread?: boolean
|
||||
proxy_forward_by_system?: boolean
|
||||
disable_encryption?: boolean
|
||||
disable_tcp_hole_punching?: boolean
|
||||
disable_udp_hole_punching?: boolean
|
||||
disable_upnp?: boolean
|
||||
enable_udp_broadcast_relay?: boolean
|
||||
disable_sym_hole_punching?: boolean
|
||||
|
||||
enable_relay_network_whitelist?: boolean
|
||||
relay_network_whitelist: string[]
|
||||
|
||||
enable_manual_routes: boolean
|
||||
routes: string[]
|
||||
|
||||
exit_nodes: string[]
|
||||
|
||||
enable_socks5?: boolean
|
||||
socks5_port: number
|
||||
|
||||
mtu: number | null
|
||||
instance_recv_bps_limit: number | null
|
||||
mapped_listeners: string[]
|
||||
instance_recv_bps_limit: number | string | null
|
||||
networking_method: NetworkingMethod | string
|
||||
}
|
||||
|
||||
enable_magic_dns?: boolean
|
||||
enable_private_mode?: boolean
|
||||
const UINT64_MAX = (1n << 64n) - 1n
|
||||
|
||||
port_forwards: PortForwardConfig[]
|
||||
acl?: Acl
|
||||
interface NetworkingConfigFields {
|
||||
peer_urls: string[]
|
||||
public_server_url?: string
|
||||
networking_method?: NetworkingMethod | string
|
||||
}
|
||||
|
||||
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
return {
|
||||
...NetworkConfigPb.create(),
|
||||
|
||||
instance_id: uuidv4(),
|
||||
|
||||
dhcp: true,
|
||||
@@ -243,33 +126,82 @@ function cleanPeerUrls(urls: string[] | undefined): string[] {
|
||||
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
|
||||
}
|
||||
|
||||
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||
const normalized: NetworkConfig = {
|
||||
...config,
|
||||
peer_urls: cleanPeerUrls(config.peer_urls),
|
||||
function normalizeUint64ForInput(v: bigint | number | string | null | undefined): number | string | null {
|
||||
if (v == null) return null
|
||||
|
||||
try {
|
||||
const n = typeof v === 'bigint' ? v : BigInt(v)
|
||||
if (n === 0n || n > UINT64_MAX) return null
|
||||
return n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : n.toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const publicServerUrl = normalized.public_server_url?.trim() ?? ''
|
||||
function normalizeNumberForInput(v: number | string | null | undefined): number | null {
|
||||
if (v == null) return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
switch (normalized.networking_method) {
|
||||
function toBackendUint64(v: number | bigint | string | null | undefined): bigint | undefined {
|
||||
if (v == null || v === '') return undefined
|
||||
try {
|
||||
const n = typeof v === 'bigint' ? v : BigInt(v)
|
||||
return n > 0n && n <= UINT64_MAX ? n : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function applyNetworkingMethod(config: NetworkingConfigFields): void {
|
||||
config.peer_urls = cleanPeerUrls(config.peer_urls)
|
||||
|
||||
const publicServerUrl = config.public_server_url?.trim() ?? ''
|
||||
const networkingMethod = config.networking_method ?? NetworkingMethod.Manual
|
||||
|
||||
switch (networkingMethod) {
|
||||
case NetworkingMethod.PublicServer:
|
||||
normalized.peer_urls = publicServerUrl ? [publicServerUrl] : []
|
||||
config.peer_urls = publicServerUrl ? [publicServerUrl] : []
|
||||
break
|
||||
case NetworkingMethod.Manual:
|
||||
break
|
||||
case NetworkingMethod.Standalone:
|
||||
default:
|
||||
normalized.peer_urls = []
|
||||
config.peer_urls = []
|
||||
break
|
||||
}
|
||||
|
||||
normalized.networking_method = NetworkingMethod.Manual
|
||||
normalized.public_server_url = ''
|
||||
config.networking_method = NetworkingMethod.Manual
|
||||
config.public_server_url = ''
|
||||
}
|
||||
|
||||
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||
const normalized = NetworkConfigPb.fromJson(prepareNetworkConfigForProtoJson(config) as any, {
|
||||
ignoreUnknownFields: true,
|
||||
}) as unknown as NetworkConfig
|
||||
|
||||
applyNetworkingMethod(normalized)
|
||||
normalized.mtu = normalizeNumberForInput(normalized.mtu)
|
||||
normalized.instance_recv_bps_limit = normalizeUint64ForInput(
|
||||
normalized.instance_recv_bps_limit as any,
|
||||
)
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||
return normalizeNetworkConfig(config)
|
||||
const backend = NetworkConfigPb.fromJson(prepareNetworkConfigForProtoJson(config) as any, {
|
||||
ignoreUnknownFields: true,
|
||||
})
|
||||
|
||||
applyNetworkingMethod(backend)
|
||||
backend.mtu = normalizeNumberForInput(config.mtu) ?? undefined
|
||||
backend.instance_recv_bps_limit = toBackendUint64(config.instance_recv_bps_limit)
|
||||
|
||||
return NetworkConfigPb.toJson(backend, {
|
||||
useProtoFieldName: true,
|
||||
}) as unknown as NetworkConfig
|
||||
}
|
||||
|
||||
export interface NetworkInstance {
|
||||
@@ -397,14 +329,6 @@ export interface PeerConnStats {
|
||||
latency_us: number
|
||||
}
|
||||
|
||||
export interface PortForwardConfig {
|
||||
bind_ip: string,
|
||||
bind_port: number,
|
||||
dst_ip: string,
|
||||
dst_port: number,
|
||||
proto: string
|
||||
}
|
||||
|
||||
// 添加新行
|
||||
export const addRow = (rows: PortForwardConfig[]) => {
|
||||
rows.push({
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Action as AclAction,
|
||||
ChainType as AclChainType,
|
||||
Protocol as AclProtocol,
|
||||
} from '../generated/proto/acl'
|
||||
import type { NetworkConfig } from './network'
|
||||
|
||||
const UINT64_MAX = (1n << 64n) - 1n
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
export function prepareNetworkConfigForProtoJson(config: NetworkConfig): NetworkConfig {
|
||||
const prepared = dropUnsupportedJsonValues(applyLegacyAclDefaults(config)) as NetworkConfig
|
||||
normalizeLegacyOptionalUint64(prepared as JsonRecord, 'instance_recv_bps_limit')
|
||||
return prepared
|
||||
}
|
||||
|
||||
function applyLegacyAclDefaults(config: NetworkConfig): NetworkConfig {
|
||||
const acl = config.acl
|
||||
const aclV1 = acl?.acl_v1
|
||||
if (!Array.isArray(aclV1?.chains)) return config
|
||||
|
||||
return {
|
||||
...config,
|
||||
acl: {
|
||||
...acl,
|
||||
acl_v1: {
|
||||
...aclV1,
|
||||
chains: aclV1.chains.map((chain) => ({
|
||||
...chain,
|
||||
chain_type: chain.chain_type ?? AclChainType.UnspecifiedChain,
|
||||
default_action: chain.default_action ?? AclAction.Allow,
|
||||
rules: (chain.rules ?? []).map((rule) => ({
|
||||
...rule,
|
||||
protocol: rule.protocol ?? AclProtocol.Any,
|
||||
action: rule.action ?? AclAction.Allow,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function dropUnsupportedJsonValues(value: unknown): unknown {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) return undefined
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(dropUnsupportedJsonValues).filter((v) => v !== undefined)
|
||||
}
|
||||
|
||||
if (isJsonRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([k, v]) => [k, dropUnsupportedJsonValues(v)])
|
||||
.filter(([, v]) => v !== undefined),
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function isJsonRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function normalizeLegacyOptionalUint64(obj: JsonRecord, key: string): void {
|
||||
const value = obj[key]
|
||||
if (typeof value !== 'string') return
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!isPositiveUint64String(trimmed)) {
|
||||
delete obj[key]
|
||||
return
|
||||
}
|
||||
|
||||
obj[key] = trimmed
|
||||
}
|
||||
|
||||
function isPositiveUint64String(value: string): boolean {
|
||||
if (!/^\d+$/.test(value)) return false
|
||||
|
||||
const n = BigInt(value)
|
||||
return n > 0n && n <= UINT64_MAX
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, reactive } from 'vue'
|
||||
import Config from '../src/components/Config.vue'
|
||||
import {
|
||||
DEFAULT_NETWORK_CONFIG,
|
||||
toBackendNetworkConfig,
|
||||
type NetworkConfig,
|
||||
} from '../src/types/network'
|
||||
|
||||
const CONFIG_FLAG_FIELDS = [
|
||||
'latency_first',
|
||||
'use_smoltcp',
|
||||
'disable_ipv6',
|
||||
'ipv6_public_addr_auto',
|
||||
'enable_kcp_proxy',
|
||||
'disable_kcp_input',
|
||||
'enable_quic_proxy',
|
||||
'disable_quic_input',
|
||||
'disable_p2p',
|
||||
'p2p_only',
|
||||
'lazy_p2p',
|
||||
'bind_device',
|
||||
'no_tun',
|
||||
'enable_exit_node',
|
||||
'relay_all_peer_rpc',
|
||||
'need_p2p',
|
||||
'multi_thread',
|
||||
'proxy_forward_by_system',
|
||||
'disable_encryption',
|
||||
'disable_tcp_hole_punching',
|
||||
'disable_udp_hole_punching',
|
||||
'enable_udp_broadcast_relay',
|
||||
'disable_upnp',
|
||||
'disable_sym_hole_punching',
|
||||
'enable_magic_dns',
|
||||
'enable_private_mode',
|
||||
] as const satisfies readonly (keyof NetworkConfig)[]
|
||||
|
||||
const CONFIG_CHECKBOX_FIELDS = [
|
||||
['dhcp', '#virtual_ip_auto'],
|
||||
...CONFIG_FLAG_FIELDS.map((field) => [field, `#${field}`] as const),
|
||||
] as const satisfies readonly (readonly [keyof NetworkConfig, string])[]
|
||||
|
||||
const CONFIG_TOGGLE_FIELDS = [
|
||||
'enable_vpn_portal',
|
||||
'enable_relay_network_whitelist',
|
||||
'enable_manual_routes',
|
||||
'enable_socks5',
|
||||
] as const satisfies readonly (keyof NetworkConfig)[]
|
||||
|
||||
const CONFIG_UI_BOOLEAN_FIELDS = [
|
||||
...CONFIG_CHECKBOX_FIELDS.map(([field]) => field),
|
||||
...CONFIG_TOGGLE_FIELDS,
|
||||
] as const satisfies readonly (keyof NetworkConfig)[]
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, values?: unknown[]) => values ? `${key}:${values.join(',')}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const PassThrough = defineComponent({
|
||||
name: 'PassThrough',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const PanelStub = defineComponent({
|
||||
name: 'Panel',
|
||||
props: {
|
||||
header: String,
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => h('section', { 'data-stub': 'panel', 'data-header': props.header }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const DividerStub = defineComponent({
|
||||
name: 'Divider',
|
||||
setup() {
|
||||
return () => h('hr', { 'data-stub': 'divider' })
|
||||
},
|
||||
})
|
||||
|
||||
function splitList(value: string): string[] {
|
||||
return value.split(',').map((item) => item.trim()).filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
const InputTextStub = defineComponent({
|
||||
name: 'InputText',
|
||||
props: {
|
||||
modelValue: [String, Number],
|
||||
id: String,
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('input', {
|
||||
...attrs,
|
||||
id: props.id,
|
||||
disabled: props.disabled,
|
||||
value: props.modelValue ?? '',
|
||||
'data-stub': 'input-text',
|
||||
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const PasswordStub = defineComponent({
|
||||
name: 'Password',
|
||||
props: {
|
||||
modelValue: [String, Number],
|
||||
id: String,
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('input', {
|
||||
...attrs,
|
||||
id: props.id,
|
||||
disabled: props.disabled,
|
||||
type: 'password',
|
||||
value: props.modelValue ?? '',
|
||||
'data-stub': 'password',
|
||||
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const InputNumberStub = defineComponent({
|
||||
name: 'InputNumber',
|
||||
props: {
|
||||
modelValue: Number,
|
||||
id: String,
|
||||
inputId: String,
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('input', {
|
||||
...attrs,
|
||||
id: props.id ?? props.inputId,
|
||||
disabled: props.disabled,
|
||||
type: 'number',
|
||||
value: props.modelValue ?? '',
|
||||
'data-stub': 'input-number',
|
||||
onInput: (event: Event) => {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
emit('update:modelValue', value === '' ? null : Number(value))
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const CheckboxStub = defineComponent({
|
||||
name: 'Checkbox',
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
inputId: String,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('input', {
|
||||
...attrs,
|
||||
id: props.inputId,
|
||||
checked: props.modelValue,
|
||||
type: 'checkbox',
|
||||
'data-stub': 'checkbox',
|
||||
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).checked),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const ToggleButtonStub = defineComponent({
|
||||
name: 'ToggleButton',
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
onIcon: String,
|
||||
offIcon: String,
|
||||
onLabel: String,
|
||||
offLabel: String,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', {
|
||||
type: 'button',
|
||||
'aria-pressed': String(Boolean(props.modelValue)),
|
||||
'data-stub': 'toggle-button',
|
||||
onClick: () => emit('update:modelValue', !props.modelValue),
|
||||
}, props.modelValue ? props.onLabel : props.offLabel)
|
||||
},
|
||||
})
|
||||
|
||||
const AutoCompleteStub = defineComponent({
|
||||
name: 'AutoComplete',
|
||||
props: {
|
||||
modelValue: Array,
|
||||
id: String,
|
||||
multiple: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue', 'complete'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('input', {
|
||||
...attrs,
|
||||
id: props.id,
|
||||
value: (props.modelValue ?? []).join(','),
|
||||
'data-stub': 'auto-complete',
|
||||
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const UrlListInputStub = defineComponent({
|
||||
name: 'UrlListInput',
|
||||
props: {
|
||||
modelValue: Array,
|
||||
id: String,
|
||||
addLabel: String,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('input', {
|
||||
...attrs,
|
||||
id: props.id,
|
||||
value: (props.modelValue ?? []).join(','),
|
||||
'data-stub': 'url-list-input',
|
||||
'data-add-label': props.addLabel,
|
||||
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const SelectButtonStub = defineComponent({
|
||||
name: 'SelectButton',
|
||||
props: {
|
||||
modelValue: String,
|
||||
options: Array,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('select', {
|
||||
value: props.modelValue,
|
||||
'data-stub': 'select-button',
|
||||
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
|
||||
}, (props.options ?? []).map((option) => h('option', { value: option as string }, option as string)))
|
||||
},
|
||||
})
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'Button',
|
||||
props: {
|
||||
label: String,
|
||||
icon: String,
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { slots, emit }) {
|
||||
return () => h('button', {
|
||||
type: 'button',
|
||||
disabled: props.disabled,
|
||||
'data-label': props.label ?? props.icon,
|
||||
onClick: (event: MouseEvent) => emit('click', event),
|
||||
}, slots.default?.() ?? props.label ?? props.icon)
|
||||
},
|
||||
})
|
||||
|
||||
const DialogStub = defineComponent({
|
||||
name: 'Dialog',
|
||||
props: {
|
||||
visible: Boolean,
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => h('div', { hidden: !props.visible, 'data-stub': 'dialog' }, [
|
||||
slots.default?.(),
|
||||
slots.footer?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const AclManagerStub = defineComponent({
|
||||
name: 'AclManager',
|
||||
props: {
|
||||
modelValue: Object,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
return () => h('pre', { 'data-stub': 'acl-manager' }, JSON.stringify(props.modelValue))
|
||||
},
|
||||
})
|
||||
|
||||
function makeConfig(): NetworkConfig {
|
||||
const config = DEFAULT_NETWORK_CONFIG()
|
||||
|
||||
return {
|
||||
...config,
|
||||
dhcp: false,
|
||||
virtual_ipv4: '10.1.2.3',
|
||||
network_length: 24,
|
||||
network_name: 'mesh-a',
|
||||
network_secret: 'secret-a',
|
||||
peer_urls: ['tcp://peer-a:11010', 'udp://peer-b:11010'],
|
||||
latency_first: true,
|
||||
use_smoltcp: true,
|
||||
disable_ipv6: true,
|
||||
no_tun: true,
|
||||
hostname: 'host-a',
|
||||
proxy_cidrs: ['10.10.0.0/16', '172.16.1.0/24'],
|
||||
enable_vpn_portal: true,
|
||||
vpn_portal_client_network_addr: '10.144.0.0',
|
||||
vpn_portal_listen_port: 22023,
|
||||
listener_urls: ['tcp://0.0.0.0:12010'],
|
||||
dev_name: 'tun-test',
|
||||
mtu: 1280,
|
||||
instance_recv_bps_limit: '9007199254740993',
|
||||
enable_relay_network_whitelist: true,
|
||||
relay_network_whitelist: ['network-a'],
|
||||
enable_manual_routes: true,
|
||||
routes: ['192.168.0.0/16'],
|
||||
enable_socks5: true,
|
||||
socks5_port: 1086,
|
||||
exit_nodes: ['exit-a'],
|
||||
mapped_listeners: ['tcp://127.0.0.1:22000'],
|
||||
port_forwards: [{
|
||||
proto: 'udp',
|
||||
bind_ip: '0.0.0.0',
|
||||
bind_port: 18080,
|
||||
dst_ip: '10.0.0.2',
|
||||
dst_port: 8080,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function mountConfig(config: NetworkConfig = makeConfig()) {
|
||||
const curNetwork = reactive(config) as NetworkConfig
|
||||
const wrapper = mount(Config, {
|
||||
props: {
|
||||
curNetwork,
|
||||
hostname: 'host-from-prop',
|
||||
},
|
||||
global: {
|
||||
directives: {
|
||||
tooltip: () => {},
|
||||
},
|
||||
stubs: {
|
||||
AclManager: AclManagerStub,
|
||||
AutoComplete: AutoCompleteStub,
|
||||
Button: ButtonStub,
|
||||
Checkbox: CheckboxStub,
|
||||
Dialog: DialogStub,
|
||||
Divider: DividerStub,
|
||||
InputGroup: PassThrough,
|
||||
InputGroupAddon: PassThrough,
|
||||
InputNumber: InputNumberStub,
|
||||
InputText: InputTextStub,
|
||||
Panel: PanelStub,
|
||||
Password: PasswordStub,
|
||||
SelectButton: SelectButtonStub,
|
||||
ToggleButton: ToggleButtonStub,
|
||||
UrlListInput: UrlListInputStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { curNetwork, wrapper }
|
||||
}
|
||||
|
||||
function input(wrapper: VueWrapper, selector: string): HTMLInputElement {
|
||||
return wrapper.find(selector).element as HTMLInputElement
|
||||
}
|
||||
|
||||
async function setInput(wrapper: VueWrapper, selector: string, value: string) {
|
||||
await wrapper.find(selector).setValue(value)
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('Config.vue network config projection', () => {
|
||||
it('projects config values into the visible form controls', async () => {
|
||||
const { curNetwork, wrapper } = mountConfig()
|
||||
await nextTick()
|
||||
|
||||
expect(input(wrapper, '#network_name').value).toBe('mesh-a')
|
||||
expect(input(wrapper, '#network_secret').value).toBe('secret-a')
|
||||
expect(input(wrapper, '#virtual_ip').value).toBe('10.1.2.3')
|
||||
expect(input(wrapper, '#initial_nodes').value).toBe('tcp://peer-a:11010,udp://peer-b:11010')
|
||||
expect(input(wrapper, '#virtual_ip_auto').checked).toBe(false)
|
||||
expect(input(wrapper, '#latency_first').checked).toBe(true)
|
||||
expect(input(wrapper, '#use_smoltcp').checked).toBe(true)
|
||||
expect(input(wrapper, '#disable_ipv6').checked).toBe(true)
|
||||
expect(input(wrapper, '#no_tun').checked).toBe(true)
|
||||
|
||||
expect(input(wrapper, '#hostname').value).toBe('host-a')
|
||||
expect(input(wrapper, '#subnet-proxy').value).toBe('10.10.0.0/16,172.16.1.0/24')
|
||||
expect(input(wrapper, 'input[placeholder="vpn_portal_client_network"]').value).toBe('10.144.0.0')
|
||||
expect(input(wrapper, '#dev_name').value).toBe('tun-test')
|
||||
expect(input(wrapper, '#mtu').value).toBe('1280')
|
||||
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('9007199254740993')
|
||||
expect(input(wrapper, '#relay_network_whitelist').value).toBe('network-a')
|
||||
expect(input(wrapper, '#routes').value).toBe('192.168.0.0/16')
|
||||
expect(input(wrapper, '#socks5_port').value).toBe('1086')
|
||||
expect(input(wrapper, '#exit_nodes').value).toBe('exit-a')
|
||||
expect(input(wrapper, 'input[data-add-label="add_listener_url"]').value).toBe('tcp://0.0.0.0:12010')
|
||||
expect(input(wrapper, 'input[data-add-label="add_mapped_listener"]').value).toBe('tcp://127.0.0.1:22000')
|
||||
|
||||
expect(wrapper.find<HTMLSelectElement>('select[data-stub="select-button"]').element.value).toBe('udp')
|
||||
expect(input(wrapper, 'input[placeholder="port_forwards_bind_addr"]').value).toBe('0.0.0.0')
|
||||
expect(input(wrapper, 'input[placeholder="port_forwards_dst_addr"]').value).toBe('10.0.0.2')
|
||||
expect(wrapper.findComponent(AclManagerStub).props('modelValue')).toStrictEqual(curNetwork.acl)
|
||||
})
|
||||
|
||||
it('projects form edits back into config and backend JSON', async () => {
|
||||
const { curNetwork, wrapper } = mountConfig()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.find('#virtual_ip_auto').setValue(false)
|
||||
await setInput(wrapper, '#network_name', 'mesh-edited')
|
||||
await setInput(wrapper, '#network_secret', 'secret-edited')
|
||||
await setInput(wrapper, '#virtual_ip', '10.7.7.7')
|
||||
await setInput(wrapper, '#initial_nodes', ' tcp://peer-x:11010, , udp://peer-y:11010 ')
|
||||
await wrapper.find('#no_tun').setValue(false)
|
||||
await wrapper.find('#disable_ipv6').setValue(false)
|
||||
await setInput(wrapper, '#hostname', 'host-edited')
|
||||
await setInput(wrapper, '#subnet-proxy', '10.7.0.0/16,172.17.0.0/16')
|
||||
await setInput(wrapper, 'input[placeholder="vpn_portal_client_network"]', '10.200.0.0')
|
||||
await setInput(wrapper, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010')
|
||||
await setInput(wrapper, '#dev_name', 'tun-edited')
|
||||
await setInput(wrapper, '#mtu', '1260')
|
||||
await setInput(wrapper, '#instance_recv_bps_limit', '9007199254740993')
|
||||
await setInput(wrapper, '#relay_network_whitelist', 'network-edited')
|
||||
await setInput(wrapper, '#routes', '192.168.10.0/24')
|
||||
await setInput(wrapper, '#socks5_port', '1089')
|
||||
await setInput(wrapper, '#exit_nodes', 'exit-edited')
|
||||
await setInput(wrapper, 'input[data-add-label="add_mapped_listener"]', 'tcp://127.0.0.1:23000')
|
||||
await wrapper.find('select[data-stub="select-button"]').setValue('tcp')
|
||||
await setInput(wrapper, 'input[placeholder="port_forwards_bind_addr"]', '127.0.0.1')
|
||||
await setInput(wrapper, 'input[placeholder="port_forwards_dst_addr"]', '10.9.0.2')
|
||||
|
||||
const portNumbers = wrapper.findAll<HTMLInputElement>('input#horizontal-buttons')
|
||||
await portNumbers[1].setValue('19090')
|
||||
await portNumbers[2].setValue('9090')
|
||||
|
||||
expect(curNetwork).toMatchObject({
|
||||
dhcp: false,
|
||||
virtual_ipv4: '10.7.7.7',
|
||||
network_name: 'mesh-edited',
|
||||
network_secret: 'secret-edited',
|
||||
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
|
||||
no_tun: false,
|
||||
disable_ipv6: false,
|
||||
hostname: 'host-edited',
|
||||
proxy_cidrs: ['10.7.0.0/16', '172.17.0.0/16'],
|
||||
vpn_portal_client_network_addr: '10.200.0.0',
|
||||
listener_urls: ['tcp://0.0.0.0:13010'],
|
||||
dev_name: 'tun-edited',
|
||||
mtu: 1260,
|
||||
instance_recv_bps_limit: '9007199254740993',
|
||||
relay_network_whitelist: ['network-edited'],
|
||||
routes: ['192.168.10.0/24'],
|
||||
socks5_port: 1089,
|
||||
exit_nodes: ['exit-edited'],
|
||||
mapped_listeners: ['tcp://127.0.0.1:23000'],
|
||||
port_forwards: [{
|
||||
proto: 'tcp',
|
||||
bind_ip: '127.0.0.1',
|
||||
bind_port: 19090,
|
||||
dst_ip: '10.9.0.2',
|
||||
dst_port: 9090,
|
||||
}],
|
||||
})
|
||||
|
||||
const backend = toBackendNetworkConfig(curNetwork)
|
||||
expect(backend).toMatchObject({
|
||||
virtual_ipv4: '10.7.7.7',
|
||||
network_name: 'mesh-edited',
|
||||
network_secret: 'secret-edited',
|
||||
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
|
||||
listener_urls: ['tcp://0.0.0.0:13010'],
|
||||
mtu: 1260,
|
||||
instance_recv_bps_limit: '9007199254740993',
|
||||
port_forwards: [{
|
||||
proto: 'tcp',
|
||||
bind_ip: '127.0.0.1',
|
||||
bind_port: 19090,
|
||||
dst_ip: '10.9.0.2',
|
||||
dst_port: 9090,
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips every visible boolean config control into backend JSON', async () => {
|
||||
const config = makeConfig()
|
||||
const originalFlagValues = new Map(
|
||||
CONFIG_UI_BOOLEAN_FIELDS.map((field, index) => {
|
||||
const value = index % 2 === 0
|
||||
config[field] = value
|
||||
return [field, value]
|
||||
}),
|
||||
)
|
||||
|
||||
const { curNetwork, wrapper } = mountConfig(config)
|
||||
await nextTick()
|
||||
|
||||
for (const [field, selector] of CONFIG_CHECKBOX_FIELDS) {
|
||||
const value = originalFlagValues.get(field)
|
||||
expect(input(wrapper, selector).checked, `${field} should project into UI`).toBe(value)
|
||||
await wrapper.find(selector).setValue(!value)
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const toggleButtons = wrapper.findAll('button[data-stub="toggle-button"]')
|
||||
expect(toggleButtons).toHaveLength(CONFIG_TOGGLE_FIELDS.length)
|
||||
for (const [index, field] of CONFIG_TOGGLE_FIELDS.entries()) {
|
||||
const value = originalFlagValues.get(field)
|
||||
expect(toggleButtons[index].attributes('aria-pressed'), `${field} should project into UI`)
|
||||
.toBe(String(value))
|
||||
await toggleButtons[index].trigger('click')
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const backend = toBackendNetworkConfig(curNetwork) as Record<string, unknown>
|
||||
for (const [field, value] of originalFlagValues) {
|
||||
const expectedValue = !value
|
||||
expect(curNetwork[field], `${field} should update config`).toBe(expectedValue)
|
||||
expect(backend[field], `${field} should be preserved in backend JSON`).toBe(expectedValue)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps uint64 input editable without losing large values', async () => {
|
||||
const { curNetwork, wrapper } = mountConfig()
|
||||
await nextTick()
|
||||
|
||||
await setInput(wrapper, '#instance_recv_bps_limit', '1234')
|
||||
expect(curNetwork.instance_recv_bps_limit).toBe(1234)
|
||||
|
||||
await setInput(wrapper, '#instance_recv_bps_limit', 'not-a-number')
|
||||
expect(curNetwork.instance_recv_bps_limit).toBe(1234)
|
||||
|
||||
await setInput(wrapper, '#instance_recv_bps_limit', '0')
|
||||
expect(curNetwork.instance_recv_bps_limit).toBeNull()
|
||||
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('')
|
||||
|
||||
await setInput(wrapper, '#instance_recv_bps_limit', '9007199254740993')
|
||||
expect(curNetwork.instance_recv_bps_limit).toBe('9007199254740993')
|
||||
|
||||
await setInput(wrapper, '#instance_recv_bps_limit', '18446744073709551616')
|
||||
expect(curNetwork.instance_recv_bps_limit).toBe('9007199254740993')
|
||||
|
||||
await setInput(wrapper, '#instance_recv_bps_limit', '')
|
||||
expect(curNetwork.instance_recv_bps_limit).toBeNull()
|
||||
})
|
||||
|
||||
it('emits runNetwork with the current projected config', async () => {
|
||||
const { curNetwork, wrapper } = mountConfig()
|
||||
await nextTick()
|
||||
|
||||
await setInput(wrapper, '#network_name', 'mesh-running')
|
||||
await wrapper.find('button[data-label="run_network"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('runNetwork')?.[0]).toEqual([curNetwork])
|
||||
expect((wrapper.emitted('runNetwork')?.[0][0] as NetworkConfig).network_name).toBe('mesh-running')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import RemoteManagement from '../src/components/RemoteManagement.vue'
|
||||
import {
|
||||
DEFAULT_NETWORK_CONFIG,
|
||||
type NetworkConfig,
|
||||
} from '../src/types/network'
|
||||
|
||||
const BOOLEAN_CONFIG_FIELDS = [
|
||||
'dhcp',
|
||||
'enable_vpn_portal',
|
||||
'advanced_settings',
|
||||
'latency_first',
|
||||
'use_smoltcp',
|
||||
'disable_ipv6',
|
||||
'enable_kcp_proxy',
|
||||
'disable_kcp_input',
|
||||
'disable_p2p',
|
||||
'bind_device',
|
||||
'no_tun',
|
||||
'enable_exit_node',
|
||||
'relay_all_peer_rpc',
|
||||
'multi_thread',
|
||||
'enable_relay_network_whitelist',
|
||||
'enable_manual_routes',
|
||||
'proxy_forward_by_system',
|
||||
'disable_encryption',
|
||||
'enable_socks5',
|
||||
'disable_udp_hole_punching',
|
||||
'enable_magic_dns',
|
||||
'enable_private_mode',
|
||||
'enable_quic_proxy',
|
||||
'disable_quic_input',
|
||||
'disable_sym_hole_punching',
|
||||
'p2p_only',
|
||||
'lazy_p2p',
|
||||
'need_p2p',
|
||||
'disable_upnp',
|
||||
'ipv6_public_addr_provider',
|
||||
'ipv6_public_addr_auto',
|
||||
'disable_relay_data',
|
||||
'enable_udp_broadcast_relay',
|
||||
'disable_tcp_hole_punching',
|
||||
] as const satisfies readonly (keyof NetworkConfig)[]
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('primevue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
|
||||
const PassThrough = defineComponent({
|
||||
name: 'PassThrough',
|
||||
props: {
|
||||
label: String,
|
||||
value: String,
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => h('div', {
|
||||
'data-label': props.label,
|
||||
'data-value': props.value,
|
||||
'data-stub': 'pass-through',
|
||||
}, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'Button',
|
||||
props: {
|
||||
label: String,
|
||||
icon: String,
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { slots, emit }) {
|
||||
return () => h('button', {
|
||||
type: 'button',
|
||||
disabled: props.disabled,
|
||||
'data-label': props.label ?? props.icon,
|
||||
onClick: (event: MouseEvent) => emit('click', event),
|
||||
}, slots.default?.() ?? props.label ?? props.icon)
|
||||
},
|
||||
})
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
name: 'Select',
|
||||
props: {
|
||||
modelValue: Object,
|
||||
options: Array,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { slots }) {
|
||||
return () => h('div', { 'data-stub': 'select' }, [
|
||||
slots.value?.({ value: props.modelValue, placeholder: '' }),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const MenuStub = defineComponent({
|
||||
name: 'Menu',
|
||||
setup(_, { expose }) {
|
||||
expose({ toggle: vi.fn() })
|
||||
return () => h('div', { 'data-stub': 'menu' })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
Button: ButtonStub,
|
||||
ConfirmPopup: PassThrough,
|
||||
Divider: PassThrough,
|
||||
IftaLabel: PassThrough,
|
||||
Menu: MenuStub,
|
||||
Message: PassThrough,
|
||||
Select: SelectStub,
|
||||
Tag: PassThrough,
|
||||
useConfirm: () => ({ require: vi.fn() }),
|
||||
useToast: () => ({ add: vi.fn() }),
|
||||
}
|
||||
})
|
||||
|
||||
const INSTANCE_ID = '00000000-0000-0000-0000-000000000001'
|
||||
const INSTANCE_UUID = {
|
||||
part1: 0,
|
||||
part2: 0,
|
||||
part3: 0,
|
||||
part4: 1,
|
||||
}
|
||||
|
||||
function makeFlagConfig(): NetworkConfig {
|
||||
const config = {
|
||||
...DEFAULT_NETWORK_CONFIG(),
|
||||
instance_id: INSTANCE_ID,
|
||||
network_name: 'mesh-save',
|
||||
}
|
||||
|
||||
BOOLEAN_CONFIG_FIELDS.forEach((field, index) => {
|
||||
config[field] = index % 2 === 0
|
||||
})
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function cloneConfig(config: NetworkConfig): NetworkConfig {
|
||||
return JSON.parse(JSON.stringify(config)) as NetworkConfig
|
||||
}
|
||||
|
||||
function snapshotBooleanConfigFields(config: NetworkConfig): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
BOOLEAN_CONFIG_FIELDS.map((field) => [field, config[field]]),
|
||||
)
|
||||
}
|
||||
|
||||
async function settleRemoteManagement() {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
describe('RemoteManagement config save', () => {
|
||||
it('saves the current network config without dropping boolean fields', async () => {
|
||||
const config = makeFlagConfig()
|
||||
const expectedFlags = snapshotBooleanConfigFields(config)
|
||||
const api = {
|
||||
delete_network: vi.fn(),
|
||||
generate_config: vi.fn(),
|
||||
get_network_config: vi.fn(async () => cloneConfig(config)),
|
||||
get_network_info: vi.fn(),
|
||||
get_network_metas: vi.fn(async (instanceIds: string[]) => ({
|
||||
metas: Object.fromEntries(instanceIds.map((id) => [id, {
|
||||
config_permission: 0xffffffff,
|
||||
inst_id: INSTANCE_UUID,
|
||||
instance_name: 'mesh-save',
|
||||
network_name: 'mesh-save',
|
||||
source: 2,
|
||||
}])),
|
||||
})),
|
||||
list_network_instance_ids: vi.fn(async () => ({
|
||||
disabled_inst_ids: [INSTANCE_UUID],
|
||||
running_inst_ids: [],
|
||||
})),
|
||||
parse_config: vi.fn(),
|
||||
run_network: vi.fn(),
|
||||
save_config: vi.fn(async () => undefined),
|
||||
update_network_instance_state: vi.fn(),
|
||||
validate_config: vi.fn(),
|
||||
}
|
||||
|
||||
const wrapper = mount(RemoteManagement, {
|
||||
props: {
|
||||
api,
|
||||
instanceId: INSTANCE_ID,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Config: true,
|
||||
ConfigEditDialog: true,
|
||||
Status: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await settleRemoteManagement()
|
||||
|
||||
const saveButton = wrapper.find('button[data-label="web.device_management.save_config"]')
|
||||
expect(saveButton.exists()).toBe(true)
|
||||
expect(saveButton.attributes('disabled')).toBeUndefined()
|
||||
|
||||
await saveButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.save_config).toHaveBeenCalledOnce()
|
||||
const savedConfig = api.save_config.mock.calls[0][0] as NetworkConfig
|
||||
|
||||
for (const field of BOOLEAN_CONFIG_FIELDS) {
|
||||
expect(savedConfig[field], `${field} should be saved`).toBe(expectedFlags[field])
|
||||
}
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import ViteYaml from '@modyfi/vite-plugin-yaml'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), ViteYaml()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['tests/**/*.spec.ts'],
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
},
|
||||
})
|
||||
@@ -4,8 +4,8 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"dev": "pnpm --dir ../frontend-lib build && vite",
|
||||
"build": "pnpm --dir ../frontend-lib build && vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -32,4 +32,4 @@
|
||||
"vite-plugin-singlefile": "^2.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user