feat: add structured agent tools and improve workspace chat UX

This commit is contained in:
2026-10-19 11:30:23 +09:00
parent 098af30fa7
commit cc589fd8cf
35 changed files with 2831 additions and 247 deletions
+249 -17
View File
@@ -58,13 +58,81 @@ test('keeps logs while loading a later source module', async () => {
assert.equal(calls, 3)
})
test('rejects repeated requests instead of returning a stalled plan', async () => {
test('makes one analysis-only completion when the model repeats a context request', async () => {
let loads = 0
await assert.rejects(resolveAgentContext(options({
requestPlan: async () => response(['Control']),
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
})), /반복 요청/)
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async (context, finalize) => {
calls++
if (!finalize) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').files[0].path, 'controller.py')
return { ...response(), plan: { ...response().plan, explanation: 'controller.py에서 조향 설정을 확인했습니다.' } }
},
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'steering = 1' }])], runtimeLogs: [] } }
}))
assert.equal(loads, 1)
assert.equal(calls, 3)
assert.match(result.plan.explanation, /조향 설정을 확인/)
assert.equal(result.usage.totalTokens, 30)
})
test('finishes honestly without an infinite loop if the model ignores the finalization request', async () => {
let loads = 0
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async () => { calls++; return response(['Control']) },
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
}))
assert.equal(loads, 1)
assert.equal(calls, 3)
assert.deepEqual(plain(result.plan.requestedModules), [])
assert.deepEqual(plain(result.plan.actionPlan), [])
assert.match(result.plan.explanation, /결론을 확정하지 못했습니다/)
assert.match(result.plan.explanation, /Control: 제공된 파일 0개/)
assert.equal(result.plan.createModule, null)
})
test('uses already provided source files without loading the same module again', async () => {
let loads = 0
let calls = 0
const result = await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'steering = 1' }])], runtimeLogs: [] },
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) },
loadRequestedContext: async () => { loads++; return { modules: [], runtimeLogs: [] } }
}))
assert.equal(loads, 0)
assert.equal(calls, 2)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('recovery cannot turn repeated source requests into executable commands or edits', async () => {
const result = await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'source' }])], runtimeLogs: [] },
requestPlan: async (_, finalize) => finalize ? {
...response(),
plan: {
...response().plan, actionPlan: ['execute'], commands: [{ id: 'run', command: './run.sh' }],
changes: [{ moduleName: 'Control', path: 'control.py', content: 'changed' }], createModule: { name: 'new' }
}
} : response(['Control']),
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
}))
for (const field of ['commands', 'changes', 'actionPlan']) assert.deepEqual(plain(result.plan[field]), [])
assert.equal(result.plan.createModule, null)
})
test('does not publish a finalization response received after cancellation', async () => {
const controller = new AbortController()
let calls = 0
await assert.rejects(resolveAgentContext(options({
signal: controller.signal,
requestPlan: async (_, finalize) => {
calls++
if (finalize) controller.abort()
return finalize ? response() : response(['Control'])
}
})), { name: 'AbortError' })
assert.equal(calls, 2)
})
test('requests only new resources when the model also repeats an earlier module', async () => {
@@ -90,11 +158,14 @@ test('does not start another API call after cancellation during context loading'
test('bounds the number of distinct automatic context requests', async () => {
let calls = 0
await assert.rejects(resolveAgentContext(options({
requestPlan: async () => response([`Module${calls++}`]),
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] })
})), /5회/)
assert.equal(calls, 6)
let loads = 0
const result = await resolveAgentContext(options({
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response([`Module${calls}`]) },
loadRequestedContext: async request => { loads++; return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
}))
assert.equal(calls, 7)
assert.equal(loads, 5)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('passes file-read failures to the model for a concrete final explanation', async () => {
@@ -109,15 +180,30 @@ test('passes file-read failures to the model for a concrete final explanation',
}))
})
test('does not silently truncate accumulated source context beyond API limits', async () => {
await assert.rejects(resolveAgentContext(options({
requestPlan: async () => response(['Control']),
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }])], runtimeLogs: [] })
})), /조회 한도/)
test('excludes oversized source files with an explicit notice and continues with complete smaller files', async () => {
let calls = 0
const smallFile = { path: 'config/steering.yaml', content: 'kinematics: ackermann' }
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
const control = context.modules.find(module => module.name === 'Control')
assert.deepEqual(plain(control.files), [smallFile])
assert.equal(control.fileContext.status, 'limited')
assert.match(control.fileContext.message, /전체 2개 중 1개/)
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }, smallFile])], runtimeLogs: [] })
}))
assert.equal(calls, 2)
})
test('does not display a context-only response when no loader is available', async () => {
await assert.rejects(resolveAgentContext(options({ requestPlan: async () => response(['Control']) })), /읽을 수 없는/)
test('requests a final answer rather than leaving a context-only response when no loader is available', async () => {
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) }
}))
assert.equal(calls, 2)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('keeps a just-started module running when a stale collector supplies source files', async () => {
@@ -133,3 +219,149 @@ test('keeps a just-started module running when a stale collector supplies source
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'node.py', content: 'code' }])], runtimeLogs: [] })
}))
})
const files = (name, count, size = 10) => Array.from({ length: count }, (_, index) => ({ path: `${name}-${index}.py`, content: String(index).padEnd(size, 'x') }))
const sourceSize = modules => modules.reduce((sum, module) => sum + module.files.reduce((total, file) => total + file.path.length + file.content.length, 0), 0)
test('balances 24 old and 24 new files instead of failing the second module lookup', async () => {
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
calls++
if (calls === 1) return response(['MKMini'])
if (calls === 2) return response(['Control'])
assert.equal(context.modules.reduce((sum, module) => sum + module.files.length, 0), 24)
for (const name of ['MKMini', 'Control']) {
const module = context.modules.find(module => module.name === name)
assert.equal(module.files.length, 12)
assert.equal(module.fileContext.status, 'limited')
assert.match(module.fileContext.message, /전체 24개 중 12개/)
}
return response()
},
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name, files(name, 24))), runtimeLogs: [] })
}))
assert.equal(calls, 3)
})
test('enforces the combined character budget without slicing source file contents', async () => {
let calls = 0
const contents = new Set(['a'.repeat(60_000), 'b'.repeat(60_000)])
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.ok(sourceSize(context.modules) <= 160_000)
for (const module of context.modules) {
assert.equal(module.files.length, 1)
assert.ok(contents.has(module.files[0].content))
assert.equal(module.fileContext.status, 'limited')
}
return response()
},
loadRequestedContext: async () => ({ modules: ['MKMini', 'Control'].map((name, index) => moduleInfo(name, [
{ path: 'one.py', content: [...contents][index] },
{ path: 'two.py', content: [...contents][index] }
])), runtimeLogs: [] }),
initialContext: { modules: [moduleInfo('MKMini', [{ path: 'one.py', content: [...contents][0] }, { path: 'two.py', content: [...contents][0] }]), moduleInfo('Control')], runtimeLogs: [] }
}))
})
test('prioritizes newly requested source when earlier context nearly fills the character budget', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('MKMini', files('old', 2, 70_000)), moduleInfo('Control')], runtimeLogs: [] },
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').files[0].content, 'new'.repeat(20_000))
assert.ok(context.modules.find(module => module.name === 'MKMini').files.length > 0)
assert.ok(sourceSize(context.modules) <= 160_000)
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'new'.repeat(20_000) }])], runtimeLogs: [] })
}))
})
test('budgets initial source context before sending the first API request', async () => {
const initialContext = { modules: [moduleInfo('Control', files('source', 30))], runtimeLogs: [] }
await resolveAgentContext(options({
initialContext,
requestPlan: async context => {
assert.equal(context.modules[0].files.length, 24)
assert.equal(context.modules[0].fileContext.status, 'limited')
return response()
}
}))
assert.equal(initialContext.modules[0].files.length, 30)
assert.equal(initialContext.modules[0].fileContext, undefined)
})
test('rotates older logs out with a notice rather than failing on a fourth module', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${moduleName} log` })) },
requestPlan: async context => {
if (calls++ === 0) return response([], ['D'])
assert.deepEqual(plain(context.runtimeLogs.map(log => log.moduleName)), ['D', 'C', 'B'])
assert.match(context.runtimeLogs[0].message, /이전 로그 제외: A/)
return response()
},
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [{ moduleName: 'D', state: 'running', content: 'latest error' }] })
}))
})
test('counts log messages in the server budget and keeps recent log content', async () => {
await resolveAgentContext(options({
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${'x'.repeat(20_000)}LATEST`, message: 'm'.repeat(500) })) },
requestPlan: async context => {
assert.equal(context.runtimeLogs.length, 3)
assert.ok(context.runtimeLogs.reduce((sum, log) => sum + log.content.length + (log.message?.length ?? 0), 0) <= 60_000)
for (const log of context.runtimeLogs) {
assert.ok(log.content.endsWith('LATEST'))
assert.match(log.message, /최근 구간/)
assert.ok(log.message.length <= 500)
}
return response()
}
}))
})
test('collects candidates from every requested module and prioritizes configuration before large source trees', async () => {
const source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let collector
function visit(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'collectAgentWorkspace') collector = node.initializer.getText(source)
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(collector)
const api = {}
vm.runInNewContext(ts.transpileModule(`exports.collect = ${collector}`, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, {
exports: api,
availableModules: ['MKMini', 'Control'].map(name => ({ name, state: 'stopped' })),
remoteWorkspace: false,
clonePathPreview: '/workspace',
currentActiveEditorFile: null,
editorFiles: {},
window: { localWorkspace: { moduleFiles: { read: async name => ({ files: Object.fromEntries([
...files(name, 30).map((file, index) => [`src/node${index}.py`, file.content]),
['config/steering.yaml', 'kinematics: ackermann']
]) }) } } }
})
const modules = await api.collect(['MKMini', 'Control'])
assert.deepEqual(plain(modules.map(module => module.files.length)), [24, 24])
for (const module of modules) assert.equal(module.files[0].path, 'config/steering.yaml')
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['MKMini', 'Control'])
for (const module of context.modules) {
assert.equal(module.files.length, 12)
assert.equal(module.files[0].path, 'config/steering.yaml')
}
return response()
},
loadRequestedContext: async request => ({ modules: await api.collect(request.modules), runtimeLogs: [] })
}))
})
+31
View File
@@ -0,0 +1,31 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const api = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceEditorPane.tsx'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText, {
exports: api,
require: id => id === '../../i18n' ? { t: value => value, useLocaleRender() {} }
: id === '@monaco-editor/react' ? { __esModule: true, default: 'Editor', DiffEditor: 'DiffEditor' }
: id === 'lucide-react' ? {} : require(id)
})
const props = { dark: true, textDim: '', editorPaneRef: { current: null }, currentActiveEditorFile: 'config/control.yaml', activeFileIsImage: false, editorLanguage: 'yaml', editorValue: 'steering: ackermann', fontSize: 12, tabSize: 4, minimapEnabled: false, exampleActive: false, beforeMount() {}, onMount() {}, onChange() {} }
for (const locked of [false, true]) {
test(`the source editor is read-only only while an agent file batch is applying (locked=${locked})`, () => {
const tree = api.default({ ...props, editingLocked: locked })
assert.equal(tree.props.children.type, 'Editor')
assert.equal(tree.props.children.props.options.readOnly, locked)
})
}
test('agent diff previews remain read-only and set tab width through text models', () => {
const tree = api.default({ ...props, diffPreview: { id: 'diff', originalContent: 'old', modifiedContent: 'new' } })
const diff = tree.props.children
assert.equal(diff.type, 'DiffEditor')
assert.equal(diff.props.options.readOnly, true)
assert.equal('tabSize' in diff.props.options, false)
const updates = []
const editor = { getModel: () => ({ updateOptions: options => updates.push(options.tabSize) }) }
diff.props.onMount({ getOriginalEditor: () => editor, getModifiedEditor: () => editor })
assert.deepEqual(updates, [4, 4])
})
+123
View File
@@ -0,0 +1,123 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const api = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentPractice.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: api, DOMException, setTimeout })
const { AGENT_PRACTICE_PROFILES: profiles, inspectAgentPractice, verifyAgentPractice, prepareAgentPractice } = api
const plain = value => JSON.parse(JSON.stringify(value))
const profile = profiles.find(item => item.id === 'mkmini-2d')
const modulesFor = p => p.modules.map((name, sortOrder) => ({ name, sortOrder, category: name.includes('MKMini') || name.includes('TurtleBot') || name === 'NAV2-Yahboom' ? 'Vehicle' : name.includes('Gazebo') ? 'Simulator' : name.includes('Perception') ? 'Sensing' : 'System', state: 'stopped', parameters: [`sim_mode:=${p.environment === 'local'}`, `platform:=${p.platform}`, `robot:=${p.platform}`, `level:=${p.level}`] }))
const probeFor = (p, overrides = {}) => ({ id: 'probe', name: 'ros.inspect', status: 'ok', target: 'test:container', observedAt: new Date().toISOString(), truncated: false, data: JSON.stringify({ topics: p.topics.map(name => ({ name, publishers: 1, samples: 3, validSamples: 3, frameIds: name === p.topics[0] ? ['laser'] : [] })), tfEdges: [{ parent: 'map', child: 'odom' }, { parent: 'odom', child: 'base_footprint' }, { parent: 'base_footprint', child: 'base_link' }, { parent: 'base_link', child: 'laser' }], ...overrides }) })
function scenario(overrides = {}) {
const modules = modulesFor(profile)
const starts = []
const controller = new AbortController()
const options = {
profileId: profile.id, environment: 'remote', signal: controller.signal,
approvedStarts: [...profile.modules],
list: async () => modules.map(module => ({ ...module, parameters: [...module.parameters] })),
start: async module => { starts.push(module.name); modules.find(item => item.name === module.name).state = 'running'; return true },
probe: async () => probeFor(profile), progress: () => {}, pause: async () => {}, ...overrides
}
return { modules, starts, controller, options }
}
test('SLAM profiles follow the module guide module sequences', () => {
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let expression
const visit = node => { if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'moduleGuidePages') expression = node.initializer.getText(source); ts.forEachChild(node, visit) }
visit(source)
const guide = {}
vm.runInNewContext(ts.transpileModule(`exports.pages = ${expression}`, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: guide })
for (const p of profiles) assert.ok(guide.pages.some(page => JSON.stringify(page.slamModules) === JSON.stringify(p.modules)))
})
test('preflight rejects wrong environments, missing modules, conflicting parameters and unrelated running modules', () => {
const modules = modulesFor(profile)
assert.equal(inspectAgentPractice(profile.id, modules, 'remote').canPrepare, true)
assert.equal(inspectAgentPractice(profile.id, modules, 'local').canPrepare, false)
assert.equal(inspectAgentPractice(profile.id, modules.slice(1), 'remote').canPrepare, false)
modules[0].parameters[0] = 'sim_mode:=true'
assert.match(inspectAgentPractice(profile.id, modules, 'remote').blockers.join(), /sim_mode/)
modules[0].parameters = []
assert.match(inspectAgentPractice(profile.id, modules, 'remote').blockers.join(), /sim_mode/)
const conflicting = [...modulesFor(profile), { name: 'NAV2-Control', category: 'Control', state: 'running', parameters: [] }]
assert.match(inspectAgentPractice(profile.id, conflicting, 'remote').blockers.join(), /NAV2-Control/)
assert.equal(inspectAgentPractice(profile.id, [...modulesFor(profile), { name: 'NAV2-Camera', category: 'Sensing', state: 'running', parameters: [] }], 'remote').canPrepare, true)
})
test('readiness requires observed sensor, odometry, map data and a connected TF chain', () => {
assert.equal(verifyAgentPractice(profile.id, probeFor(profile)).ready, true)
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { tfEdges: [] })).ready, false)
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { topics: profile.topics.map(name => ({ name, publishers: 1, samples: 0 })) })).ready, false)
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { tfEdges: [{ parent: 'map', child: 'odom' }, { parent: 'odom', child: 'base_link' }] })).ready, false)
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), status: 'error' }).ready, false)
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), truncated: true }).ready, false)
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), data: 'null' }).ready, false)
})
test('prepares only missing starts, in dependency order, and verifies in the SLAM container', async () => {
const s = scenario()
s.modules[0].state = 'running'
let observed
s.options.probe = async (module, topics) => { observed = { module: module.name, topics }; return probeFor(profile) }
const result = await prepareAgentPractice(s.options)
assert.equal(result.ready, true)
assert.deepEqual(s.starts, [...profile.modules].slice(1))
assert.equal(observed.module, 'NAV2-SLAM')
assert.ok(observed.topics.includes('/tf_static'))
assert.equal(result.steps[0].status, 'already-running')
})
test('a failed start stops subsequent starts and does not issue rollback stop commands', async () => {
const s = scenario()
s.options.start = async module => { s.starts.push(module.name); if (s.starts.length === 2) return false; s.modules[0].state = 'running'; return true }
const result = await prepareAgentPractice(s.options)
assert.equal(result.ready, false)
assert.deepEqual(s.starts, [...profile.modules].slice(0, 2))
assert.match(result.failures[0], /자동 종료하지/)
})
test('does not start modules that became stopped after approval', async () => {
const s = scenario({ approvedStarts: ['NAV2-SLAM'] })
await assert.rejects(prepareAgentPractice(s.options), /승인 이후/)
assert.equal(s.starts.length, 0)
})
test('does not start with changed parameters after approval', async () => {
const s = scenario()
s.options.approvedParameters = Object.fromEntries(s.modules.map(module => [module.name, [...module.parameters]]))
s.modules[0].parameters.push('custom_speed:=9')
await assert.rejects(prepareAgentPractice(s.options), /실행 파라미터가 바뀌/)
assert.equal(s.starts.length, 0)
})
test('cancellation during a start prevents any subsequent module start', async () => {
const s = scenario()
s.options.start = async module => { s.starts.push(module.name); s.controller.abort(); return true }
await assert.rejects(prepareAgentPractice(s.options), { name: 'AbortError' })
assert.equal(s.starts.length, 1)
})
test('verification retries are bounded and never report ready for publisher-only data', async () => {
const s = scenario()
let probes = 0
s.options.probe = async () => { probes++; return probeFor(profile, { topics: [] }) }
const result = await prepareAgentPractice(s.options)
assert.equal(result.ready, false)
assert.equal(probes, 3)
assert.equal(result.failures.length, 4)
assert.ok(result.failures.some(message => message.includes('센서 프레임')))
})
test('the common confirmation and apply-before-build guard are connected in the workspace', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
assert.match(source, /<ConfirmStatusModal[\s\S]*?title="SLAM 실습 준비"/)
assert.match(source, /requiresAppliedChanges && message.applyState !== 'applied'/)
assert.match(source, /agent\.applyFiles\(/)
assert.match(source, /getDirtyModuleFiles\(change.moduleName\)/)
assert.match(source, /background: true, freshSlam: true/)
assert.match(source, /onResult: result =>/)
})
+22 -2
View File
@@ -3,6 +3,10 @@ const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const contextApi = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: contextApi, DOMException })
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let payloadExpression
@@ -15,9 +19,10 @@ function visit(node) {
visit(source)
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
function serialize(attachments) {
function serialize(attachments, finalize = false, request = '설정 위치 알려줘') {
return JSON.parse(vm.runInNewContext(payloadExpression, {
courseId: 'course-1', weekNumber: 3, request: '설정 위치 알려줘', options: { attachments },
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
agentPlanRequestText: contextApi.agentPlanRequestText,
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
}))
}
@@ -36,3 +41,18 @@ test('preserves actual attachment contents rather than silently dropping them',
const attachments = [{ name: 'config.yaml', content: 'steering: ackermann' }]
assert.deepEqual(serialize(attachments).attachments, attachments)
})
test('finalization instruction reaches the API without sending unsupported DTO fields', () => {
const body = serialize(undefined, true)
assert.match(body.request, /자료 조회는 종료/)
assert.match(body.request, /설정 위치 알려줘/)
assert.match(body.request, /최종 답변/)
assert.equal(Object.hasOwn(body, 'finalize'), false)
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'request', 'runtimeLogs', 'week'])
})
test('finalization keeps the request within the backend 4000-character limit', () => {
const body = serialize(undefined, true, 'x'.repeat(4000))
assert.equal(body.request.length, 4000)
assert.match(body.request, /원래 학생 질문/)
})
+241
View File
@@ -0,0 +1,241 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function load(file, overrides = {}, globals = {}) {
const exports = {}
const source = fs.readFileSync(require.resolve(file), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
} }).outputText
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
const reveal = load('../src/renderer/src/components/agentResponseReveal.ts')
const { createAgentRevealDocument, agentRevealFrame, AGENT_REVEAL_INTERVAL } = reveal
const paragraph = 'MK-mini는 바퀴가 4개라도 전륜 조향 모델을 사용합니다.'
const content = `${paragraph}\n\n1. 물리적 바퀴 수와 운동학 모델은 다릅니다.\n - 앞바퀴로 조향합니다.\n2. 제어 설정을 확인합니다.\n\n마지막 설명입니다.`
test('reveals paragraphs and complete list items at fixed intervals without splitting Korean sentences', () => {
const document = createAgentRevealDocument(content)
const first = agentRevealFrame(document, 1000, 1000)
assert.equal(first.content, paragraph)
assert.equal(first.complete, false)
const second = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL)
assert.match(second.content, /1\. 물리적/)
assert.match(second.content, /앞바퀴로 조향합니다\./)
assert.doesNotMatch(second.content, /2\. 제어/)
const third = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL * 2)
assert.match(third.content, /2\. 제어 설정을 확인합니다\./)
assert.doesNotMatch(third.content, /마지막 설명/)
})
test('keeps fenced code and GFM tables intact', () => {
const text = '설명입니다.\n\n```python\nprint("시작")\nprint("종료")\n```\n\n| 항목 | 값 |\n| --- | --- |\n| 조향 | 전륜 |'
const document = createAgentRevealDocument(text)
assert.equal(document.boundaries.length, 3)
const code = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL).content
assert.equal((code.match(/```/g) ?? []).length, 2)
assert.match(code, /print\("종료"\)/)
assert.doesNotMatch(code, /\| 항목/)
assert.equal(agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL * 2).content, text)
})
test('reference link definitions are available before their position in the answer is revealed', () => {
const document = createAgentRevealDocument('[설명][guide]\n\n다음 문단\n\n[guide]: https://example.com/guide')
const first = agentRevealFrame(document, 0, 0)
assert.match(first.content, /\[guide\]: https:\/\/example.com\/guide/)
assert.doesNotMatch(first.content, /다음 문단/)
})
test('history, elapsed answers and reduced-motion users see the complete response immediately', () => {
const document = createAgentRevealDocument(content)
for (const frame of [agentRevealFrame(document, undefined, 0), agentRevealFrame(document, 0, 60_000), agentRevealFrame(document, 0, 0, true)]) {
assert.equal(frame.content, content)
assert.equal(frame.complete, true)
assert.equal(frame.fadeFrom, undefined)
}
})
test('long answers are grouped into at most twenty steps and finish within five seconds', () => {
const text = Array.from({ length: 100 }, (_, index) => `문단 ${index}입니다.`).join('\n\n')
const document = createAgentRevealDocument(text)
assert.ok(document.boundaries.length <= 20)
assert.ok(agentRevealFrame(document, 0, 0).duration < 5000)
assert.equal(agentRevealFrame(document, 0, 5000).content, text)
})
test('only the newly appended paragraph gets the fade class', () => {
const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}
})
const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL)
const html = renderToStaticMarkup(React.createElement(MarkdownMessage, { content: frame.content, fadeFrom: frame.fadeFrom, dark: true }))
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
assert.match(html, /첫 번째 문단/)
assert.match(html, /두 번째 문단/)
assert.doesNotMatch(html, /세 번째 문단/)
})
function renderResponse(message, now, reducedMotion = false, extra = {}) {
const MarkdownMessage = ({ content }) => React.createElement('p', null, content)
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage: MarkdownMessage }, './agentResponseReveal': reveal,
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: reducedMotion }) } })
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
}
test('approval controls wait for the explanation; restored history does not replay', () => {
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '조향 설명', explanation: content }, provider: 'QWEN', model: 'test' }
const first = renderResponse(message, 1000)
assert.match(first, /조향 설명/)
assert.match(first, /aria-busy="true"/)
assert.doesNotMatch(first, /승인|QWEN/)
for (const html of [renderResponse(message, 10_000), renderResponse({ ...message, revealStartedAt: undefined }, 1000), renderResponse(message, 1000, true)]) {
assert.match(html, /aria-busy="false"/)
assert.match(html, /마지막 설명/)
assert.match(html, /승인/)
assert.match(html, /QWEN/)
}
})
test('reveal timing is not included in conversation persistence API payloads', () => {
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let expression
function visit(node) {
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('messages: batch')) expression = node.getText(source)
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(expression)
const batch = [{ id: '1', role: 'assistant', content: '전체 답변', revealStartedAt: 1000 }]
const payload = JSON.parse(vm.runInNewContext(expression, { batch, courseId: 'course', weekNumber: 1 }))
assert.equal(Object.hasOwn(payload.messages[0], 'revealStartedAt'), false)
assert.equal(payload.messages[0].content, '전체 답변')
})
test('animation honors reduced motion in CSS', () => {
const css = fs.readFileSync(require.resolve('../src/renderer/src/index.css'), 'utf8')
assert.match(css, /@keyframes agent-response-fade/)
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*?\.agent-response-fade\s*\{\s*animation: none/)
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
test('agent answers use the shared collapse widget after reveal, without hiding action controls', () => {
const MarkdownMessage = () => null
const ExpandableMarkdownMessage = () => null
let now = 1000
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage }, './agentResponseReveal': reveal,
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: false }) } })
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '요약', explanation: '상세 내용입니다.\n\n'.repeat(60) } }
const render = (extra = {}) => ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra })
assert.equal(elements(render(), node => node.type === ExpandableMarkdownMessage).length, 0)
now = 10_000
const fresh = render()
assert.equal(elements(fresh, node => node.type === ExpandableMarkdownMessage)[0].props.defaultCollapsed, false)
assert.equal(elements(fresh, node => node.type === 'button').length, 1)
const history = render({ message: { ...message, revealStartedAt: undefined }, collapseOnSignal: true, collapseSignal: 4 })
const widget = elements(history, node => node.type === ExpandableMarkdownMessage)[0]
assert.equal(widget.props.defaultCollapsed, true)
assert.equal(widget.props.collapseOnSignal, true)
assert.equal(widget.props.collapseSignal, 4)
assert.equal(elements(history, node => node.type === 'button').length, 1)
})
for (const dark of [false, true]) {
test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => {
let expanded
const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {},
react: { ...React, useEffect() {}, useState(initial) {
if (expanded === undefined) expanded = initial
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }]
} }
})
const content = '파일과 파라미터에 대한 상세 설명입니다.\n'.repeat(50)
const render = () => ExpandableMarkdownMessage({ content, dark, defaultCollapsed: true })
let tree = render()
let button = elements(tree, node => node.type === 'button')[0]
assert.equal(button.props['aria-label'], '자세히 보기')
assert.ok(elements(tree, node => node.type === MarkdownMessage)[0].props.content.length < content.length)
button.props.onClick()
tree = render()
button = elements(tree, node => node.type === 'button')[0]
assert.equal(button.props['aria-label'], '접기')
assert.equal(elements(tree, node => node.type === MarkdownMessage)[0].props.content, content)
button.props.onClick()
assert.equal(elements(render(), node => node.type === 'button')[0].props['aria-label'], '자세히 보기')
})
}
test('short answers do not show an unnecessary collapse button', () => {
const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {},
react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
})
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
assert.equal(elements(tree, node => node.type === 'button').length, 0)
})
for (const dark of [false, true]) {
test(`model label and copy action share the last row after the operation controls (${dark ? 'dark' : 'light'})`, () => {
const html = renderResponse({ content: '전체 답변', provider: 'QWEN', model: 'qwen-model-name'.repeat(8) }, 1000, false, {
dark,
footerAction: React.createElement('button', { title: '복사', className: 'copy-action' }, 'Copy')
})
assert.match(html, /class="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2"><p[^>]*class="min-w-0 flex-1 truncate[^>]*>QWEN[^<]*<\/p><button title="복사"/)
assert.ok(html.indexOf('승인') < html.indexOf('QWEN'))
})
}
test('copy footer remains available when the message has no model metadata', () => {
const html = renderResponse({ content: '오류 안내' }, 1000, false, {
footerAction: React.createElement('button', { title: '복사' }, 'Copy')
})
assert.match(html, /min-h-6/)
assert.match(html, /title="복사"/)
})
test('reply copy is an inline fixed-size hover button and still copies the full response', async () => {
const source = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declaration = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'MessageCopyButton')
let copied = false
let clipboardText
const { MessageCopyButton } = compileCopy(declaration.getText(source))
function compileCopy(text) {
const code = ts.transpileModule(`export ${text}`, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
} }).outputText
const exports = {}
vm.runInNewContext(code, {
exports, require,
useState: () => [copied, value => { copied = value }], t: key => key,
Check: () => null, Copy: () => null,
navigator: { clipboard: { writeText: async text => { clipboardText = text } } },
window: { setTimeout() {} }
})
return exports
}
const content = '# 요약\n\n전체 답변입니다.'
const button = MessageCopyButton({ content, dark: true })
assert.match(button.props.className, /opacity-0.*group-hover:opacity-100/)
assert.match(button.props.className, /shrink-0 cursor-pointer/)
assert.doesNotMatch(button.props.className, /absolute|bottom-|right-/)
button.props.onClick()
await Promise.resolve()
assert.equal(clipboardText, content)
assert.equal(MessageCopyButton({ content, dark: true }).props.title, '복사됨')
})
+207
View File
@@ -0,0 +1,207 @@
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 { spawnSync } = require('node:child_process')
const ts = require('typescript')
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(require('node:fs').readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, filename)
const { validateAgentTool, validateAgentOperation, safeAgentPath } = require('../src/shared/agentTools.ts')
const { runAgentFileTool, applyAgentFiles, agentFileHash } = require('../src/main/agentFileTools.ts')
const { agentRemoteFilesScript } = require('../src/main/agentRemoteFiles.ts')
const { inspectAgentRuntime, agentRosProbePython } = require('../src/main/agentRuntimeTools.ts')
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
const plain = value => JSON.parse(JSON.stringify(value))
const request = (name, args = {}) => ({ id: 'query-1', name, moduleName: 'NAV2-MKMini', args })
function remote(input) {
const result = spawnSync('bash', ['--noprofile', '--norc', '-s'], { input: agentRemoteFilesScript(input), encoding: 'utf8', maxBuffer: 4_000_000, timeout: 10_000 })
if (result.error || result.status !== 0) throw new Error(result.stderr || result.error?.message)
return JSON.parse(result.stdout)
}
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-agent-tools-'))
t.after(() => fs.rm(root, { recursive: true, force: true }))
await fs.mkdir(path.join(root, 'config'))
await fs.writeFile(path.join(root, 'config', 'sensor.yaml'), 'host_ip: 192.168.2.88\n조향: ackermann\n')
await fs.writeFile(path.join(root, 'CMakeLists.txt'), 'project(vehicle)\n')
return root
}
test('tool requests reject writes, extra shell arguments, unsafe paths and unbounded observations', () => {
for (const name of ['shell', 'files.write', 'network.set', 'ros.publish']) assert.throws(() => validateAgentTool(request(name)))
for (const name of ['files.list', 'files.read', 'files.search']) {
for (const p of ['../secret', '/etc/passwd', '.env', 'config/.env', 'build/secret.py', 'config/id_rsa', 'config/key.pem', 'config\\x.py', 'a//b']) assert.throws(() => validateAgentTool(request(name, { path: p, ...(name === 'files.search' ? { query: 'ip' } : {}) })))
}
assert.throws(() => validateAgentTool(request('network.inspect', { command: 'ip addr replace' })))
assert.throws(() => validateAgentTool(request('ros.inspect', { topics: ['/scan; reboot'] })))
assert.throws(() => validateAgentTool(request('files.read', { path: 'a.py', lineCount: 401 })))
assert.throws(() => validateAgentOperation({ name: 'modules.stop' }))
assert.throws(() => validateAgentOperation({ name: 'practice.prepare', profile: 'guess' }))
assert.equal(validateAgentOperation({ name: 'practice.prepare', profile: 'mkmini-3d' }).name, 'practice.prepare')
assert.equal(safeAgentPath('src/launch/vehicle.launch.py'), 'src/launch/vehicle.launch.py')
})
for (const backend of ['local', 'remote']) {
const read = async (root, tool) => backend === 'local' ? runAgentFileTool(root, validateAgentTool(tool)) : remote({ root, request: validateAgentTool(tool) })
const apply = changes => backend === 'local' ? applyAgentFiles(changes) : Promise.resolve().then(() => remote({ changes }))
test(`${backend}: lists and searches sources without hidden, generated or symlinked data`, async t => {
const root = await fixture(t)
await fs.writeFile(path.join(root, '.env'), 'host_ip: PRIVATE')
await fs.mkdir(path.join(root, 'build'))
await fs.writeFile(path.join(root, 'build', 'generated.yaml'), 'host_ip: PRIVATE')
await fs.symlink('/etc/passwd', path.join(root, 'config', 'outside.txt'))
const listing = await read(root, request('files.list'))
assert.deepEqual([...listing.data.paths].sort(), ['CMakeLists.txt', 'config/sensor.yaml'])
const search = await read(root, request('files.search', { query: 'HOST_IP' }))
assert.equal(search.data.matches.length, 1)
assert.equal(search.data.matches[0].path, 'config/sensor.yaml')
assert.equal(search.data.matches[0].line, 1)
await assert.rejects(read(root, request('files.read', { path: 'config/outside.txt' })))
})
test(`${backend}: full file reads include a reproducible hash, partial and missing reads are explicit`, async t => {
const root = await fixture(t)
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' }))
assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
assert.equal(full.data.complete, true)
const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 }))
assert.equal(partial.data.content, '조향: ackermann')
assert.equal(partial.data.complete, false)
assert.equal(partial.truncated, true)
const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
assert.equal(missing.data.exists, false)
assert.equal(missing.data.hash, null)
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' })))
})
test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
const root = await fixture(t)
await fs.mkdir(path.join(root, 'code'))
for (let i = 0; i < 5; i++) await fs.writeFile(path.join(root, 'code', `large${i}.py`), 'x'.repeat(900_000))
const result = await read(root, request('files.search', { query: 'host_ip', path: 'config' }))
assert.equal(result.truncated, false)
assert.equal(result.data.matches[0].path, 'config/sensor.yaml')
})
test(`${backend}: validates every original before changing any file and preserves permissions`, async t => {
const root = await fixture(t)
const original = await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')
const first = { root, moduleName: 'NAV2-MKMini', path: 'config/sensor.yaml', content: 'host_ip: 192.168.1.88\n', expectedHash: agentFileHash(original) }
const stale = { root, moduleName: 'NAV2-MKMini', path: 'CMakeLists.txt', content: 'changed', expectedHash: '0'.repeat(64) }
await assert.rejects(apply([first, stale]))
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), original)
await fs.chmod(path.join(root, first.path), 0o664)
await apply([first, { root, moduleName: 'NAV2-MKMini', path: 'config/new.yaml', content: 'created: true', expectedHash: null }])
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), first.content)
assert.equal((await fs.stat(path.join(root, first.path))).mode & 0o777, 0o664)
assert.equal(await fs.readFile(path.join(root, 'config/new.yaml'), 'utf8'), 'created: true')
await assert.rejects(apply([first]))
})
}
test('remote batch streams large content rather than putting it into command arguments', async t => {
const root = await fixture(t)
const content = 'large YAML value '.repeat(11_000)
remote({ changes: [{ root, moduleName: 'NAV2-MKMini', path: 'config/large.yaml', expectedHash: null, content }] })
assert.equal(await fs.readFile(path.join(root, 'config/large.yaml'), 'utf8'), content)
})
test('local batch rolls back earlier replacements if a later replacement fails', async t => {
const root = await fixture(t)
const rename = fs.rename
let calls = 0
fs.rename = async (...args) => { if (++calls === 2) throw new Error('injected rename error'); return rename(...args) }
try {
await assert.rejects(applyAgentFiles([
{ root, moduleName: 'M', path: 'config/new.yaml', expectedHash: null, content: 'new' },
{ root, moduleName: 'M', path: 'CMakeLists.txt', expectedHash: agentFileHash('project(vehicle)\n'), content: 'changed' }
]), /injected/)
await assert.rejects(fs.stat(path.join(root, 'config/new.yaml')), { code: 'ENOENT' })
assert.equal(await fs.readFile(path.join(root, 'CMakeLists.txt'), 'utf8'), 'project(vehicle)\n')
} finally { fs.rename = rename }
})
test('network inspection keeps real host and container observations separate', async () => {
const calls = []
const result = await inspectAgentRuntime(request('network.inspect'), async (executable, args) => { calls.push([executable, args]); return '[{"ifname":"eth0"}]' }, async command => { calls.push(command); throw new Error('container is stopped') })
assert.equal(result.host.interfaces[0].ifname, 'eth0')
assert.match(result.container.error, /stopped/)
assert.equal(calls[0][0], 'ip')
assert.equal(calls[1][0], 'ip')
assert.equal(calls[2], 'ip -j address show')
})
test('ROS probe samples retained maps with matching QoS and distinguishes empty sensor payloads', () => {
const preamble = String.raw`
import sys, types, time
NS = types.SimpleNamespace
tick = [0]
def monotonic():
tick[0] += 1
return tick[0]
time.monotonic = monotonic
class Node:
def get_topic_names_and_types(self):
return [(topic, ['test/Message']) for topic in ['/map', '/scan', '/tf_static']]
def get_publishers_info_by_topic(self, topic):
return [NS(qos_profile=NS(durability='retained' if topic in ['/map', '/tf_static'] else 'volatile'))]
def create_subscription(self, message_type, topic, callback, qos):
if topic in ['/map', '/tf_static'] and qos.durability != 'retained':
raise ValueError('wrong durability')
if topic == '/map':
callback(NS(info=NS(width=10, height=10), data=[]))
elif topic == '/scan':
callback(NS(ranges=[1, 2, 3]))
else:
callback(NS(transforms=[NS(header=NS(frame_id='map'), child_frame_id='odom')]))
return object()
def count_publishers(self, topic):
return 1
def get_node_names(self):
return ['probe']
def destroy_node(self):
pass
sys.modules['rclpy'] = NS(init=lambda **kw: None, create_node=lambda name: Node(), spin_once=lambda *a, **kw: None, shutdown=lambda: None)
sys.modules['rclpy.qos'] = NS(QoSProfile=lambda **kw: NS(**kw), ReliabilityPolicy=NS(BEST_EFFORT='best-effort'), DurabilityPolicy=NS(TRANSIENT_LOCAL='retained', VOLATILE='volatile'))
sys.modules['rosidl_runtime_py'] = types.ModuleType('rosidl_runtime_py')
sys.modules['rosidl_runtime_py.utilities'] = NS(get_message=lambda name: object)
`
const run = spawnSync('python3', ['-c', `${preamble}\nexec(${JSON.stringify(agentRosProbePython)})`, Buffer.from(JSON.stringify(['/map', '/scan', '/tf_static'])).toString('base64')], { encoding: 'utf8', timeout: 5000 })
assert.equal(run.status, 0, run.stderr)
const data = JSON.parse(run.stdout)
assert.equal(data.topics.find(topic => topic.name === '/map').samples, 1)
assert.equal(data.topics.find(topic => topic.name === '/map').validSamples, 0)
assert.equal(data.topics.find(topic => topic.name === '/scan').validSamples, 1)
assert.deepEqual(data.tfEdges, [{ parent: 'map', child: 'odom' }])
})
test('restoring an interrupted action does not leave history permanently running', () => {
const { recoverInterruptedAgentMessages } = require('../src/renderer/src/features/workspace/agentContext.ts')
const messages = [{ id: 'reply', role: 'assistant', content: 'result', applyState: 'applying', commandExecutions: { start: { status: 'running', output: 'started one module' }, build: { status: 'completed' } } }]
const restored = recoverInterruptedAgentMessages(messages)
assert.equal(restored[0].applyState, 'error')
assert.equal(restored[0].commandExecutions.start.status, 'failed')
assert.equal(restored[0].commandExecutions.start.output, 'started one module')
assert.equal(restored[0].commandExecutions.build.status, 'completed')
assert.equal(messages[0].commandExecutions.start.status, 'running')
})
test('tool loop sends actual evidence, deduplicates requests despite new IDs and never runs actions', async () => {
let calls = 0
const executed = []
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
executeTool: async tool => { executed.push(tool); return { id: tool.id, name: tool.name, status: 'ok', target: 'test', observedAt: new Date().toISOString(), data: '{"paths":["config/a.yaml"]}', truncated: false } },
requestPlan: async (context, finalize) => {
calls++
if (calls === 2) assert.equal(context.toolResults.length, 1)
return { provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'explanation', requestedModules: [], requestedLogs: [], actionPlan: [], commands: finalize ? [{ operation: { name: 'modules.start' } }] : [], changes: [], createModule: null, toolRequests: [request('files.list', {})] } }
}
})
assert.equal(executed.length, 1)
assert.equal(calls, 3)
assert.deepEqual(plain(result.plan.commands), [])
assert.deepEqual(plain(result.plan.toolRequests), [])
assert.match(result.plan.explanation, /files.list: ok/)
})
test('backend and desktop share exactly the same tool validation contract', async () => {
assert.equal(await fs.readFile(path.resolve(__dirname, '../../lms-be/src/ai/agent-tools.ts'), 'utf8'), await fs.readFile(path.resolve(__dirname, '../src/shared/agentTools.ts'), 'utf8'))
})
+124
View File
@@ -0,0 +1,124 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function compile(source, overrides = {}, globals = {}) {
const exports = {}
const code = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
} }).outputText
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}
})
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
t: key => key, useLocaleRender() {},
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
ChatEmptyState: widgets.ChatEmptyState,
ProgressiveChatResponse,
MessageCopyButton: () => null,
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
})
for (const dark of [false, true]) {
for (const mode of ['AiMessages', 'AgentMessages']) {
test(`${mode} uses a three-line skeleton instead of a spinner (${dark ? 'dark' : 'light'})`, () => {
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, statusLoading: false, pending: true, messages: [] }))
assert.match(html, /role="status"/)
assert.equal((html.match(/<span class="h-2 /g) ?? []).length, 3)
for (const width of ['w-full', 'w-[76%]', 'w-[52%]']) assert.ok(html.includes(width))
assert.doesNotMatch(html, /animate-spin/)
assert.match(html, /motion-reduce:animate-none/)
})
}
}
test('AI answer forwards reveal timing and collapse settings to the same presentation component', () => {
const html = renderToStaticMarkup(messages.AiMessages({
dark: true, pending: false, collapseSignal: 2,
messages: [{ id: 'answer', role: 'assistant', content: '전체 답변입니다.', revealStartedAt: 1234, keepExpanded: true }]
}))
assert.match(html, /data-reveal-start="1234"/)
assert.match(html, /data-collapsed="false"/)
assert.match(html, /전체 답변/)
})
for (const dark of [false, true]) {
for (const [mode, expected] of [
['AiMessages', /AI에게 질문할<br\/>내용을 입력하세요/],
['AgentMessages', /현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요\.<br\/>변경 내용은 확인 후 직접 적용합니다\./],
['TeacherMessages', /강사에게 문의할<br\/>내용을 입력하세요/]
]) {
test(`empty ${mode} view uses the shared speech bubble (${dark ? 'dark' : 'light'})`, () => {
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, pending: false, messages: [] }))
assert.match(html, expected)
assert.match(html, /rounded-lg border px-8 py-5/)
assert.match(html, /aria-hidden="true"[^>]*rotate-45 border-b border-r/)
assert.doesNotMatch(html, /<button|role="status"/)
})
}
}
function chatHarness(revealInView) {
const states = []
const refs = []
let stateIndex = 0
let refIndex = 0
let waits = 0
const content = '첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n마지막 문단입니다.'
const { useQaChat } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useQaChat.ts'), 'utf8'), {
react: {
useEffect() {},
useState(initial) {
const index = stateIndex++
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial
return [states[index], value => { states[index] = typeof value === 'function' ? value(states[index]) : value }]
},
useRef(initial) { const index = refIndex++; return refs[index] ??= { current: initial } }
},
'socket.io-client': {}
}, {
URLSearchParams, AbortController, DOMException,
fetch: async () => ({ ok: true, json: async () => ({ threadId: 'thread', content, message: { id: 'answer', content, createdAt: '2026-10-19T03:00:00Z' } }) }),
window: { setTimeout(fn) { waits++; fn() } }
})
return {
render() {
stateIndex = 0; refIndex = 0
return useQaChat({ accessToken: 'test', courseId: 'course', weekNumber: 1, revealInView, getFreshAccessToken: async () => 'test', onTitlebarNotice() {} })
},
content, waits: () => waits
}
}
test('workspace AI stores the complete answer immediately and leaves animation to the view', async () => {
const harness = chatHarness(true)
await harness.render().sendQaMessage('조향 설정을 알려줘')
const state = harness.render()
const answer = state.visibleAiMessages.find(message => message.id === 'answer')
assert.equal(answer.content, harness.content)
assert.equal(typeof answer.revealStartedAt, 'number')
assert.equal(answer.keepExpanded, true)
assert.equal(state.aiPending, false)
assert.equal(state.aiTypingMessageId, null)
assert.equal(harness.waits(), 0)
})
test('the separate AI guide keeps its existing reveal mode unless explicitly opted in', async () => {
const harness = chatHarness(false)
await harness.render().sendQaMessage('질문')
const state = harness.render()
assert.equal(state.visibleAiMessages.find(message => message.id === 'answer').content, harness.content)
assert.ok(harness.waits() > 0)
assert.equal(state.aiTypingMessageId, null)
})
+37 -3
View File
@@ -25,7 +25,7 @@ function elements(root, predicate) {
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function setup(overrides = {}) {
function setup(overrides = {}, browser = {}) {
const state = []
const effects = []
let index = 0
@@ -34,6 +34,7 @@ function setup(overrides = {}) {
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
'../../i18n': i18n,
'../../components/AppStatusModals': { ConfirmStatusModal },
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
'../../components/ChatWidgets': { QaInputBox },
'react-dom': { createPortal: child => child },
react: {
@@ -45,7 +46,7 @@ function setup(overrides = {}) {
return [state[slot], value => { state[slot] = value }]
}
}
}, { document: { body: {} } }).default
}, { document: { body: {} }, window: { getSelection: () => null, ...browser } }).default
const props = {
dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }],
visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null },
@@ -82,13 +83,46 @@ test('context menu deletion also requests confirmation', () => {
const harness = setup()
const root = harness.render()
const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ target: { closest: () => null }, preventDefault() {}, clientX: 10, clientY: 10 })
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 })
const clear = elements(harness.render(), node => node.type === 'button' && node.props.children === '대화내역 삭제')[0]
clear.props.onClick()
assert.equal(harness.cleared(), 0)
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
})
for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
test(`native chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
let request
const node = {}
const harness = setup({}, {
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null,
chatTextMenu: { show: async options => { request = options; return null } }
})
const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0]
area.props.onContextMenu({
currentTarget: { contains: () => inside }, target: { closest: () => ({}) },
preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10
})
await Promise.resolve()
assert.equal(request.kind, 'message')
assert.equal(request.canCopy, selected && inside)
assert.equal(request.canClear, true)
assert.equal(harness.cleared(), 0)
})
}
test('native menu deletion still goes through the shared confirmation dialog', async () => {
const harness = setup({}, { chatTextMenu: { show: async () => 'clear' } })
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
await Promise.resolve()
assert.equal(harness.cleared(), 0)
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
assert.ok(modal)
modal.props.onConfirm()
assert.equal(harness.cleared(), 1)
})
for (const props of [{ inputBusy: true }, { qaMessagesLoading: true }, { visibleAiMessages: [] }]) {
test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => {
const harness = setup(props)
+145
View File
@@ -0,0 +1,145 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
function compile(file, overrides = {}, globals = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
function nativeMenu() {
let template
let close
let clipboard = ''
let copies = 0
let pastes = 0
const frame = {}
const sender = { mainFrame: frame, isDestroyed: () => false, copy() { copies++ }, paste() { pastes++ } }
const event = { sender, senderFrame: frame }
const { showChatTextMenu } = compile('../src/main/chatTextMenu.ts', {
electron: {
BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) },
clipboard: { readText: () => clipboard },
Menu: { buildFromTemplate(items) {
template = items
return { popup(options) { close = options.callback } }
} }
}
})
return {
show: options => showChatTextMenu(event, options),
items: () => template,
close: () => close(),
setClipboard: text => { clipboard = text },
copies: () => copies, pastes: () => pastes, event
}
}
const labels = { copy: '복사', paste: '붙여넣기', clear: '대화내역 삭제' }
for (const canCopy of [false, true]) {
test(`message copy is enabled only with a selection (selected=${canCopy})`, async () => {
const menu = nativeMenu()
const result = menu.show({ kind: 'message', canCopy, canClear: true, labels })
assert.equal(menu.items()[0].enabled, canCopy)
assert.equal(menu.items()[0].label, '복사')
assert.equal(menu.items().some(item => item.label === '붙여넣기'), false)
menu.items()[0].click()
assert.equal(await result, null)
assert.equal(menu.copies(), canCopy ? 1 : 0)
})
}
for (const clipboard of ['', '복사해 둔 내용']) {
test(`input paste availability is read from the OS clipboard (empty=${!clipboard})`, async () => {
const menu = nativeMenu()
menu.setClipboard(clipboard)
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
assert.equal(menu.items().length, 2)
assert.equal(menu.items()[0].enabled, false)
assert.equal(menu.items()[1].label, '붙여넣기')
assert.equal(menu.items()[1].enabled, Boolean(clipboard))
menu.items()[1].click()
await result
assert.equal(menu.pastes(), clipboard ? 1 : 0)
})
}
test('read-only input never allows paste, even with clipboard text', async () => {
const menu = nativeMenu()
menu.setClipboard('text')
const result = menu.show({ kind: 'input', canCopy: true, canPaste: false, labels })
assert.equal(menu.items()[1].enabled, false)
menu.items()[1].click()
await result
assert.equal(menu.pastes(), 0)
})
test('paste rechecks an emptied clipboard before editing the input', async () => {
const menu = nativeMenu()
menu.setClipboard('text')
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
assert.equal(menu.items()[1].enabled, true)
menu.setClipboard('')
menu.items()[1].click()
await result
assert.equal(menu.pastes(), 0)
})
test('delete selection returns a confirmation request rather than deleting data', async () => {
const menu = nativeMenu()
const result = menu.show({ kind: 'message', canCopy: false, canClear: true, labels })
menu.items().find(item => item.label === '대화내역 삭제').click()
assert.equal(await result, 'clear')
})
test('closing a menu without choosing an action does nothing', async () => {
const menu = nativeMenu()
const result = menu.show({ kind: 'message', canCopy: true, labels })
menu.close()
assert.equal(await result, null)
assert.equal(menu.copies(), 0)
})
test('untrusted frames and invalid menu targets cannot open a native menu', () => {
const menu = nativeMenu()
assert.equal(menu.show({ kind: 'unknown', canCopy: true, labels }), null)
menu.event.senderFrame = {}
assert.equal(menu.show({ kind: 'input', canCopy: true, labels }), null)
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
for (const selected of [false, true]) {
test(`textarea opens the menu with native selection and editability (selected=${selected})`, async () => {
let options
let prevented = false
let stopped = false
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {},
react: { ...React, useState: initial => [initial, () => {}], useEffect() {}, useRef: () => ({ current: null }) }
}, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
const textarea = elements(tree, node => node.type === 'textarea')[0]
textarea.props.onContextMenu({
currentTarget: { selectionStart: 2, selectionEnd: selected ? 5 : 2, disabled: false, readOnly: false },
preventDefault() { prevented = true }, stopPropagation() { stopped = true }
})
await Promise.resolve()
assert.equal(options.kind, 'input')
assert.equal(options.canCopy, selected)
assert.equal(options.canPaste, true)
assert.equal(options.labels.paste, '붙여넣기')
assert.equal(prevented, true)
assert.equal(stopped, true)
})
}
+111
View File
@@ -0,0 +1,111 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
const source = fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx'), 'utf8')
const output = {}
let videos = []
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, {
exports: output,
require: name => name === '../../i18n' ? { t: key => key, useLocaleRender() {} }
: name === 'react' ? { ...React, useId: () => 'guide' } : require(name),
URL,
window: { learningVideo: { open: async url => { videos.push(url) } } }
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function setup(overrides = {}) {
const toggled = []
const props = {
dark: true, workspaceActive: true, minimized: false, resourceMode: 'material',
materialItems: [{ title: 'SLAM', description: '센서 토픽과 TF 상태를 점검하고 지도를 생성합니다.', concepts: ['TF 점검', '지도 저장'], videoUrl: '' }],
activeMaterialIndex: 0, expandedMaterialIndexes: [0], topics: [], topicDetails: [], overview: '',
resources: [], downloadingResourceIds: [], onToggleMaterial: index => toggled.push(index), ...overrides
}
const tree = output.default(props)
const headers = elements(tree, node => node.type === 'button' && node.props['aria-controls'])
return { tree, headers, toggled }
}
for (const dark of [false, true]) {
test(`only the material header toggles the card (${dark ? 'dark' : 'light'})`, () => {
const { tree, headers, toggled } = setup({ dark })
assert.equal(headers.length, 1)
const header = headers[0]
assert.equal(header.props.type, 'button')
assert.equal(header.props['aria-expanded'], true)
assert.equal(header.props.disabled, false)
assert.doesNotMatch(header.props.className, /hover:bg/)
assert.match(header.props.className, /focus-visible:ring-2/)
const card = elements(tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
assert.ok(card)
assert.equal(card.props.onClick, undefined)
assert.equal(card.props.onKeyDown, undefined)
assert.equal(card.props.role, undefined)
assert.equal(card.props.tabIndex, undefined)
header.props.onClick()
assert.deepEqual(toggled, [0])
})
test(`description is selectable outside the interactive header (${dark ? 'dark' : 'light'})`, () => {
const { tree, headers, toggled } = setup({ dark })
const contentId = headers[0].props['aria-controls']
const body = elements(tree, node => node.props.id === contentId)[0]
assert.match(body.props.className, /select-text/)
assert.equal(body.props.onClick, undefined)
assert.equal(elements(headers[0], node => node.props.id === contentId).length, 0)
assert.ok(renderToStaticMarkup(body).includes('센서 토픽과 TF 상태'))
assert.equal(elements(body, node => Boolean(node.props.onClick || node.props.onKeyDown)).length, 0)
assert.deepEqual(toggled, [])
})
test(`header padding and dimensions stay identical when toggled (${dark ? 'dark' : 'light'})`, () => {
const expanded = setup({ dark, expandedMaterialIndexes: [0] })
const collapsed = setup({ dark, expandedMaterialIndexes: [] })
for (const state of [expanded, collapsed]) {
const header = state.headers[0]
const card = elements(state.tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
assert.match(card.props.className, /\bpx-3 pt-3\b/)
assert.doesNotMatch(card.props.className, /px-2\.5|pt-2\.5/)
assert.match(header.props.className, /\bflex h-6 w-full\b/)
}
assert.equal(expanded.headers[0].props.className, collapsed.headers[0].props.className)
})
}
test('future material remains disabled, while previous/current steps keep native keyboard buttons', () => {
const items = ['이전', '현재', '다음'].map(title => ({ title, description: '', videoUrl: '' }))
const { headers } = setup({ materialItems: items, activeMaterialIndex: 1, expandedMaterialIndexes: [1] })
assert.deepEqual(headers.map(header => header.props.disabled), [false, false, true])
assert.equal(headers[2].props.onClick, undefined)
for (const header of headers) {
assert.equal(header.props.type, 'button')
assert.equal(header.props.onKeyDown, undefined)
}
assert.ok(setup({ materialItems: items, allowFutureMaterialSteps: true }).headers.every(header => !header.props.disabled))
})
test('video playback is not nested in the collapse button and does not toggle the card', async () => {
videos = []
const { tree, headers, toggled } = setup({ materialItems: [{ title: 'SLAM', description: '설명', videoUrl: 'https://www.youtube.com/watch?v=test123' }] })
const video = elements(tree, node => node.type === 'button' && node.props.className?.includes('aspect-video'))[0]
assert.ok(video)
assert.equal(elements(headers[0], node => node === video).length, 0)
video.props.onClick({ stopPropagation() {} })
await Promise.resolve()
assert.deepEqual(videos, ['https://www.youtube.com/embed/test123'])
assert.deepEqual(toggled, [])
for (const button of elements(tree, node => node.type === 'button')) {
assert.equal(React.Children.toArray(button.props.children).flatMap(child => elements(child, node => node.type === 'button')).length, 0)
}
})