feat: support simultaneous Tailscale and LAN communication for Yahboom cameras
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const ts = require('typescript')
|
||||
|
||||
const source = ts.createSourceFile('index.ts', fs.readFileSync(path.join(__dirname, '../src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
const names = ['buildFastDdsPeerConfig', 'parseCameraLanAddresses', 'cameraLanEnabled', 'rosNetworkEnabled', 'prepareRemoteRosNetworkConfig']
|
||||
const code = source.statements.filter(node => ts.isFunctionDeclaration(node) && names.includes(node.name?.text)).map(node => node.getText(source)).join('\n')
|
||||
function load(overrides = {}) {
|
||||
const dependencies = {
|
||||
parseRosDomainId: value => Number(value ?? 0),
|
||||
readLocalTailscaleIpv4: async () => '100.64.0.10',
|
||||
readRemoteTailscaleIpv4: async () => '100.64.0.20',
|
||||
runSshOutput: async () => JSON.stringify([{ addr_info: [{ family: 'inet', scope: 'global', local: '192.168.1.20' }] }]),
|
||||
runSsh: async () => {},
|
||||
quoteShellSingle: value => "'" + value.replaceAll("'", "'\\''") + "'",
|
||||
expandRemotePathVar: () => [],
|
||||
encodeBase64: value => Buffer.from(value).toString('base64'),
|
||||
...overrides,
|
||||
}
|
||||
return new Function(...Object.keys(dependencies), ts.transpile(code, { target: ts.ScriptTarget.ES2022 }) + `;return {${names.join(',')}}`)(...Object.values(dependencies))
|
||||
}
|
||||
|
||||
test('LAN discovery adds physical addresses and multicast while keeping Tailscale peers', () => {
|
||||
const { buildFastDdsPeerConfig } = load()
|
||||
const xml = buildFastDdsPeerConfig('100.64.0.10', '100.64.0.20', '77', ['192.168.1.20'])
|
||||
const whitelist = xml.match(/<interfaceWhiteList>(.*?)<\/interfaceWhiteList>/s)[1]
|
||||
for (const address of ['127.0.0.1', '100.64.0.20', '192.168.1.20']) assert.ok(whitelist.includes(address))
|
||||
assert.ok(xml.includes('<address>100.64.0.10</address>'))
|
||||
assert.ok(xml.includes('<address>239.255.0.1</address><port>26650</port>'))
|
||||
assert.ok(xml.includes('<metatrafficMulticastLocatorList>'))
|
||||
const original = buildFastDdsPeerConfig('100.64.0.10', '100.64.0.20', '77')
|
||||
assert.ok(!original.includes('192.168.1.20'))
|
||||
assert.ok(!original.includes('239.255.0.1'))
|
||||
})
|
||||
|
||||
test('only the Yahboom camera opts in', () => {
|
||||
const { cameraLanEnabled } = load()
|
||||
assert.equal(cameraLanEnabled('[lms-nav2-yahboom-camera]yahboom_camera_v1.0.0_ws'), true)
|
||||
for (const role of ['vehicle', 'agent', 'control', 'slam', 'planning']) {
|
||||
assert.equal(cameraLanEnabled(`[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`), false)
|
||||
}
|
||||
assert.equal(cameraLanEnabled('[lms-nav2-custom]camera_v1.0.0_ws'), false)
|
||||
})
|
||||
|
||||
test('physical interface responses are parsed, validated and deduplicated', () => {
|
||||
const { parseCameraLanAddresses } = load()
|
||||
const output = JSON.stringify([{ addr_info: [
|
||||
{ family: 'inet', scope: 'global', local: '192.168.1.20' },
|
||||
{ family: 'inet', scope: 'global', local: '999.1.1.1' },
|
||||
{ family: 'inet6', scope: 'global', local: 'fe80::1' },
|
||||
] }])
|
||||
assert.deepEqual(parseCameraLanAddresses(output + '\n' + output), ['192.168.1.20'])
|
||||
})
|
||||
|
||||
test('camera config uses its own path and LAN works without ROS platform linking', async () => {
|
||||
let script = ''
|
||||
const api = load({
|
||||
readLocalTailscaleIpv4: async () => { throw new Error('Must not require local Tailscale') },
|
||||
readRemoteTailscaleIpv4: async () => { throw new Error('Must not require remote Tailscale') },
|
||||
runSsh: async (_target, command) => { script = command },
|
||||
})
|
||||
assert.equal(await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: false }, '77'), null)
|
||||
const result = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: false }, '77', true)
|
||||
assert.ok(result.endsWith('-camera-lan.xml'))
|
||||
const encoded = script.match(/printf %s '([^']+)'/)[1]
|
||||
const xml = Buffer.from(encoded, 'base64').toString()
|
||||
assert.ok(xml.includes('192.168.1.20'))
|
||||
assert.ok(xml.includes('239.255.0.1'))
|
||||
})
|
||||
|
||||
test('linked camera retains both networks and does not overwrite restricted config', async () => {
|
||||
const api = load()
|
||||
const restricted = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77')
|
||||
const camera = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77', true)
|
||||
assert.notEqual(camera, restricted)
|
||||
assert.ok(camera.includes('100-64-0-10'))
|
||||
const noLan = load({ runSshOutput: async () => '' })
|
||||
await assert.rejects(noLan.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77', true), /IPv4/)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const root = path.resolve(__dirname, '../src')
|
||||
const app = fs.readFileSync(path.join(root, 'renderer/src/App.tsx'), 'utf8')
|
||||
const view = fs.readFileSync(path.join(root, 'renderer/src/features/workspace/ModuleListView.tsx'), 'utf8')
|
||||
|
||||
test('remote creation and Git import are enabled while raw folder import remains local', () => {
|
||||
assert.match(app, /allowCustomModuleCreation=\{true\}/)
|
||||
assert.match(app, /allowCustomModuleImport=\{true\}/)
|
||||
assert.match(app, /remoteGitImport=\{remoteWorkspace \? \{ workspaceRoot: localVisualizationWorkspaceRoot, onImport: importGitModule \} : undefined\}/)
|
||||
assert.match(view, /remoteGitImport \? <RemoteModuleImportDialog/)
|
||||
assert.match(app, /const importCustomModule[\s\S]*?if \(remoteWorkspace\) throw new Error/)
|
||||
const importButton = view.slice(view.indexOf('aria-label={t("모듈 불러오기")}'), view.indexOf('{deleteConfirmModule &&'))
|
||||
assert.match(importButton, /disabled=\{!allowCustomModuleImport\}/)
|
||||
assert.doesNotMatch(importButton, /allowCustomModuleCreation/)
|
||||
assert.match(view, /allowCustomModuleImport && moduleImportDialogOpen/)
|
||||
})
|
||||
|
||||
test('new module dialog requests images from the current workspace', () => {
|
||||
assert.match(app, /loadDockerImages=\{\(\) => remoteWorkspace\s*\? window\.remoteWorkspace\.modules\.listDockerImages\(getRemoteWorkspaceTarget\(\)\)/)
|
||||
const dialog = view.slice(view.indexOf('function ModuleAddDialog('), view.indexOf('function ModuleImportDialog('))
|
||||
assert.match(dialog, /void loadDockerImages\(\)/)
|
||||
assert.doesNotMatch(dialog, /window\.localWorkspace/)
|
||||
const preload = fs.readFileSync(path.join(root, 'preload/index.ts'), 'utf8')
|
||||
const main = fs.readFileSync(path.join(root, 'main/index.ts'), 'utf8')
|
||||
assert.match(preload, /invoke\('remote-workspace:modules:docker-images', target\)/)
|
||||
assert.match(main, /handle\('remote-workspace:modules:docker-images'/)
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const { execFile } = require('node:child_process')
|
||||
const { promisify } = require('node:util')
|
||||
const exec = promisify(execFile)
|
||||
const root = path.resolve(__dirname, '..')
|
||||
|
||||
async function loadHelpers() {
|
||||
const exports = {}
|
||||
const code = ts.transpileModule(await fs.readFile(path.join(root, 'src/main/gitModuleImport.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
vm.runInNewContext(code, { exports, require, URL })
|
||||
return exports
|
||||
}
|
||||
|
||||
test('repository addresses strip credentials and reject local/helper protocols', async () => {
|
||||
const api = await loadHelpers()
|
||||
assert.equal(api.repositoryAddress('https://user:secret@example.com/team/module.git?token=secret'), 'https://example.com/team/module.git')
|
||||
assert.equal(api.repositoryAddress('git@example.com:team/module.git'), 'ssh://git@example.com/team/module.git')
|
||||
for (const url of ['file:///tmp/repo', 'ext::command', '/tmp/repo']) assert.throws(() => api.repositoryAddress(url))
|
||||
assert.equal(api.remoteBranches('a'.repeat(40) + '\trefs/heads/main\ninvalid')[0].name, 'main')
|
||||
})
|
||||
|
||||
test('local module discovery ignores malformed folders and rejects symlink escapes', async () => {
|
||||
const api = await loadHelpers()
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-git-list-'))
|
||||
try {
|
||||
const workspace = path.join(dir, 'workspace')
|
||||
const folder = '[lms-nav2-custom]demo_v1.0.0_ws'
|
||||
await fs.mkdir(path.join(workspace, folder), { recursive: true })
|
||||
await fs.writeFile(path.join(workspace, folder, '.autonomy-studio-module.json'), JSON.stringify({ sourceType: 'CUSTOM', name: 'Demo', category: 'Control', packageName: 'demo', launchFile: 'demo.launch.py' }))
|
||||
assert.equal((await api.listLocalGitModules(workspace))[0].name, 'Demo')
|
||||
await fs.symlink(dir, path.join(workspace, '[lms-nav2-custom]escape_v1.0.0_ws'))
|
||||
await assert.rejects(api.localGitModulePath(workspace, '[lms-nav2-custom]escape_v1.0.0_ws'), /outside/)
|
||||
await assert.rejects(api.localGitModulePath(workspace, '../escape'), /Invalid/)
|
||||
assert.equal((await api.listLocalGitModules(path.join(dir, 'missing'))).length, 0)
|
||||
} finally { await fs.rm(dir, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
test('Git clone imports only the selected commit, refuses overwrite and cleans failed staging', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-git-import-'))
|
||||
const repo = path.join(dir, 'source')
|
||||
const workspace = path.join(dir, 'remote')
|
||||
const folderName = '[lms-nav2-custom]demo_v1.0.0_ws'
|
||||
const git = (...args) => exec('git', args, { cwd: repo })
|
||||
try {
|
||||
await fs.mkdir(path.join(repo, 'src/demo/launch'), { recursive: true })
|
||||
await fs.writeFile(path.join(repo, 'src/demo/package.xml'), '<package><name>demo</name></package>')
|
||||
await fs.writeFile(path.join(repo, 'src/demo/launch/demo.launch.py'), '# launch')
|
||||
for (const file of ['build.sh', 'run.sh']) await fs.writeFile(path.join(repo, file), '#!/bin/bash\nexit 0\n')
|
||||
await git('init', '-b', 'main')
|
||||
await git('add', '.')
|
||||
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-m', 'initial')
|
||||
const commit = (await git('rev-parse', 'HEAD')).stdout.trim()
|
||||
await fs.writeFile(path.join(repo, 'uncommitted.txt'), 'must not transfer')
|
||||
let dirty = false
|
||||
let pushed = true
|
||||
let requestedCommit = commit
|
||||
const index = await fs.readFile(path.join(root, 'src/main/index.ts'), 'utf8')
|
||||
const fn = index.slice(index.indexOf('async function importRemoteGitModule('), index.indexOf('async function transferLocalWorkspaceModuleToRemote('))
|
||||
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"
|
||||
const context = {
|
||||
URL, Buffer,
|
||||
normalizeRemoteTarget: target => target,
|
||||
validateCustomModuleScaffold: input => input,
|
||||
inspectLocalGitModule: async () => ({ repository: 'https://example.com/module.git', dirty, pushed, branches: [{ name: 'main', commit: requestedCommit }] }),
|
||||
remoteRepositoryUrl: () => 'file://' + repo,
|
||||
withGitCredential: value => value,
|
||||
customModuleConfigContent: input => JSON.stringify(input),
|
||||
encodeBase64: value => Buffer.from(value).toString('base64'),
|
||||
quoteShellSingle: quote,
|
||||
expandRemotePathVar: () => [],
|
||||
runSshOutput: async (_target, script) => (await exec('bash', ['-c', script], { timeout: 15000 })).stdout,
|
||||
}
|
||||
vm.runInNewContext(ts.transpileModule(fn, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText, context)
|
||||
const input = { sourceFolder: folderName, folderName, branch: 'main', commit, name: 'Remote demo', category: 'Control', packageName: 'demo', launchFile: 'demo.launch.py', dockerImage: 'vehicle:arm64' }
|
||||
const run = () => context.importRemoteGitModule({ workspaceRoot: workspace }, repo, input)
|
||||
dirty = true
|
||||
await assert.rejects(run(), /커밋/)
|
||||
dirty = false
|
||||
pushed = false
|
||||
await assert.rejects(run(), /푸시/)
|
||||
pushed = true
|
||||
assert.equal((await run()).ok, true)
|
||||
const target = path.join(workspace, folderName)
|
||||
assert.equal((await exec('git', ['rev-parse', 'HEAD'], { cwd: target })).stdout.trim(), commit)
|
||||
await assert.rejects(fs.stat(path.join(target, 'uncommitted.txt')), { code: 'ENOENT' })
|
||||
assert.equal(JSON.parse(await fs.readFile(path.join(target, '.autonomy-studio-module.json'), 'utf8')).dockerImage, 'vehicle:arm64')
|
||||
await fs.writeFile(path.join(target, 'keep.txt'), 'keep')
|
||||
await assert.rejects(run(), /복제에 실패/)
|
||||
assert.equal(await fs.readFile(path.join(target, 'keep.txt'), 'utf8'), 'keep')
|
||||
await fs.rm(target, { recursive: true })
|
||||
requestedCommit = 'a'.repeat(40)
|
||||
input.commit = requestedCommit
|
||||
await assert.rejects(run(), /복제에 실패/)
|
||||
assert.equal((await fs.readdir(workspace)).length, 0)
|
||||
await fs.mkdir(path.join(repo, 'install'))
|
||||
await fs.writeFile(path.join(repo, 'install/binary'), 'not portable')
|
||||
await git('add', 'install')
|
||||
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-m', 'bad artifacts')
|
||||
input.commit = requestedCommit = (await git('rev-parse', 'HEAD')).stdout.trim()
|
||||
await assert.rejects(run(), /복제에 실패/)
|
||||
assert.equal((await fs.readdir(workspace)).length, 0)
|
||||
} finally { await fs.rm(dir, { recursive: true, force: true }) }
|
||||
})
|
||||
@@ -45,3 +45,43 @@ test('keeps discovered topics without endpoints and deduplicates node entries',
|
||||
test('module selection and guessed topic mappings are removed from the view', () => {
|
||||
assert.doesNotMatch(source, /selectedModuleKeys|moduleTopics|fallbackPublishers|fallbackSubscribers|onToggleModule|ListChecks|ListX/)
|
||||
})
|
||||
|
||||
test('active toggle filters only topics, preserves totals and hides filtered details', () => {
|
||||
const graph = { topics: [
|
||||
{ name: '/active', types: [], publishers: [endpoint('/pub')], subscribers: [endpoint('/sub')] },
|
||||
{ name: '/waiting', types: [], publishers: [endpoint('/pub')], subscribers: [] },
|
||||
] }
|
||||
function render(activeOnly) {
|
||||
const states = [graph, false, activeOnly, '/waiting', true, 320]
|
||||
let index = 0
|
||||
const api = {}
|
||||
const jsx = (type, props, key) => ({ type, props, key })
|
||||
vm.runInNewContext(ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText, { exports: api, require: (name) => {
|
||||
if (name === 'react') return { useState: () => [states[index++], () => {}], useMemo: fn => fn(), useEffect: () => {} }
|
||||
if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx }
|
||||
if (name === '../../i18n') return { t: value => value, useLocaleRender: () => {} }
|
||||
return {}
|
||||
} })
|
||||
const tree = api.default({ dark: false, modules: [{ name: 'Running module', state: 'running' }], remoteWorkspace: false, rosDomainId: '77' })
|
||||
const nodes = []
|
||||
function visit(node) {
|
||||
if (Array.isArray(node)) return node.forEach(visit)
|
||||
if (!node || typeof node !== 'object') return
|
||||
nodes.push(node)
|
||||
visit(node.props?.children)
|
||||
}
|
||||
visit(tree)
|
||||
return nodes
|
||||
}
|
||||
for (const activeOnly of [false, true]) {
|
||||
const nodes = render(activeOnly)
|
||||
assert.equal(nodes.filter(node => node.type === 'button' && node.key?.startsWith('/')).length, activeOnly ? 1 : 2)
|
||||
const toggle = nodes.find(node => node.props?.title === '활성 연결만 보기')
|
||||
assert.equal(toggle.props['aria-pressed'], activeOnly)
|
||||
assert.equal(toggle.props.children.at(-1), 1)
|
||||
assert.equal(nodes.find(node => node.type?.name === 'RuntimeLane').props.modules.length, 1)
|
||||
assert.equal(nodes.some(node => node.type?.name === 'TopicDetailDrawer'), !activeOnly)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user