feat: add daily logging and improve workspace UI and ROS status

This commit is contained in:
2026-09-29 16:42:34 +09:00
parent 0a15102f8c
commit fb8c15dfd3
25 changed files with 328 additions and 283 deletions
+78
View File
@@ -0,0 +1,78 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const exportsObject = {}
const source = fs.readFileSync(path.resolve(__dirname, '../src/main/dailyLog.ts'), 'utf8')
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: exportsObject, require })
const { createDailyLog, redactLog } = exportsObject
test('appends and rotates by local date, retaining unrelated files', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lms-log-test-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
fs.writeFileSync(path.join(root, 'renderer.log'), 'existing log')
let date = new Date(2026, 8, 29, 23, 59)
const log = createDailyLog([root], () => date)
log.write('test', 'first')
log.write('test', 'second')
const oldPath = log.path()
assert.match(oldPath, /lms-2026-09-29\.log$/)
assert.match(fs.readFileSync(oldPath, 'utf8'), /first[\s\S]*second/)
assert.equal(fs.statSync(oldPath).mode & 0o777, 0o600)
date = new Date(2026, 8, 30, 0, 1)
log.write('test', 'next day')
assert.match(log.path(), /lms-2026-09-30\.log$/)
assert.equal(fs.existsSync(oldPath), false)
assert.equal(fs.readFileSync(path.join(root, 'renderer.log'), 'utf8'), 'existing log')
})
test('falls back when the primary directory is unusable', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lms-log-test-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const blocked = path.join(root, 'file')
fs.writeFileSync(blocked, '')
const log = createDailyLog([blocked, path.join(root, 'fallback')])
log.write('test', 'fallback')
assert.equal(path.dirname(log.path()), path.join(root, 'fallback'))
const unavailable = createDailyLog([blocked])
assert.doesNotThrow(() => unavailable.write('test', 'ignored'))
assert.equal(unavailable.path(), '')
})
test('redacts common authentication fields and credential URLs', () => {
const result = redactLog('https://oauth2:secret123@example.com/repo.git Bearer secret456 --handoff-token=secret789 ?password=secret000 {"accessToken":"secret111"} password=secret222')
for (const secret of ['secret123', 'secret456', 'secret789', 'secret000', 'secret111', 'secret222']) {
assert.equal(result.includes(secret), false)
}
assert.match(result, /example.com\/repo.git/)
})
test('bootstrap logs dependency loading failures before exiting', async () => {
const events = []
const bootstrap = fs.readFileSync(path.resolve(__dirname, '../src/main/bootstrap.ts'), 'utf8')
const code = ts.transpileModule(bootstrap, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText
vm.runInNewContext(code, {
exports: {}, Error,
require: (name) => {
if (name === 'electron') return { app: { exit: (code) => events.push(['exit', code]) } }
if (name === './appLogging') {
events.push(['logger-ready'])
return { appLog: { write: (...args) => events.push(args) } }
}
if (name === './index') throw new Error('test native module failure')
throw new Error(`Unexpected dependency: ${name}`)
}
})
await new Promise((resolve) => setImmediate(resolve))
assert.equal(events[0][0], 'logger-ready')
assert.equal(events[1][0], 'startup-error')
assert.match(events[1][1], /test native module failure/)
assert.deepEqual(events[2], ['exit', 1])
})
+38
View File
@@ -9,6 +9,44 @@ const { createInstance } = require('i18next')
const root = path.resolve(__dirname, '../src/renderer/src')
const dictionary = JSON.parse(fs.readFileSync(path.join(root, 'i18n/en.json'), 'utf8'))
test('close studio modal translates the built-in Local PC title but preserves vehicle names', () => {
const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8')
const expression = source.match(/platformTitle=\{(runningPlatformCloseTarget\.platformKey[^\n]+)\}/)?.[1]
assert.ok(expression)
for (const language of ['en', 'ko']) {
const { api } = setup(language)
const title = vm.runInNewContext(expression, {
runningPlatformCloseTarget: { platformKey: 'futuredrive-local-pc', title: '로컬 PC' }, t: api.t
})
assert.equal(title, language === 'en' ? 'Local PC' : '로컬 PC')
if (language === 'en') assert.equal(api.t('{{v0}} 개발 스튜디오를 종료할까요?', { v0: title }), 'Close the Local PC studio?')
assert.equal(vm.runInNewContext(expression, {
runningPlatformCloseTarget: { platformKey: 'vehicle', title: '교육용 차량' }, t: api.t
}), '교육용 차량')
}
})
test('session confirmation uses a localized platform display name', () => {
const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8')
const expression = source.match(/const activePlatformDisplayName = ([\s\S]*?)\n const sessionPlatformLabel/)[1]
for (const language of ['en', 'ko']) {
const { api } = setup(language)
const name = vm.runInNewContext(expression, {
workspacePlatform: null,
sessionPlatform: { platformKey: 'futuredrive-local-pc' },
activePlatformName: '로컬 PC', t: api.t
})
assert.equal(name, language === 'en' ? 'Local PC' : '로컬 PC')
if (language === 'en') assert.equal(api.t('{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.', { v0: name }),
'Disconnect from the Local PC studio only. Platform control is retained.')
assert.equal(vm.runInNewContext(expression, {
workspacePlatform: { platformKey: 'vehicle' }, sessionPlatform: null,
activePlatformName: '교육용 차량', t: api.t
}), '교육용 차량')
}
assert.match(source, /<SessionConfirmModal[\s\S]*?platformName=\{activePlatformDisplayName\}/)
})
test('all execution guide page titles have English translations', () => {
const source = fs.readFileSync(path.join(root, 'features/workspace/ModuleListView.tsx'), 'utf8')
const titles = [...source.matchAll(/environment: '([^']+)'/g)].map(match => match[1])
+47
View File
@@ -0,0 +1,47 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const exportsObject = {}
const source = fs.readFileSync(path.resolve(__dirname, '../src/renderer/src/features/workspace/WorkspaceRosConnectionsView.tsx'), 'utf8')
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports: exportsObject, require: () => ({}) })
const { rosGraphConnections } = exportsObject
const endpoint = (node, type = 'sensor_msgs/msg/CompressedImage') => ({ node, type })
test('shows image and custom topics without module definitions', () => {
const connections = rosGraphConnections({ topics: [
{ name: '/camera/image/compressed', types: ['sensor_msgs/msg/CompressedImage'], publishers: [endpoint('/camera')], subscribers: [endpoint('/bridge')] },
{ name: '/custom/new_topic', types: ['custom_msgs/msg/Value'], publishers: [], subscribers: [endpoint('/custom', 'custom_msgs/msg/Value')] },
] })
assert.equal(connections.length, 2)
assert.equal(connections[0].topic.name, '/camera/image/compressed')
assert.equal(connections[0].topic.type, 'sensor_msgs/msg/CompressedImage')
assert.equal(connections[0].active, true)
assert.equal(connections[1].publishers.length, 0)
assert.equal(connections[1].active, false)
})
test('empty and failed queries never synthesize topics or nodes', () => {
assert.equal(rosGraphConnections({ topics: [] }).length, 0)
assert.equal(rosGraphConnections({ topics: [], message: 'SSH timeout' }).length, 0)
})
test('keeps discovered topics without endpoints and deduplicates node entries', () => {
const connections = rosGraphConnections({ topics: [
{ name: '/z', types: [], publishers: [], subscribers: [] },
{ name: '/a', types: ['sensor_msgs/msg/CompressedImage'], publishers: [endpoint('/camera'), endpoint('/camera')], subscribers: [] },
] })
assert.equal(connections.length, 2)
assert.equal(connections[0].topic.name, '/a')
assert.equal(connections[0].publishers.length, 1)
assert.equal(connections[1].topic.name, '/z')
assert.equal(connections.every((connection) => !connection.active), true)
})
test('module selection and guessed topic mappings are removed from the view', () => {
assert.doesNotMatch(source, /selectedModuleKeys|moduleTopics|fallbackPublishers|fallbackSubscribers|onToggleModule|ListChecks|ListX/)
})