Compare commits
77 Commits
9a504013c6
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 017093077f | |||
| af6dc560ea | |||
| 065de83ffd | |||
| a8dd23675a | |||
| cb5872c326 | |||
| af54e875df | |||
| c17763f59d | |||
| 00a83ce4ac | |||
| b7870cffa2 | |||
| 8b501266ff | |||
| af21fa0510 | |||
| 7cfd466350 | |||
| 4321d944b5 | |||
| 234c9ad467 | |||
| 6512f5fa04 | |||
| 7b32668e1d | |||
| 105342fbf3 | |||
| cc589fd8cf | |||
| 098af30fa7 | |||
| 280b000496 | |||
| 4bdc5af620 | |||
| ef260c8fb4 | |||
| 1c0d861051 | |||
| 832420f8f9 | |||
| 53518d1114 | |||
| 654fc722f9 | |||
| cf8d81348d | |||
| db9201fd05 | |||
| c9cceb4c01 | |||
| debb38a29b | |||
| b0a4c2b1e6 | |||
| 08f74575c2 | |||
| 9387ade537 | |||
| 6b4ab4365e | |||
| a7fe81e658 | |||
| fb8c15dfd3 | |||
| 0a15102f8c | |||
| d44180db67 | |||
| 4125f71792 | |||
| 656b1bf62b | |||
| a7c9cdc16e | |||
| 11ba2bf8b9 | |||
| 36806e4dc1 | |||
| 33aac68f16 | |||
| 1d959e2c51 | |||
| 76a87fc20d | |||
| 978b782606 | |||
| 54ef5836b1 | |||
| 104c083995 | |||
| 189ca80343 | |||
| 5aef38a99d | |||
| bfb3cbaf9b | |||
| 01017f03fe | |||
| cb1106f319 | |||
| b051ac75cf | |||
| 342fcaeb34 | |||
| fdffd4617a | |||
| e1d7b7d532 | |||
| f29b44da07 | |||
| 99f0451412 | |||
| 56db72a054 | |||
| e07b4fe359 | |||
| 744ce37d6d | |||
| 54d3eb7014 | |||
| c010d0c913 | |||
| f6be625a8f | |||
| a88f0920cf | |||
| c46c8df475 | |||
| 755546713c | |||
| 2ab7e279eb | |||
| 9cbf4ac7e9 | |||
| c7de438d57 | |||
| 8cc0e46422 | |||
| 951d07cf31 | |||
| 97d6a0219f | |||
| d560ce6f82 | |||
| 4b181e5ad3 |
@@ -4,9 +4,9 @@ VITE_LAUNCHER_API_BASE_URL=http://localhost:4200/api
|
|||||||
# Electron main process runtime overrides
|
# Electron main process runtime overrides
|
||||||
LOCAL_AUTOWARE_BRIDGE_IMAGE=autoware-ros-bridge
|
LOCAL_AUTOWARE_BRIDGE_IMAGE=autoware-ros-bridge
|
||||||
LOCAL_NAV2_BRIDGE_IMAGE=nav2-ros-bridge
|
LOCAL_NAV2_BRIDGE_IMAGE=nav2-ros-bridge
|
||||||
LOCAL_NAV2_RUNTIME_IMAGE=nav2-slam:humble
|
LOCAL_NAV2_RUNTIME_IMAGE=future-nav2-gazebo:humble
|
||||||
LOCAL_SLAM_BRIDGE_IMAGE=slam-ros-bridge
|
LOCAL_SLAM_BRIDGE_IMAGE=slam-ros-bridge
|
||||||
LOCAL_SLAM_RUNTIME_IMAGE=nav2-slam:humble
|
LOCAL_SLAM_RUNTIME_IMAGE=future-nav2-gazebo:humble
|
||||||
|
|
||||||
# Remote Jetson runtime overrides
|
# Remote Jetson runtime overrides
|
||||||
JETSON_AGENT_URL=http://100.64.0.2:8787
|
JETSON_AGENT_URL=http://100.64.0.2:8787
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ out
|
|||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,3 +1,128 @@
|
|||||||
# lms-fe
|
# Autonomy Studio LMS
|
||||||
|
|
||||||
lms 프론트엔드
|
Autonomy Studio LMS의 학생용 데스크톱 애플리케이션입니다. Electron 기반으로 동작하며, 학습 워크스페이스에서 ROS 2/NAV2 모듈 설치와 실행, 코드 편집, Git 연동, 터미널, 맵 및 시각화를 제공합니다.
|
||||||
|
|
||||||
|
로컬 PC 시뮬레이션과 원격 플랫폼 워크스페이스를 모두 지원합니다.
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- 강좌, 학습 단계, 모듈 기반 워크스페이스 관리
|
||||||
|
- 로컬 및 원격 플랫폼의 모듈 설치, 빌드, 실행, 종료
|
||||||
|
- 코드 편집기, Git 소스 제어, 내장 터미널
|
||||||
|
- ROS 토픽 연결 상태와 모듈 간 발행/구독 관계 확인
|
||||||
|
- 2D/3D SLAM 맵 저장 및 주행 시각화
|
||||||
|
- Docker 기반 ROS 런타임과 SLAM ROS 브리지 관리
|
||||||
|
- 원격 플랫폼 에이전트와 연동한 워크스페이스 및 교보재 제어
|
||||||
|
|
||||||
|
## 프로젝트 구조
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
main/ Electron main process, Docker/워크스페이스/원격 제어
|
||||||
|
preload/ renderer에 노출하는 IPC API
|
||||||
|
renderer/src/ React 사용자 인터페이스
|
||||||
|
resources/
|
||||||
|
slam-ros-bridge/ SLAM 및 시각화용 ROS 브리지
|
||||||
|
bin/ 패키징에 포함되는 실행 파일
|
||||||
|
licenses/ 배포 라이선스
|
||||||
|
```
|
||||||
|
|
||||||
|
## 요구 사항
|
||||||
|
|
||||||
|
- Node.js와 npm
|
||||||
|
- Docker Engine
|
||||||
|
- Git
|
||||||
|
- Linux AppImage 패키징 시 필요한 시스템 의존성
|
||||||
|
|
||||||
|
GPU 가속을 사용할 경우에는 NVIDIA 드라이버 외에 Docker용 NVIDIA Container Toolkit이 필요합니다. GPU 가속은 Docker 컨테이너에 `--gpus all` 옵션을 전달하므로, Docker 런타임이 GPU를 인식하지 못하면 모듈 실행이 실패합니다.
|
||||||
|
|
||||||
|
## 개발 환경 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
renderer 개발 서버는 기본적으로 `5176` 포트를 사용합니다.
|
||||||
|
|
||||||
|
## 환경 변수
|
||||||
|
|
||||||
|
`.env.example`을 복사해 로컬 환경에 맞게 설정합니다.
|
||||||
|
|
||||||
|
| 변수 | 설명 |
|
||||||
|
| --- | --- |
|
||||||
|
| `VITE_API_BASE_URL` | LMS 백엔드 API 주소 |
|
||||||
|
| `VITE_LAUNCHER_API_BASE_URL` | 런처/플랫폼 API 주소 |
|
||||||
|
| `LOCAL_AUTOWARE_BRIDGE_IMAGE` | 로컬 Autoware ROS 브리지 이미지 |
|
||||||
|
| `LOCAL_NAV2_BRIDGE_IMAGE` | 로컬 NAV2 ROS 브리지 이미지 |
|
||||||
|
| `LOCAL_NAV2_RUNTIME_IMAGE` | 로컬 NAV2 런타임 이미지 |
|
||||||
|
| `LOCAL_SLAM_BRIDGE_IMAGE` | 로컬 SLAM ROS 브리지 이미지 |
|
||||||
|
| `LOCAL_SLAM_RUNTIME_IMAGE` | 로컬 SLAM 런타임 이미지 |
|
||||||
|
| `JETSON_AGENT_URL` | 원격 Jetson 에이전트 주소 |
|
||||||
|
| `JETSON_AGENT_TOKEN` | 원격 Jetson 에이전트 인증 토큰 |
|
||||||
|
| `JETSON_AUTOWARE_BRIDGE_IMAGE` | 원격 Autoware ROS 브리지 이미지 |
|
||||||
|
| `JETSON_NAV2_BRIDGE_IMAGE` | 원격 NAV2 ROS 브리지 이미지 |
|
||||||
|
| `JETSON_NAV2_RUNTIME_IMAGE` | 원격 NAV2 런타임 이미지 |
|
||||||
|
|
||||||
|
`VITE_` 접두사 변수는 renderer 번들에 포함됩니다. 운영 AppImage를 만들 때는 운영 API 주소로 빌드해야 하며, 토큰이나 비밀번호 같은 민감한 값은 `VITE_` 변수에 넣으면 안 됩니다.
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_API_BASE_URL=https://workspace.autonomystudio.net/lms-api/api \
|
||||||
|
VITE_LAUNCHER_API_BASE_URL=https://portal-api.autonomystudio.net/api \
|
||||||
|
npm run build:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
## 빌드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Electron main, preload, renderer 빌드
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Linux AppImage 생성
|
||||||
|
npm run build:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
패키징 결과물은 `dist/`에 생성됩니다. AppImage에는 아이콘, SLAM ROS 브리지, 실행 도구, 라이선스 리소스가 함께 포함됩니다.
|
||||||
|
|
||||||
|
운영 배포용 AppImage는 관리자 웹의 빌드 변수와 동일한 API 주소, 동일한 소스 커밋을 기준으로 빌드해야 합니다. Kubernetes의 LMS 웹 배포 환경 변수는 Electron AppImage에 런타임으로 주입되지 않습니다.
|
||||||
|
|
||||||
|
## 워크스페이스와 맵
|
||||||
|
|
||||||
|
모듈, 빌드 결과물, 저장 맵은 선택한 사용자 워크스페이스 루트 아래에 관리됩니다. SLAM 맵은 기본적으로 다음 경로에 저장됩니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
<workspace>/maps
|
||||||
|
```
|
||||||
|
|
||||||
|
앱은 해당 워크스페이스를 Docker 컨테이너의 `/workspace`로 마운트하고, SLAM 브리지에는 `SAVED_MAPS_PATH=/workspace/maps`를 전달합니다.
|
||||||
|
|
||||||
|
## GPU 가속
|
||||||
|
|
||||||
|
GPU 가속을 켠 상태에서 다음과 비슷한 오류가 발생하면 Docker GPU 런타임이 설정되지 않은 상태입니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
failed to discover GPU vendor from CDI
|
||||||
|
```
|
||||||
|
|
||||||
|
NVIDIA Container Toolkit을 설치하고 Docker를 재시작한 뒤 아래 명령이 정상 동작하는지 확인합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
GPU 설정이 준비되지 않은 PC에서는 앱 설정에서 GPU 가속을 끈 상태로 사용합니다.
|
||||||
|
|
||||||
|
## 로그와 문제 확인
|
||||||
|
|
||||||
|
- 앱 renderer 로그는 Electron의 사용자 로그 경로에 `renderer.log`로 기록됩니다.
|
||||||
|
- 모듈 실행 실패는 워크스페이스 화면의 모듈 로그와 Docker 컨테이너 로그를 함께 확인합니다.
|
||||||
|
- 원격 플랫폼 문제는 에이전트 상태, Tailscale/Headscale 연결, ROS Domain ID, 원격 Docker 이미지를 순서대로 점검합니다.
|
||||||
|
|
||||||
|
## 보안 유의 사항
|
||||||
|
|
||||||
|
- `.env`는 저장소에 커밋하지 않습니다.
|
||||||
|
- 에이전트 토큰, API 키, 인증 정보는 빌드 변수 또는 별도 비밀 저장소로 관리합니다.
|
||||||
|
- 운영 배포 전에는 API 주소와 원격 런타임 이미지 태그가 대상 환경과 일치하는지 확인합니다.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Korean and English UI
|
||||||
|
|
||||||
|
Use the language selector at the top of the studio settings screen (local or
|
||||||
|
remote workspace). Select a language and press Save to apply it. Unsaved choices
|
||||||
|
do not change the UI or stored preference. Korean is the default. The choice is
|
||||||
|
stored under `autonomy-studio.lms.language` in local storage and applies on the
|
||||||
|
next launch. Switching language updates React subscribers without remounting the
|
||||||
|
workspace, terminal, editor, or visualization iframe.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- App navigation, curriculum UI, studio controls, settings, dialogs and help.
|
||||||
|
- Known application status/error messages and NAV2/SLAM viewer controls.
|
||||||
|
- Course titles, learning content, materials, module names, source code, terminal
|
||||||
|
output and server-authored notifications remain in their original language.
|
||||||
|
- Unrecognized backend/SSH/Docker errors are preserved verbatim for diagnosis.
|
||||||
|
- Embedded text in guide images and external pages is not translated.
|
||||||
|
- This does not change ROS, Docker, SSH, the OS locale, or module parameters.
|
||||||
|
|
||||||
|
## Adding Text
|
||||||
|
|
||||||
|
Use `t('Korean source text')` from `src/renderer/src/i18n` at the display boundary
|
||||||
|
and add the English value to `en.json`. Call `useLocaleRender()` in components
|
||||||
|
which render translations so an open screen reacts to language changes.
|
||||||
|
|
||||||
|
Keep identifiers and state values unchanged. Do not translate stored form values,
|
||||||
|
parameter strings, module identifiers, or values used in conditions. Use whole
|
||||||
|
sentences with named interpolation placeholders instead of joining translated
|
||||||
|
fragments. Korean falls back to the source key; unknown messages retain the source.
|
||||||
|
|
||||||
|
The standalone SLAM viewer uses `public/slam-viewer/locale.js` and explicitly
|
||||||
|
marked text/attributes. It follows the same saved preference through storage
|
||||||
|
events, without reconnecting the bridge or resetting the scene.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:i18n
|
||||||
|
npx tsc --noEmit -p tsconfig.web.json
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests cover language persistence, storage failure, cross-window updates,
|
||||||
|
interpolation, source fallback and literal translation-key coverage. Browser
|
||||||
|
checks should additionally cover both languages, narrow studio panels, preserving
|
||||||
|
unsaved edits, and language changes with the viewer open. `npm run build` does not
|
||||||
|
package an AppImage.
|
||||||
@@ -4,6 +4,12 @@ import react from '@vitejs/plugin-react';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/main/bootstrap.ts') },
|
||||||
|
output: { inlineDynamicImports: false, chunkFileNames: '[name]-[hash].js' }
|
||||||
|
}
|
||||||
|
},
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/main/bootstrap.ts') },
|
||||||
|
output: { inlineDynamicImports: false, chunkFileNames: '[name]-[hash].js' }
|
||||||
|
}
|
||||||
|
},
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
|
|||||||
@@ -1,25 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.0.19",
|
"version": "1.2.25",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.0.19",
|
"version": "1.2.25",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@types/three": "^0.181.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
|
"i18next": "^26.4.2",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
"react-i18next": "^17.0.15",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"socket.io-client": "^4.8.3"
|
"remark-parse": "11.0.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
|
"three": "^0.181.2",
|
||||||
|
"unified": "11.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
@@ -286,6 +294,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -352,6 +369,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@electron-toolkit/preload": {
|
"node_modules/@electron-toolkit/preload": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@electron-toolkit/preload/-/preload-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@electron-toolkit/preload/-/preload-3.0.2.tgz",
|
||||||
@@ -1427,6 +1450,15 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mkkellogg/gaussian-splats-3d": {
|
||||||
|
"version": "0.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mkkellogg/gaussian-splats-3d/-/gaussian-splats-3d-0.4.7.tgz",
|
||||||
|
"integrity": "sha512-0vy9/i9sJLFH/v3WJZ4axCsqjkToe8UsV3xY7bvK5EUC0akiRsWZODoCiSzpxhTLNyzSKTsyQKozIFeNA5RWRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": ">=0.160.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@monaco-editor/loader": {
|
"node_modules/@monaco-editor/loader": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||||
@@ -2248,6 +2280,12 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -2427,6 +2465,27 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.181.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz",
|
||||||
|
"integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": "*",
|
||||||
|
"@webgpu/types": "*",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~0.22.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -2448,6 +2507,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yauzl": {
|
"node_modules/@types/yauzl": {
|
||||||
"version": "2.10.3",
|
"version": "2.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||||
@@ -2485,6 +2550,12 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@webgpu/types": {
|
||||||
|
"version": "0.1.71",
|
||||||
|
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
|
||||||
|
"integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/@xmldom/xmldom": {
|
"node_modules/@xmldom/xmldom": {
|
||||||
"version": "0.9.10",
|
"version": "0.9.10",
|
||||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
||||||
@@ -4666,6 +4737,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/filelist": {
|
"node_modules/filelist": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||||
@@ -5173,6 +5250,15 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/highlight.js": {
|
||||||
|
"version": "11.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz",
|
||||||
|
"integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hosted-git-info": {
|
"node_modules/hosted-git-info": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||||
@@ -5206,6 +5292,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://locize.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-url-attributes": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
@@ -5273,6 +5368,34 @@
|
|||||||
"ms": "^2.0.0"
|
"ms": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "26.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||||
|
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com/i18next"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-corefoundation": {
|
"node_modules/iconv-corefoundation": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
||||||
@@ -6065,6 +6188,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lowlight": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"highlight.js": "~11.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -6501,6 +6639,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "0.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||||
|
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromark": {
|
"node_modules/micromark": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||||
@@ -7864,6 +8008,33 @@
|
|||||||
"react": "^19.2.6"
|
"react": "^19.2.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "17.0.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.15.tgz",
|
||||||
|
"integrity": "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.7",
|
||||||
|
"html-parse-stringify": "^4.0.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 26.2.0",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-markdown": {
|
"node_modules/react-markdown": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||||
@@ -8763,6 +8934,12 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.181.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz",
|
||||||
|
"integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -8848,7 +9025,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -9027,6 +9204,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/utf8-byte-length": {
|
"node_modules/utf8-byte-length": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.0.23",
|
"version": "1.2.25",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
"email": "hooladhoon@gmail.com"
|
"email": "hooladhoon@gmail.com"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:i18n": "node --test scripts/test-i18n.cjs",
|
||||||
|
"test:theme": "node --test scripts/test-studio-theme.cjs",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"build:linux": "npm run build && electron-builder --linux"
|
"build:linux": "npm run build && electron-builder --linux"
|
||||||
@@ -18,6 +20,10 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"out/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "resources/icon.png",
|
"from": "resources/icon.png",
|
||||||
@@ -45,16 +51,24 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@types/three": "^0.181.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
|
"i18next": "^26.4.2",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
"react-i18next": "^17.0.15",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"socket.io-client": "^4.8.3"
|
"remark-parse": "11.0.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
|
"three": "^0.181.2",
|
||||||
|
"unified": "11.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
|
|||||||
@@ -209,6 +209,9 @@ class SubscriberNode(Node):
|
|||||||
self._trajectory_event = threading.Event()
|
self._trajectory_event = threading.Event()
|
||||||
self._behavior_path = None
|
self._behavior_path = None
|
||||||
self._behavior_path_event = threading.Event()
|
self._behavior_path_event = threading.Event()
|
||||||
|
self._nav2_plan = None
|
||||||
|
self._nav2_plan_revision = 0
|
||||||
|
self._nav2_plan_lock = threading.Lock()
|
||||||
if _ROBOT_STACK == "autoware" and HAS_PLANNING:
|
if _ROBOT_STACK == "autoware" and HAS_PLANNING:
|
||||||
self.create_subscription(
|
self.create_subscription(
|
||||||
Trajectory,
|
Trajectory,
|
||||||
@@ -350,11 +353,20 @@ class SubscriberNode(Node):
|
|||||||
|
|
||||||
def _on_nav2_plan(self, msg: NavPath):
|
def _on_nav2_plan(self, msg: NavPath):
|
||||||
if len(msg.poses) > 0:
|
if len(msg.poses) > 0:
|
||||||
|
with self._nav2_plan_lock:
|
||||||
|
self._nav2_plan = msg
|
||||||
|
self._nav2_plan_revision += 1
|
||||||
self._behavior_path = msg
|
self._behavior_path = msg
|
||||||
self._trajectory = msg
|
self._trajectory = msg
|
||||||
self._behavior_path_event.set()
|
self._behavior_path_event.set()
|
||||||
self._trajectory_event.set()
|
self._trajectory_event.set()
|
||||||
|
|
||||||
|
def get_nav2_plan_snapshot(self):
|
||||||
|
with self._nav2_plan_lock:
|
||||||
|
if self._nav2_plan is None:
|
||||||
|
return self._nav2_plan_revision, []
|
||||||
|
return self._nav2_plan_revision, self._nav_path_to_points(self._nav2_plan)
|
||||||
|
|
||||||
def _on_kinematic_state(self, msg: Odometry):
|
def _on_kinematic_state(self, msg: Odometry):
|
||||||
with self._kinematic_lock:
|
with self._kinematic_lock:
|
||||||
self._kinematic_state = msg
|
self._kinematic_state = msg
|
||||||
@@ -1104,6 +1116,36 @@ async def ws_map(websocket: WebSocket, stack: Optional[str] = None):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/nav2")
|
||||||
|
async def ws_nav2(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
if _ROBOT_STACK != "nav2":
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "stack_mismatch",
|
||||||
|
"requested": "nav2",
|
||||||
|
"active": _ROBOT_STACK,
|
||||||
|
"message": "Restart bridge with ROBOT_STACK=nav2 to stream Nav2 paths",
|
||||||
|
})
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
last_revision = -1
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if sub_node:
|
||||||
|
revision, poses = sub_node.get_nav2_plan_snapshot()
|
||||||
|
if poses and revision != last_revision:
|
||||||
|
last_revision = revision
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "nav2_path",
|
||||||
|
"revision": revision,
|
||||||
|
"poses": poses,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
except (WebSocketDisconnect, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@app.post("/set_initial_pose")
|
@app.post("/set_initial_pose")
|
||||||
def set_initial_pose(payload: PosePayload):
|
def set_initial_pose(payload: PosePayload):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM ros:humble-ros-base
|
FROM future-nav2-gazebo:humble
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -6,8 +6,8 @@ COPY requirements.txt /app/requirements.txt
|
|||||||
COPY bridge.py /app/bridge.py
|
COPY bridge.py /app/bridge.py
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends python3-pip ros-humble-nav2-msgs && \
|
apt-get install -y --no-install-recommends python3-pip && \
|
||||||
pip3 install --no-cache-dir -r /app/requirements.txt && \
|
pip3 install --no-cache-dir --retries 5 --timeout 180 -r /app/requirements.txt && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
CMD bash -c "source /opt/ros/humble/setup.bash && python3 /app/bridge.py"
|
CMD bash -c "source /opt/ros/humble/setup.bash && python3 /app/bridge.py"
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Optional
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class CameraTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.now = 10.0
|
||||||
|
source = ast.parse(Path(__file__).with_name('bridge.py').read_text())
|
||||||
|
node = next(item for item in source.body if isinstance(item, ast.ClassDef) and item.name == 'SlamNode')
|
||||||
|
names = ('_discover_cameras', '_on_camera_image', 'camera_sources', 'camera_frame_jpeg')
|
||||||
|
methods = [item for item in node.body if isinstance(item, ast.FunctionDef) and item.name in names]
|
||||||
|
scope = {'re': re, 'Optional': Optional, 'CompressedImage': object,
|
||||||
|
'time': SimpleNamespace(time=lambda: self.now), '_CAMERA_FRAME_MAX_AGE_SECONDS': 3,
|
||||||
|
'qos_profile_sensor_data': object()}
|
||||||
|
exec(compile(ast.Module(body=methods, type_ignores=[]), 'camera-bridge', 'exec'), scope)
|
||||||
|
self.node = type('CameraNode', (), {name: scope[name] for name in names})()
|
||||||
|
self.node._lock = threading.Lock()
|
||||||
|
self.node._camera_frames = {}
|
||||||
|
self.node._camera_subscriptions = {}
|
||||||
|
self.node._camera_image = None
|
||||||
|
self.node._last_camera_image_wall_time = None
|
||||||
|
self.node._camera_image_count = 0
|
||||||
|
self.node.camera_image_topic = '/camera_test/image_jpeg'
|
||||||
|
self.topics = []
|
||||||
|
self.callbacks = {}
|
||||||
|
self.node.get_topic_names_and_types = lambda: self.topics
|
||||||
|
def subscribe(kind, topic, callback, qos):
|
||||||
|
self.callbacks[topic] = callback
|
||||||
|
return topic
|
||||||
|
self.node.create_subscription = subscribe
|
||||||
|
self.node.destroy_subscription = lambda sub: self.callbacks.pop(sub)
|
||||||
|
|
||||||
|
def test_independent_frames_and_disconnect(self):
|
||||||
|
for key in ['video0', 'video2']:
|
||||||
|
self.topics.append((f'/camera_test/cameras/{key}/image_jpeg', ['sensor_msgs/msg/CompressedImage']))
|
||||||
|
self.topics += [('/other/image_jpeg', ['sensor_msgs/msg/CompressedImage']),
|
||||||
|
('/camera_test/cameras/video9/image_jpeg', ['sensor_msgs/msg/Image'])]
|
||||||
|
self.node._discover_cameras()
|
||||||
|
self.assertEqual(len(self.callbacks), 2)
|
||||||
|
for topic, callback in self.callbacks.items():
|
||||||
|
callback(SimpleNamespace(data=topic.encode(), format='jpeg'))
|
||||||
|
self.assertEqual([item['id'] for item in self.node.camera_sources()], ['video0', 'video2'])
|
||||||
|
self.assertIn(b'video2', self.node.camera_frame_jpeg('video2'))
|
||||||
|
self.assertIsNone(self.node.camera_frame_jpeg('../video0'))
|
||||||
|
self.now += 4
|
||||||
|
self.assertTrue(all(not item['available'] for item in self.node.camera_sources()))
|
||||||
|
self.assertIsNone(self.node.camera_frame_jpeg('video0'))
|
||||||
|
self.topics = self.topics[:1]
|
||||||
|
self.node._discover_cameras()
|
||||||
|
self.assertNotIn('video2', self.node._camera_frames)
|
||||||
|
|
||||||
|
def test_single_camera_compatibility_and_non_jpeg(self):
|
||||||
|
self.node._on_camera_image(SimpleNamespace(data=b'jpeg', format='jpeg'))
|
||||||
|
self.assertEqual(self.node.camera_frame_jpeg(), b'jpeg')
|
||||||
|
self.assertEqual(self.node.camera_frame_jpeg('legacy'), b'jpeg')
|
||||||
|
self.node._on_camera_image(SimpleNamespace(data=b'png', format='png'))
|
||||||
|
self.assertIsNone(self.node.camera_frame_jpeg('legacy'))
|
||||||
|
self.assertFalse(self.node.camera_sources()[0]['available'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import ast
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class Twist:
|
||||||
|
def __init__(self):
|
||||||
|
self.linear = SimpleNamespace(x=0.0)
|
||||||
|
self.angular = SimpleNamespace(z=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TeleopTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.now = 10.0
|
||||||
|
self.messages = []
|
||||||
|
source = ast.parse(Path(__file__).with_name('bridge.py').read_text())
|
||||||
|
node = next(item for item in source.body if isinstance(item, ast.ClassDef) and item.name == 'SlamNode')
|
||||||
|
methods = [item for item in node.body if isinstance(item, ast.FunctionDef)
|
||||||
|
and item.name in ('publish_cmd_vel', '_check_teleop_timeout')]
|
||||||
|
scope = {'math': math, 'Twist': Twist, 'time': SimpleNamespace(monotonic=lambda: self.now)}
|
||||||
|
exec(compile(ast.Module(body=methods, type_ignores=[]), 'bridge-teleop', 'exec'), scope)
|
||||||
|
cls = type('Teleop', (), {name: scope[name] for name in ('publish_cmd_vel', '_check_teleop_timeout')})
|
||||||
|
self.node = cls()
|
||||||
|
self.node.read_only = False
|
||||||
|
self.node._teleop_lock = threading.Lock()
|
||||||
|
self.node._teleop_deadline = None
|
||||||
|
self.node._cmd_vel_pub = SimpleNamespace(publish=lambda msg: self.messages.append((msg.linear.x, msg.angular.z)))
|
||||||
|
|
||||||
|
def test_low_speed_and_expiry(self):
|
||||||
|
self.node.publish_cmd_vel(5.0, -5.0, True)
|
||||||
|
self.assertEqual(self.messages[-1], (0.1, -0.5))
|
||||||
|
self.now += 0.6
|
||||||
|
self.node._check_teleop_timeout()
|
||||||
|
self.assertEqual(self.messages[-1], (0.0, 0.0))
|
||||||
|
count = len(self.messages)
|
||||||
|
self.node._check_teleop_timeout()
|
||||||
|
self.assertEqual(len(self.messages), count)
|
||||||
|
|
||||||
|
def test_heartbeat_and_release(self):
|
||||||
|
self.node.publish_cmd_vel(0.1, 0, True)
|
||||||
|
self.now += 0.4
|
||||||
|
self.node.publish_cmd_vel(0.1, 0, True)
|
||||||
|
self.now += 0.2
|
||||||
|
self.node._check_teleop_timeout()
|
||||||
|
self.assertEqual(len(self.messages), 2)
|
||||||
|
self.node.publish_cmd_vel(0, 0, True)
|
||||||
|
self.assertIsNone(self.node._teleop_deadline)
|
||||||
|
|
||||||
|
def test_simulation_and_idle_navigation_unchanged(self):
|
||||||
|
self.node._check_teleop_timeout()
|
||||||
|
self.assertEqual(self.messages, [])
|
||||||
|
self.node.publish_cmd_vel(0.22, 2.84)
|
||||||
|
self.now += 2
|
||||||
|
self.node._check_teleop_timeout()
|
||||||
|
self.assertEqual(self.messages, [(0.22, 2.84)])
|
||||||
|
|
||||||
|
def test_invalid_and_readonly_rejected(self):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.node.publish_cmd_vel(float('nan'), 0, True)
|
||||||
|
self.node.read_only = True
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.node.publish_cmd_vel(0.1, 0, True)
|
||||||
|
self.assertEqual(self.messages, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Regenerate font coverage after replacing the bundled NanumSquare WOFF2 files.
|
||||||
|
|
||||||
|
Requires fonttools[woff]. Normal app builds use the checked-in CSS, not Python.
|
||||||
|
Run: python3 scripts/generate-nanumsquare-css.py [--check]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fontTools.pens.boundsPen import BoundsPen
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
FONTS = ROOT / "src/renderer/public/assets/fonts"
|
||||||
|
OUTPUT = ROOT / "src/renderer/src/styles/nanumSquare.css"
|
||||||
|
FACES = [(300, "NanumSquareL"), (400, "NanumSquare"), (700, "NanumSquareB"), (800, "NanumSquareEB")]
|
||||||
|
# Hangul fillers are intentionally invisible, like whitespace and controls.
|
||||||
|
FILLERS = {0x115F, 0x1160, 0x3164, 0xFFA0}
|
||||||
|
|
||||||
|
|
||||||
|
def coverage(font):
|
||||||
|
glyphs = font.getGlyphSet()
|
||||||
|
supported = []
|
||||||
|
for code, name in sorted(font.getBestCmap().items()):
|
||||||
|
pen = BoundsPen(glyphs)
|
||||||
|
glyphs[name].draw(pen)
|
||||||
|
intentional = chr(code).isspace() or unicodedata.category(chr(code)) in {"Cc", "Cf"} or code in FILLERS
|
||||||
|
if pen.bounds is not None or intentional:
|
||||||
|
supported.append(code)
|
||||||
|
return supported
|
||||||
|
|
||||||
|
|
||||||
|
def unicode_ranges(codes):
|
||||||
|
ranges = []
|
||||||
|
start = end = codes[0]
|
||||||
|
for code in codes[1:]:
|
||||||
|
if code == end + 1:
|
||||||
|
end = code
|
||||||
|
else:
|
||||||
|
ranges.append((start, end))
|
||||||
|
start = end = code
|
||||||
|
ranges.append((start, end))
|
||||||
|
return [f"U+{start:X}" if start == end else f"U+{start:X}-{end:X}" for start, end in ranges]
|
||||||
|
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
sections = [
|
||||||
|
"/* Generated by scripts/generate-nanumsquare-css.py. Do not edit manually.\n"
|
||||||
|
" * Empty glyphs are excluded so the existing system-font fallback can render them.\n"
|
||||||
|
" * Font binaries and visible glyphs are unchanged.\n */"
|
||||||
|
]
|
||||||
|
for weight, name in FACES:
|
||||||
|
file = FONTS / f"{name}.woff2"
|
||||||
|
with TTFont(file) as font:
|
||||||
|
ranges = unicode_ranges(coverage(font))
|
||||||
|
lines = [", ".join(ranges[index:index + 10]) for index in range(0, len(ranges), 10)]
|
||||||
|
sections.append(
|
||||||
|
f"/* {file.name} sha256: {hashlib.sha256(file.read_bytes()).hexdigest()} */\n"
|
||||||
|
"@font-face {\n"
|
||||||
|
" font-family: 'NanumSquare';\n"
|
||||||
|
f" src: url('/assets/fonts/{file.name}') format('woff2');\n"
|
||||||
|
f" font-weight: {weight};\n"
|
||||||
|
" font-style: normal;\n"
|
||||||
|
" font-display: swap;\n"
|
||||||
|
" unicode-range:\n " + ",\n ".join(lines) + ";\n}"
|
||||||
|
)
|
||||||
|
return "\n\n".join(sections) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--check", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
generated = generate()
|
||||||
|
if args.check:
|
||||||
|
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != generated:
|
||||||
|
raise SystemExit("NanumSquare coverage CSS is stale; regenerate it")
|
||||||
|
print("NanumSquare coverage matches all four font files")
|
||||||
|
else:
|
||||||
|
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
OUTPUT.write_text(generated, encoding="utf-8")
|
||||||
|
print(f"Generated {OUTPUT.relative_to(ROOT)}")
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
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 { createHash, webcrypto } = require('node:crypto')
|
||||||
|
|
||||||
|
function compile(source, globals = {}, requireModule = require) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, {
|
||||||
|
exports, require: requireModule, Error, crypto: webcrypto, TextEncoder, window: { setTimeout: callback => setTimeout(callback, 1) }, ...globals
|
||||||
|
})
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const tools = compile(fs.readFileSync(require.resolve('../src/shared/agentTools.ts'), 'utf8'))
|
||||||
|
const navigation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentCodeNavigation.ts'), 'utf8'), {}, name => name.endsWith('shared/agentTools') ? tools : require(name))
|
||||||
|
const content = 'def configure():\n speed = 0.4\n return speed\n'
|
||||||
|
const hash = value => createHash('sha256').update(value).digest('hex')
|
||||||
|
const reference = { id: 'location', moduleName: 'Control', path: 'src/node.py', startLine: 2, endLine: 3, reason: 'speed config', snippet: ' speed = 0.4\n return speed', expectedHash: hash(content) }
|
||||||
|
|
||||||
|
test('unchanged source reveals the verified line range, while unsaved insertions relocate it', async () => {
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content, reference))), { startLine: 2, endLine: 3 })
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange('# unsaved\n' + content, reference))), { startLine: 3, endLine: 4 })
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content.replace(/\n/g, '\r\n'), reference))), { startLine: 2, endLine: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects missing, ambiguous or altered code rather than highlighting a guessed line', async () => {
|
||||||
|
for (const changed of [content.replace('0.4', '0.8'), `${content}\n${content}`]) await assert.rejects(navigation.resolveCodeReferenceRange(changed, reference), /위치를 확정/)
|
||||||
|
for (const patch of [{ expectedHash: undefined }, { snippet: '' }, { snippet: '[REDACTED]' }, { snippet: 'one line' }, { path: '../etc/passwd' }, { path: '.env' }, { startLine: 0 }, { endLine: 500 }]) {
|
||||||
|
await assert.rejects(navigation.resolveCodeReferenceRange(content, { ...reference, ...patch }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function editorHarness(text = content) {
|
||||||
|
const calls = []
|
||||||
|
const collection = { clear: () => calls.push('clear') }
|
||||||
|
const model = { uri: { path: '/modules/Control/src/node.py' }, getValue: () => text, isDisposed: () => false, getLineMaxColumn: line => text.split('\n')[line - 1].length + 1 }
|
||||||
|
const editor = { getModel: () => model, getDomNode: () => ({ clientWidth: 800, clientHeight: 600 }), layout: () => calls.push('layout'), createDecorationsCollection: value => { calls.push(['decorate', value]); return collection }, revealRangeInCenter: value => calls.push(['reveal', value]), setPosition: value => calls.push(['position', value]), focus: () => calls.push('focus') }
|
||||||
|
const options = { file: 'modules/Control/src/node.py', reference, isCurrent: () => true, getEditor: () => editor, getActiveFile: () => 'modules/Control/src/node.py', isReady: () => true, attempts: 4 }
|
||||||
|
return { model, editor, options, calls, collection }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('focuses and highlights only after the correct Monaco model has loaded', async () => {
|
||||||
|
const h = editorHarness()
|
||||||
|
let reads = 0
|
||||||
|
h.options.getEditor = () => ++reads < 3 ? null : h.editor
|
||||||
|
assert.equal(await navigation.revealAgentCodeReference(h.options), h.collection)
|
||||||
|
assert.deepEqual(h.calls.map(call => Array.isArray(call) ? call[0] : call), ['layout', 'decorate', 'position', 'reveal', 'focus'])
|
||||||
|
const selection = h.calls.find(call => call[0] === 'reveal')[1]
|
||||||
|
assert.equal(selection.startLineNumber, 2)
|
||||||
|
assert.equal(selection.endLineNumber, 3)
|
||||||
|
assert.equal(h.calls[1][1][0].options.isWholeLine, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not decorate another file, a hidden/diff editor, stale scope or unready model', async () => {
|
||||||
|
for (const setup of [
|
||||||
|
h => { h.model.uri.path = '/modules/Other/src/node.py' },
|
||||||
|
h => { h.options.getActiveFile = () => 'other.py' },
|
||||||
|
h => { h.options.isReady = () => false },
|
||||||
|
h => { h.options.isCurrent = () => false },
|
||||||
|
h => { h.editor.getDomNode = () => ({ clientWidth: 0, clientHeight: 0 }) },
|
||||||
|
h => { h.model.isDisposed = () => true }
|
||||||
|
]) {
|
||||||
|
const h = editorHarness()
|
||||||
|
setup(h)
|
||||||
|
await assert.rejects(navigation.revealAgentCodeReference(h.options))
|
||||||
|
assert.equal(h.calls.length, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
||||||
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(hookSource) === 'openCodeReference')
|
||||||
|
function consentHarness() {
|
||||||
|
const session = {}
|
||||||
|
const messageStateRef = { current: { session, messages: [{ id: 'answer', role: 'assistant', plan: { codeReferences: [reference] } }] } }
|
||||||
|
const globals = { session, messageStateRef, navigatingRef: { current: false }, applyingRef: { current: false }, abortControllerRef: { current: null }, updateMessages: update => { messageStateRef.current.messages = update(messageStateRef.current.messages) } }
|
||||||
|
return { ...compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), globals), globals, message: () => messageStateRef.current.messages[0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('code navigation opens only when the user selects View in editor', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
let opened = 0
|
||||||
|
const open = async () => { opened++ }
|
||||||
|
assert.equal(opened, 0)
|
||||||
|
await h.openCodeReference('answer', 'location', open)
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy cancelled references can be opened with the same read-only navigation action', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
h.message().codeReferenceStates = { location: { status: 'cancelled' } }
|
||||||
|
let opened = 0
|
||||||
|
await h.openCodeReference('answer', 'location', async ref => { assert.equal(ref.path, reference.path); opened++ })
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('code navigation does not block or abort a pending model response', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
const controller = new AbortController()
|
||||||
|
h.globals.abortControllerRef.current = controller
|
||||||
|
let opened = 0
|
||||||
|
await h.openCodeReference('answer', 'location', async (_reference, isCurrent) => {
|
||||||
|
assert.equal(isCurrent(), true)
|
||||||
|
opened++
|
||||||
|
})
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
assert.equal(controller.signal.aborted, false)
|
||||||
|
assert.equal(h.globals.abortControllerRef.current, controller)
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an arriving model response does not cancel an in-flight code navigation', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
h.globals.abortControllerRef.current = new AbortController()
|
||||||
|
let finish, current
|
||||||
|
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
||||||
|
current = isCurrent
|
||||||
|
return new Promise(resolve => { finish = resolve })
|
||||||
|
})
|
||||||
|
h.globals.messageStateRef.current.messages.push({ id: 'new-answer', role: 'assistant' })
|
||||||
|
h.globals.abortControllerRef.current = null
|
||||||
|
assert.equal(current(), true)
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('starting file application invalidates a navigation that is still reading', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
let finish, current
|
||||||
|
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
||||||
|
current = isCurrent
|
||||||
|
return new Promise((_resolve, reject) => { finish = () => reject(Error('applying')) })
|
||||||
|
})
|
||||||
|
h.globals.applyingRef.current = true
|
||||||
|
assert.equal(current(), false)
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
let finish, opened = 0
|
||||||
|
const first = h.openCodeReference('answer', 'location', () => { opened++; return new Promise(resolve => { finish = resolve }) })
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'opening')
|
||||||
|
await h.openCodeReference('answer', 'location', async () => { opened++ })
|
||||||
|
finish()
|
||||||
|
await first
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
await h.openCodeReference('answer', 'invented', async () => { opened++ })
|
||||||
|
h.globals.applyingRef.current = true
|
||||||
|
await h.openCodeReference('answer', 'location', async () => { opened++ })
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('errors remain visible and late navigation cannot update another conversation', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
await h.openCodeReference('answer', 'location', async () => { throw Error('missing file') })
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'error')
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.error, 'missing file')
|
||||||
|
let finish
|
||||||
|
const pending = h.openCodeReference('answer', 'location', () => new Promise(resolve => { finish = resolve }))
|
||||||
|
const other = { id: 'other' }
|
||||||
|
h.globals.messageStateRef.current = { session: {}, messages: [other] }
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal(h.message(), other)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a new question cancels pending navigation without leaving its old state stuck opening', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
let finish, current
|
||||||
|
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
||||||
|
current = isCurrent
|
||||||
|
return new Promise((_resolve, reject) => { finish = () => reject(Error('cancelled')) })
|
||||||
|
})
|
||||||
|
h.globals.messageStateRef.current.messages.push({ id: 'new-question', role: 'user' })
|
||||||
|
assert.equal(current(), false)
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'error')
|
||||||
|
assert.equal(h.globals.messageStateRef.current.messages.at(-1).id, 'new-question')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restored interrupted navigation becomes retryable without reopening any file', () => {
|
||||||
|
const evidence = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
|
||||||
|
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {}, name => name === './agentEvidence' ? evidence : require(name))
|
||||||
|
const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
|
||||||
|
const restored = recoverInterruptedAgentMessages([message])[0]
|
||||||
|
assert.equal(restored.codeReferenceStates.pending.status, 'error')
|
||||||
|
assert.equal(restored.codeReferenceStates.approved.status, 'opened')
|
||||||
|
assert.equal(restored.codeReferenceStates.declined.status, 'cancelled')
|
||||||
|
assert.equal(message.codeReferenceStates.pending.status, 'opening')
|
||||||
|
})
|
||||||
|
|
||||||
|
function elements(tree, predicate) {
|
||||||
|
if (!React.isValidElement(tree)) return []
|
||||||
|
return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
function text(tree) {
|
||||||
|
return typeof tree === 'string' ? tree : React.isValidElement(tree) ? React.Children.toArray(tree.props.children).map(text).join('') : ''
|
||||||
|
}
|
||||||
|
const render = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentCodeReferences.tsx'), 'utf8'), {}, name => name.endsWith('/i18n') ? { t: key => key, useLocaleRender() {} } : require(name)).AgentCodeReferences
|
||||||
|
for (const dark of [true, false]) test(`code navigation has only a vertically aligned View in editor button (${dark})`, () => {
|
||||||
|
const clicked = []
|
||||||
|
const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`) }
|
||||||
|
const buttons = elements(render(props), element => element.type === 'button')
|
||||||
|
assert.equal(clicked.length, 0)
|
||||||
|
assert.deepEqual(buttons.map(text), ['코드에서 보기'])
|
||||||
|
const label = elements(buttons[0], element => element.type === 'span')[0]
|
||||||
|
assert.match(buttons[0].props.className, /items-center.*leading-none/)
|
||||||
|
assert.match(label.props.className, /top-px leading-none/)
|
||||||
|
buttons[0].props.onClick()
|
||||||
|
assert.deepEqual(clicked, ['open:location'])
|
||||||
|
for (const button of elements(render({ ...props, busy: true }), element => element.type === 'button')) assert.equal(button.props.disabled, true)
|
||||||
|
for (const status of ['cancelled', 'opened', 'error']) {
|
||||||
|
assert.deepEqual(elements(render({ ...props, states: { location: { status } } }), element => element.type === 'button').map(text), ['코드에서 보기'])
|
||||||
|
}
|
||||||
|
const opening = elements(render({ ...props, states: { location: { status: 'opening' } } }), element => element.type === 'button')[0]
|
||||||
|
assert.equal(opening.props.disabled, true)
|
||||||
|
assert.equal(text(opening), '파일 여는 중...')
|
||||||
|
assert.equal(elements(render({ ...props, states: { location: { status: 'error', error: 'missing' } } }), element => element.props.role === 'alert').length, 1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
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 output = {}
|
||||||
|
const evidenceApi = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: evidenceApi })
|
||||||
|
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: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
|
||||||
|
const { resolveAgentContext } = output
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
|
||||||
|
const response = (requestedModules = [], requestedLogs = []) => ({
|
||||||
|
provider: 'QWEN', model: 'test', usage: { totalTokens: 10 },
|
||||||
|
plan: { summary: 'result', explanation: 'analysis', requestedModules, requestedLogs, actionPlan: [], commands: [], changes: [], createModule: null }
|
||||||
|
})
|
||||||
|
const options = overrides => ({
|
||||||
|
initialContext: { modules: [moduleInfo('MKMini'), moduleInfo('Control')], runtimeLogs: [] },
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
test('continues MKMini -> Control -> final analysis and retains earlier source files', async () => {
|
||||||
|
const inputs = []
|
||||||
|
const loads = []
|
||||||
|
const result = await resolveAgentContext(options({
|
||||||
|
requestPlan: async context => {
|
||||||
|
inputs.push(plain(context))
|
||||||
|
return [response(['MKMini']), response(['Control']), response()][inputs.length - 1]
|
||||||
|
},
|
||||||
|
loadRequestedContext: async request => {
|
||||||
|
loads.push(plain(request))
|
||||||
|
return { modules: ['MKMini', 'Control'].map(name => moduleInfo(name, request.modules.includes(name) ? [{ path: `${name}.py`, content: `${name} source` }] : [])), runtimeLogs: [] }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
assert.equal(inputs.length, 3)
|
||||||
|
assert.deepEqual(loads, [{ modules: ['MKMini'], logs: [] }, { modules: ['Control'], logs: [] }])
|
||||||
|
assert.equal(inputs[2].modules[0].files[0].content, 'MKMini source')
|
||||||
|
assert.equal(inputs[2].modules[1].files[0].content, 'Control source')
|
||||||
|
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||||
|
assert.equal(result.usage.totalTokens, 30)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps logs while loading a later source module', async () => {
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
requestPlan: async context => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 3) assert.equal(context.runtimeLogs[0].content, 'error log')
|
||||||
|
return [response([], ['Control']), response(['Control']), response()][calls - 1]
|
||||||
|
},
|
||||||
|
loadRequestedContext: async request => ({
|
||||||
|
modules: [moduleInfo('Control', request.modules.length ? [{ path: 'node.py', content: 'code' }] : [])],
|
||||||
|
runtimeLogs: request.logs.length ? [{ moduleName: 'Control', state: 'running', content: 'error log' }] : []
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
assert.equal(calls, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('makes one analysis-only completion when the model repeats a context request', async () => {
|
||||||
|
let loads = 0
|
||||||
|
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 () => {
|
||||||
|
let count = 0
|
||||||
|
const loads = []
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
requestPlan: async () => [response(['MKMini']), response(['MKMini', 'Control']), response()][count++],
|
||||||
|
loadRequestedContext: async request => { loads.push(plain(request)); return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
|
||||||
|
}))
|
||||||
|
assert.deepEqual(loads[1], { modules: ['Control'], logs: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not start another API call after cancellation during context loading', async () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
let calls = 0
|
||||||
|
await assert.rejects(resolveAgentContext(options({
|
||||||
|
signal: controller.signal,
|
||||||
|
requestPlan: async () => { calls += 1; return response(['Control']) },
|
||||||
|
loadRequestedContext: async () => { controller.abort(); return { modules: [], runtimeLogs: [] } }
|
||||||
|
})), { name: 'AbortError' })
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bounds the number of distinct automatic context requests', async () => {
|
||||||
|
let calls = 0
|
||||||
|
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 () => {
|
||||||
|
let count = 0
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
requestPlan: async context => {
|
||||||
|
if (count++ === 0) return response(['Control'])
|
||||||
|
assert.equal(context.modules.find(module => module.name === 'Control').fileContext.status, 'error')
|
||||||
|
return response()
|
||||||
|
},
|
||||||
|
loadRequestedContext: async () => ({ modules: [{ ...moduleInfo('Control'), fileContext: { status: 'error', message: 'permission denied' } }], 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('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 () => {
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
initialContext: { modules: [{ ...moduleInfo('Control'), state: 'running' }], runtimeLogs: [] },
|
||||||
|
requestPlan: async context => {
|
||||||
|
if (calls++ === 0) return response(['Control'])
|
||||||
|
assert.equal(context.modules[0].state, 'running')
|
||||||
|
assert.equal(context.modules[0].files[0].content, 'code')
|
||||||
|
return response()
|
||||||
|
},
|
||||||
|
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: [] })
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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/agentConversationContext.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: api })
|
||||||
|
const { agentConversationWorkspace: scope, buildAgentConversationContext: build } = api
|
||||||
|
const now = Date.now()
|
||||||
|
const local = scope('local', 'local-host', '/workspace/a')
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const turn = (n, workspace = local, createdAt = now) => [
|
||||||
|
{ id: `u${n}`, role: 'user', content: `question-${n}`, conversationScope: { workspace, createdAt } },
|
||||||
|
{ id: `a${n}`, role: 'assistant', content: `summary-${n}`, conversationScope: { workspace, createdAt }, plan: {
|
||||||
|
summary: `summary-${n}`, explanation: `answer-${n}`, conversationSummary: `누적 기억-${n}`, changes: [], commands: [], requestedModules: [], requestedLogs: [], createModule: null
|
||||||
|
} }
|
||||||
|
]
|
||||||
|
|
||||||
|
test('uses the latest cumulative summary with only two recent exchanges and no duplicate current question', () => {
|
||||||
|
const messages = Array.from({ length: 20 }, (_, i) => turn(i)).flat()
|
||||||
|
messages.push({ ...turn(20)[0], content: 'pending current question' })
|
||||||
|
const context = build(messages, local, now)
|
||||||
|
assert.deepEqual(plain(context.recentMessages).map(item => item.content), ['question-18', 'summary-18\nanswer-18', 'question-19', 'summary-19\nanswer-19'])
|
||||||
|
assert.equal(context.summary, '누적 기억-19')
|
||||||
|
assert.doesNotMatch(JSON.stringify(context), /question-17|pending current question/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hard caps payload text even with huge original answers and requests', () => {
|
||||||
|
const messages = Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const pair = turn(i)
|
||||||
|
pair[0].content = 'q'.repeat(4000)
|
||||||
|
pair[1].plan.summary = 's'.repeat(500)
|
||||||
|
pair[1].plan.explanation = 'e'.repeat(12000)
|
||||||
|
pair[1].plan.conversationSummary = 'm'.repeat(2000)
|
||||||
|
return pair
|
||||||
|
}).flat()
|
||||||
|
const context = build(messages, local, now)
|
||||||
|
assert.ok(context.summary.length <= 1000)
|
||||||
|
assert.equal(context.recentMessages.length, 4)
|
||||||
|
assert.ok(context.recentMessages.every(message => message.content.length <= 700))
|
||||||
|
assert.ok(context.summary.length + context.recentMessages.reduce((sum, item) => sum + item.content.length, 0) <= 3800)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not mix hosts, roots, SSH identities, unscoped legacy messages or expired history', () => {
|
||||||
|
for (const other of [scope('remote', 'ssh:a', '/workspace/a'), scope('local', 'local-host', '/workspace/b'), scope('remote', 'ssh:b', '/workspace/a')]) {
|
||||||
|
assert.equal(build(turn(1, other), local, now), undefined)
|
||||||
|
}
|
||||||
|
assert.equal(build(turn(1, local, now - 86400000), local, now), undefined)
|
||||||
|
assert.equal(build(turn(1, local, now + 1), local, now), undefined)
|
||||||
|
assert.equal(build(turn(1).map(({ conversationScope, ...message }) => message), local, now), undefined)
|
||||||
|
assert.equal(scope('local', 'local-host'), undefined)
|
||||||
|
assert.equal(build(turn(1), undefined, now), undefined)
|
||||||
|
assert.equal(build([], local, now), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing or empty generated summary preserves earlier valid memory without an extra model call', () => {
|
||||||
|
const first = turn(1), second = turn(2)
|
||||||
|
delete second[1].plan.conversationSummary
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
|
||||||
|
second[1].plan.conversationSummary = ''
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
|
||||||
|
delete first[1].plan.conversationSummary
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '')
|
||||||
|
second[1].plan.conversationSummary = '새 누적 요약'
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '새 누적 요약')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps file identities and action outcomes, but excludes payloads and tool evidence', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
pair[1].plan.explanation = '설명\n```python\nPRIVATE_SOURCE\n```\n끝'
|
||||||
|
pair[1].plan.changes = [{ moduleName: 'Control', path: 'config.yaml', content: 'PRIVATE_CHANGE' }]
|
||||||
|
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build', command: 'PRIVATE_COMMAND' }]
|
||||||
|
pair[1].plan.evidence = [{ data: 'PRIVATE_LOG' }]
|
||||||
|
pair[1].plan.codeReferences = [{ moduleName: 'Control', path: 'node.py', startLine: 20, endLine: 23, snippet: 'PRIVATE_SNIPPET' }]
|
||||||
|
pair[1].commandExecutions = { build: { status: 'completed', output: 'PRIVATE_OUTPUT' } }
|
||||||
|
pair[1].applyState = 'cancelled'
|
||||||
|
const context = JSON.stringify(build(pair, local, now))
|
||||||
|
assert.match(context, /Control\/node.py:20-23/)
|
||||||
|
assert.match(context, /file change: cancelled/)
|
||||||
|
assert.match(context, /build: completed/)
|
||||||
|
assert.doesNotMatch(context, /PRIVATE_/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores errors, interrupted turns and initial lookup responses', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
const error = { ...pair[1], plan: undefined, content: 'network error' }
|
||||||
|
assert.equal(build([pair[0], error], local, now), undefined)
|
||||||
|
pair[1].plan.toolRequests = [{ name: 'files.read' }]
|
||||||
|
assert.equal(build(pair, local, now), undefined)
|
||||||
|
assert.equal(build([turn(1)[0], ...turn(2, scope('remote', 'ssh:x', '/workspace'))], local, now), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rebuilds from persisted metadata and reflects updated execution outcomes without new memory storage', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build' }]
|
||||||
|
assert.match(JSON.stringify(build(plain(pair), local, now)), /pending/)
|
||||||
|
pair[1].commandExecutions = { build: { status: 'failed' } }
|
||||||
|
assert.match(JSON.stringify(build(plain(pair), local, now)), /failed/)
|
||||||
|
const followUp = { ...pair[1], content: 'result', plan: { ...pair[1].plan, summary: '실행 결과 분석', explanation: '실패했습니다.' } }
|
||||||
|
assert.match(JSON.stringify(build([...pair, followUp], local, now)), /실행 결과 분석/)
|
||||||
|
assert.equal(build([], local, now), undefined)
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
})
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const ts = require('typescript')
|
||||||
|
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, filename)
|
||||||
|
const { compactAgentEvidence, missingAgentRead, readFileEvidence } = require('../src/renderer/src/features/workspace/agentEvidence.ts')
|
||||||
|
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
|
||||||
|
const file = (startLine, endLine, extra = {}, result = {}) => ({
|
||||||
|
id: `read-${startLine}-${endLine}`, name: 'files.read', moduleName: 'Control', target: 'ssh:jetson', status: 'ok', observedAt: '2026-10-20T00:00:00.000Z', truncated: true,
|
||||||
|
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), content: Array.from({ length: endLine - startLine + 1 }, (_, i) => `line${startLine + i}`).join('\n'),
|
||||||
|
startLine, endLine, totalLines: 100, complete: false, contentTruncated: false, ...extra }), ...result
|
||||||
|
})
|
||||||
|
const read = (startLine, lineCount, path = 'config/car.yaml') => ({ id: 'read', name: 'files.read', moduleName: 'Control', args: { path, ...(startLine === undefined ? {} : { startLine }), ...(lineCount === undefined ? {} : { lineCount }) } })
|
||||||
|
const answer = toolRequests => ({ provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'verified code', toolRequests, changes: [], commands: [], actionPlan: [], createModule: null } })
|
||||||
|
|
||||||
|
test('overlapping and adjacent ranges merge without promoting partial evidence to editable full files', () => {
|
||||||
|
const results = compactAgentEvidence([file(1, 10), file(8, 15), file(16, 20)])
|
||||||
|
assert.equal(results.length, 1)
|
||||||
|
const data = readFileEvidence(results[0])
|
||||||
|
assert.equal(data.startLine, 1)
|
||||||
|
assert.equal(data.endLine, 20)
|
||||||
|
assert.equal(data.content.split('\n').length, 20)
|
||||||
|
assert.equal(data.complete, false)
|
||||||
|
assert.equal(results[0].truncated, true)
|
||||||
|
assert.equal(missingAgentRead(read(3, 16), results), undefined)
|
||||||
|
assert.deepEqual(missingAgentRead(read(), results), read())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('queries only the first missing range and never joins disjoint or conflicting content', () => {
|
||||||
|
const results = compactAgentEvidence([file(1, 5), file(10, 15)])
|
||||||
|
assert.equal(results.length, 2)
|
||||||
|
assert.deepEqual(missingAgentRead(read(3, 15), results).args, { path: 'config/car.yaml', startLine: 6, lineCount: 4 })
|
||||||
|
assert.equal(compactAgentEvidence([file(1, 10), file(8, 15, { content: 'different\ncontent' })]).length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('host boundaries and file revisions cannot be combined or used to fill each other’s gaps', () => {
|
||||||
|
const changed = compactAgentEvidence([file(1, 10), file(11, 20, { hash: 'b'.repeat(64) })])
|
||||||
|
assert.equal(changed.length, 1)
|
||||||
|
assert.equal(missingAgentRead(read(1, 20), changed).args.startLine, 1)
|
||||||
|
const hosts = compactAgentEvidence([file(1, 10, {}, { target: 'local-host' }), file(11, 20)])
|
||||||
|
assert.equal(hosts.length, 2)
|
||||||
|
assert.equal(missingAgentRead(read(1, 20), hosts).args.startLine, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('full and missing observations are reused but partial character fragments are never coverage', () => {
|
||||||
|
const full = file(1, 10, { complete: true }, { truncated: false })
|
||||||
|
assert.equal(missingAgentRead(read(5, 100), [full]), undefined)
|
||||||
|
assert.equal(compactAgentEvidence([full, file(5, 8)]).length, 1)
|
||||||
|
const missing = file(1, 1, { exists: false, content: '', hash: null, complete: true }, { truncated: false })
|
||||||
|
assert.equal(missingAgentRead(read(), [missing]), undefined)
|
||||||
|
const cut = file(1, 2, { content: 'whole\ncut', endLine: 1, contentTruncated: true })
|
||||||
|
assert.equal(missingAgentRead(read(2, 1), [cut]).args.startLine, 2)
|
||||||
|
const legacy = file(1, 2, { endLine: undefined, contentTruncated: undefined })
|
||||||
|
assert.equal(missingAgentRead(read(2, 1), [legacy]).args.startLine, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('malformed or oversized evidence never becomes reusable complete coverage', () => {
|
||||||
|
assert.equal(readFileEvidence(file(1, 10, { hash: 'not-a-hash' })), undefined)
|
||||||
|
assert.equal(readFileEvidence(file(1, 10, { endLine: 999 })), undefined)
|
||||||
|
const large = compactAgentEvidence([file(1, 1, { content: 'a'.repeat(30_000) }), file(2, 2, { content: 'b'.repeat(30_000) })])
|
||||||
|
assert.equal(large.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('overlap requests execute only missing lines and finish once the requested range is cached', async () => {
|
||||||
|
const executions = []
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
|
||||||
|
executeTool: async tool => { executions.push(tool.args); return file(tool.args.startLine, tool.args.startLine + tool.args.lineCount - 1) },
|
||||||
|
requestPlan: async (context, final) => {
|
||||||
|
calls++
|
||||||
|
if (final) {
|
||||||
|
assert.equal(context.toolResults.length, 1)
|
||||||
|
assert.equal(JSON.parse(context.toolResults[0].data).endLine, 15)
|
||||||
|
return answer([])
|
||||||
|
}
|
||||||
|
return answer([[read(1, 10)], [read(5, 11)], [read(4, 10)]][calls - 1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.deepEqual(executions, [read(1, 10).args, read(11, 5).args])
|
||||||
|
assert.equal(calls, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source evidence survives many later searches and omissions are reported within DTO limits', async () => {
|
||||||
|
const searchResults = Array.from({ length: 15 }, (_, i) => ({ ...file(1, 1), id: `search-${i}`, name: 'files.search', data: JSON.stringify({ query: `speed${i}`, matches: [] }) }))
|
||||||
|
await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10), ...searchResults] }, signal: new AbortController().signal,
|
||||||
|
requestPlan: async context => {
|
||||||
|
assert.equal(context.toolResults.length, 12)
|
||||||
|
assert.equal(context.toolResults[0].name, 'files.read')
|
||||||
|
assert.match(context.contextNotes[0], /4개/)
|
||||||
|
assert.ok(context.contextNotes.length <= 12)
|
||||||
|
assert.ok(context.contextNotes.every(note => note.length <= 500))
|
||||||
|
return answer([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a cached file omitted by budgets is restored when the model requests it again', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const results = Array.from({ length: 13 }, (_, i) => file(1, 10, { path: `file${i}.yaml` }))
|
||||||
|
await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: results }, signal: new AbortController().signal,
|
||||||
|
executeTool: async () => { throw Error('cached file must not be queried again') },
|
||||||
|
requestPlan: async (context, final) => {
|
||||||
|
calls++
|
||||||
|
if (!final) return answer([read(1, 5, 'file0.yaml')])
|
||||||
|
assert.ok(context.toolResults.some(result => JSON.parse(result.data).path === 'file0.yaml'))
|
||||||
|
return answer([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finalization retains verified code locations while stripping all proposed mutations', async () => {
|
||||||
|
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
|
||||||
|
const result = await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
|
||||||
|
executeTool: async () => { throw Error('already cached') },
|
||||||
|
requestPlan: async (_, final) => ({ ...answer([read(1, 5)]), plan: { ...answer([read(1, 5)]).plan, ...(final ? { changes: [{ path: 'unsafe' }], commands: [{ command: 'unsafe' }] } : { codeReferences: [reference] }) } })
|
||||||
|
})
|
||||||
|
assert.equal(result.plan.codeReferences.length, 1)
|
||||||
|
assert.match(result.plan.explanation, /verified code/)
|
||||||
|
assert.deepEqual(result.plan.changes, [])
|
||||||
|
assert.deepEqual(result.plan.commands, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recovery never resurrects code locations after a different file revision was observed', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
|
||||||
|
const result = await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
|
||||||
|
executeTool: async () => file(11, 20, { hash: 'b'.repeat(64) }),
|
||||||
|
requestPlan: async (_, final) => {
|
||||||
|
calls++
|
||||||
|
return !final && calls === 1 ? { ...answer([read(11, 10)]), plan: { ...answer([read(11, 10)]).plan, codeReferences: [reference] } } : answer([read(11, 10)])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.deepEqual(result.plan.codeReferences, [])
|
||||||
|
assert.match(result.plan.summary, /확정하지 못했습니다/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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(path, overrides = {}) {
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(require.resolve(path), 'utf8')
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText,
|
||||||
|
{ exports, document: { body: {} }, require: name => overrides[name] ?? require(name) })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const models = compile('../src/renderer/src/features/workspace/agentModels.ts')
|
||||||
|
const options = [{ provider: 'OPENAI', model: 'gpt-6.1-sol', imagesEnabled: false }, { provider: 'QWEN', model: 'qwen3.8-flash', imagesEnabled: true }]
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
|
||||||
|
test('normalizes enabled models, validates IDs and preserves old single-model servers', () => {
|
||||||
|
const status = models.normalizeAgentStatus({ enabled: true, models: [options[1], options[0], options[0], { provider: 'FORGED', model: 'bad' }, { provider: 'GEMINI', model: '' }] })
|
||||||
|
assert.deepEqual(plain(status.models), options)
|
||||||
|
assert.equal(status.selectionSupported, true)
|
||||||
|
assert.deepEqual(plain(models.normalizeAgentStatus({ enabled: true, ...options[1] }).models), [options[1]])
|
||||||
|
assert.equal(models.normalizeAgentStatus({ enabled: true, ...options[1] }).selectionSupported, false)
|
||||||
|
assert.equal(models.normalizeAgentStatus({ enabled: false, models: options }).models.length, 0)
|
||||||
|
assert.equal(models.normalizeAgentStatus({ enabled: true, models: [] }).enabled, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selects the saved provider only while it is available, and never invents one', () => {
|
||||||
|
assert.equal(models.selectedAgentModel(options, 'QWEN'), options[1])
|
||||||
|
assert.equal(models.selectedAgentModel(options, 'ANTHROPIC'), options[0])
|
||||||
|
assert.equal(models.selectedAgentModel([options[0]], 'QWEN'), options[0])
|
||||||
|
assert.equal(models.selectedAgentModel([], 'QWEN'), 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))]
|
||||||
|
}
|
||||||
|
function selectorHarness(props) {
|
||||||
|
const state = [], refs = []
|
||||||
|
let stateIndex = 0, refIndex = 0
|
||||||
|
const { AgentModelSelector } = compile('../src/renderer/src/components/AgentModelSelector.tsx', {
|
||||||
|
'../i18n': { t: value => value }, '../features/workspace/agentModels': models,
|
||||||
|
'react-dom': { createPortal: child => child },
|
||||||
|
react: { ...React, useId: () => 'model-menu', useEffect() {}, useLayoutEffect() {},
|
||||||
|
useState(initial) { const index = stateIndex++; if (!(index in state)) state[index] = initial; return [state[index], value => { state[index] = typeof value === 'function' ? value(state[index]) : value }] },
|
||||||
|
useRef(initial) { return refs[refIndex++] ?? (refs[refIndex - 1] = { current: initial }) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { render(patch = {}) { stateIndex = 0; refIndex = 0; return AgentModelSelector({ ...props, ...patch }) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [true, false]) test(`model control lists only allowed providers and follows the theme (${dark})`, () => {
|
||||||
|
let selected
|
||||||
|
const h = selectorHarness({ models: options, provider: 'QWEN', dark, disabled: false, onChange: value => { selected = value } })
|
||||||
|
const tree = h.render()
|
||||||
|
const trigger = elements(tree, node => node.type === 'button')[0]
|
||||||
|
assert.equal(trigger.props['aria-haspopup'], 'menu')
|
||||||
|
assert.equal(trigger.props['aria-expanded'], false)
|
||||||
|
assert.equal(trigger.props.title, 'Qwen · qwen3.8-flash')
|
||||||
|
assert.equal(elements(tree, node => node.props.role === 'menu').length, 0)
|
||||||
|
assert.equal(elements(tree, node => node.type === 'select').length, 0)
|
||||||
|
trigger.props.onClick()
|
||||||
|
const menu = elements(h.render(), node => node.props.role === 'menu')[0]
|
||||||
|
assert.match(menu.props.className, dark ? /bg-\[#252526\]/ : /bg-white/)
|
||||||
|
assert.match(menu.props.className, /fixed/)
|
||||||
|
const items = elements(menu, node => node.props.role === 'menuitemradio')
|
||||||
|
assert.deepEqual(items.map(node => node.props.title), ['OpenAI · gpt-6.1-sol', 'Qwen · qwen3.8-flash'])
|
||||||
|
assert.deepEqual(items.map(node => node.props['aria-checked']), [false, true])
|
||||||
|
assert.ok(items.every(node => elements(node, item => item.type === 'span' && item.props.className.includes('truncate')).length === 1))
|
||||||
|
items[0].props.onClick()
|
||||||
|
assert.equal(selected, 'OPENAI')
|
||||||
|
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 0)
|
||||||
|
assert.ok(elements(h.render({ disabled: true }), node => node.type === 'button')[0].props.disabled)
|
||||||
|
const empty = elements(h.render({ models: [], provider: null }), node => node.type === 'button')[0]
|
||||||
|
assert.ok(empty.props.disabled)
|
||||||
|
assert.equal(empty.props.title, '사용 가능한 모델 없음')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('model menu opens from arrow keys and closes when a request blocks the selector', () => {
|
||||||
|
const h = selectorHarness({ models: options, provider: 'QWEN', dark: true, disabled: false, onChange() {} })
|
||||||
|
const trigger = elements(h.render(), node => node.type === 'button')[0]
|
||||||
|
trigger.props.onKeyDown({ key: 'ArrowUp', preventDefault() {} })
|
||||||
|
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 1)
|
||||||
|
assert.equal(elements(h.render({ disabled: true }), node => node.props.role === 'menu').length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('input footer places the model action before the attachment and clear controls', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||||
|
const row = source.slice(source.indexOf('{(attachmentsEnabled || onClearConversation || footerAction)'))
|
||||||
|
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'파일 첨부\')}'))
|
||||||
|
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'대화내역 삭제\')}'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A-Copilot has a single managed label', () => {
|
||||||
|
assert.equal(models.agentModelLabel({ provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false }), 'A-Copilot')
|
||||||
|
assert.equal(models.agentModelLabel(options[0]), 'OpenAI · gpt-6.1-sol')
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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(source, globals) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, { exports, require, ...globals })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
||||||
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['applyPlan', 'cancelPlan'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
|
||||||
|
const proposal = (extra = {}) => ({
|
||||||
|
id: 'proposal', role: 'assistant', content: '로그 추가', applyState: 'ready',
|
||||||
|
plan: { summary: '로그 추가', explanation: '시작 로그', requestedModules: [], requestedLogs: [], actionPlan: [], createModule: null,
|
||||||
|
changes: [{ moduleName: 'Control', path: 'node.py', content: 'new', reason: 'log' }],
|
||||||
|
commands: [{ id: 'build', moduleName: 'Control', command: 'bash build.sh', purpose: 'build' }] },
|
||||||
|
commandExecutions: { build: { status: 'pending' } }, ...extra
|
||||||
|
})
|
||||||
|
function harness(message) {
|
||||||
|
const session = {}
|
||||||
|
const messageStateRef = { current: { session, messages: [message] } }
|
||||||
|
const applyingRef = { current: false }
|
||||||
|
const abortControllerRef = { current: null }
|
||||||
|
const saved = []
|
||||||
|
const api = compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), {
|
||||||
|
session, messageStateRef, applyingRef, abortControllerRef,
|
||||||
|
updateMessages(update) {
|
||||||
|
const next = update(messageStateRef.current.messages)
|
||||||
|
messageStateRef.current.messages = next
|
||||||
|
saved.push(JSON.parse(JSON.stringify(next)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { ...api, messageStateRef, applyingRef, abortControllerRef, saved, current: () => messageStateRef.current.messages[0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cancel marks the proposal and unfinished commands without applying files', async () => {
|
||||||
|
const h = harness(proposal({ applyError: 'old error' }))
|
||||||
|
assert.equal(h.cancelPlan('proposal'), true)
|
||||||
|
assert.equal(h.current().applyState, 'cancelled')
|
||||||
|
assert.equal(h.current().applyError, undefined)
|
||||||
|
assert.equal(h.current().commandExecutions.build.status, 'rejected')
|
||||||
|
assert.equal(h.saved.at(-1)[0].applyState, 'cancelled')
|
||||||
|
let applied = false
|
||||||
|
await h.applyPlan('proposal', async () => { applied = true })
|
||||||
|
assert.equal(applied, false)
|
||||||
|
assert.equal(h.cancelPlan('proposal'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancel preserves existing successful command results and rejects failed commands', () => {
|
||||||
|
const message = proposal()
|
||||||
|
message.plan.commands.push({ id: 'inspect', moduleName: 'Control', command: 'inspect', purpose: 'inspect' })
|
||||||
|
message.commandExecutions = { build: { status: 'failed', output: 'build failed' }, inspect: { status: 'completed', output: 'done' } }
|
||||||
|
const h = harness(message)
|
||||||
|
h.cancelPlan('proposal')
|
||||||
|
assert.equal(h.current().commandExecutions.inspect.status, 'completed')
|
||||||
|
assert.equal(h.current().commandExecutions.inspect.output, 'done')
|
||||||
|
assert.equal(h.current().commandExecutions.build.status, 'rejected')
|
||||||
|
assert.equal(h.current().commandExecutions.build.output, 'build failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const applyState of ['applying', 'applied', 'cancelled']) {
|
||||||
|
test(`cannot cancel a proposal in ${applyState} state`, () => {
|
||||||
|
const h = harness(proposal({ applyState }))
|
||||||
|
assert.equal(h.cancelPlan('proposal'), false)
|
||||||
|
assert.equal(h.saved.length, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cannot cancel during a running command, another apply, pending request, or changed conversation', () => {
|
||||||
|
for (const setup of [
|
||||||
|
h => { h.current().commandExecutions.build.status = 'running' },
|
||||||
|
h => { h.applyingRef.current = true },
|
||||||
|
h => { h.abortControllerRef.current = {} },
|
||||||
|
h => { h.messageStateRef.current.session = {} }
|
||||||
|
]) {
|
||||||
|
const h = harness(proposal())
|
||||||
|
setup(h)
|
||||||
|
assert.equal(h.cancelPlan('proposal'), false)
|
||||||
|
assert.equal(h.saved.length, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an apply already started cannot be cancelled and completes normally', async () => {
|
||||||
|
const h = harness(proposal())
|
||||||
|
let finish
|
||||||
|
const applying = h.applyPlan('proposal', () => new Promise(resolve => { finish = resolve }))
|
||||||
|
assert.equal(h.current().applyState, 'applying')
|
||||||
|
assert.equal(h.cancelPlan('proposal'), false)
|
||||||
|
finish()
|
||||||
|
await applying
|
||||||
|
assert.equal(h.current().applyState, 'applied')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('new module proposals can be cancelled without creating anything', () => {
|
||||||
|
const message = proposal()
|
||||||
|
message.plan.changes = []
|
||||||
|
message.plan.createModule = { name: 'Example' }
|
||||||
|
const h = harness(message)
|
||||||
|
assert.equal(h.cancelPlan('proposal'), true)
|
||||||
|
assert.equal(h.current().applyState, 'cancelled')
|
||||||
|
})
|
||||||
|
|
||||||
|
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 text(root) {
|
||||||
|
if (typeof root === 'string') return root
|
||||||
|
return React.isValidElement(root) ? React.Children.toArray(root.props.children).map(text).join('') : ''
|
||||||
|
}
|
||||||
|
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 renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, {
|
||||||
|
...require('lucide-react'), t: key => key, useLocaleRender() {},
|
||||||
|
ProgressiveChatResponse: () => null, MessageCopyButton: () => null,
|
||||||
|
AgentWorkDuration: () => null,
|
||||||
|
agentMessageCopyText: message => message.content
|
||||||
|
}).AgentMessages
|
||||||
|
|
||||||
|
for (const dark of [true, false]) {
|
||||||
|
test(`cancel is left of apply, disabled during work, and absent after cancellation (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
let cancelled
|
||||||
|
const props = { dark, enabled: true, statusLoading: false, pending: false,
|
||||||
|
messages: [proposal()], onApply() {}, onCancel: id => { cancelled = id }, onOpenChange() {}, onRunCommand() {}, onRejectCommand() {} }
|
||||||
|
let tree = renderAgent(props)
|
||||||
|
let buttons = elements(tree, node => node.type === 'button')
|
||||||
|
const cancel = buttons.find(button => text(button) === '취소')
|
||||||
|
const apply = buttons.find(button => text(button) === '변경 적용')
|
||||||
|
assert.ok(buttons.indexOf(cancel) < buttons.indexOf(apply))
|
||||||
|
const cancelLabel = elements(cancel, node => node.type === 'span' && text(node) === '취소')[0]
|
||||||
|
assert.match(cancelLabel.props.className, /top-px leading-none/)
|
||||||
|
cancel.props.onClick()
|
||||||
|
assert.equal(cancelled, 'proposal')
|
||||||
|
const busyCancel = elements(renderAgent({ ...props, busy: true }), node => node.type === 'button' && text(node) === '취소')[0]
|
||||||
|
assert.equal(busyCancel.props.disabled, true)
|
||||||
|
tree = renderAgent({ ...props, messages: [proposal({ applyState: 'cancelled' })] })
|
||||||
|
buttons = elements(tree, node => node.type === 'button')
|
||||||
|
assert.equal(buttons.some(button => ['취소', '승인', '다시 적용', '변경 적용'].includes(text(button))), false)
|
||||||
|
assert.equal(buttons.find(button => text(button) === '취소됨').props.disabled, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 =>/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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')
|
||||||
|
|
||||||
|
function compile(source, globals = {}) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports, DOMException, Error, ...globals })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const evidenceApi = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
|
||||||
|
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||||
|
require: name => { assert.equal(name, './agentEvidence'); return evidenceApi }
|
||||||
|
})
|
||||||
|
const conversation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'))
|
||||||
|
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
||||||
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source)))
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const response = (toolRequests = []) => ({ provider: 'QWEN', model: 'fixture', plan: { summary: '분석 완료', explanation: '최종 설명', requestedModules: [], requestedLogs: [], changes: [], commands: [], createModule: null, actionPlan: [], toolRequests } })
|
||||||
|
const deferred = () => { let resolve; let reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||||
|
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||||||
|
const result = (extra = {}) => ({ id: 'tool', name: 'files.read', status: 'ok', target: 'fixture', observedAt: new Date().toISOString(), data: '{"exists":true,"complete":true,"content":"PRIVATE_SOURCE"}', truncated: false, ...extra })
|
||||||
|
|
||||||
|
function harness(requestApi) {
|
||||||
|
const session = {}
|
||||||
|
let now = 1000
|
||||||
|
class Clock extends Date { static now() { return now } }
|
||||||
|
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
|
||||||
|
const messageStateRef = { current: { session, messages: [] } }
|
||||||
|
const selectedModel = { provider: 'QWEN', model: 'fixture', imagesEnabled: true }
|
||||||
|
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
|
||||||
|
...context, ...conversation, session, messageStateRef, AbortController, FormData,
|
||||||
|
Date: Clock,
|
||||||
|
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false,
|
||||||
|
selectedModel,
|
||||||
|
status: { enabled: true, selectionSupported: true }, abortControllerRef: { current: null },
|
||||||
|
setPending: value => { state.pending = value },
|
||||||
|
setRequestStartedAt: value => { state.requestStartedAt = value },
|
||||||
|
setActivity: value => { state.activity = value },
|
||||||
|
setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value },
|
||||||
|
updateMessages: update => { state.messages = update(state.messages); messageStateRef.current.messages = state.messages; state.saved.push(plain(state.messages)) },
|
||||||
|
onResponseRef: { current: () => { state.notices++ } }, requestApi
|
||||||
|
})
|
||||||
|
return { ...api, state, selectedModel, messageStateRef, advance: ms => { now += ms } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shows and persists the question, pending state and timer before a slow module snapshot finishes', async () => {
|
||||||
|
const snapshot = deferred(), completion = deferred()
|
||||||
|
const bodies = []
|
||||||
|
let loads = 0
|
||||||
|
const h = harness((_, request) => { bodies.push(JSON.parse(request.body)); return completion.promise })
|
||||||
|
const running = h.requestPlan('원격 차량 속도 제한 위치', [], [], {
|
||||||
|
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10', executeTool: async () => result(),
|
||||||
|
loadInitialContext: signal => { loads++; assert.equal(signal.aborted, false); return snapshot.promise }
|
||||||
|
})
|
||||||
|
assert.equal(h.state.messages[0].content, '원격 차량 속도 제한 위치')
|
||||||
|
assert.equal(h.state.saved[0][0].role, 'user')
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.requestStartedAt, 1000)
|
||||||
|
assert.equal(h.state.activity, '모듈 상태 조회 중')
|
||||||
|
assert.equal(bodies.length, 0)
|
||||||
|
await h.requestPlan('중복 전송', [], [], { loadInitialContext: () => { loads++; return snapshot.promise } })
|
||||||
|
assert.equal(loads, 1)
|
||||||
|
assert.equal(h.state.messages.length, 1)
|
||||||
|
h.advance(5000)
|
||||||
|
h.selectedModel.provider = 'OPENAI'
|
||||||
|
snapshot.resolve({ modules: [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], runtimeLogs: [] })
|
||||||
|
await tick()
|
||||||
|
assert.equal(bodies.length, 1)
|
||||||
|
assert.equal(bodies[0].provider, 'QWEN')
|
||||||
|
assert.equal(bodies[0].modules[0].state, 'running')
|
||||||
|
assert.deepEqual(bodies[0].modules[0].parameters, ['sim_mode:=false'])
|
||||||
|
h.advance(2000)
|
||||||
|
completion.resolve(response())
|
||||||
|
await running
|
||||||
|
assert.equal(h.state.messages[1].durationMs, 7000)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const reject of [false, true]) test(`cancels during initial module lookup and ignores its late ${reject ? 'failure' : 'result'} without affecting a new request`, async () => {
|
||||||
|
const old = deferred(), next = deferred()
|
||||||
|
let calls = 0, signal
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const first = h.requestPlan('첫 질문', [], [], { loadInitialContext: current => { signal = current; return old.promise } })
|
||||||
|
h.stop()
|
||||||
|
assert.equal(signal.aborted, true)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
const second = h.requestPlan('다음 질문', [], [], { loadInitialContext: () => next.promise })
|
||||||
|
if (reject) old.reject(Error('늦게 도착한 SSH 오류'))
|
||||||
|
else old.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await first
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.activity, '모듈 상태 조회 중')
|
||||||
|
assert.equal(h.state.messages.length, 2)
|
||||||
|
assert.equal(h.state.notices, 0)
|
||||||
|
next.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await second
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
assert.equal(h.state.messages.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('initial snapshot errors preserve the question and show a timed error without calling the model', async () => {
|
||||||
|
const snapshot = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const running = h.requestPlan('질문', [], [], { loadInitialContext: () => snapshot.promise })
|
||||||
|
h.advance(3000)
|
||||||
|
snapshot.reject(Error('원격 모듈 상태 조회 실패'))
|
||||||
|
await running
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.messages[0].content, '질문')
|
||||||
|
assert.equal(h.state.messages[1].content, '원격 모듈 상태 조회 실패')
|
||||||
|
assert.equal(h.state.messages[1].durationMs, 3000)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
assert.equal(h.state.requestStartedAt, null)
|
||||||
|
assert.equal(h.state.activity, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scope changes during initial snapshot loading suppress later API calls and errors', async () => {
|
||||||
|
const snapshot = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const running = h.requestPlan('이전 강좌 질문', [], [], { loadInitialContext: () => snapshot.promise })
|
||||||
|
h.messageStateRef.current.session = {}
|
||||||
|
snapshot.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await running
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.messages.length, 1)
|
||||||
|
assert.equal(h.state.notices, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
|
||||||
|
const first = deferred(), tool = deferred(), final = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(() => (++calls === 1 ? first.promise : final.promise))
|
||||||
|
const running = h.requestPlan('카메라 FPS 확인', [{ name: 'Camera', files: [] }], [], { executeTool: () => tool.promise })
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.requestStartedAt, 1000)
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
first.resolve(response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config/camera.yaml' } }]))
|
||||||
|
await tick()
|
||||||
|
assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis'])
|
||||||
|
assert.match(h.state.activity, /files.read 조회 중/)
|
||||||
|
tool.resolve(result())
|
||||||
|
await tick()
|
||||||
|
assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis', 'tool'])
|
||||||
|
assert.equal(h.state.steps[1].path, 'config/camera.yaml')
|
||||||
|
assert.equal(JSON.stringify(h.state.steps).includes('PRIVATE_SOURCE'), false)
|
||||||
|
assert.equal(h.state.messages.length, 1)
|
||||||
|
assert.equal(h.state.saved.flat().some(message => message.progressSteps), false)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
h.advance(20_900)
|
||||||
|
final.resolve(response())
|
||||||
|
await running
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
assert.equal(h.state.activity, '')
|
||||||
|
assert.equal(h.state.requestStartedAt, null)
|
||||||
|
assert.equal(h.state.messages.length, 2)
|
||||||
|
assert.equal(h.state.messages[1].plan.summary, '분석 완료')
|
||||||
|
assert.equal(h.state.messages[1].durationMs, 20_900)
|
||||||
|
assert.equal(h.state.saved.at(-1)[1].durationMs, 20_900)
|
||||||
|
assert.equal(h.state.notices, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('image uploads and every lookup round retain the original provider without uploading the image twice', async () => {
|
||||||
|
const bodies = []
|
||||||
|
let uploads = 0
|
||||||
|
const h = harness(async (path, request) => {
|
||||||
|
if (path.endsWith('/attachments/images')) {
|
||||||
|
uploads++
|
||||||
|
assert.equal(request.body.get('provider'), 'QWEN')
|
||||||
|
assert.equal(request.body.get('courseId'), 'course')
|
||||||
|
assert.equal(request.body.get('week'), '3')
|
||||||
|
assert.equal(request.body.get('file').name, 'screen.png')
|
||||||
|
return { token: 'image-reference', name: 'screen.png', url: 'https://images.test/screen.png' }
|
||||||
|
}
|
||||||
|
bodies.push(JSON.parse(request.body))
|
||||||
|
h.selectedModel.provider = 'OPENAI'
|
||||||
|
return bodies.length === 1
|
||||||
|
? { ...response(), plan: { ...response().plan, requestedModules: ['Control'] } }
|
||||||
|
: response()
|
||||||
|
})
|
||||||
|
await h.requestPlan('이미지 확인', [{ name: 'Control', files: [] }], [], {
|
||||||
|
attachments: [{ kind: 'image', name: 'screen.png', file: new Blob(['fixture'], { type: 'image/png' }) }],
|
||||||
|
loadRequestedContext: async () => ({ modules: [{ name: 'Control', files: [{ path: 'config/control.yaml', content: 'speed: 0.3' }] }], runtimeLogs: [] })
|
||||||
|
})
|
||||||
|
assert.equal(uploads, 1)
|
||||||
|
assert.equal(bodies.length, 2)
|
||||||
|
assert.ok(bodies.every(body => body.provider === 'QWEN'))
|
||||||
|
assert.ok(bodies.every(body => body.imageTokens[0] === 'image-reference'))
|
||||||
|
assert.equal(h.state.messages.at(-1).model, 'fixture')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remote target and observed source ranges reach each provider request including finalization', async () => {
|
||||||
|
const bodies = []
|
||||||
|
const tool = { id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config/car.yaml', startLine: 20, lineCount: 1 } }
|
||||||
|
const h = harness(async (_, request) => {
|
||||||
|
bodies.push(JSON.parse(request.body))
|
||||||
|
return bodies.length < 3 ? response([tool]) : response()
|
||||||
|
})
|
||||||
|
await h.requestPlan('속도 제한 코드 위치', [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], [], {
|
||||||
|
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10',
|
||||||
|
executeTool: async input => result({ id: input.id, moduleName: 'Control', target: 'ssh:100.64.0.10', truncated: true,
|
||||||
|
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), complete: false, content: 'max_speed: 0.4', startLine: 20, endLine: 20, contentTruncated: false, totalLines: 100 }) })
|
||||||
|
})
|
||||||
|
assert.equal(bodies.length, 3)
|
||||||
|
for (const body of bodies) {
|
||||||
|
assert.equal(body.environment, 'remote')
|
||||||
|
assert.equal(body.workspaceTarget, 'ssh:100.64.0.10')
|
||||||
|
assert.deepEqual(body.modules[0].parameters, ['sim_mode:=false'])
|
||||||
|
}
|
||||||
|
assert.equal(JSON.parse(bodies[1].toolResults[0].data).content, 'max_speed: 0.4')
|
||||||
|
assert.equal(JSON.parse(bodies[2].toolResults[0].data).endLine, 20)
|
||||||
|
assert.match(bodies[2].request, /자료 조회는 종료되었습니다/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends only prior same-workspace context and keeps it stable throughout tool lookups', async () => {
|
||||||
|
const bodies = []
|
||||||
|
const h = harness(async (_, request) => {
|
||||||
|
bodies.push(JSON.parse(request.body))
|
||||||
|
const reply = bodies.length === 2 ? response([{ id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config.yaml' } }]) : response()
|
||||||
|
return { ...reply, plan: { ...reply.plan, conversationSummary: 'Control의 config.yaml 설명을 진행 중입니다.' } }
|
||||||
|
})
|
||||||
|
const options = { environment: 'local', workspaceTarget: 'local-host', workspaceRoot: '/workspace/a', executeTool: async () => result() }
|
||||||
|
await h.requestPlan('Control 설정 위치', [], [], options)
|
||||||
|
assert.equal(bodies[0].conversationContext, undefined)
|
||||||
|
await h.requestPlan('그 파일 설명해줘', [], [], options)
|
||||||
|
assert.equal(bodies.length, 3)
|
||||||
|
assert.equal(bodies[1].conversationContext.recentMessages[0].content, 'Control 설정 위치')
|
||||||
|
assert.equal(bodies[1].conversationContext.summary, 'Control의 config.yaml 설명을 진행 중입니다.')
|
||||||
|
assert.equal(h.state.saved[1][1].plan.conversationSummary, 'Control의 config.yaml 설명을 진행 중입니다.')
|
||||||
|
assert.equal(JSON.stringify(bodies[1].conversationContext).includes('그 파일 설명해줘'), false)
|
||||||
|
assert.deepEqual(bodies[1].conversationContext, bodies[2].conversationContext)
|
||||||
|
assert.equal(bodies[1].workspaceRoot, undefined)
|
||||||
|
await h.requestPlan('다른 원격 질문', [], [], { ...options, environment: 'remote', workspaceTarget: 'ssh:device' })
|
||||||
|
assert.equal(bodies.at(-1).conversationContext, undefined)
|
||||||
|
await h.requestPlan('다른 로컬 폴더', [], [], { ...options, workspaceRoot: '/workspace/b' })
|
||||||
|
assert.equal(bodies.at(-1).conversationContext, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('errors replace temporary progress with the existing error reply', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => {
|
||||||
|
if (++calls === 1) return response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }])
|
||||||
|
throw Error('서버 오류')
|
||||||
|
})
|
||||||
|
await h.requestPlan('확인', [{ name: 'Camera', files: [] }], [], { executeTool: async () => result({ status: 'error' }) })
|
||||||
|
assert.equal(h.state.messages.at(-1).content, '서버 오류')
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
assert.equal(h.state.requestStartedAt, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stopping clears progress and a late response cannot overwrite a newer request', async () => {
|
||||||
|
const old = deferred(), next = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(() => (++calls === 1 ? old.promise : next.promise))
|
||||||
|
const first = h.requestPlan('첫 질문', [], [])
|
||||||
|
h.stop()
|
||||||
|
assert.equal(h.state.requestStartedAt, null)
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
assert.equal(h.state.activity, '')
|
||||||
|
const second = h.requestPlan('다음 질문', [], [])
|
||||||
|
old.resolve(response())
|
||||||
|
await first
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 0)
|
||||||
|
next.resolve(response())
|
||||||
|
await second
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scope changes suppress late progress callbacks', async () => {
|
||||||
|
const tool = deferred()
|
||||||
|
const h = harness(async () => response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }]))
|
||||||
|
const running = h.requestPlan('확인', [], [], { executeTool: () => tool.promise })
|
||||||
|
await tick()
|
||||||
|
h.messageStateRef.current.session = {}
|
||||||
|
const previous = h.state.steps.length
|
||||||
|
h.stop()
|
||||||
|
tool.resolve(result())
|
||||||
|
await running
|
||||||
|
assert.equal(previous, 1)
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
assert.equal(h.state.notices, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source context stages report missing and limited modules and empty logs honestly', async () => {
|
||||||
|
const steps = []
|
||||||
|
let calls = 0
|
||||||
|
await context.resolveAgentContext({
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
initialContext: { modules: [{ name: 'Missing', files: [] }, { name: 'Limited', files: [] }], runtimeLogs: [] },
|
||||||
|
onStep: step => steps.push(step),
|
||||||
|
requestPlan: async () => ++calls === 1 ? { ...response(), plan: { ...response().plan, requestedModules: ['Missing', 'Limited'], requestedLogs: ['Missing'] } } : response(),
|
||||||
|
loadRequestedContext: async () => ({ modules: [{ name: 'Missing', files: [], fileContext: { status: 'uninstalled' } }, { name: 'Limited', files: [{ path: 'x', content: 'PRIVATE_SOURCE' }], fileContext: { status: 'limited' } }], runtimeLogs: [] })
|
||||||
|
})
|
||||||
|
assert.deepEqual(plain(steps).map(step => [step.kind, step.status]), [['analysis', 'ok'], ['files', 'missing'], ['files', 'partial'], ['logs', 'empty'], ['analysis', 'ok']])
|
||||||
|
assert.equal(JSON.stringify(steps).includes('PRIVATE_SOURCE'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tool stages distinguish missing files, partial reads and counts without exposing raw output', async () => {
|
||||||
|
const steps = []
|
||||||
|
const tools = [
|
||||||
|
{ id: 'missing', name: 'files.read', moduleName: 'Camera', args: { path: 'missing.yaml' } },
|
||||||
|
{ id: 'partial', name: 'files.read', moduleName: 'Camera', args: { path: 'large.py' } },
|
||||||
|
{ id: 'list', name: 'files.list', moduleName: 'Camera' },
|
||||||
|
{ id: 'search', name: 'files.search', moduleName: 'Camera', args: { query: 'fps' } }
|
||||||
|
]
|
||||||
|
let calls = 0
|
||||||
|
await context.resolveAgentContext({
|
||||||
|
signal: new AbortController().signal, initialContext: { modules: [], runtimeLogs: [] }, onStep: step => steps.push(step),
|
||||||
|
requestPlan: async () => response(++calls === 1 ? tools : []),
|
||||||
|
executeTool: async tool => result({ data: JSON.stringify(tool.args?.path === 'missing.yaml' ? { exists: false } : tool.args?.path === 'large.py' ? { complete: false } : tool.name === 'files.list' ? { paths: ['a', 'b'] } : { matches: [] }) })
|
||||||
|
})
|
||||||
|
assert.deepEqual(plain(steps.filter(step => step.kind === 'tool')).map(step => [step.status, step.count]), [['missing', undefined], ['partial', undefined], ['ok', 2], ['ok', 0]])
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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 contextApi = {}
|
||||||
|
const evidenceApi = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: evidenceApi })
|
||||||
|
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, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
|
||||||
|
|
||||||
|
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
let payloadExpression
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('imageTokens')) {
|
||||||
|
payloadExpression = node.getText(source)
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(source)
|
||||||
|
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
|
||||||
|
|
||||||
|
test('user messages preserve only typed text while attachments remain separate metadata', () => {
|
||||||
|
let initializer
|
||||||
|
function find(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'userMessage') initializer = node.initializer.getText(source)
|
||||||
|
ts.forEachChild(node, find)
|
||||||
|
}
|
||||||
|
find(source)
|
||||||
|
assert.ok(initializer)
|
||||||
|
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
|
||||||
|
const message = vm.runInNewContext(`(${initializer})`, { request, conversationScope: undefined, options: { attachments: [{ name: '스크린샷.png' }] } })
|
||||||
|
assert.equal(message.content, request)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
|
||||||
|
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
||||||
|
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
||||||
|
textAttachments: attachments ?? [], imageTokens, conversationContext: undefined,
|
||||||
|
selection: provider ? { provider } : {},
|
||||||
|
agentPlanRequestText: contextApi.agentPlanRequestText,
|
||||||
|
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sends image reference tokens without image bytes or public URLs on every context round', () => {
|
||||||
|
const body = serialize(undefined, true, '이미지 분석', ['signed-image-token'])
|
||||||
|
assert.deepEqual(body.imageTokens, ['signed-image-token'])
|
||||||
|
assert.equal(Object.hasOwn(body, 'attachments'), false)
|
||||||
|
assert.equal(JSON.stringify(body).includes('base64'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const attachments of [undefined, []]) {
|
||||||
|
test(`omits attachments when ${attachments ? 'empty' : 'undefined'}`, () => {
|
||||||
|
const body = serialize(attachments)
|
||||||
|
assert.equal(Object.hasOwn(body, 'attachments'), false)
|
||||||
|
assert.equal(body.request, '설정 위치 알려줘')
|
||||||
|
assert.equal(body.modules[0].name, 'Control')
|
||||||
|
assert.equal(body.week, 3)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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', 'provider', '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, /원래 학생 질문/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends only a provider selection rather than student-supplied models or keys', () => {
|
||||||
|
for (const finalize of [false, true]) {
|
||||||
|
const body = serialize(undefined, finalize, '확인', [], 'OPENAI')
|
||||||
|
assert.equal(body.provider, 'OPENAI')
|
||||||
|
assert.equal(Object.hasOwn(body, 'model'), false)
|
||||||
|
assert.equal(Object.hasOwn(body, 'apiKey'), false)
|
||||||
|
assert.equal(Object.hasOwn(body, 'endpointUrl'), false)
|
||||||
|
}
|
||||||
|
assert.equal(Object.hasOwn(serialize(undefined, false, '확인', [], null), 'provider'), false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
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': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
|
||||||
|
})
|
||||||
|
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('internal cumulative memory is not rendered in the response bubble', () => {
|
||||||
|
const html = renderResponse({ content: '화면 답변', plan: { summary: '화면 요약', explanation: '화면 설명', conversationSummary: 'HIDDEN_CONVERSATION_MEMORY' } }, 0)
|
||||||
|
assert.match(html, /화면 설명/)
|
||||||
|
assert.doesNotMatch(html, /HIDDEN_CONVERSATION_MEMORY/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
|
||||||
|
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
|
||||||
|
assert.ok(html.includes('A-Copilot'))
|
||||||
|
assert.ok(!html.includes('A_COPILOT'))
|
||||||
|
assert.ok(!html.includes('A-Copilot · A-Copilot'))
|
||||||
|
assert.equal((html.match(/>A-Copilot<\/p>/g) ?? []).length, 1)
|
||||||
|
assert.ok(renderResponse({ content: '결과', provider: 'QWEN', model: 'qwen3.8-max' }, 0).includes('QWEN · qwen3.8-max'))
|
||||||
|
})
|
||||||
|
|
||||||
|
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': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
|
||||||
|
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': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
|
||||||
|
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, '복사됨')
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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 exportsObject = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentSessionSync.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: exportsObject, console: { warn() {} } })
|
||||||
|
const { createAgentSessionSync } = exportsObject
|
||||||
|
const message = (id, content = id) => ({ id, role: 'assistant', content })
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve
|
||||||
|
const promise = new Promise(done => { resolve = done })
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
const storage = () => {
|
||||||
|
const data = new Map()
|
||||||
|
return {
|
||||||
|
getItem: key => data.get(key) ?? null,
|
||||||
|
setItem: (key, value) => data.set(key, value),
|
||||||
|
removeItem: key => data.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('saves replies in order and preserves a newer result arriving during an active save', async () => {
|
||||||
|
const gate = deferred()
|
||||||
|
const calls = []
|
||||||
|
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => {
|
||||||
|
calls.push(plain(batch))
|
||||||
|
if (calls.length === 1) await gate.promise
|
||||||
|
} })
|
||||||
|
sync.enqueue([message('command', 'running')])
|
||||||
|
await Promise.resolve()
|
||||||
|
sync.enqueue([message('command', 'completed'), message('answer')])
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
gate.resolve()
|
||||||
|
await sync.flush()
|
||||||
|
assert.equal(calls.length, 2)
|
||||||
|
assert.deepEqual(calls[1], [message('command', 'completed'), message('answer')])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps a reply cached before network completion and after failed persistence', async () => {
|
||||||
|
const disk = storage()
|
||||||
|
const sync = createAgentSessionSync({ storage: disk, storageKey: 'student-a', save: async () => { throw Error('offline') } })
|
||||||
|
sync.enqueue([message('answer')])
|
||||||
|
assert.deepEqual(JSON.parse(disk.getItem('student-a')), [message('answer')])
|
||||||
|
await sync.flush()
|
||||||
|
assert.ok(sync.getError())
|
||||||
|
const batches = []
|
||||||
|
const reopened = createAgentSessionSync({ storage: disk, storageKey: 'student-a', save: async batch => { batches.push(plain(batch)) } })
|
||||||
|
assert.deepEqual(plain(reopened.restore([{ id: 'question', role: 'user', content: '?' }])), [
|
||||||
|
{ id: 'question', role: 'user', content: '?' }, message('answer')
|
||||||
|
])
|
||||||
|
await reopened.flush()
|
||||||
|
assert.deepEqual(batches, [[message('answer')]])
|
||||||
|
assert.equal(disk.getItem('student-a'), null)
|
||||||
|
assert.equal(reopened.getError(), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not overwrite a new reply with an older history response', async () => {
|
||||||
|
const sync = createAgentSessionSync({ storageKey: 'test', save: async () => {} })
|
||||||
|
sync.enqueue([message('answer', 'new answer')])
|
||||||
|
await sync.flush()
|
||||||
|
assert.deepEqual(plain(sync.restore([message('answer', 'old answer')])), [message('answer', 'new answer')])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('saves only modified messages even when restored history exceeds 100 messages', async () => {
|
||||||
|
const batches = []
|
||||||
|
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { batches.push(plain(batch)) } })
|
||||||
|
const history = Array.from({ length: 150 }, (_, index) => message(`message-${index}`))
|
||||||
|
sync.restore(history)
|
||||||
|
sync.enqueue([...history, message('new-answer')])
|
||||||
|
await sync.flush()
|
||||||
|
assert.deepEqual(batches, [[message('new-answer')]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('chunks a large backlog below the server message-count limit', async () => {
|
||||||
|
const batches = []
|
||||||
|
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { batches.push(plain(batch)) } })
|
||||||
|
sync.enqueue(Array.from({ length: 205 }, (_, index) => message(`message-${index}`)))
|
||||||
|
await sync.flush()
|
||||||
|
assert.deepEqual(batches.map(batch => batch.length), [100, 100, 5])
|
||||||
|
assert.equal(new Set(batches.flat().map(item => item.id)).size, 205)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaving the view does not cancel an accepted write', async () => {
|
||||||
|
const gate = deferred()
|
||||||
|
const disk = storage()
|
||||||
|
let saved = false
|
||||||
|
const sync = createAgentSessionSync({ storage: disk, storageKey: 'test', save: async () => { await gate.promise; saved = true } })
|
||||||
|
const unsubscribe = sync.subscribe(() => {})
|
||||||
|
sync.enqueue([message('answer')])
|
||||||
|
unsubscribe()
|
||||||
|
gate.resolve()
|
||||||
|
await sync.flush()
|
||||||
|
assert.equal(saved, true)
|
||||||
|
assert.equal(disk.getItem('test'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('archives after outstanding writes complete and clears cached replies', async () => {
|
||||||
|
const gate = deferred()
|
||||||
|
const order = []
|
||||||
|
const disk = storage()
|
||||||
|
const sync = createAgentSessionSync({ storage: disk, storageKey: 'test', save: async () => { await gate.promise; order.push('saved') } })
|
||||||
|
sync.enqueue([message('answer')])
|
||||||
|
const clearing = sync.clear(async () => { order.push('archived') })
|
||||||
|
gate.resolve()
|
||||||
|
await clearing
|
||||||
|
assert.deepEqual(order, ['saved', 'archived'])
|
||||||
|
assert.deepEqual(plain(sync.restore([])), [])
|
||||||
|
assert.equal(disk.getItem('test'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pending replies stay isolated by student and course scope', async () => {
|
||||||
|
const disk = storage()
|
||||||
|
const first = createAgentSessionSync({ storage: disk, storageKey: 'student-a:course-1', save: async () => { throw Error('offline') } })
|
||||||
|
first.enqueue([message('private-answer')])
|
||||||
|
await first.flush()
|
||||||
|
const second = createAgentSessionSync({ storage: disk, storageKey: 'student-b:course-1', save: async () => {} })
|
||||||
|
assert.deepEqual(plain(second.restore([])), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('late command updates cannot recreate a conversation while it is being cleared', async () => {
|
||||||
|
const archive = deferred()
|
||||||
|
const calls = []
|
||||||
|
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { calls.push(plain(batch)) } })
|
||||||
|
sync.enqueue([message('answer')])
|
||||||
|
await sync.flush()
|
||||||
|
const clearing = sync.clear(() => archive.promise)
|
||||||
|
sync.enqueue([message('answer', 'late command result')])
|
||||||
|
archive.resolve()
|
||||||
|
await clearing
|
||||||
|
await sync.flush()
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
assert.deepEqual(plain(sync.restore([])), [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
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 rangedFull = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 1, lineCount: 200 }))
|
||||||
|
assert.equal(rangedFull.data.complete, true)
|
||||||
|
assert.equal(rangedFull.truncated, false)
|
||||||
|
assert.equal(rangedFull.data.endLine, 3)
|
||||||
|
assert.equal(rangedFull.data.contentTruncated, false)
|
||||||
|
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)
|
||||||
|
assert.equal(partial.data.endLine, 2)
|
||||||
|
assert.equal(partial.data.contentTruncated, false)
|
||||||
|
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}: character truncation never advertises a cut line as fully observed`, async t => {
|
||||||
|
const root = await fixture(t)
|
||||||
|
await fs.writeFile(path.join(root, 'config/long.yaml'), `first: true\n${'x'.repeat(40_000)}\nlast: true`)
|
||||||
|
const readout = await read(root, request('files.read', { path: 'config/long.yaml', startLine: 1, lineCount: 3 }))
|
||||||
|
assert.equal(readout.data.complete, false)
|
||||||
|
assert.equal(readout.data.contentTruncated, true)
|
||||||
|
assert.equal(readout.data.endLine, 1)
|
||||||
|
})
|
||||||
|
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'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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 harness(initialNow = 1000) {
|
||||||
|
let now = initialNow, state, effect, interval, cleared = 0
|
||||||
|
const windowEvents = new Map(), documentEvents = new Map()
|
||||||
|
class Clock extends Date { static now() { return now } }
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, {
|
||||||
|
exports, Date: Clock,
|
||||||
|
window: {
|
||||||
|
setInterval(callback, delay) { assert.equal(delay, 1000); interval = callback; return 7 },
|
||||||
|
clearInterval(id) { assert.equal(id, 7); interval = undefined; cleared++ },
|
||||||
|
addEventListener: (name, callback) => windowEvents.set(name, callback),
|
||||||
|
removeEventListener: name => windowEvents.delete(name)
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
addEventListener: (name, callback) => documentEvents.set(name, callback),
|
||||||
|
removeEventListener: name => documentEvents.delete(name)
|
||||||
|
},
|
||||||
|
require(name) {
|
||||||
|
if (name === '../i18n') return { t: (key, values) => key.replace(/\{\{seconds\}\}/g, values.seconds), useLocaleRender() {} }
|
||||||
|
if (name === 'react') return { ...React,
|
||||||
|
useState(initial) { state ??= typeof initial === 'function' ? initial() : initial; return [state, value => { state = value }] },
|
||||||
|
useEffect(callback) { effect = callback }
|
||||||
|
}
|
||||||
|
return require(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
render: props => renderToStaticMarkup(exports.AgentWorkDuration({ dark: true, ...props })),
|
||||||
|
mount: () => effect(), advance: ms => { now += ms }, tick: () => interval?.(),
|
||||||
|
visible: () => documentEvents.get('visibilitychange')?.(), focus: () => windowEvents.get('focus')?.(),
|
||||||
|
interval: () => interval, cleared: () => cleared,
|
||||||
|
listeners: () => windowEvents.size + documentEvents.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('running duration is based on start time, not the number of interval ticks', () => {
|
||||||
|
const h = harness()
|
||||||
|
assert.match(h.render({ startedAt: 1000 }), /0초 동안 작업 중입니다/)
|
||||||
|
const cleanup = h.mount()
|
||||||
|
h.advance(12_900)
|
||||||
|
h.tick()
|
||||||
|
assert.match(h.render({ startedAt: 1000 }), /12초 동안 작업 중입니다/)
|
||||||
|
h.advance(60_000)
|
||||||
|
h.visible()
|
||||||
|
assert.match(h.render({ startedAt: 1000 }), /72초 동안 작업 중입니다/)
|
||||||
|
h.advance(5_000)
|
||||||
|
h.focus()
|
||||||
|
assert.match(h.render({ startedAt: 1000 }), /77초 동안 작업 중입니다/)
|
||||||
|
assert.equal(h.listeners(), 2)
|
||||||
|
cleanup()
|
||||||
|
assert.equal(h.interval(), undefined)
|
||||||
|
assert.equal(h.listeners(), 0)
|
||||||
|
assert.equal(h.cleared(), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returning to a remounted chat shows the full elapsed time immediately', () => {
|
||||||
|
const h = harness(74_000)
|
||||||
|
assert.match(h.render({ startedAt: 1000 }), /73초 동안 작업 중입니다/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('completed duration stays fixed and never starts a timer', () => {
|
||||||
|
const h = harness()
|
||||||
|
assert.match(h.render({ startedAt: 1000, durationMs: 20_900 }), /20초 동안 작업함/)
|
||||||
|
assert.equal(h.mount(), undefined)
|
||||||
|
h.advance(60_000)
|
||||||
|
h.tick()
|
||||||
|
assert.match(h.render({ durationMs: 20_900 }), /20초 동안 작업함/)
|
||||||
|
assert.equal(h.interval(), undefined)
|
||||||
|
assert.equal(h.listeners(), 0)
|
||||||
|
assert.match(h.render({ durationMs: 0 }), /0초 동안 작업함/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy replies and invalid timing do not invent an elapsed duration', () => {
|
||||||
|
const h = harness()
|
||||||
|
for (const durationMs of [undefined, -1, NaN, Infinity]) assert.equal(h.render({ durationMs }), '')
|
||||||
|
assert.match(h.render({ startedAt: 2000 }), /0초 동안 작업 중입니다/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const app = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const declarations = new Map()
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) declarations.set(node.name.text, node.initializer?.getText(app))
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(app)
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
|
||||||
|
function harness(remoteWorkspace) {
|
||||||
|
const executions = []
|
||||||
|
const calls = []
|
||||||
|
const contexts = []
|
||||||
|
const events = []
|
||||||
|
const errors = []
|
||||||
|
const target = { host: '100.64.0.10', user: 'fixture', workspaceRoot: '/fixture/workspace' }
|
||||||
|
const live = [{ name: 'NAV2-MKMini', folderName: 'vehicle', state: 'running', parameters: ['sim_mode:=false', 'robot:=mkmini'] }]
|
||||||
|
const readTool = backend => async (...args) => { executions.push({ backend, args: plain(args) }); return { status: 'ok', data: '{"content":"remote source"}' } }
|
||||||
|
const exports = {}
|
||||||
|
const globals = {
|
||||||
|
exports, remoteWorkspace, availableModules: [{ ...live[0], state: 'stopped', parameters: ['sim_mode:=true'] }],
|
||||||
|
clonePathPreview: '/local/workspace', runtimeModules: [], agentActionControllerRef: { current: null }, agentApplying: false,
|
||||||
|
getRemoteWorkspaceTarget: () => target, applyModuleParameterOverrides: modules => modules,
|
||||||
|
validateAgentTool: request => request, AGENT_PRACTICE_PROFILES: [],
|
||||||
|
window: {
|
||||||
|
remoteWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('remote') } },
|
||||||
|
localWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('local') } }
|
||||||
|
},
|
||||||
|
requestAgentPlan: async (...args) => {
|
||||||
|
calls.push(args)
|
||||||
|
events.push('plan-start')
|
||||||
|
contexts.push(await args[3].loadInitialContext(new AbortController().signal))
|
||||||
|
},
|
||||||
|
onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} },
|
||||||
|
loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
|
||||||
|
}
|
||||||
|
const names = ['collectAgentWorkspace', 'readAgentModules', 'executeWorkspaceAgentTool', 'submitAgentRequest']
|
||||||
|
vm.runInNewContext(ts.transpileModule(names.map(name => `const ${name} = ${declarations.get(name)}; exports.${name} = ${name}`).join('\n'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, globals)
|
||||||
|
return { ...exports, executions, calls, contexts, events, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const remote of [false, true]) {
|
||||||
|
test(`${remote ? 'remote' : 'local'}: enters the chat lifecycle immediately and refreshes state before the provider request`, async () => {
|
||||||
|
const h = harness(remote)
|
||||||
|
await h.submitAgentRequest('최대 속도 제한 코드 위치')
|
||||||
|
assert.deepEqual(h.errors, [])
|
||||||
|
assert.equal(h.calls.length, 1)
|
||||||
|
const [, initialModules, , options] = h.calls[0]
|
||||||
|
assert.deepEqual(plain(initialModules), [])
|
||||||
|
assert.deepEqual(h.events, ['plan-start', 'snapshot-read'])
|
||||||
|
const { modules } = h.contexts[0]
|
||||||
|
assert.equal(modules[0].state, 'running')
|
||||||
|
assert.deepEqual(plain(modules[0].parameters), ['sim_mode:=false', 'robot:=mkmini'])
|
||||||
|
assert.deepEqual(plain(modules[0].files), [])
|
||||||
|
assert.equal(options.environment, remote ? 'remote' : 'local')
|
||||||
|
assert.equal(options.workspaceTarget, remote ? 'ssh:100.64.0.10' : 'local-host')
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${remote ? 'remote' : 'local'}: source reads use only the connected workspace transport and module folder`, async () => {
|
||||||
|
const h = harness(remote)
|
||||||
|
const tool = { id: 'read-1', name: 'files.read', moduleName: 'NAV2-MKMini', args: { path: 'src/profiles.py', startLine: 10, lineCount: 20 } }
|
||||||
|
await h.executeWorkspaceAgentTool(tool)
|
||||||
|
assert.equal(h.executions.length, 1)
|
||||||
|
assert.equal(h.executions[0].backend, remote ? 'remote' : 'local')
|
||||||
|
const args = h.executions[0].args
|
||||||
|
assert.deepEqual(args[remote ? 1 : 0], tool)
|
||||||
|
assert.equal(args[2], 'vehicle')
|
||||||
|
assert.equal(remote ? args[0].host : args[1], remote ? '100.64.0.10' : '/local/workspace')
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
|
||||||
|
})
|
||||||
|
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 ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
|
||||||
|
const AgentCodeReferences = () => null
|
||||||
|
const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
|
||||||
|
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
|
||||||
|
})
|
||||||
|
const { AgentProgressBubble } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentProgressBubble.tsx'), 'utf8'), {
|
||||||
|
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} },
|
||||||
|
react: { ...React, useEffect() {}, useRef: () => ({ current: null }) },
|
||||||
|
'./AgentWorkDuration': { AgentWorkDuration }
|
||||||
|
})
|
||||||
|
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
|
||||||
|
t: key => key, useLocaleRender() {},
|
||||||
|
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
|
||||||
|
ChatEmptyState: widgets.ChatEmptyState,
|
||||||
|
ProgressiveChatResponse,
|
||||||
|
ChatImageAttachments,
|
||||||
|
AgentCodeReferences,
|
||||||
|
AgentProgressBubble,
|
||||||
|
AgentWorkDuration,
|
||||||
|
MessageCopyButton: () => null,
|
||||||
|
agentMessageCopyText: message => message.content,
|
||||||
|
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('existing code locations stay usable during model and command work but not file application', () => {
|
||||||
|
for (const applying of [false, true]) {
|
||||||
|
const onOpen = () => {}
|
||||||
|
const tree = messages.AgentMessages({
|
||||||
|
dark: true, enabled: true, statusLoading: false, pending: true, busy: true,
|
||||||
|
messages: [{ id: 'answer', role: 'assistant', content: '코드 위치', applyState: applying ? 'applying' : undefined, plan: { codeReferences: [{ id: 'code-1' }], changes: [], commands: [], actionPlan: [], createModule: null } }],
|
||||||
|
onOpenCodeReference: onOpen
|
||||||
|
})
|
||||||
|
let references
|
||||||
|
function visit(element) {
|
||||||
|
if (!React.isValidElement(element)) return
|
||||||
|
if (element.type === AgentCodeReferences) references = element
|
||||||
|
React.Children.toArray(element.props.children).forEach(visit)
|
||||||
|
}
|
||||||
|
visit(tree)
|
||||||
|
assert.ok(references)
|
||||||
|
assert.equal(references.props.busy, applying)
|
||||||
|
assert.equal(typeof references.props.onOpen, 'function')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
|
||||||
|
const tree = messages.AgentMessages({
|
||||||
|
dark: true, enabled: true, statusLoading: false, pending: false,
|
||||||
|
messages: [{ id: 'user', role: 'user', content: '이 화면을 확인해줘\n두 번째 줄', imageAttachments: [{ name: '스크린샷.png', url: 'https://files.test/image.png' }] }]
|
||||||
|
})
|
||||||
|
let row
|
||||||
|
function visit(element) {
|
||||||
|
if (!React.isValidElement(element)) return
|
||||||
|
const children = React.Children.toArray(element.props.children)
|
||||||
|
if (children.some(child => React.isValidElement(child) && child.props['data-agent-user-bubble'])) row = children
|
||||||
|
children.forEach(visit)
|
||||||
|
}
|
||||||
|
visit(tree)
|
||||||
|
assert.ok(row)
|
||||||
|
assert.equal(row[0].props.children, '이 화면을 확인해줘\n두 번째 줄')
|
||||||
|
assert.match(row[0].props.className, /whitespace-pre-wrap/)
|
||||||
|
assert.equal(row[1].type, ChatImageAttachments)
|
||||||
|
assert.equal(row[1].props.variant, 'sent')
|
||||||
|
assert.equal(row[1].props.images.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 [true, false]) {
|
||||||
|
test(`agent progress accumulates only during the request and is replaced by the final reply (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const progressSteps = [
|
||||||
|
{ id: 'analysis-0', kind: 'analysis', status: 'ok', round: 1, requests: 1 },
|
||||||
|
{ id: 'tool-0-0', kind: 'tool', name: 'files.list', moduleName: 'Camera', status: 'ok', count: 3 },
|
||||||
|
{ id: 'tool-1-0', kind: 'tool', name: 'files.read', moduleName: 'Camera', path: 'config/camera.yaml', status: 'missing' },
|
||||||
|
{ id: 'tool-1-1', kind: 'tool', name: 'logs.read', moduleName: 'Camera', status: 'error' },
|
||||||
|
{ id: 'tool-1-2', kind: 'tool', name: 'files.search', moduleName: 'Camera', status: 'partial', count: 2 }
|
||||||
|
]
|
||||||
|
const props = { dark, enabled: true, statusLoading: false, pending: true, activity: '조회 결과 분석 중', progressSteps, messages: [] }
|
||||||
|
const html = renderToStaticMarkup(messages.AgentMessages(props))
|
||||||
|
assert.equal((html.match(/data-agent-progress="true"/g) ?? []).length, 1)
|
||||||
|
assert.match(html, /1차 분석 완료 · 추가 조회 1건/)
|
||||||
|
assert.match(html, /Camera · 파일 목록 · 조회 완료 · 3건/)
|
||||||
|
assert.match(html, /Camera · 파일 읽기 · config\/camera.yaml · 대상 없음/)
|
||||||
|
assert.match(html, /Camera · 로그 조회 · 조회 실패/)
|
||||||
|
assert.match(html, /Camera · 파일 검색 · 일부 결과 · 2건/)
|
||||||
|
assert.match(html, /조회 결과 분석 중/)
|
||||||
|
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 5)
|
||||||
|
const final = renderToStaticMarkup(messages.AgentMessages({ ...props, pending: false, messages: [{ id: 'answer', role: 'assistant', content: '최종 답변입니다.', durationMs: 20_900 }] }))
|
||||||
|
assert.doesNotMatch(final, /data-agent-progress|config\/camera.yaml|진행 상황/)
|
||||||
|
assert.match(final, /최종 답변입니다/)
|
||||||
|
assert.match(final, /20초 동안 작업함/)
|
||||||
|
assert.ok(final.indexOf('20초 동안 작업함') < final.indexOf('최종 답변입니다'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -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,35 @@
|
|||||||
|
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 output = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: output, TextDecoder })
|
||||||
|
const { readChatAttachments } = output
|
||||||
|
const file = (name, content) => new File([content], name)
|
||||||
|
|
||||||
|
test('reads source/config/log files and preserves Korean UTF-8 text', async () => {
|
||||||
|
const files = await readChatAttachments([file('control.py', '# 조향 설정'), file('config.yaml', 'robot: mkmini'), file('runtime.log', 'started')], [])
|
||||||
|
assert.equal(files[0].content, '# 조향 설정')
|
||||||
|
assert.equal(files[2].name, 'runtime.log')
|
||||||
|
})
|
||||||
|
test('rejects unsupported image/PDF files', async () => {
|
||||||
|
await assert.rejects(readChatAttachments([file('photo.png', 'image')], []), /현재 모델/)
|
||||||
|
await assert.rejects(readChatAttachments([file('guide.pdf', 'pdf')], []), /PDF/)
|
||||||
|
})
|
||||||
|
test('rejects more than five attachments including existing ones', async () => {
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', 'a')], Array(5).fill({ name: 'b.txt', content: '' })), /최대 5개/)
|
||||||
|
})
|
||||||
|
test('rejects binary and non-UTF8 content even under a text extension', async () => {
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', 'a\0b')], []), /바이너리/)
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', new Uint8Array([255, 254]))], []), /UTF-8/)
|
||||||
|
})
|
||||||
|
test('rejects oversized files instead of silently truncating', async () => {
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(100_001))], []), /100KB/)
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_001))], []), /5만 자/)
|
||||||
|
})
|
||||||
|
test('rejects aggregate attachment context above 100000 characters', async () => {
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_000)), file('b.txt', 'a'.repeat(50_000))], []), /10만 자/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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(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 i18n = { t: key => key, useLocaleRender() {} }
|
||||||
|
const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n })
|
||||||
|
const QaInputBox = () => null
|
||||||
|
const ChatTextContextMenu = () => 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))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(overrides = {}, browser = {}) {
|
||||||
|
const state = []
|
||||||
|
const effects = []
|
||||||
|
let index = 0
|
||||||
|
let cleared = 0
|
||||||
|
const modeChanges = []
|
||||||
|
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
|
||||||
|
'../../i18n': i18n,
|
||||||
|
'../../components/AppStatusModals': { ConfirmStatusModal },
|
||||||
|
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
|
||||||
|
'../../components/ChatWidgets': { QaInputBox },
|
||||||
|
'../../components/ChatTextContextMenu': { ChatTextContextMenu },
|
||||||
|
'../../components/ChatImageAttachments': {},
|
||||||
|
'../../components/AgentProgressBubble': {},
|
||||||
|
'../../components/AgentWorkDuration': {},
|
||||||
|
'../../components/AgentCodeReferences': {},
|
||||||
|
'../../components/AgentModelSelector': { AgentModelSelector: () => null },
|
||||||
|
'react-dom': { createPortal: child => child },
|
||||||
|
react: {
|
||||||
|
...React,
|
||||||
|
useEffect(effect) { effects.push(effect) },
|
||||||
|
useState(initial) {
|
||||||
|
const slot = index++
|
||||||
|
if (!(slot in state)) state[slot] = initial
|
||||||
|
return [state[slot], value => { state[slot] = value }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { 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 },
|
||||||
|
onModeChange(mode) { modeChanges.push(mode) },
|
||||||
|
onClearConversation() { cleared++ }, ...overrides
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
render() { index = 0; effects.length = 0; return panel(props) },
|
||||||
|
cleared: () => cleared,
|
||||||
|
runEffects() { effects.forEach(effect => effect()) },
|
||||||
|
modeChanges
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentProvider of ['A_COPILOT', 'QWEN', 'OPENAI', 'ANTHROPIC', 'GEMINI']) {
|
||||||
|
for (const qaMode of ['ai', 'agent', 'teacher']) {
|
||||||
|
test(`attachment controls follow the provider and chat mode (${agentProvider}, ${qaMode})`, () => {
|
||||||
|
const harness = setup({ qaMode, agentProvider })
|
||||||
|
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
|
||||||
|
assert.equal(input.props.attachmentsEnabled, qaMode === 'agent' && agentProvider !== 'A_COPILOT')
|
||||||
|
assert.equal(typeof input.props.onClearConversation, 'function')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the workspace passes the current provider from agent status to the chat panel', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
|
||||||
|
assert.match(source, /provider: agentProvider,[\s\S]*?\} = useAiAgent\(/)
|
||||||
|
assert.match(source, /<WorkspaceQaPanel[\s\S]*?agentProvider=\{agentProvider\}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const qaMode of ['ai', 'agent', 'teacher']) {
|
||||||
|
test(`${qaMode} history is deleted only after shared modal confirmation`, () => {
|
||||||
|
const harness = setup({ qaMode, agentMessages: [{ id: 'a', content: 'answer' }], visibleTeacherMessages: [{ id: 't', content: 'answer' }] })
|
||||||
|
const input = tree => elements(tree, node => node.type === QaInputBox)[0]
|
||||||
|
const modal = tree => elements(tree, node => node.type === ConfirmStatusModal)[0]
|
||||||
|
assert.equal(modal(harness.render()), undefined)
|
||||||
|
input(harness.render()).props.onClearConversation()
|
||||||
|
assert.equal(harness.cleared(), 0)
|
||||||
|
modal(harness.render()).props.onCancel()
|
||||||
|
assert.equal(modal(harness.render()), undefined)
|
||||||
|
assert.equal(harness.cleared(), 0)
|
||||||
|
input(harness.render()).props.onClearConversation()
|
||||||
|
modal(harness.render()).props.onConfirm()
|
||||||
|
assert.equal(harness.cleared(), 1)
|
||||||
|
assert.equal(modal(harness.render()), undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 })
|
||||||
|
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
||||||
|
menu.props.onClear()
|
||||||
|
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(`themed chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
|
||||||
|
const node = {}
|
||||||
|
const harness = setup({}, {
|
||||||
|
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null,
|
||||||
|
chatTextMenu: { show: async () => assert.fail('the themed menu must not open an OS menu') }
|
||||||
|
})
|
||||||
|
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()
|
||||||
|
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
||||||
|
assert.equal(menu.props.menu.kind, 'message')
|
||||||
|
assert.equal(Boolean(menu.props.menu.text), selected && inside)
|
||||||
|
assert.equal(menu.props.canClear, true)
|
||||||
|
assert.equal(harness.cleared(), 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('themed menu deletion still goes through the shared confirmation dialog', async () => {
|
||||||
|
const harness = setup()
|
||||||
|
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
|
||||||
|
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
|
||||||
|
await Promise.resolve()
|
||||||
|
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
||||||
|
menu.props.onClear()
|
||||||
|
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)
|
||||||
|
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
|
||||||
|
assert.equal(input.props.clearDisabled, true)
|
||||||
|
input.props.onClearConversation()
|
||||||
|
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 0)
|
||||||
|
assert.equal(harness.cleared(), 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
test(`shared confirmation renders cancel left and delete right (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const harness = setup({ dark })
|
||||||
|
elements(harness.render(), node => node.type === QaInputBox)[0].props.onClearConversation()
|
||||||
|
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
|
||||||
|
assert.equal(modal.props.dark, dark)
|
||||||
|
assert.match(modal.props.overlayClassName, /fixed.*z-\[180\]/)
|
||||||
|
const html = renderToStaticMarkup(modal)
|
||||||
|
assert.match(html, /취소<\/button><button[^>]*>삭제<\/button>/)
|
||||||
|
assert.match(html, /bg-\[#d14b4b\]/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('input no longer opens a native confirmation dialog', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||||
|
assert.doesNotMatch(source, /window\.confirm/)
|
||||||
|
assert.match(source, /onClick=\{onClearConversation\}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
for (const agentEnabled of [false, true]) {
|
||||||
|
test(`agent availability controls tab count and layout (${dark ? 'dark' : 'light'}, enabled=${agentEnabled})`, () => {
|
||||||
|
const harness = setup({ dark, agentEnabled })
|
||||||
|
const grids = elements(harness.render(), node => node.props.className?.includes('mt-3 grid'))
|
||||||
|
assert.equal(grids.length, 1)
|
||||||
|
const tabs = elements(grids[0], node => node.type === 'button')
|
||||||
|
assert.deepEqual(tabs.map(tab => tab.props.children[1].props.children), agentEnabled ? ['AI', '에이전트', '강사'] : ['AI', '강사'])
|
||||||
|
assert.ok(grids[0].props.className.includes(agentEnabled ? 'grid-cols-3' : 'grid-cols-2'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('returns to AI when the selected agent is disabled after status loading', () => {
|
||||||
|
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: false, minimized: true })
|
||||||
|
harness.render()
|
||||||
|
harness.runEffects()
|
||||||
|
assert.deepEqual(harness.modeChanges, ['ai'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not reset the selected agent while status is still loading', () => {
|
||||||
|
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: true, minimized: true })
|
||||||
|
harness.render()
|
||||||
|
harness.runEffects()
|
||||||
|
assert.deepEqual(harness.modeChanges, [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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(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
|
||||||
|
}
|
||||||
|
|
||||||
|
test('custom menu clipboard IPC validates the main frame and rechecks paste availability', () => {
|
||||||
|
let clipboard = ''
|
||||||
|
let pastes = 0
|
||||||
|
const frame = {}
|
||||||
|
const sender = { mainFrame: frame, isDestroyed: () => false, paste() { pastes++ } }
|
||||||
|
const event = { sender, senderFrame: frame }
|
||||||
|
const native = compile('../src/main/chatTextMenu.ts', {
|
||||||
|
electron: { BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) }, clipboard: { readText: () => clipboard, writeText: text => { clipboard = text } } }
|
||||||
|
})
|
||||||
|
assert.equal(native.readChatClipboardState(event).hasText, false)
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'selected text' }), true)
|
||||||
|
assert.equal(clipboard, 'selected text')
|
||||||
|
assert.equal(native.readChatClipboardState(event).hasText, true)
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), true)
|
||||||
|
assert.equal(pastes, 1)
|
||||||
|
clipboard = ''
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
|
||||||
|
assert.equal(pastes, 1)
|
||||||
|
event.senderFrame = {}
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'blocked' }), false)
|
||||||
|
assert.equal(native.readChatClipboardState(event).hasText, false)
|
||||||
|
event.senderFrame = frame
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: '' }), false)
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'x'.repeat(5_000_001) }), false)
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'unknown' }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderMenu({ dark, kind = 'message', text = '', hasText = false, readOnly = false }) {
|
||||||
|
const menu = { kind, x: 20, y: 30, text, input: { readOnly, disabled: false } }
|
||||||
|
let state = 0
|
||||||
|
const { ChatTextContextMenu } = compile('../src/renderer/src/components/ChatTextContextMenu.tsx', {
|
||||||
|
'../i18n': { t: key => key, useLocaleRender() {} },
|
||||||
|
'react-dom': { createPortal: child => child },
|
||||||
|
react: { ...React, useEffect() {}, useLayoutEffect() {}, useRef: initial => ({ current: initial }), useState() { return [state++ === 0 ? { menu, hasText } : { x: 20, y: 30 }, () => {}] } }
|
||||||
|
}, { document: { body: {} } })
|
||||||
|
return ChatTextContextMenu({ dark, menu, onClose() {}, onClear() {}, canClear: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [true, false]) {
|
||||||
|
test(`chat menu uses the terminal menu surface and item metrics (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const tree = renderMenu({ dark, text: 'selected' })
|
||||||
|
const html = renderToStaticMarkup(tree)
|
||||||
|
assert.match(html, dark ? /bg-\[#252526\]/ : /bg-white/)
|
||||||
|
assert.match(html, dark ? /enabled:hover:bg-\[#37373d\]/ : /enabled:hover:bg-\[#eef1f7\]/)
|
||||||
|
assert.match(html, /rounded-md border py-1 text-\[12px\] font-medium/)
|
||||||
|
assert.match(html, /flex h-8 w-full cursor-pointer/)
|
||||||
|
assert.match(html, /role="menu"/)
|
||||||
|
assert.match(html, /role="menuitem"/)
|
||||||
|
assert.match(html, /대화내역 삭제/)
|
||||||
|
assert.doesNotMatch(html, /붙여넣기/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hasText of [true, false]) {
|
||||||
|
for (const readOnly of [true, false]) {
|
||||||
|
test(`paste disabled state follows clipboard and input editability (clipboard=${hasText}, readOnly=${readOnly})`, () => {
|
||||||
|
const tree = renderMenu({ dark: true, kind: 'input', hasText, readOnly })
|
||||||
|
const buttons = React.Children.toArray(tree.props.children).filter(child => child.type === 'button')
|
||||||
|
assert.equal(buttons[0].props.disabled, true)
|
||||||
|
assert.equal(buttons[1].props.disabled, !hasText || readOnly)
|
||||||
|
assert.doesNotMatch(renderToStaticMarkup(tree), /대화내역 삭제/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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 setup() {
|
||||||
|
const api = {}
|
||||||
|
const revoked = []
|
||||||
|
let count = 0
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, {
|
||||||
|
exports: api, TextDecoder,
|
||||||
|
createImageBitmap: async file => {
|
||||||
|
if (file.invalid) throw Error('invalid')
|
||||||
|
return { width: file.width ?? 10, height: file.height ?? 10, close() {} }
|
||||||
|
},
|
||||||
|
URL: { createObjectURL: () => `blob:fixture-${++count}`, revokeObjectURL: url => revoked.push(url) }
|
||||||
|
})
|
||||||
|
return { api, read: (files, existing = []) => api.readChatAttachments(files, existing, { imagesEnabled: true }), revoked }
|
||||||
|
}
|
||||||
|
const image = (extra = {}) => ({ name: 'screen.png', size: 100, ...extra })
|
||||||
|
|
||||||
|
test('image and text attachments coexist without counting image bytes as text context', async () => {
|
||||||
|
const h = setup()
|
||||||
|
const files = await h.read([image({ size: 5 * 1024 * 1024 }), { name: 'config.yaml', size: 10, arrayBuffer: async () => new TextEncoder().encode('robot: mini').buffer }])
|
||||||
|
assert.equal(files[0].kind, 'image')
|
||||||
|
assert.equal(files[0].previewUrl, 'blob:fixture-1')
|
||||||
|
assert.equal(files[1].content, 'robot: mini')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bounds image counts, byte sizes, decoded pixels and rejects malformed images', async () => {
|
||||||
|
const h = setup()
|
||||||
|
await assert.rejects(h.read([image({ size: 5 * 1024 * 1024 + 1 })]), /5MB/)
|
||||||
|
await assert.rejects(h.read([image({ width: 4001, height: 4001 })]), /1600만/)
|
||||||
|
await assert.rejects(h.read([image({ invalid: true })]), /읽지 못/)
|
||||||
|
await assert.rejects(h.read(Array.from({ length: 4 }, () => image())), /3장/)
|
||||||
|
assert.equal(h.revoked.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a mixed batch failure revokes only newly created image previews', async () => {
|
||||||
|
const h = setup()
|
||||||
|
const existing = await h.read([image()])
|
||||||
|
await assert.rejects(h.read([image(), { name: 'file.pdf', size: 20 }], existing), /PDF/)
|
||||||
|
assert.deepEqual(h.revoked, ['blob:fixture-2'])
|
||||||
|
assert.equal(existing[0].previewUrl, 'blob:fixture-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restores cached multipart filenames while preserving valid Unicode and Latin-1 names', () => {
|
||||||
|
const { api } = setup()
|
||||||
|
const name = '스크린샷 2026-10-19 14-30-45.png'
|
||||||
|
assert.equal(api.displayAttachmentName(Buffer.from(name).toString('latin1')), name)
|
||||||
|
for (const value of [name, 'screen.png', 'caf\u00e9.png', '\uD83D\uDCF7.png']) assert.equal(api.displayAttachmentName(value), value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function previewHarness() {
|
||||||
|
const { api } = setup()
|
||||||
|
const exports = {}
|
||||||
|
let preview = null
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatImageAttachments.tsx'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, {
|
||||||
|
exports,
|
||||||
|
document: { body: {} },
|
||||||
|
require(name) {
|
||||||
|
if (name === 'react') return { ...React, useEffect() {}, useRef: () => ({ current: null }), useState: () => [preview, value => { preview = value }] }
|
||||||
|
if (name === 'react-dom') return { createPortal: child => child }
|
||||||
|
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
|
||||||
|
if (name === './chatAttachments') return api
|
||||||
|
return require(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const find = (node, predicate) => {
|
||||||
|
if (!React.isValidElement(node)) return null
|
||||||
|
if (predicate(node)) return node
|
||||||
|
return React.Children.toArray(node.props.children).map(child => find(child, predicate)).find(Boolean)
|
||||||
|
}
|
||||||
|
return { render: props => exports.ChatImageAttachments(props), find }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [true, false]) {
|
||||||
|
test(`draft combines thumbnail, truncated filename and removal in one bounded item (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const h = previewHarness()
|
||||||
|
const name = '스크린샷-긴파일이름-'.repeat(12) + '.png'
|
||||||
|
let removed
|
||||||
|
const props = { images: [{ name, url: 'blob:fixture' }], dark, onRemove: image => { removed = image.url } }
|
||||||
|
const tree = h.render(props)
|
||||||
|
const item = h.find(tree, node => node.props['data-chat-image-draft'])
|
||||||
|
assert.ok(item)
|
||||||
|
assert.match(item.props.className, /w-56.*max-w-full/)
|
||||||
|
assert.ok(h.find(item, node => node.type === 'img'))
|
||||||
|
const label = h.find(item, node => node.type === 'span')
|
||||||
|
assert.equal(label.props.children, name)
|
||||||
|
assert.match(label.props.className, /truncate/)
|
||||||
|
const zoom = h.find(item, node => node.type === 'button' && node.props.title === name)
|
||||||
|
assert.equal(zoom.props.title, name)
|
||||||
|
h.find(item, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.onClick()
|
||||||
|
assert.equal(removed, 'blob:fixture')
|
||||||
|
const busyItem = h.find(h.render({ ...props, removeDisabled: true }), node => node.props['data-chat-image-draft'])
|
||||||
|
assert.equal(h.find(busyItem, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.disabled, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cached garbled names are restored in the thumbnail label and enlarged dialog', () => {
|
||||||
|
const h = previewHarness()
|
||||||
|
const name = '스크린샷 2026-10-19 14-30-45.png'
|
||||||
|
const props = { images: [{ name: Buffer.from(name).toString('latin1'), url: 'https://files.test/image.png' }], variant: 'sent' }
|
||||||
|
const tree = h.render(props)
|
||||||
|
assert.equal(h.find(tree, node => node.type === 'button').props.title, name)
|
||||||
|
assert.equal(h.find(tree, node => node.props['data-chat-image-draft']), undefined)
|
||||||
|
h.find(tree, node => node.type === 'button').props.onClick()
|
||||||
|
const html = renderToStaticMarkup(h.render(props))
|
||||||
|
assert.match(html, /<dialog[^>]*aria-label="스크린샷 2026-10-19 14-30-45.png"/)
|
||||||
|
assert.ok(html.includes(`>${name}</span>`))
|
||||||
|
})
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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 harness() {
|
||||||
|
let layoutEffects = []
|
||||||
|
let effects = []
|
||||||
|
let resizeCallback
|
||||||
|
let disconnected = false
|
||||||
|
const states = []
|
||||||
|
const refs = []
|
||||||
|
let stateIndex = 0
|
||||||
|
let refIndex = 0
|
||||||
|
const submissions = []
|
||||||
|
const input = {
|
||||||
|
style: {}, scrollHeight: 56,
|
||||||
|
getBoundingClientRect: () => ({ width: 300 })
|
||||||
|
}
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, {
|
||||||
|
exports,
|
||||||
|
window: { getComputedStyle: () => ({ lineHeight: '20px', paddingTop: '10px', paddingBottom: '10px', borderTopWidth: '0px', borderBottomWidth: '0px', minHeight: '56px' }) },
|
||||||
|
ResizeObserver: class {
|
||||||
|
constructor(callback) { resizeCallback = callback }
|
||||||
|
observe(element) { assert.equal(element, input) }
|
||||||
|
disconnect() { disconnected = true }
|
||||||
|
},
|
||||||
|
require(name) {
|
||||||
|
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
|
||||||
|
if (name === './chatAttachments') return {}
|
||||||
|
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
|
||||||
|
if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null }
|
||||||
|
if (name === './ChatCodeBlock') return {}
|
||||||
|
if (name === 'react') return {
|
||||||
|
...React,
|
||||||
|
useState(initial) {
|
||||||
|
const index = stateIndex++
|
||||||
|
if (!(index in states)) states[index] = initial
|
||||||
|
return [states[index], value => { states[index] = value }]
|
||||||
|
},
|
||||||
|
useRef(initial) { return refs[refIndex++] ??= { current: initial } },
|
||||||
|
useEffect(callback) { effects.push(callback) },
|
||||||
|
useLayoutEffect(callback) { layoutEffects.push(callback) }
|
||||||
|
}
|
||||||
|
return require(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
function find(node, type) {
|
||||||
|
if (!React.isValidElement(node)) return null
|
||||||
|
if (node.type === type) return node
|
||||||
|
return React.Children.toArray(node.props.children).map(child => find(child, type)).find(Boolean)
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
stateIndex = 0
|
||||||
|
refIndex = 0
|
||||||
|
layoutEffects = []
|
||||||
|
effects = []
|
||||||
|
const tree = exports.QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit: content => submissions.push(content) })
|
||||||
|
const textarea = find(tree, 'textarea')
|
||||||
|
textarea.props.ref.current = input
|
||||||
|
layoutEffects.forEach(callback => callback())
|
||||||
|
return textarea
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
input, render, submissions,
|
||||||
|
mountEffects: () => effects.map(callback => callback()),
|
||||||
|
resize: width => resizeCallback([{ contentRect: { width } }]),
|
||||||
|
disconnected: () => disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('input grows to eight visible lines and scrolls only above the cap', () => {
|
||||||
|
const h = harness()
|
||||||
|
h.render()
|
||||||
|
assert.equal(h.input.style.height, '56px')
|
||||||
|
assert.equal(h.input.style.overflowY, 'hidden')
|
||||||
|
h.input.scrollHeight = 100
|
||||||
|
h.render()
|
||||||
|
assert.equal(h.input.style.height, '100px')
|
||||||
|
h.input.scrollHeight = 180
|
||||||
|
h.render()
|
||||||
|
assert.equal(h.input.style.height, '180px')
|
||||||
|
assert.equal(h.input.style.overflowY, 'hidden')
|
||||||
|
h.input.scrollHeight = 300
|
||||||
|
h.render()
|
||||||
|
assert.equal(h.input.style.height, '180px')
|
||||||
|
assert.equal(h.input.style.overflowY, 'auto')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deleting text and submitting restore the initial height', () => {
|
||||||
|
const h = harness()
|
||||||
|
let textarea = h.render()
|
||||||
|
textarea.props.onChange({ target: { value: 'question\nsecond line\nthird line' } })
|
||||||
|
h.input.scrollHeight = 80
|
||||||
|
textarea = h.render()
|
||||||
|
assert.equal(h.input.style.height, '80px')
|
||||||
|
let prevented = false
|
||||||
|
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: {}, preventDefault() { prevented = true } })
|
||||||
|
assert.equal(prevented, true)
|
||||||
|
assert.equal(h.submissions.length, 1)
|
||||||
|
h.input.scrollHeight = 40
|
||||||
|
textarea = h.render()
|
||||||
|
assert.equal(textarea.props.value, '')
|
||||||
|
assert.equal(h.input.style.height, '56px')
|
||||||
|
assert.equal(h.input.style.overflowY, 'hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Shift+Enter and IME composition do not submit', () => {
|
||||||
|
const h = harness()
|
||||||
|
let textarea = h.render()
|
||||||
|
textarea.props.onChange({ target: { value: 'question' } })
|
||||||
|
textarea = h.render()
|
||||||
|
const preventDefault = () => assert.fail('newline or composing Enter must not be intercepted')
|
||||||
|
textarea.props.onKeyDown({ key: 'Enter', shiftKey: true, nativeEvent: {}, preventDefault })
|
||||||
|
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: { isComposing: true }, preventDefault })
|
||||||
|
assert.equal(h.submissions.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('panel width changes recalculate wrapped lines and observer disconnects on unmount', () => {
|
||||||
|
const h = harness()
|
||||||
|
h.render()
|
||||||
|
const cleanups = h.mountEffects()
|
||||||
|
h.input.scrollHeight = 120
|
||||||
|
h.resize(180)
|
||||||
|
assert.equal(h.input.style.height, '120px')
|
||||||
|
h.input.scrollHeight = 50
|
||||||
|
h.resize(400)
|
||||||
|
assert.equal(h.input.style.height, '56px')
|
||||||
|
cleanups.forEach(cleanup => cleanup?.())
|
||||||
|
assert.equal(h.disconnected(), true)
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
|
||||||
|
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), clearTimeout, setTimeout, ...globals
|
||||||
|
})
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const i18n = { t: key => key, useLocaleRender() {} }
|
||||||
|
const block = compile('../src/renderer/src/components/ChatCodeBlock.tsx', { '../i18n': i18n })
|
||||||
|
const { MarkdownMessage } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||||
|
'../i18n': i18n, './ChatCodeBlock': block, './chatAttachments': {}, './ChatImageAttachments': {}, './ChatTextContextMenu': {}
|
||||||
|
})
|
||||||
|
const render = (content, dark = true, extra = {}) => renderToStaticMarkup(React.createElement(MarkdownMessage, { content, dark, ...extra }))
|
||||||
|
|
||||||
|
for (const dark of [true, false]) {
|
||||||
|
test(`renders numbered/nested/task lists without losing the starting number (${dark})`, () => {
|
||||||
|
const html = render('3. 설정 확인\n - 속도 제한\n - 조향 제한\n4. 결과 확인\n\n- [x] 완료\n- [ ] 대기', dark)
|
||||||
|
assert.match(html, /<ol start="3" class="[^"]*list-decimal/)
|
||||||
|
assert.match(html, /<ul class="[^"]*list-disc/)
|
||||||
|
assert.match(html, /contains-task-list list-none/)
|
||||||
|
assert.match(html, /task-list-item list-none/)
|
||||||
|
assert.equal((html.match(/type="checkbox"/g) ?? []).length, 2)
|
||||||
|
})
|
||||||
|
test(`renders GFM tables with keyboard-scrollable framing and column alignment (${dark})`, () => {
|
||||||
|
const html = render('| 모듈 | 속도 |\n| --- | ---: |\n| Control | `0.3 m/s` |', dark)
|
||||||
|
assert.match(html, /data-chat-table/)
|
||||||
|
assert.match(html, /tabindex="0" role="region" aria-label="표"/)
|
||||||
|
assert.match(html, /overflow-x-auto/)
|
||||||
|
assert.match(html, /<table class="[^"]*table-auto/)
|
||||||
|
assert.match(html, /text-align:right/)
|
||||||
|
assert.match(html, /chat-inline-code/)
|
||||||
|
})
|
||||||
|
test(`renders fenced Python with language, token colors, copy control and preserved indentation (${dark})`, () => {
|
||||||
|
const html = render('```python\ndef speed():\n return 0.3\n```', dark)
|
||||||
|
assert.match(html, /data-chat-code-block/)
|
||||||
|
assert.ok(html.includes(`data-code-theme="${dark ? 'dark' : 'light'}"`))
|
||||||
|
assert.match(html, />Python<\/span>/)
|
||||||
|
assert.match(html, /aria-label="코드 복사"/)
|
||||||
|
assert.match(html, /hljs-keyword/)
|
||||||
|
assert.match(html, /hljs-number/)
|
||||||
|
assert.match(html, /\n <span/)
|
||||||
|
assert.match(html, /max-h-80 max-w-full overflow-auto/)
|
||||||
|
assert.match(html, /whitespace-pre font-mono/)
|
||||||
|
assert.doesNotMatch(html, /chat-inline-code/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('unlabelled and unknown languages remain code blocks rather than inline code', () => {
|
||||||
|
for (const language of ['', 'unknown-language']) {
|
||||||
|
const html = render('```' + language + '\n unformatted <value>\n```')
|
||||||
|
assert.match(html, /data-chat-code-block/)
|
||||||
|
assert.ok(html.includes(language || '일반 텍스트'))
|
||||||
|
assert.ok(html.includes(' unformatted <value>'))
|
||||||
|
assert.doesNotMatch(html, /hljs-/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('HTML-looking code is rendered as escaped text, never live elements', () => {
|
||||||
|
const html = render('```html\n<script>window.injected = true</script>\n<img src=x onerror="window.injected=true">\n```')
|
||||||
|
assert.doesNotMatch(html, /<script\b|<img\b/)
|
||||||
|
assert.match(html, /</)
|
||||||
|
assert.match(html, /window.injected/)
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatCodeBlock.tsx'), 'utf8')
|
||||||
|
assert.doesNotMatch(source, /dangerouslySetInnerHTML/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only newly revealed code and tables receive the fade class', () => {
|
||||||
|
const markdown = '설명\n\n```yaml\nspeed: 0.3\n```\n\n| 키 | 값 |\n| --- | --- |\n| speed | 0.3 |'
|
||||||
|
const html = render(markdown, true, { fadeFrom: markdown.indexOf('| 키') })
|
||||||
|
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
|
||||||
|
assert.match(html, /overflow-x-auto rounded-md agent-response-fade/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('oversized code skips tokenization but still preserves all content', () => {
|
||||||
|
const source = '# comment\n'.repeat(6000)
|
||||||
|
const html = renderToStaticMarkup(React.createElement(block.ChatCodeBlock, { source, language: 'python', dark: true }))
|
||||||
|
assert.doesNotMatch(html, /hljs-comment/)
|
||||||
|
assert.equal((html.match(/# comment/g) ?? []).length, 6000)
|
||||||
|
})
|
||||||
|
|
||||||
|
function elements(tree, predicate) {
|
||||||
|
if (!React.isValidElement(tree)) return []
|
||||||
|
return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
for (const fails of [false, true]) test(`code copy preserves the full source and reports clipboard ${fails ? 'failure' : 'success'}`, async () => {
|
||||||
|
let state = 'idle', copied, timer
|
||||||
|
const api = compile('../src/renderer/src/components/ChatCodeBlock.tsx', {
|
||||||
|
'../i18n': i18n,
|
||||||
|
react: { ...React, useState: () => [state, value => { state = value }], useMemo: fn => fn(), useRef: () => ({ current: null }), useEffect() {} }
|
||||||
|
}, { navigator: { clipboard: { writeText: async text => { if (fails) throw Error('denied'); copied = text } } }, setTimeout: callback => { timer = callback; return 1 }, clearTimeout() {} })
|
||||||
|
const source = '\t# 한글 주석\n speed = 0.3\n\n'
|
||||||
|
const renderBlock = () => api.ChatCodeBlock({ source, language: 'python', dark: true })
|
||||||
|
const button = elements(renderBlock(), element => element.type === 'button')[0]
|
||||||
|
let stopped = false
|
||||||
|
button.props.onClick({ stopPropagation() { stopped = true } })
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
assert.equal(stopped, true)
|
||||||
|
assert.equal(state, fails ? 'error' : 'copied')
|
||||||
|
if (!fails) assert.equal(copied, source)
|
||||||
|
assert.equal(elements(renderBlock(), element => element.type === 'button')[0].props['aria-label'], fails ? '코드를 복사하지 못했습니다.' : '복사됨')
|
||||||
|
timer()
|
||||||
|
assert.equal(state, 'idle')
|
||||||
|
})
|
||||||
@@ -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 themed menu with a selection snapshot (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': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null }, './ChatCodeBlock': {},
|
||||||
|
react: { ...React, useState: initial => [initial, value => { if (value?.kind === 'input') options = value }], useEffect() {}, useLayoutEffect() {}, 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: { value: 'abcdef', 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.text, selected ? 'cde' : '')
|
||||||
|
assert.equal(options.input.disabled, false)
|
||||||
|
assert.equal(options.selectionStart, 2)
|
||||||
|
assert.equal(prevented, true)
|
||||||
|
assert.equal(stopped, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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/components/AppStatusModals.tsx'), 'utf8')
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText
|
||||||
|
|
||||||
|
function loadModals() {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(compiled, {
|
||||||
|
exports,
|
||||||
|
require: name => name === '../i18n' ? { t: key => key, useLocaleRender() {} } : require(name)
|
||||||
|
})
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttons(element) {
|
||||||
|
if (!React.isValidElement(element)) return []
|
||||||
|
if (element.type === 'button') return [element]
|
||||||
|
return React.Children.toArray(element.props.children).flatMap(buttons)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
test(`extension action stays on the right in both dialogs (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const { StatusNoticeModal, ControlExtensionConfirmModal } = loadModals()
|
||||||
|
let confirmed = 0
|
||||||
|
let extended = 0
|
||||||
|
const notice = StatusNoticeModal({
|
||||||
|
overlayClassName: 'overlay', dark, tone: 'clock', title: '제어 시간 안내', message: '제어 시간이 곧 종료됩니다.',
|
||||||
|
actionLabel: '연장', onAction: () => { extended++ }, onConfirm: () => { confirmed++ }
|
||||||
|
})
|
||||||
|
const noticeButtons = buttons(notice)
|
||||||
|
assert.deepEqual(noticeButtons.map(button => button.props.children), ['확인', '연장'])
|
||||||
|
assert.match(noticeButtons[1].props.className, /bg-\[#6f4df6\]/)
|
||||||
|
assert.doesNotMatch(noticeButtons[0].props.className, /bg-\[#6f4df6\]/)
|
||||||
|
noticeButtons[0].props.onClick()
|
||||||
|
noticeButtons[1].props.onClick()
|
||||||
|
assert.equal(confirmed, 1)
|
||||||
|
assert.equal(extended, 1)
|
||||||
|
|
||||||
|
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark, platformName: 'MK-mini', onCancel() {}, onConfirm() {} })
|
||||||
|
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장'])
|
||||||
|
assert.match(buttons(confirmation)[1].props.className, /bg-\[#6f4df6\]/)
|
||||||
|
assert.match(renderToStaticMarkup(confirmation), /취소<\/button><button[^>]*>연장<\/button>/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('notices without an extension action retain their single primary confirmation button', () => {
|
||||||
|
const { StatusNoticeModal } = loadModals()
|
||||||
|
const notice = StatusNoticeModal({ overlayClassName: 'overlay', dark: true, tone: 'warning', title: '안내', message: '메시지', onConfirm() {} })
|
||||||
|
assert.equal(buttons(notice).length, 1)
|
||||||
|
assert.equal(buttons(notice)[0].props.children, '확인')
|
||||||
|
assert.match(buttons(notice)[0].props.className, /bg-\[#6f4df6\]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the extension confirmation keeps the right button disabled while submitting', () => {
|
||||||
|
const { ControlExtensionConfirmModal } = loadModals()
|
||||||
|
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark: true, platformName: 'MK-mini', submitting: true, onCancel() {}, onConfirm() {} })
|
||||||
|
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장 중'])
|
||||||
|
assert.ok(buttons(confirmation).every(button => button.props.disabled))
|
||||||
|
})
|
||||||
@@ -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,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])
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const crypto = require('node:crypto')
|
||||||
|
const postcss = require('postcss')
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const source = fs.readFileSync(path.join(root, 'src/renderer/src/styles/nanumSquare.css'), 'utf8')
|
||||||
|
const css = postcss.parse(source)
|
||||||
|
const faces = []
|
||||||
|
css.walkAtRules('font-face', rule => {
|
||||||
|
faces.push(Object.fromEntries(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node.value])))
|
||||||
|
})
|
||||||
|
|
||||||
|
function codepoints(range) {
|
||||||
|
const result = new Set()
|
||||||
|
let previous = -1
|
||||||
|
for (const entry of range.split(',')) {
|
||||||
|
const match = entry.trim().match(/^U\+([0-9A-F]+)(?:-([0-9A-F]+))?$/)
|
||||||
|
assert.ok(match, `Invalid unicode-range: ${entry}`)
|
||||||
|
const start = parseInt(match[1], 16)
|
||||||
|
const end = parseInt(match[2] ?? match[1], 16)
|
||||||
|
assert.ok(start > previous && end >= start && end <= 0x10ffff)
|
||||||
|
for (let code = start; code <= end; code++) result.add(code)
|
||||||
|
previous = end
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
test('coverage is tied to the original bundled font bytes for all four weights', () => {
|
||||||
|
assert.equal(faces.length, 4)
|
||||||
|
assert.deepEqual(faces.map(face => face['font-weight']), ['300', '400', '700', '800'])
|
||||||
|
for (const face of faces) {
|
||||||
|
assert.equal(face['font-family'], "'NanumSquare'")
|
||||||
|
assert.equal(face['font-display'], 'swap')
|
||||||
|
const filename = face.src.match(/\/([^/]+\.woff2)'/)[1]
|
||||||
|
const hash = crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'src/renderer/public/assets/fonts', filename))).digest('hex')
|
||||||
|
assert.ok(source.includes(`${filename} sha256: ${hash}`), 'Regenerate coverage if a font changes')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const face of faces) {
|
||||||
|
test(`weight ${face['font-weight']} preserves visible Hangul and lets blank glyphs fall back`, () => {
|
||||||
|
const included = codepoints(face['unicode-range'])
|
||||||
|
assert.equal([...included].filter(code => code >= 0xac00 && code <= 0xd7a3).length, 2479)
|
||||||
|
for (const char of '가모한글학생SLAM0123 →') assert.ok(included.has(char.codePointAt(0)), `Keep ${char}`)
|
||||||
|
for (const char of ['몯', '힣', '\u2022']) assert.equal(included.has(char.codePointAt(0)), false, `Let ${char} use fallback`)
|
||||||
|
for (const code of [0x20, 0x3000, 0x3164]) assert.ok(included.has(code), 'Preserve intentional spacing')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('UI keeps its original default font and native fallback without Neo', () => {
|
||||||
|
const entry = fs.readFileSync(path.join(root, 'src/renderer/src/index.css'), 'utf8')
|
||||||
|
assert.match(entry, /@import "\.\/styles\/nanumSquare\.css"/)
|
||||||
|
assert.match(entry, /font-family: 'NanumSquare', Inter, system-ui, sans-serif/)
|
||||||
|
assert.doesNotMatch(entry + source, /NanumSquareNeo/)
|
||||||
|
const directFaces = []
|
||||||
|
postcss.parse(entry).walkAtRules('font-face', rule => directFaces.push(rule))
|
||||||
|
assert.equal(directFaces.length, 0, 'Unrestricted duplicate faces would suppress fallback')
|
||||||
|
})
|
||||||
@@ -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 }) }
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
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('only the default simulation description follows language; vehicle and custom text stay unchanged', () => {
|
||||||
|
const { api } = setup('ko')
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(path.join(root, 'features/platforms/PlatformCards.tsx'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => name === '../../i18n'
|
||||||
|
? { ...api, useLocaleRender() {} } : require(name) })
|
||||||
|
const { createElement } = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
const description = '내 PC에서 직접 개발 환경을 실행합니다.'
|
||||||
|
const platform = { id: 'local', platformKey: 'futuredrive-local-pc', title: '로컬 PC', description, state: 'ready' }
|
||||||
|
const render = value => renderToStaticMarkup(createElement(exports.PlatformOptionCard, {
|
||||||
|
platform: value, Icon: () => null, dotClassName: '', unavailable: false,
|
||||||
|
statusPending: false, displayStatus: '사용 가능', primaryAction: '연결',
|
||||||
|
actionDisabled: false, running: false, onAction() {}, onSettings() {}
|
||||||
|
}))
|
||||||
|
assert.ok(render(platform).includes(description))
|
||||||
|
api.setLanguage('en')
|
||||||
|
assert.ok(render(platform).includes('Run the development environment directly on this PC.'))
|
||||||
|
assert.ok(!render(platform).includes(description))
|
||||||
|
const vehicleDescription = '기초 SLAM 및 자율주행 실습 장비입니다.'
|
||||||
|
assert.ok(render({ ...platform, platformKey: 'mk-mini', description: vehicleDescription }).includes(vehicleDescription))
|
||||||
|
assert.ok(render({ ...platform, platformKey: 'mk-mini' }).includes(description))
|
||||||
|
assert.ok(render({ ...platform, description: '강사가 작성한 시뮬레이션 안내' }).includes('강사가 작성한 시뮬레이션 안내'))
|
||||||
|
assert.ok(render({ ...platform, description: '설정' }).includes('>설정</p>'))
|
||||||
|
api.setLanguage('ko')
|
||||||
|
assert.ok(render(platform).includes(description))
|
||||||
|
assert.equal(platform.description, description)
|
||||||
|
})
|
||||||
|
|
||||||
|
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('close and disconnect modals localize raw Local PC names at render time', () => {
|
||||||
|
const source = fs.readFileSync(path.join(root, 'components/AppStatusModals.tsx'), 'utf8')
|
||||||
|
for (const [key, variable] of [
|
||||||
|
['{{v0}} 개발 스튜디오를 종료할까요?', 'platformTitle'],
|
||||||
|
['{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.', 'platformName']
|
||||||
|
]) {
|
||||||
|
const line = source.split('\n').find(line => line.includes(key))
|
||||||
|
const expression = line.match(/v0: (.*?) \}/)[1]
|
||||||
|
for (const language of ['ko', 'en']) {
|
||||||
|
const { api } = setup(language)
|
||||||
|
const name = vm.runInNewContext(expression, { [variable]: '로컬 PC', t: api.t })
|
||||||
|
assert.equal(name, language === 'en' ? 'Local PC' : '로컬 PC')
|
||||||
|
if (language === 'en') assert.ok(!api.t(key, { v0: name }).includes('로컬'))
|
||||||
|
assert.equal(vm.runInNewContext(expression, { [variable]: 'Yahboom Micro #02', t: api.t }), 'Yahboom Micro #02')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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])
|
||||||
|
assert.equal(titles.length, 4)
|
||||||
|
for (const title of titles) assert.ok(dictionary[title], title)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('visualization progress uses exact runtime translation keys', () => {
|
||||||
|
const { api } = setup('en')
|
||||||
|
assert.equal(api.t('맵 환경 준비 중'), 'Preparing map environment')
|
||||||
|
assert.equal(api.t('시작 중'), 'Starting')
|
||||||
|
assert.equal(api.t('종료 중'), 'Stopping')
|
||||||
|
const panel = fs.readFileSync(path.join(root, 'features/workspace/WorkspaceVisualizationPanel.tsx'), 'utf8')
|
||||||
|
assert.ok(panel.includes('busy ? t(statusMessage)'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('viewer translates before 3D initialization and accepts language only from its parent', () => {
|
||||||
|
const events = new Map()
|
||||||
|
const sent = []
|
||||||
|
const parent = { postMessage: message => sent.push(message) }
|
||||||
|
const label = { dataset: { i18n: '응답을 기다리는 중입니다.' }, textContent: '' }
|
||||||
|
const document = {
|
||||||
|
documentElement: {},
|
||||||
|
querySelectorAll: selector => selector === '[data-i18n]' ? [label] : []
|
||||||
|
}
|
||||||
|
const source = fs.readFileSync(path.join(root, '../public/slam-viewer/locale.js'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, allowJs: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, {
|
||||||
|
exports: {}, document,
|
||||||
|
localStorage: { getItem: () => 'ko' },
|
||||||
|
window: { parent, addEventListener: (type, listener) => events.set(type, listener) }
|
||||||
|
})
|
||||||
|
assert.equal(sent[0].type, 'lms:language:ready')
|
||||||
|
events.get('message')({ source: {}, data: { type: 'lms:language', language: 'en' } })
|
||||||
|
assert.equal(label.textContent, '응답을 기다리는 중입니다.')
|
||||||
|
events.get('message')({ source: parent, data: { type: 'lms:language', language: 'en' } })
|
||||||
|
assert.equal(label.textContent, 'Waiting for a response.')
|
||||||
|
events.get('message')({ source: parent, data: { type: 'lms:language', language: 'ko' } })
|
||||||
|
assert.equal(label.textContent, '응답을 기다리는 중입니다.')
|
||||||
|
const html = fs.readFileSync(path.join(root, '../public/slam-viewer/index.html'), 'utf8')
|
||||||
|
assert.ok(html.includes('<script type="module" src="./locale.js"></script>'))
|
||||||
|
})
|
||||||
|
|
||||||
|
function setup(savedLanguage, storageBlocked = false) {
|
||||||
|
const instance = createInstance()
|
||||||
|
const storage = new Map([['autonomy-studio.lms.language', savedLanguage]])
|
||||||
|
const events = new Map()
|
||||||
|
const document = { documentElement: { lang: '' } }
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(path.join(root, 'i18n/index.ts'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, {
|
||||||
|
exports, document,
|
||||||
|
localStorage: {
|
||||||
|
getItem(key) { if (storageBlocked) throw Error('blocked'); return storage.get(key) },
|
||||||
|
setItem(key, value) { if (storageBlocked) throw Error('blocked'); storage.set(key, value) }
|
||||||
|
},
|
||||||
|
window: { addEventListener: (name, listener) => events.set(name, listener) },
|
||||||
|
require(name) {
|
||||||
|
if (name === 'i18next') return instance
|
||||||
|
if (name === './en.json') return dictionary
|
||||||
|
return require(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { api: exports, storage, document, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Korean defaults, English persists, interpolation and fallback preserve user content', () => {
|
||||||
|
const { api, storage, document } = setup('invalid')
|
||||||
|
assert.equal(api.t('설정'), '설정')
|
||||||
|
assert.equal(document.documentElement.lang, 'ko')
|
||||||
|
api.setLanguage('en')
|
||||||
|
assert.equal(api.t('설정'), 'Settings')
|
||||||
|
assert.equal(api.t('{{week}}주차', { week: 3 }), 'Week 3')
|
||||||
|
assert.equal(api.t('강사가 작성한 강좌 제목'), '강사가 작성한 강좌 제목')
|
||||||
|
assert.equal(api.t('sim_mode:=false'), 'sim_mode:=false')
|
||||||
|
assert.equal(storage.get(api.LANGUAGE_STORAGE_KEY), 'en')
|
||||||
|
assert.equal(document.documentElement.lang, 'en')
|
||||||
|
api.setLanguage('ko')
|
||||||
|
assert.equal(api.t('{{week}}주차', { week: 3 }), '3주차')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restores preference and synchronizes other renderer windows', () => {
|
||||||
|
const { api, document, events } = setup('en')
|
||||||
|
assert.equal(api.t('설정'), 'Settings')
|
||||||
|
events.get('storage')({ key: api.LANGUAGE_STORAGE_KEY, newValue: 'ko' })
|
||||||
|
assert.equal(document.documentElement.lang, 'ko')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blocked storage does not prevent switching language', () => {
|
||||||
|
const { api } = setup(undefined, true)
|
||||||
|
api.setLanguage('en')
|
||||||
|
assert.equal(api.t('설정'), 'Settings')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('language selector edits a draft without applying or persisting it', () => {
|
||||||
|
const { api, storage, document } = setup('ko')
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(path.join(root, 'i18n/LanguageSelect.tsx'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => name === './index' ? api : require(name) })
|
||||||
|
let draft = 'ko'
|
||||||
|
const element = exports.default({ dark: false, value: draft, onChange: value => { draft = value } })
|
||||||
|
const select = element.props.children.find(child => child.type === 'select')
|
||||||
|
select.props.onChange({ target: { value: 'en' } })
|
||||||
|
assert.equal(draft, 'en')
|
||||||
|
assert.equal(document.documentElement.lang, 'ko')
|
||||||
|
assert.equal(storage.get(api.LANGUAGE_STORAGE_KEY), 'ko')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('built-in map labels follow language without changing map data or saved names', () => {
|
||||||
|
const { api } = setup('ko')
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(path.join(root, 'features/workspace/WorldMapSelectorModal.tsx'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => {
|
||||||
|
if (name === '../../i18n') return api
|
||||||
|
if (name === './studioTheme') return { useStudioTheme: () => 'dark' }
|
||||||
|
if (name.endsWith('.css')) return {}
|
||||||
|
return require(name)
|
||||||
|
} })
|
||||||
|
const map = exports.NAV2_MAP_OPTIONS[0]
|
||||||
|
assert.equal(exports.getMapDisplayName(map), '기본 월드')
|
||||||
|
api.setLanguage('en')
|
||||||
|
assert.equal(exports.getMapDisplayName(map), 'Default world')
|
||||||
|
assert.equal(exports.getMapDisplayName({ ...map, saved: true }), '기본 월드')
|
||||||
|
assert.equal(map.name, '기본 월드')
|
||||||
|
assert.equal(map.worldFile, 'turtlebot3_world.world')
|
||||||
|
assert.equal(api.t('새 2D 맵'), 'New 2D map')
|
||||||
|
assert.equal(api.t('새 3D 맵'), 'New 3D map')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('translations preserve every interpolation placeholder', () => {
|
||||||
|
const placeholders = value => [...value.matchAll(/{{\s*([^}]+)\s*}}/g)].map(match => match[1]).sort()
|
||||||
|
for (const [key, value] of Object.entries(dictionary)) {
|
||||||
|
assert.ok(value.trim(), `Empty translation: ${key}`)
|
||||||
|
assert.deepEqual(placeholders(value), placeholders(key), key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('learning statistics translate API labels but preserve numeric values', () => {
|
||||||
|
const { api } = setup('ko')
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(path.join(root, 'features/curriculum/LearningStatsCard.tsx'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => name === '../../i18n'
|
||||||
|
? { ...api, useLocaleRender() {} } : require(name) })
|
||||||
|
const stats = [
|
||||||
|
{ key: 'weeks', label: '완료한 주차', value: '0/3', iconKey: 'check' },
|
||||||
|
{ key: 'time', label: '학습 시간', value: '02:08', iconKey: 'clock' },
|
||||||
|
{ key: 'commits', label: '커밋 수', value: '0', iconKey: 'trending' },
|
||||||
|
{ key: 'score', label: '평균 점수', value: '0', iconKey: 'gauge' }
|
||||||
|
]
|
||||||
|
const { createElement } = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
const render = () => renderToStaticMarkup(createElement(exports.default, { stats }))
|
||||||
|
assert.ok(render().includes('완료한 주차'))
|
||||||
|
api.setLanguage('en')
|
||||||
|
const html = render()
|
||||||
|
for (const text of ['Completed weeks', 'Learning time', 'Commits', 'Average score', '0/3', '02:08']) {
|
||||||
|
assert.ok(html.includes(text), text)
|
||||||
|
}
|
||||||
|
for (const item of stats) assert.ok(!html.includes(item.label), item.label)
|
||||||
|
api.setLanguage('ko')
|
||||||
|
assert.ok(render().includes('평균 점수'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('all literal translation calls have an English entry', () => {
|
||||||
|
function check(dir) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const file = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) { check(file); continue }
|
||||||
|
if (!/\.tsx?$/.test(file)) continue
|
||||||
|
const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isCallExpression(node) && node.expression.getText(source) === 't' && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) {
|
||||||
|
assert.ok(Object.hasOwn(dictionary, node.arguments[0].text), `${file}: ${node.arguments[0].text}`)
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(root)
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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')
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const data = new Map()
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/sourceSettings.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||||
|
}).outputText, {
|
||||||
|
exports,
|
||||||
|
require: () => ({ STUDIO_SOURCE_SETTINGS_STORAGE_KEY: 'test-settings' }),
|
||||||
|
window: { localStorage: { getItem: key => data.get(key) ?? null, setItem: (key, value) => data.set(key, value) } },
|
||||||
|
})
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
test('remote workspace reads local domain and linked platform by actual platform ID', () => {
|
||||||
|
const api = setup()
|
||||||
|
api.storeStudioSourceSettings('local-platform-uuid', { ...api.defaultStudioSourceSettings, rosDomainId: '75', rosLinkedPlatformId: 'yahboom-uuid' })
|
||||||
|
const result = api.readLocalStudioSourceSettings([
|
||||||
|
{ id: 'yahboom-uuid', platformKey: 'yahboom', sourceSettings: { rosDomainId: '77' } },
|
||||||
|
{ id: 'local-platform-uuid', platformKey: 'futuredrive-local-pc' },
|
||||||
|
])
|
||||||
|
assert.equal(result.rosDomainId, '75')
|
||||||
|
assert.equal(result.rosLinkedPlatformId, 'yahboom-uuid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('local platform defaults and unlinked selection are preserved', () => {
|
||||||
|
const api = setup()
|
||||||
|
const platforms = [{ id: 'local-platform-uuid', platformKey: 'futuredrive-local-pc', sourceSettings: { rosDomainId: '76' } }]
|
||||||
|
assert.equal(api.readLocalStudioSourceSettings(platforms).rosDomainId, '76')
|
||||||
|
api.storeStudioSourceSettings('local-platform-uuid', { ...api.defaultStudioSourceSettings, rosDomainId: '75', rosLinkedPlatformId: null })
|
||||||
|
assert.equal(api.readLocalStudioSourceSettings(platforms).rosLinkedPlatformId, null)
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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 source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const names = ['isRemotePlatformStatusPending', 'filteredPlatformOptions', 'platformFilterCounts']
|
||||||
|
const declarations = new Map()
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && names.includes(node.name.getText(source))) {
|
||||||
|
declarations.set(node.name.getText(source), node.getText(source))
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(source)
|
||||||
|
for (const name of names) assert.ok(declarations.has(name), `missing ${name}`)
|
||||||
|
const filterScript = ts.transpileModule(`${names.map(name => `const ${declarations.get(name)}`).join('\n')}
|
||||||
|
result = { counts: platformFilterCounts, ids: filteredPlatformOptions.map(platform => platform.id) }`, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText
|
||||||
|
const controlSource = fs.readFileSync(require.resolve('../src/renderer/src/features/platforms/platformControl.ts'), 'utf8')
|
||||||
|
const control = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(controlSource, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, {
|
||||||
|
exports: control,
|
||||||
|
require: name => {
|
||||||
|
assert.equal(name, '../../config/appConfig')
|
||||||
|
return { LMS_API_BASE_URL: 'http://unused.invalid' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function classify(platformOptions, checkedIds = ['mini'], platformFilter = 'all') {
|
||||||
|
const context = {
|
||||||
|
platformOptions, platformFilter,
|
||||||
|
checkedRemotePlatformStatusIds: new Set(checkedIds),
|
||||||
|
isPlatformUnavailable: control.isPlatformUnavailable
|
||||||
|
}
|
||||||
|
vm.runInNewContext(filterScript, context)
|
||||||
|
return JSON.parse(JSON.stringify(context.result))
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulation = { id: 'sim', platformKey: 'futuredrive-local-pc', state: 'ready', status: '사용 가능', action: '시작' }
|
||||||
|
const mini = { id: 'mini', platformKey: 'mkmini', state: 'ready', status: '사용 가능', action: '제어권 요청', connection: { headscaleOnline: true, agentHealthy: true } }
|
||||||
|
|
||||||
|
test('disconnected ready platform counts as unavailable and appears only in that tab', () => {
|
||||||
|
const disconnected = { ...mini, status: '연결 끊김', connection: { headscaleOnline: false, agentHealthy: false } }
|
||||||
|
const platforms = [simulation, disconnected]
|
||||||
|
assert.deepEqual(classify(platforms), { counts: { all: 2, available: 1, unavailable: 1 }, ids: ['sim', 'mini'] })
|
||||||
|
assert.deepEqual(classify(platforms, ['mini'], 'available').ids, ['sim'])
|
||||||
|
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('offline network, unhealthy agent and explicit unavailability all exclude ready platforms', () => {
|
||||||
|
for (const patch of [
|
||||||
|
{ connection: { headscaleOnline: false, agentHealthy: true } },
|
||||||
|
{ connection: { headscaleOnline: true, agentHealthy: false } },
|
||||||
|
{ connection: undefined, status: '연결 끊김' },
|
||||||
|
{ connection: undefined, status: '연결 불가' },
|
||||||
|
{ connection: undefined, action: '연결 불가' }
|
||||||
|
]) {
|
||||||
|
const platforms = [simulation, { ...mini, ...patch }]
|
||||||
|
assert.deepEqual(classify(platforms).counts, { all: 2, available: 1, unavailable: 1 })
|
||||||
|
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pending remote status remains in All without prematurely entering either availability tab', () => {
|
||||||
|
const platforms = [simulation, mini]
|
||||||
|
assert.deepEqual(classify(platforms, []).counts, { all: 2, available: 1, unavailable: 0 })
|
||||||
|
assert.deepEqual(classify(platforms, [], 'available').ids, ['sim'])
|
||||||
|
assert.deepEqual(classify(platforms, [], 'unavailable').ids, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reconnecting moves a platform back to available without changing All', () => {
|
||||||
|
assert.deepEqual(classify([simulation, mini]).counts, { all: 2, available: 2, unavailable: 0 })
|
||||||
|
assert.deepEqual(classify([simulation, mini], ['mini'], 'available').ids, ['sim', 'mini'])
|
||||||
|
assert.deepEqual(classify([simulation, mini], ['mini'], 'unavailable').ids, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('locked, requested and reserved platform classifications are preserved', () => {
|
||||||
|
for (const state of ['locked', 'request', 'reserved']) {
|
||||||
|
const platforms = [simulation, { ...mini, state }]
|
||||||
|
assert.deepEqual(classify(platforms).counts, { all: 2, available: 1, unavailable: 1 })
|
||||||
|
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const { readFileSync, mkdtempSync, writeFileSync, rmSync } = require('node:fs')
|
||||||
|
const { tmpdir } = require('node:os')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
const { spawn } = require('node:child_process')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
const transpile = (source) => ts.transpileModule(source, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||||
|
}).outputText
|
||||||
|
const helpers = { exports: {} }
|
||||||
|
vm.runInNewContext(transpile(readFileSync(join(__dirname, '../src/main/remoteShell.ts'), 'utf8')), helpers)
|
||||||
|
const { remoteShellCommand, remoteShellEnvironment } = helpers.exports
|
||||||
|
const source = readFileSync(join(__dirname, '../src/main/index.ts'), 'utf8')
|
||||||
|
const start = source.indexOf('async function runCommandOutput(')
|
||||||
|
const end = source.indexOf('\nasync function runCommand(', start)
|
||||||
|
const context = { spawn, process, setTimeout, clearTimeout, createModuleProgressParser: () => () => {} }
|
||||||
|
vm.runInNewContext(transpile(source.slice(start, end)), context)
|
||||||
|
const run = context.runCommandOutput
|
||||||
|
|
||||||
|
test('SSH data output excludes locale warnings even when command succeeds', async () => {
|
||||||
|
const result = await run('/bin/bash', ['--noprofile', '--norc', '-c', 'printf %s "/tmp/module with spaces"'], undefined,
|
||||||
|
{ LC_ALL: 'missing_TEST.UTF-8' }, 5000, 'stdout')
|
||||||
|
assert.equal(result, '/tmp/module with spaces')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed commands retain stderr diagnostics and stdout', async () => {
|
||||||
|
await assert.rejects(run('/bin/sh', ['-c', 'printf context; printf "permission denied" >&2; exit 7'], undefined, undefined, 5000, 'stdout'),
|
||||||
|
error => error.message.includes('context') && error.message.includes('permission denied'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('non-SSH combined output stays compatible and explicit log merging works', async () => {
|
||||||
|
const command = 'printf output; printf diagnostic >&2'
|
||||||
|
const combined = await run('/bin/sh', ['-c', command])
|
||||||
|
assert.match(combined, /output/)
|
||||||
|
assert.match(combined, /diagnostic/)
|
||||||
|
const logs = await run('/bin/sh', ['-c', `{ ${command}; } 2>&1`], undefined, undefined, 5000, 'stdout')
|
||||||
|
assert.match(logs, /diagnostic/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SSH environment neutralizes locale forwarding without losing other settings', () => {
|
||||||
|
const original = { LANG: 'ko_KR.UTF-8', LC_ALL: 'en_US.UTF-8', LC_CTYPE: 'bad', LANGUAGE: 'ko:en', PATH: '/bin', TERM: 'xterm-256color' }
|
||||||
|
const env = remoteShellEnvironment(original)
|
||||||
|
assert.equal(env.LANG, 'C')
|
||||||
|
assert.equal(env.LC_ALL, 'C')
|
||||||
|
assert.equal(env.LC_CTYPE, undefined)
|
||||||
|
assert.equal(env.LANGUAGE, undefined)
|
||||||
|
assert.equal(env.PATH, '/bin')
|
||||||
|
assert.equal(env.TERM, 'xterm-256color')
|
||||||
|
assert.equal(original.LC_CTYPE, 'bad')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remote wrapper preserves multiline commands, Unicode, quotes and exit status', async () => {
|
||||||
|
const command = `cat <<'END'
|
||||||
|
/tmp/한글 module's folder
|
||||||
|
END`
|
||||||
|
const result = await run('/bin/sh', ['-c', remoteShellCommand(command)], undefined, remoteShellEnvironment(process.env), 5000, 'stdout')
|
||||||
|
assert.equal(result, "/tmp/한글 module's folder\n")
|
||||||
|
await assert.rejects(run('/bin/sh', ['-c', remoteShellCommand('echo remote-failure >&2; exit 9')], undefined, undefined, 5000, 'stdout'), /remote-failure/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('locale selection prefers C UTF-8, supports other UTF-8 locales and falls back to C', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'lms-locale-test-'))
|
||||||
|
try {
|
||||||
|
for (const [locales, expected] of [['en_GB.utf8 C.utf8', 'C.utf8'], ['C en_GB.utf8', 'en_GB.utf8'], ['C POSIX', 'C']]) {
|
||||||
|
writeFileSync(join(dir, 'locale'), `#!/bin/sh\nprintf '%s\\n' ${locales}\n`, { mode: 0o755 })
|
||||||
|
const result = await run('/bin/sh', ['-c', remoteShellCommand('printf "%s|%s" "$LANG" "$LC_ALL"')], undefined,
|
||||||
|
{ PATH: `${dir}:${process.env.PATH}`, LC_ALL: 'C' }, 5000, 'stdout')
|
||||||
|
assert.equal(result, `${expected}|${expected}`)
|
||||||
|
}
|
||||||
|
} finally { rmSync(dir, { recursive: true, force: true }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('queued SSH, stats probes and remote PTY use normalization; container logs keep stderr', () => {
|
||||||
|
const ssh = source.slice(source.indexOf('async function runSshOutput('), source.indexOf('async function readRemoteDockerStatsOutput('))
|
||||||
|
assert.equal((ssh.match(/remoteShellCommand\(command\)/g) || []).length, 2)
|
||||||
|
assert.equal((ssh.match(/'stdout'/g) || []).length, 2)
|
||||||
|
const terminal = source.slice(source.indexOf("ipcMain.handle('remote-workspace:terminal:start'"), source.indexOf("ipcMain.handle('terminal-resize'"))
|
||||||
|
assert.match(terminal, /remoteShellCommand\(mode === 'container'/)
|
||||||
|
assert.match(terminal, /remoteShellEnvironment\(terminalEnvironment/)
|
||||||
|
assert.match(source, /target \? remoteShellCommand\(init\)/)
|
||||||
|
assert.match(source, /docker logs --timestamps --tail 300.*2>&1/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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/)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 root = path.resolve(__dirname, '../src/renderer/src/features/workspace')
|
||||||
|
|
||||||
|
function setup(saved, blocked = false) {
|
||||||
|
const storage = new Map([['autonomy-studio.lms.studio-theme', saved]])
|
||||||
|
const events = new Map()
|
||||||
|
const exports = {}
|
||||||
|
const code = ts.transpileModule(fs.readFileSync(path.join(root, 'studioTheme.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText
|
||||||
|
vm.runInNewContext(code, { exports,
|
||||||
|
require: () => ({ useSyncExternalStore: (_subscribe, snapshot) => snapshot() }),
|
||||||
|
localStorage: {
|
||||||
|
getItem(key) { if (blocked) throw Error('blocked'); return storage.get(key) },
|
||||||
|
setItem(key, value) { if (blocked) throw Error('blocked'); storage.set(key, value) }
|
||||||
|
},
|
||||||
|
window: { addEventListener: (type, listener) => events.set(type, listener) }
|
||||||
|
})
|
||||||
|
return { api: exports, storage, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('theme defaults dark, persists light, and restores the preference', () => {
|
||||||
|
const { api, storage } = setup('invalid')
|
||||||
|
assert.equal(api.useStudioTheme(), 'dark')
|
||||||
|
api.setStudioTheme('light')
|
||||||
|
assert.equal(api.useStudioTheme(), 'light')
|
||||||
|
assert.equal(storage.get(api.STUDIO_THEME_KEY), 'light')
|
||||||
|
assert.equal(setup('light').api.useStudioTheme(), 'light')
|
||||||
|
})
|
||||||
|
test('theme remains usable when storage is blocked', () => {
|
||||||
|
const { api } = setup(null, true)
|
||||||
|
api.setStudioTheme('light')
|
||||||
|
assert.equal(api.useStudioTheme(), 'light')
|
||||||
|
})
|
||||||
|
test('theme synchronizes across windows without writing runtime settings', () => {
|
||||||
|
const { api, storage, events } = setup('dark')
|
||||||
|
storage.set(api.STUDIO_THEME_KEY, 'light')
|
||||||
|
events.get('storage')({ key: api.STUDIO_THEME_KEY })
|
||||||
|
assert.equal(api.useStudioTheme(), 'light')
|
||||||
|
storage.clear()
|
||||||
|
events.get('storage')({ key: null })
|
||||||
|
assert.equal(api.useStudioTheme(), 'dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('module content scrollbar uses workspace colors without thinner scrollbar dimensions', () => {
|
||||||
|
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(path.join(root, 'ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const scrollClasses = []
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isJsxAttribute(node) && node.name.getText(source) === 'className' && node.initializer && ts.isStringLiteral(node.initializer)) {
|
||||||
|
const classes = node.initializer.text.split(/\s+/)
|
||||||
|
if (classes.includes('overflow-x-auto')) scrollClasses.push(classes)
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(source)
|
||||||
|
assert.ok(scrollClasses.length > 0)
|
||||||
|
for (const classes of scrollClasses) {
|
||||||
|
assert.ok(classes.includes('module-content-scrollbar'))
|
||||||
|
assert.ok(!classes.includes('thin-x-scrollbar'))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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, '..')
|
||||||
|
const placeholder = 'Binary or large file preview is not available.'
|
||||||
|
const xml = '<?xml version="1.0"?>\n<model><name>mkmini_gazebo</name><description>주행 모델</description></model>\n'
|
||||||
|
|
||||||
|
function compile(source, context = {}) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports, require, Buffer, ...context })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(run) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-xml-editor-'))
|
||||||
|
try {
|
||||||
|
for (const name of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) await fs.writeFile(path.join(dir, name), xml)
|
||||||
|
await fs.writeFile(path.join(dir, 'large.config'), 'x'.repeat(1024 * 1024 + 1))
|
||||||
|
await fs.writeFile(path.join(dir, 'model.glb'), Buffer.from([0, 1, 2, 3]))
|
||||||
|
await run(dir)
|
||||||
|
} finally { await fs.rm(dir, { recursive: true, force: true }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('local module and root collectors read small Gazebo XML files without changing binary and size exclusions', async () => {
|
||||||
|
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(root, 'src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const functions = source.statements.filter(node => ts.isFunctionDeclaration(node) && ['collectModuleFiles', 'collectRootFiles'].includes(node.name?.text))
|
||||||
|
const pattern = source.statements.find(node => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(source) === 'textFilePattern')
|
||||||
|
assert.equal(functions.length, 2)
|
||||||
|
assert.ok(pattern)
|
||||||
|
await fixture(async dir => {
|
||||||
|
const api = compile([pattern.getText(source), ...functions.map(node => `export ${node.getText(source)}`)].join('\n'), {
|
||||||
|
readdir: fs.readdir, stat: fs.stat, readFile: fs.readFile,
|
||||||
|
resolve: path.resolve, basename: path.basename,
|
||||||
|
skippedCacheDirs: new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']),
|
||||||
|
findModuleRoot: async () => dir,
|
||||||
|
toRendererModulePath: (name, file, base) => `modules/${name}/${path.relative(base, file)}`,
|
||||||
|
toRendererRootPath: (name, file, base) => `${name}/${path.relative(base, file)}`
|
||||||
|
})
|
||||||
|
for (const result of [await api.collectModuleFiles('NAV2-MKMini'), await api.collectRootFiles(dir, 'examples')]) {
|
||||||
|
const files = Object.fromEntries(Object.entries(result.files).map(([file, content]) => [path.basename(file), content]))
|
||||||
|
for (const name of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) assert.equal(files[name], xml)
|
||||||
|
for (const name of ['large.config', 'model.glb']) assert.equal(files[name], placeholder)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remote listing, content and search include Gazebo XML while preserving size, NUL and path protections', async () => {
|
||||||
|
const api = compile(await fs.readFile(path.join(root, 'src/main/remoteModuleFiles.ts'), 'utf8'))
|
||||||
|
await fixture(async dir => {
|
||||||
|
const request = async input => JSON.parse((await exec('bash', ['-c', api.remoteModuleFilesScript(dir, input)], { timeout: 10000 })).stdout)
|
||||||
|
const listing = await request({ action: 'list' })
|
||||||
|
assert.ok(listing.filePaths.includes('model.config'))
|
||||||
|
for (const file of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) assert.equal((await request({ action: 'content', path: file })).content, xml)
|
||||||
|
for (const file of ['large.config', 'model.glb']) assert.equal((await request({ action: 'content', path: file })).content, placeholder)
|
||||||
|
await fs.writeFile(path.join(dir, 'binary.config'), Buffer.from('text\0binary'))
|
||||||
|
assert.equal((await request({ action: 'content', path: 'binary.config' })).content, placeholder)
|
||||||
|
const matches = await request({ action: 'search', query: 'mkmini_gazebo' })
|
||||||
|
assert.deepEqual(matches.map(match => match.file).sort(), ['MODEL.CONFIG', 'model.config', 'model.sdf', 'simulation.world'].sort())
|
||||||
|
await assert.rejects(request({ action: 'content', path: '../outside.config' }), /Invalid module path/)
|
||||||
|
await fs.symlink(path.join(dir, '..'), path.join(dir, 'escape'))
|
||||||
|
await assert.rejects(request({ action: 'content', path: 'escape/outside.config' }), /Invalid module path/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Gazebo model.config, SDF and world files use XML highlighting without treating arbitrary config formats as XML', async () => {
|
||||||
|
const api = compile(await fs.readFile(path.join(root, 'src/renderer/src/features/workspace/workspaceFiles.ts'), 'utf8'))
|
||||||
|
for (const file of ['model.config', 'models/mkmini/model.config', 'models/MODEL.CONFIG', 'model.sdf', 'simulation.world', 'WORLD.SDF', 'package.xml', 'robot.urdf', 'robot.xacro']) assert.equal(api.getEditorLanguage(file), 'xml', file)
|
||||||
|
assert.equal(api.getEditorLanguage('app.config'), 'plaintext')
|
||||||
|
assert.equal(api.getEditorLanguage('control.yaml'), 'yaml')
|
||||||
|
assert.equal(api.getEditorLanguage('model.glb'), 'plaintext')
|
||||||
|
})
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
|
import { chmod, lstat, readdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'
|
||||||
|
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||||
|
import { safeAgentPath, type AgentCheckedChange, type AgentToolRequest } from '../shared/agentTools'
|
||||||
|
|
||||||
|
const textFile = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$/i
|
||||||
|
export const agentFileHash = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex')
|
||||||
|
|
||||||
|
async function checkedPath(root: string, path: string, allowMissing = false): Promise<string> {
|
||||||
|
safeAgentPath(path)
|
||||||
|
const base = await realpath(root)
|
||||||
|
const absolute = resolve(base, path)
|
||||||
|
if (!absolute.startsWith(base + sep)) throw new Error('모듈 경로를 벗어날 수 없습니다.')
|
||||||
|
let current = base
|
||||||
|
const parts = relative(base, absolute).split(sep)
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
current = join(current, parts[i])
|
||||||
|
try {
|
||||||
|
const info = await lstat(current)
|
||||||
|
if (info.isSymbolicLink() || (i < parts.length - 1 && !info.isDirectory())) throw new Error('심볼릭 링크와 일반 파일이 아닌 경로는 사용할 수 없습니다.')
|
||||||
|
} catch (error) {
|
||||||
|
if (allowMissing && i === parts.length - 1 && (error as NodeJS.ErrnoException).code === 'ENOENT') return absolute
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return absolute
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readText(root: string, path: string): Promise<string> {
|
||||||
|
if (!textFile.test(path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 조회·수정할 수 있습니다.')
|
||||||
|
const absolute = await checkedPath(root, path)
|
||||||
|
const info = await lstat(absolute)
|
||||||
|
if (!info.isFile() || info.size > 1_048_576) throw new Error('일반 텍스트 파일(1MB 이하)만 조회할 수 있습니다.')
|
||||||
|
const text = await readFile(absolute, 'utf8')
|
||||||
|
if (text.includes('\0') || text.includes('\uFFFD')) throw new Error('UTF-8 텍스트 파일이 아닙니다.')
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAgentFileTool(root: string, request: AgentToolRequest): Promise<{ data: unknown; truncated: boolean }> {
|
||||||
|
const args = request.args ?? {}
|
||||||
|
if (request.name === 'files.read') {
|
||||||
|
const path = safeAgentPath(args.path)
|
||||||
|
try {
|
||||||
|
const content = await readText(root, path)
|
||||||
|
const lines = content.split('\n')
|
||||||
|
const startLine = args.startLine ?? 1
|
||||||
|
const ranged = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
|
||||||
|
const selection = ranged ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n') : content
|
||||||
|
const selected = selection.slice(0, 40_000)
|
||||||
|
const contentTruncated = selected.length < selection.length
|
||||||
|
const complete = startLine === 1 && selected === content
|
||||||
|
const endLine = Math.min(lines.length, startLine + selected.split('\n').length - 1 - Number(contentTruncated))
|
||||||
|
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete, startLine, endLine, contentTruncated, totalLines: lines.length }, truncated: !complete }
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
// A missing leaf in an existing directory is evidence for a new file.
|
||||||
|
await checkedPath(root, path, true)
|
||||||
|
return { data: { path, exists: false, hash: null, content: '', complete: true }, truncated: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const prefix = safeAgentPath(args.path, true)
|
||||||
|
if (prefix) await checkedPath(root, prefix)
|
||||||
|
const paths: string[] = []
|
||||||
|
const matches: Array<{ path: string; line: number; text: string }> = []
|
||||||
|
let visited = 0
|
||||||
|
let scanned = 0
|
||||||
|
let truncated = false
|
||||||
|
const visit = async (directory: string, depth: number): Promise<void> => {
|
||||||
|
if (depth > 10 || visited >= 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; return }
|
||||||
|
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
for (const entry of entries) {
|
||||||
|
const path = relative(root, join(directory, entry.name)).split(sep).join('/')
|
||||||
|
if (prefix && path !== prefix && !path.startsWith(prefix + '/') && !prefix.startsWith(path + '/')) continue
|
||||||
|
if (++visited > 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; break }
|
||||||
|
try { safeAgentPath(path) } catch { continue }
|
||||||
|
if (entry.isSymbolicLink()) continue
|
||||||
|
if (entry.isDirectory()) { await visit(join(directory, entry.name), depth + 1); continue }
|
||||||
|
if (!entry.isFile() || !textFile.test(entry.name)) continue
|
||||||
|
if (prefix && path !== prefix && !path.startsWith(prefix + '/')) continue
|
||||||
|
if (request.name === 'files.list') { paths.push(path); continue }
|
||||||
|
let content: string
|
||||||
|
try { content = await readText(root, path) } catch { continue }
|
||||||
|
scanned += content.length
|
||||||
|
for (const [index, line] of content.split('\n').entries()) {
|
||||||
|
if (line.toLowerCase().includes(args.query!.toLowerCase())) {
|
||||||
|
matches.push({ path, line: index + 1, text: line.slice(0, 500) })
|
||||||
|
if (matches.length >= 80) { truncated = true; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit(root, 0)
|
||||||
|
return { data: request.name === 'files.list' ? { paths } : { query: args.query, matches }, truncated }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAgentFiles(changes: Array<AgentCheckedChange & { root: string }>): Promise<void> {
|
||||||
|
if (!changes.length || changes.length > 24 || changes.reduce((n, c) => n + c.content.length, 0) > 1_000_000) throw new Error('변경 파일 용량을 확인하세요.')
|
||||||
|
const prepared: Array<{ path: string; temp: string; original: string | null; mode: number }> = []
|
||||||
|
const committed: typeof prepared = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
try {
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash)) throw new Error('원본 파일 조회 정보가 없습니다. 다시 분석해주세요.')
|
||||||
|
if (!textFile.test(change.path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 수정할 수 있습니다.')
|
||||||
|
const path = await checkedPath(change.root, change.path, true)
|
||||||
|
if (seen.has(path)) throw new Error('중복된 변경 파일입니다.')
|
||||||
|
seen.add(path)
|
||||||
|
let original: string | null = null
|
||||||
|
let mode = 0o644
|
||||||
|
try { original = await readText(change.root, change.path); mode = (await lstat(path)).mode } catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
if ((original === null ? null : agentFileHash(original)) !== change.expectedHash) throw new Error(`분석 이후 파일이 변경되었습니다: ${change.moduleName}/${change.path}. 다시 분석해주세요.`)
|
||||||
|
prepared.push({ path, original, mode, temp: join(dirname(path), `.lms-agent-${randomUUID()}.tmp`) })
|
||||||
|
}
|
||||||
|
for (const [index, file] of prepared.entries()) {
|
||||||
|
await writeFile(file.temp, changes[index].content, { encoding: 'utf8', mode: file.mode, flag: 'wx' })
|
||||||
|
await chmod(file.temp, file.mode & 0o777)
|
||||||
|
}
|
||||||
|
// Recheck the entire batch immediately before committing any file.
|
||||||
|
for (const file of prepared) {
|
||||||
|
let latest: string | null = null
|
||||||
|
try { latest = await readFile(file.path, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
||||||
|
if (latest !== file.original) throw new Error('적용 직전 파일이 변경되었습니다. 다시 분석해주세요.')
|
||||||
|
}
|
||||||
|
for (const file of prepared) { await rename(file.temp, file.path); committed.push(file) }
|
||||||
|
} catch (error) {
|
||||||
|
const failures: string[] = []
|
||||||
|
for (const file of committed.reverse()) {
|
||||||
|
try { if (file.original === null) await unlink(file.path); else await writeFile(file.path, file.original, { mode: file.mode }) } catch { failures.push(file.path) }
|
||||||
|
}
|
||||||
|
if (failures.length) throw new Error(`일부 파일 복구에 실패했습니다: ${failures.join(', ')}. ${String(error)}`)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await Promise.all(prepared.map(file => unlink(file.temp).catch(() => undefined)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type { AgentCheckedChange, AgentToolRequest } from '../shared/agentTools'
|
||||||
|
|
||||||
|
export function agentRemoteFilesScript(input: { root: string; request: AgentToolRequest } | { changes: Array<AgentCheckedChange & { root: string }> }): string {
|
||||||
|
const encoded = Buffer.from(JSON.stringify(input)).toString('base64')
|
||||||
|
return `python3 - <<'LMS_AGENT_FILES'
|
||||||
|
import sys
|
||||||
|
sys.argv = ['lms-agent', '${encoded}']
|
||||||
|
${agentRemoteFilesPython}
|
||||||
|
LMS_AGENT_FILES`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentRemoteFilesPython = String.raw`
|
||||||
|
import base64, hashlib, json, os, re, stat, sys, tempfile
|
||||||
|
args = json.loads(base64.b64decode(sys.argv[1]))
|
||||||
|
blocked = {'node_modules', 'build', 'install', 'log', 'logs', '__pycache__', 'id_rsa', 'id_ed25519', 'credentials'}
|
||||||
|
text_pattern = re.compile(r'\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$', re.I)
|
||||||
|
def safe(value):
|
||||||
|
if not isinstance(value, str) or not value or len(value) > 500 or value.startswith('/') or any(c in value for c in '\\\x00\r\n'):
|
||||||
|
raise ValueError('Invalid source path')
|
||||||
|
if any(not p or p.startswith('.') or p.lower() in blocked or re.search(r'\.(pem|key|p12|pfx)$', p, re.I) for p in value.split('/')):
|
||||||
|
raise ValueError('Private or excluded source path')
|
||||||
|
return value
|
||||||
|
def checked(root, path, missing=False):
|
||||||
|
safe(path)
|
||||||
|
root = os.path.realpath(root)
|
||||||
|
current = root
|
||||||
|
parts = path.split('/')
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
current = os.path.join(current, part)
|
||||||
|
try:
|
||||||
|
info = os.lstat(current)
|
||||||
|
if stat.S_ISLNK(info.st_mode) or (i < len(parts)-1 and not stat.S_ISDIR(info.st_mode)):
|
||||||
|
raise ValueError('Symlink or non-directory source path')
|
||||||
|
except FileNotFoundError:
|
||||||
|
if not (missing and i == len(parts)-1):
|
||||||
|
raise
|
||||||
|
if os.path.commonpath([root, current]) != root:
|
||||||
|
raise ValueError('Source path escapes module')
|
||||||
|
return current
|
||||||
|
def read(root, path):
|
||||||
|
if not text_pattern.search(os.path.basename(path)):
|
||||||
|
raise ValueError('Only text source files are supported')
|
||||||
|
absolute = checked(root, path)
|
||||||
|
info = os.lstat(absolute)
|
||||||
|
if not stat.S_ISREG(info.st_mode) or info.st_size > 1048576:
|
||||||
|
raise ValueError('Only regular source files up to 1 MB are supported')
|
||||||
|
with open(absolute, 'rb') as fp:
|
||||||
|
content = fp.read(1048577)
|
||||||
|
if len(content) > 1048576 or b'\x00' in content:
|
||||||
|
raise ValueError('Binary or oversized file')
|
||||||
|
return content.decode('utf-8')
|
||||||
|
def digest(content):
|
||||||
|
return hashlib.sha256(content.encode('utf-8')).hexdigest()
|
||||||
|
if 'changes' in args:
|
||||||
|
changes = args['changes']
|
||||||
|
if not changes or len(changes) > 24 or sum(len(c['content']) for c in changes) > 1000000:
|
||||||
|
raise ValueError('Invalid change batch size')
|
||||||
|
prepared, committed, seen = [], [], set()
|
||||||
|
try:
|
||||||
|
for c in changes:
|
||||||
|
path = checked(c['root'], c['path'], True)
|
||||||
|
if path in seen or not text_pattern.search(os.path.basename(path)):
|
||||||
|
raise ValueError('Duplicate or unsupported change file')
|
||||||
|
seen.add(path)
|
||||||
|
try:
|
||||||
|
original = read(c['root'], c['path'])
|
||||||
|
mode = stat.S_IMODE(os.stat(path).st_mode)
|
||||||
|
except FileNotFoundError:
|
||||||
|
original, mode = None, 0o644
|
||||||
|
if (None if original is None else digest(original)) != c.get('expectedHash', 'missing'):
|
||||||
|
raise ValueError('File changed after analysis: ' + c['moduleName'] + '/' + c['path'])
|
||||||
|
prepared.append({'path': path, 'original': original, 'mode': mode, 'content': c['content'], 'temp': None})
|
||||||
|
for p in prepared:
|
||||||
|
fd, p['temp'] = tempfile.mkstemp(prefix='.lms-agent-', dir=os.path.dirname(p['path']))
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as fp:
|
||||||
|
fp.write(p['content'])
|
||||||
|
os.chmod(p['temp'], p['mode'])
|
||||||
|
for p in prepared:
|
||||||
|
try:
|
||||||
|
with open(p['path'], 'r', encoding='utf-8', newline='') as fp:
|
||||||
|
latest = fp.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
latest = None
|
||||||
|
if latest != p['original']:
|
||||||
|
raise ValueError('File changed immediately before apply')
|
||||||
|
for p in prepared:
|
||||||
|
os.replace(p['temp'], p['path'])
|
||||||
|
committed.append(p)
|
||||||
|
except Exception as error:
|
||||||
|
failed = []
|
||||||
|
for p in reversed(committed):
|
||||||
|
try:
|
||||||
|
if p['original'] is None:
|
||||||
|
os.unlink(p['path'])
|
||||||
|
else:
|
||||||
|
with open(p['path'], 'w', encoding='utf-8', newline='') as fp:
|
||||||
|
fp.write(p['original'])
|
||||||
|
os.chmod(p['path'], p['mode'])
|
||||||
|
except Exception:
|
||||||
|
failed.append(p['path'])
|
||||||
|
if failed:
|
||||||
|
raise ValueError('Rollback failed: ' + ', '.join(failed)) from error
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
for p in prepared:
|
||||||
|
if p['temp'] and os.path.exists(p['temp']):
|
||||||
|
os.unlink(p['temp'])
|
||||||
|
print(json.dumps({'ok': True}))
|
||||||
|
else:
|
||||||
|
root = os.path.realpath(args['root'])
|
||||||
|
req = args['request']
|
||||||
|
options = req.get('args') or {}
|
||||||
|
name = req['name']
|
||||||
|
if name == 'files.read':
|
||||||
|
path = options['path']
|
||||||
|
try:
|
||||||
|
content = read(root, path)
|
||||||
|
lines = content.split('\n')
|
||||||
|
ranged = 'startLine' in options or 'lineCount' in options or len(content) > 40000
|
||||||
|
start = options.get('startLine', 1)
|
||||||
|
selection = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)]) if ranged else content
|
||||||
|
selected = selection[:40000]
|
||||||
|
content_truncated = len(selected) < len(selection)
|
||||||
|
complete = start == 1 and selected == content
|
||||||
|
partial = not complete
|
||||||
|
end = min(len(lines), start + len(selected.split('\n')) - 1 - int(content_truncated))
|
||||||
|
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': complete, 'startLine': start, 'endLine': end, 'contentTruncated': content_truncated, 'totalLines': len(lines)}
|
||||||
|
except FileNotFoundError:
|
||||||
|
checked(root, path, True)
|
||||||
|
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
|
||||||
|
print(json.dumps({'data': data, 'truncated': partial}, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
prefix = options.get('path', '')
|
||||||
|
if prefix:
|
||||||
|
checked(root, prefix)
|
||||||
|
paths, matches = [], []
|
||||||
|
visited, scanned, truncated = 0, 0, False
|
||||||
|
def fail(error):
|
||||||
|
raise error
|
||||||
|
for directory, dirs, files in os.walk(root, followlinks=False, onerror=fail):
|
||||||
|
depth = len(os.path.relpath(directory, root).split('/')) if directory != root else 0
|
||||||
|
kept = []
|
||||||
|
for d in sorted(dirs):
|
||||||
|
rel = os.path.relpath(os.path.join(directory, d), root)
|
||||||
|
if prefix and rel != prefix and not rel.startswith(prefix + '/') and not prefix.startswith(rel + '/'):
|
||||||
|
continue
|
||||||
|
visited += 1
|
||||||
|
try:
|
||||||
|
safe(rel)
|
||||||
|
if not os.path.islink(os.path.join(directory, d)):
|
||||||
|
kept.append(d)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
dirs[:] = kept if depth < 10 else []
|
||||||
|
for f in sorted(files):
|
||||||
|
path = os.path.relpath(os.path.join(directory, f), root)
|
||||||
|
if prefix and path != prefix and not path.startswith(prefix + '/'):
|
||||||
|
continue
|
||||||
|
visited += 1
|
||||||
|
if visited > 5000 or len(paths) >= 250 or len(matches) >= 80 or scanned >= 4000000:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
safe(path)
|
||||||
|
if os.path.islink(os.path.join(directory, f)) or not text_pattern.search(f):
|
||||||
|
continue
|
||||||
|
if prefix and path != prefix and not path.startswith(prefix + '/'):
|
||||||
|
continue
|
||||||
|
if name == 'files.list':
|
||||||
|
paths.append(path)
|
||||||
|
continue
|
||||||
|
content = read(root, path)
|
||||||
|
except (ValueError, OSError, UnicodeError):
|
||||||
|
continue
|
||||||
|
scanned += len(content)
|
||||||
|
for number, line in enumerate(content.split('\n'), 1):
|
||||||
|
if options['query'].lower() in line.lower():
|
||||||
|
matches.append({'path': path, 'line': number, 'text': line[:500]})
|
||||||
|
if len(matches) >= 80:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
if truncated:
|
||||||
|
break
|
||||||
|
data = {'paths': paths} if name == 'files.list' else {'query': options['query'], 'matches': matches}
|
||||||
|
print(json.dumps({'data': data, 'truncated': truncated}, ensure_ascii=False))
|
||||||
|
`
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { AgentToolRequest } from '../shared/agentTools'
|
||||||
|
|
||||||
|
export const DEFAULT_AGENT_TOPICS = ['/raw/lidar', '/scan', '/lidar_filtered', '/cloud_map', '/odom', '/map', '/tf', '/tf_static']
|
||||||
|
|
||||||
|
export function agentRosProbe(topics: string[] = DEFAULT_AGENT_TOPICS): string {
|
||||||
|
const encoded = Buffer.from(JSON.stringify(topics)).toString('base64')
|
||||||
|
return `python3 -c ${shellQuote(agentRosProbePython)} ${shellQuote(encoded)}`
|
||||||
|
}
|
||||||
|
export function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'` }
|
||||||
|
|
||||||
|
export const agentRosProbePython = String.raw`
|
||||||
|
import base64, json, sys, time
|
||||||
|
import rclpy
|
||||||
|
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
|
||||||
|
from rosidl_runtime_py.utilities import get_message
|
||||||
|
rclpy.init(args=[])
|
||||||
|
node = rclpy.create_node('lms_agent_readonly_probe_' + str(int(time.time() * 1000)))
|
||||||
|
topics = json.loads(base64.b64decode(sys.argv[1]))
|
||||||
|
samples, valid_samples, frames, edges, errors, subscriptions = {}, {}, {}, set(), {}, []
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 1.5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
rclpy.spin_once(node, timeout_sec=0.1)
|
||||||
|
graph = dict(node.get_topic_names_and_types())
|
||||||
|
def callback(topic):
|
||||||
|
def receive(message):
|
||||||
|
samples[topic] = samples.get(topic, 0) + 1
|
||||||
|
if hasattr(message, 'header') and message.header.frame_id:
|
||||||
|
frame_set = frames.setdefault(topic, set())
|
||||||
|
if len(frame_set) < 8:
|
||||||
|
frame_set.add(message.header.frame_id)
|
||||||
|
valid = True
|
||||||
|
if hasattr(message, 'ranges'):
|
||||||
|
valid = len(message.ranges) > 0
|
||||||
|
elif hasattr(message, 'width') and hasattr(message, 'data'):
|
||||||
|
valid = message.width * message.height > 0 and len(message.data) > 0
|
||||||
|
elif hasattr(message, 'info') and hasattr(message, 'data'):
|
||||||
|
valid = message.info.width * message.info.height > 0 and len(message.data) > 0
|
||||||
|
if valid:
|
||||||
|
valid_samples[topic] = valid_samples.get(topic, 0) + 1
|
||||||
|
if topic in ('/tf', '/tf_static'):
|
||||||
|
for transform in message.transforms:
|
||||||
|
if len(edges) < 200:
|
||||||
|
edges.add((transform.header.frame_id, transform.child_frame_id))
|
||||||
|
return receive
|
||||||
|
for topic in topics:
|
||||||
|
if topic not in graph:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
retained = any(info.qos_profile.durability == DurabilityPolicy.TRANSIENT_LOCAL for info in node.get_publishers_info_by_topic(topic))
|
||||||
|
qos = QoSProfile(depth=1, reliability=ReliabilityPolicy.BEST_EFFORT, durability=DurabilityPolicy.TRANSIENT_LOCAL if retained else DurabilityPolicy.VOLATILE)
|
||||||
|
subscriptions.append(node.create_subscription(get_message(graph[topic][0]), topic, callback(topic), qos))
|
||||||
|
except Exception as error:
|
||||||
|
errors[topic] = str(error)[:500]
|
||||||
|
deadline = time.monotonic() + 3.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
rclpy.spin_once(node, timeout_sec=0.1)
|
||||||
|
graph = dict(node.get_topic_names_and_types())
|
||||||
|
print(json.dumps({'nodes': node.get_node_names()[:200], 'topics': [{'name': t, 'types': graph.get(t, []), 'publishers': node.count_publishers(t), 'samples': samples.get(t, 0), 'validSamples': valid_samples.get(t, 0), 'frameIds': sorted(frames.get(t, [])), 'error': errors.get(t)} for t in topics], 'tfEdges': [{'parent': p, 'child': c} for p, c in sorted(edges)], 'observationSeconds': 3.0}))
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
`
|
||||||
|
|
||||||
|
export async function inspectAgentRuntime(
|
||||||
|
request: AgentToolRequest,
|
||||||
|
host: (executable: string, args: string[]) => Promise<string>,
|
||||||
|
container: (command: string) => Promise<string>
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (request.name === 'network.inspect') {
|
||||||
|
const interfaces = JSON.parse(await host('ip', ['-j', 'address', 'show']))
|
||||||
|
const routes = JSON.parse(await host('ip', ['-j', 'route', 'show']))
|
||||||
|
let containerNetwork: unknown
|
||||||
|
try { containerNetwork = JSON.parse(await container('ip -j address show')) } catch (error) { containerNetwork = { error: String(error).slice(0, 500) } }
|
||||||
|
return { host: { interfaces, routes }, container: containerNetwork, note: '호스트는 해당 모듈이 설치된 로컬 또는 SSH 대상 장치입니다. 라이다·공유기 자체 설정은 조회하지 않았습니다.' }
|
||||||
|
}
|
||||||
|
if (request.name === 'ros.inspect') return JSON.parse(await container(agentRosProbe(request.args?.topics)))
|
||||||
|
throw new Error('지원하지 않는 런타임 조회입니다.')
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { inspect } from 'util'
|
||||||
|
import { createDailyLog } from './dailyLog'
|
||||||
|
|
||||||
|
const fallback = app.getPath('logs')
|
||||||
|
export const appLog = createDailyLog([
|
||||||
|
process.env.APPIMAGE
|
||||||
|
? join(dirname(process.env.APPIMAGE), 'logs')
|
||||||
|
: app.isPackaged ? join(dirname(process.execPath), 'logs') : fallback,
|
||||||
|
fallback
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const level of ['warn', 'error'] as const) {
|
||||||
|
const original = console[level].bind(console)
|
||||||
|
console[level] = (...args: unknown[]) => {
|
||||||
|
appLog.write(level, args.map((value) => typeof value === 'string' ? value : inspect(value, { depth: 4 })).join(' '))
|
||||||
|
original(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor fatal exceptions without changing Node's normal termination behavior.
|
||||||
|
process.on('uncaughtExceptionMonitor', (error, origin) => {
|
||||||
|
appLog.write(origin, error.stack ?? String(error))
|
||||||
|
})
|
||||||
|
process.on('warning', (warning) => appLog.write('warning', warning.stack ?? warning.message))
|
||||||
|
process.on('exit', (code) => appLog.write('exit', `code=${code}`))
|
||||||
|
app.on('child-process-gone', (_event, details) => appLog.write('child-process-gone', JSON.stringify(details)))
|
||||||
|
appLog.write('startup', `version=${app.getVersion()} platform=${process.platform} arch=${process.arch} electron=${process.versions.electron} executable=${process.env.APPIMAGE || process.execPath}`)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { appLog } from './appLogging'
|
||||||
|
|
||||||
|
// Load native dependencies only after the startup logger is ready.
|
||||||
|
void import('./index').catch((error: unknown) => {
|
||||||
|
appLog.write('startup-error', error instanceof Error ? error.stack ?? error.message : String(error))
|
||||||
|
app.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts'
|
||||||
|
|
||||||
|
test('PC and Jetson bridges have separate pinned downloads', () => {
|
||||||
|
const pc = bridgeArtifact(localBridgeImage)!
|
||||||
|
const jetson = bridgeArtifact(jetsonBridgeImage)!
|
||||||
|
assert.equal(pc.architecture, 'amd64')
|
||||||
|
assert.equal(jetson.architecture, 'arm64')
|
||||||
|
assert.notEqual(pc.url, jetson.url)
|
||||||
|
assert.notEqual(pc.fileName, jetson.fileName)
|
||||||
|
for (const artifact of [pc, jetson]) {
|
||||||
|
assert.match(artifact.checksumSha256, /^[a-f0-9]{64}$/)
|
||||||
|
assert.equal(new URL(artifact.url).protocol, 'https:')
|
||||||
|
assert.ok(artifact.url.includes(artifact.checksumSha256.slice(0, 12)))
|
||||||
|
assert.equal(artifact.required, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('custom images do not resolve to an unrelated built-in artifact', () => {
|
||||||
|
assert.equal(bridgeArtifact('custom-bridge:test'), undefined)
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const root = 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge'
|
||||||
|
|
||||||
|
export const localBridgeImage = 'slam-ros-bridge:20260813-3d-map-v2'
|
||||||
|
export const jetsonBridgeImage = 'slam-ros-bridge:jetson_orin'
|
||||||
|
|
||||||
|
const artifacts = [
|
||||||
|
{
|
||||||
|
image: localBridgeImage,
|
||||||
|
architecture: 'amd64',
|
||||||
|
url: `${root}/amd64/20260915-489a3e8b370c/slam-ros-bridge-amd64.tar.zst`,
|
||||||
|
fileName: 'slam-ros-bridge-amd64-489a3e8b370c.tar.zst',
|
||||||
|
checksumSha256: '489a3e8b370c19903043a39e92935f2e32e4b4423c28210f130df1acab1a9889',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: jetsonBridgeImage,
|
||||||
|
architecture: 'arm64',
|
||||||
|
url: `${root}/arm64/20261013-21acdf0cbdee/slam-ros-bridge-jetson-orin.tar.zst`,
|
||||||
|
fileName: 'slam-ros-bridge-jetson-orin-21acdf0cbdee.tar.zst',
|
||||||
|
checksumSha256: '21acdf0cbdee45d8b7b9dba78c3d321d67d8d7a2467511c4af0826e36ea0c74f',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function bridgeArtifact(image: string) {
|
||||||
|
return artifacts.find((artifact) => artifact.image === image)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const image = 'future-nav2-camera-test:humble-jetson_orin'
|
||||||
|
|
||||||
|
export function cameraRuntimeArtifacts<T extends { image?: string | null }>(
|
||||||
|
folder: string,
|
||||||
|
configured: T[] | null | undefined,
|
||||||
|
): T[] | null | undefined {
|
||||||
|
if (!configured) return configured
|
||||||
|
const camera = folder.includes('lms-nav2-camera-test')
|
||||||
|
// The optional camera image must not be downloaded for other Nav2 modules.
|
||||||
|
return configured.filter((item) => (item.image?.trim() === image) === camera)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { BrowserWindow, clipboard, Menu } from 'electron'
|
||||||
|
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
|
||||||
|
import type { ChatClipboardAction, ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu'
|
||||||
|
|
||||||
|
function trustedChatFrame(event: IpcMainInvokeEvent): boolean {
|
||||||
|
if (event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return false
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
return Boolean(window && !window.isDestroyed())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readChatClipboardState(event: IpcMainInvokeEvent): { hasText: boolean } {
|
||||||
|
return { hasText: trustedChatFrame(event) && clipboard.readText().length > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function performChatClipboardAction(event: IpcMainInvokeEvent, input: ChatClipboardAction): boolean {
|
||||||
|
if (!trustedChatFrame(event) || !input) return false
|
||||||
|
if (input.action === 'copy' && typeof input.text === 'string' && input.text.length > 0 && input.text.length <= 5_000_000) {
|
||||||
|
clipboard.writeText(input.text)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (input.action === 'paste' && clipboard.readText().length > 0) {
|
||||||
|
event.sender.paste()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showChatTextMenu(event: IpcMainInvokeEvent, input: ChatTextMenuOptions): Promise<ChatTextMenuAction> | null {
|
||||||
|
if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (!window || window.isDestroyed()) return null
|
||||||
|
const label = (value: unknown, fallback: string): string => typeof value === 'string' && value.trim() ? value.slice(0, 80) : fallback
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const items: MenuItemConstructorOptions[] = [{
|
||||||
|
label: label(input.labels?.copy, 'Copy'),
|
||||||
|
enabled: input.canCopy === true,
|
||||||
|
click: () => {
|
||||||
|
if (!event.sender.isDestroyed() && input.canCopy === true) event.sender.copy()
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
if (input.kind === 'input') {
|
||||||
|
items.push({
|
||||||
|
label: label(input.labels?.paste, 'Paste'),
|
||||||
|
enabled: input.canPaste === true && clipboard.readText().length > 0,
|
||||||
|
click: () => {
|
||||||
|
if (!event.sender.isDestroyed() && input.canPaste === true && clipboard.readText().length > 0) event.sender.paste()
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (input.canClear !== undefined) {
|
||||||
|
items.push({ type: 'separator' }, {
|
||||||
|
label: label(input.labels?.clear, 'Delete conversation'),
|
||||||
|
enabled: input.canClear === true,
|
||||||
|
click: () => resolve(input.canClear === true ? 'clear' : null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Menu.buildFromTemplate(items).popup({ window, callback: () => resolve(null) })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { resolveContainerTerminal } from './containerTerminal'
|
||||||
|
|
||||||
|
const inspection = (running = true) => JSON.stringify([{
|
||||||
|
Id: 'container-id', State: { Running: running }, Mounts: [
|
||||||
|
{ Source: '/home/user/ws', Destination: '/workspace' },
|
||||||
|
{ Source: '/home/user/ws/module', Destination: '/module' }
|
||||||
|
]
|
||||||
|
}])
|
||||||
|
|
||||||
|
test('resolves module path from the most specific actual mount', () => {
|
||||||
|
assert.deepEqual(resolveContainerTerminal(inspection(), '/home/user/ws/module'), { id: 'container-id', cwd: '/module' })
|
||||||
|
assert.equal(resolveContainerTerminal(inspection(), '/home/user/ws/other').cwd, '/workspace/other')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects stopped containers and unrelated workspace paths', () => {
|
||||||
|
assert.throws(() => resolveContainerTerminal(inspection(false), '/home/user/ws/module'), /not running/)
|
||||||
|
assert.throws(() => resolveContainerTerminal(inspection(), '/home/user/ws-other/module'), /not mounted/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { posix } from 'node:path'
|
||||||
|
|
||||||
|
export function resolveContainerTerminal(inspectJson: string, moduleRoot: string): { id: string; cwd: string } {
|
||||||
|
const [container] = JSON.parse(inspectJson)
|
||||||
|
if (!container?.State?.Running) throw new Error('Module container is not running')
|
||||||
|
const root = posix.resolve(moduleRoot)
|
||||||
|
const mounts = (container.Mounts ?? []) as { Source: string; Destination: string }[]
|
||||||
|
const mount = mounts
|
||||||
|
.filter((item) => root === item.Source || root.startsWith(`${item.Source.replace(/\/$/, '')}/`))
|
||||||
|
.sort((a, b) => b.Source.length - a.Source.length)[0]
|
||||||
|
if (!mount) throw new Error('Module workspace is not mounted in this container')
|
||||||
|
return { id: container.Id, cwd: posix.join(mount.Destination, posix.relative(mount.Source, root)) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { appendFileSync, closeSync, mkdirSync, openSync, readdirSync, unlinkSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export function redactLog(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/(https?:\/\/)[^\s/@]+@/gi, '$1[REDACTED]@')
|
||||||
|
.replace(/\bBearer\s+[^\s"',;]+/gi, 'Bearer [REDACTED]')
|
||||||
|
.replace(/((?:[?&]|--)(?:[^\s=&]*token|password|secret|code|key)=)[^\s&#"']+/gi, '$1[REDACTED]')
|
||||||
|
.replace(/(["']?(?:[\w-]*token|password|secret|authorization|api[_-]?key)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)/gi, '$1[REDACTED]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDate(now: Date): string {
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDailyLog(directories: string[], now: () => Date = () => new Date()) {
|
||||||
|
let activePath = ''
|
||||||
|
let activeDay = ''
|
||||||
|
|
||||||
|
function selectFile(): string {
|
||||||
|
const day = localDate(now())
|
||||||
|
if (activePath && activeDay === day) return activePath
|
||||||
|
for (const directory of [...new Set(directories)]) {
|
||||||
|
try {
|
||||||
|
mkdirSync(directory, { recursive: true })
|
||||||
|
const path = join(directory, `lms-${day}.log`)
|
||||||
|
closeSync(openSync(path, 'a', 0o600))
|
||||||
|
activePath = path
|
||||||
|
activeDay = day
|
||||||
|
for (const name of readdirSync(directory)) {
|
||||||
|
if (/^lms-\d{4}-\d{2}-\d{2}\.log$/.test(name) && name < `lms-${day}.log`) {
|
||||||
|
try { unlinkSync(join(directory, name)) } catch { /* Retention must not block startup. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
} catch { /* Try the user-writable fallback directory. */ }
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: selectFile,
|
||||||
|
write(kind: string, message: string): void {
|
||||||
|
try {
|
||||||
|
const path = selectFile()
|
||||||
|
if (!path) return
|
||||||
|
appendFileSync(path, `[${now().toISOString()}] [${redactLog(kind)}] ${redactLog(message)}\n`, { mode: 0o600 })
|
||||||
|
} catch { /* Diagnostics must not interrupt the app. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { classifyDockerError } from './dockerStatus'
|
||||||
|
|
||||||
|
test('missing CLI is distinguished from an unavailable daemon', () => {
|
||||||
|
assert.equal(classifyDockerError('ENOENT', 'spawn docker ENOENT'), 'missing')
|
||||||
|
assert.equal(classifyDockerError(1, 'Cannot connect to the Docker daemon'), 'unavailable')
|
||||||
|
assert.equal(classifyDockerError(1, 'dial unix /run/docker.sock: connect: connection refused'), 'unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('permission errors take precedence over connection errors', () => {
|
||||||
|
assert.equal(classifyDockerError(1, 'Cannot connect: permission denied'), 'permission')
|
||||||
|
assert.equal(classifyDockerError('EACCES', 'spawn docker EACCES'), 'permission')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('timeouts and unknown failures do not imply Docker needs installing', () => {
|
||||||
|
assert.equal(classifyDockerError(null, 'Command failed: docker info'), 'error')
|
||||||
|
assert.equal(classifyDockerError(1, 'context not found'), 'error')
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
|
||||||
|
export type DockerStatus = 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'
|
||||||
|
|
||||||
|
export function classifyDockerError(code: string | number | null | undefined, message: string): DockerStatus {
|
||||||
|
if (code === 'ENOENT') return 'missing'
|
||||||
|
if (/permission denied|access is denied|EACCES/i.test(message)) return 'permission'
|
||||||
|
if (/cannot connect|connection refused|is the docker daemon running/i.test(message)) return 'unavailable'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDockerStatus(): Promise<{ status: DockerStatus; platform: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile('docker', ['info', '--format', '{{.ServerVersion}}'], {
|
||||||
|
timeout: 5000,
|
||||||
|
maxBuffer: 256 * 1024,
|
||||||
|
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
||||||
|
}, (error, stdout, stderr) => {
|
||||||
|
resolve({
|
||||||
|
status: error
|
||||||
|
? classifyDockerError(error.code, `${error.message}\n${stderr}`)
|
||||||
|
: stdout.trim() ? 'ready' : 'error',
|
||||||
|
platform: process.platform,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { readdir, readFile, realpath } from 'fs/promises'
|
||||||
|
import { resolve, sep } from 'path'
|
||||||
|
import type { LocalGitModule } from '../shared/gitModuleImport'
|
||||||
|
|
||||||
|
export async function localGitModulePath(root: string, folder: string): Promise<string> {
|
||||||
|
if (!/^\[lms-nav2-custom\][a-z][a-z0-9_]*_v[^/\\]+_ws$/.test(folder)) throw new Error('Invalid custom module folder')
|
||||||
|
const base = await realpath(root)
|
||||||
|
const target = await realpath(resolve(base, folder))
|
||||||
|
if (!target.startsWith(base + sep)) throw new Error('Module is outside the local workspace')
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLocalGitModules(root: string): Promise<LocalGitModule[]> {
|
||||||
|
const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === 'ENOENT') return []
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
const modules: LocalGitModule[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !entry.name.startsWith('[lms-nav2-custom]')) continue
|
||||||
|
try {
|
||||||
|
const target = await localGitModulePath(root, entry.name)
|
||||||
|
const data = JSON.parse(await readFile(resolve(target, '.autonomy-studio-module.json'), 'utf8'))
|
||||||
|
if (data.sourceType !== 'CUSTOM' || !['name', 'category', 'packageName', 'launchFile'].every(key => typeof data[key] === 'string' && data[key])) continue
|
||||||
|
modules.push({ name: data.name, category: data.category, packageName: data.packageName, launchFile: data.launchFile, folderName: entry.name })
|
||||||
|
} catch { /* Ignore incomplete module folders. */ }
|
||||||
|
}
|
||||||
|
return modules.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repositoryAddress(value: string): string {
|
||||||
|
// Do not allow local paths, Git remote helpers, or credentials in the UI.
|
||||||
|
const normalized = /^git@[^:]+:.+/.test(value) ? value.replace(/^git@([^:]+):(.+)$/, 'ssh://git@$1/$2') : value
|
||||||
|
const url = new URL(normalized)
|
||||||
|
if (!['https:', 'http:', 'ssh:'].includes(url.protocol)) throw new Error('Unsupported Git repository protocol')
|
||||||
|
url.password = ''
|
||||||
|
if (url.protocol !== 'ssh:') url.username = ''
|
||||||
|
url.search = ''
|
||||||
|
url.hash = ''
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remoteBranches(output: string): Array<{ name: string; commit: string }> {
|
||||||
|
return output.split('\n').flatMap(line => {
|
||||||
|
const match = line.trim().match(/^([a-f0-9]{40,64})\s+refs\/heads\/(.+)$/)
|
||||||
|
return match ? [{ name: match[2], commit: match[1] }] : []
|
||||||
|
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
|
||||||
|
const { moduleProgress, createModuleProgressParser } = createRequire(import.meta.url)('./moduleProgress.ts') as typeof import('./moduleProgress')
|
||||||
|
|
||||||
|
test('progress parser handles split lines and ignores normal command output', () => {
|
||||||
|
const messages: string[] = []
|
||||||
|
const parse = moduleProgress.run((message) => messages.push(message), createModuleProgressParser)
|
||||||
|
parse('ordinary output\nLMS_MODULE_PRO')
|
||||||
|
parse('GRESS:download\nLMS_MODULE_PROGRESS:load\n')
|
||||||
|
assert.deepEqual(messages, ['download', 'load'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('concurrent operations retain their own progress callbacks', () => {
|
||||||
|
const first: string[] = []
|
||||||
|
const second: string[] = []
|
||||||
|
const a = moduleProgress.run((message) => first.push(message), createModuleProgressParser)
|
||||||
|
const b = moduleProgress.run((message) => second.push(message), createModuleProgressParser)
|
||||||
|
b('LMS_MODULE_PROGRESS:build\n')
|
||||||
|
a('LMS_MODULE_PROGRESS:download\n')
|
||||||
|
assert.deepEqual(first, ['download'])
|
||||||
|
assert.deepEqual(second, ['build'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('commands outside a module operation do not report progress', () => {
|
||||||
|
assert.doesNotThrow(() => createModuleProgressParser()('LMS_MODULE_PROGRESS:ignored\n'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
|
|
||||||
|
export const moduleProgress = new AsyncLocalStorage<(message: string) => void>()
|
||||||
|
|
||||||
|
export function reportModuleProgress(message: string): void {
|
||||||
|
moduleProgress.getStore()?.(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModuleProgressParser(): (chunk: string) => void {
|
||||||
|
const report = moduleProgress.getStore()
|
||||||
|
let pending = ''
|
||||||
|
return (chunk) => {
|
||||||
|
if (!report) return
|
||||||
|
pending += chunk
|
||||||
|
const lines = pending.split('\n')
|
||||||
|
pending = lines.pop()!.slice(-4096)
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('LMS_MODULE_PROGRESS:')) report(line.slice('LMS_MODULE_PROGRESS:'.length).trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
type Request = { action: 'list' } | { action: 'content'; path: string } | { action: 'search'; query: string }
|
||||||
|
|
||||||
|
export function remoteModuleFilesScript(root: string, request: Request): string {
|
||||||
|
const input = Buffer.from(JSON.stringify({ root, ...request })).toString('base64')
|
||||||
|
return `python3 - '${input}' <<'PY'
|
||||||
|
import base64, json, os, stat, sys
|
||||||
|
args = json.loads(base64.b64decode(sys.argv[1]))
|
||||||
|
root = os.path.realpath(args['root'])
|
||||||
|
skip = {'.git', 'build', 'install', 'log', 'node_modules', 'docker'}
|
||||||
|
extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.sdf', '.world', '.config', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'}
|
||||||
|
placeholder = 'Binary or large file preview is not available.'
|
||||||
|
def content(relative):
|
||||||
|
if os.path.isabs(relative) or '..' in relative.split('/'):
|
||||||
|
raise ValueError('Invalid module path')
|
||||||
|
path = os.path.realpath(os.path.join(root, relative))
|
||||||
|
if os.path.commonpath([root, path]) != root:
|
||||||
|
raise ValueError('Invalid module path')
|
||||||
|
info = os.stat(path)
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
raise ValueError('Not a regular file')
|
||||||
|
if os.path.splitext(path)[1].lower() not in extensions or info.st_size > 1048576:
|
||||||
|
return placeholder
|
||||||
|
with open(path, 'rb') as fp:
|
||||||
|
data = fp.read(1048577)
|
||||||
|
if len(data) > 1048576 or b'\\x00' in data:
|
||||||
|
return placeholder
|
||||||
|
return data.decode('utf-8', errors='replace')
|
||||||
|
def entries():
|
||||||
|
def fail(error):
|
||||||
|
raise error
|
||||||
|
for current, dirs, files in os.walk(root, onerror=fail, followlinks=False):
|
||||||
|
depth = len(os.path.relpath(current, root).split('/')) if current != root else 0
|
||||||
|
dirs[:] = sorted(d for d in dirs if d not in skip and not os.path.islink(os.path.join(current, d)))
|
||||||
|
if depth >= 10:
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
for name in dirs:
|
||||||
|
yield 'D', os.path.relpath(os.path.join(current, name), root)
|
||||||
|
for name in sorted(files):
|
||||||
|
path = os.path.join(current, name)
|
||||||
|
if not os.path.islink(path) and os.path.isfile(path):
|
||||||
|
yield 'F', os.path.relpath(path, root)
|
||||||
|
if args['action'] == 'content':
|
||||||
|
print(json.dumps({'content': content(args['path'])}))
|
||||||
|
elif args['action'] == 'list':
|
||||||
|
paths, folders = [], []
|
||||||
|
for kind, path in entries():
|
||||||
|
(folders if kind == 'D' else paths).append(path)
|
||||||
|
print(json.dumps({'filePaths': paths, 'folders': folders}))
|
||||||
|
else:
|
||||||
|
query = args['query'].strip().lower()
|
||||||
|
results = []
|
||||||
|
if len(query) >= 2:
|
||||||
|
for kind, path in entries():
|
||||||
|
if kind != 'F':
|
||||||
|
continue
|
||||||
|
text = content(path)
|
||||||
|
if text == placeholder:
|
||||||
|
continue
|
||||||
|
for number, line in enumerate(text.split('\\n'), 1):
|
||||||
|
if query in line.lower():
|
||||||
|
results.append({'file': path, 'lineNumber': number, 'lineText': (line.strip() or line)[:2000]})
|
||||||
|
if len(results) >= 200:
|
||||||
|
break
|
||||||
|
if len(results) >= 200:
|
||||||
|
break
|
||||||
|
print(json.dumps(results))
|
||||||
|
PY`
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
function quote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, `'"'"'`)}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH may forward LANG/LC_* through the user's SendEnv configuration.
|
||||||
|
// Undefined entries also remove inherited keys when merged by the command runner.
|
||||||
|
export function remoteShellEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
const result = { ...env }
|
||||||
|
for (const key of Object.keys(result)) {
|
||||||
|
if (key === 'LANGUAGE' || key.startsWith('LC_')) result[key] = undefined
|
||||||
|
}
|
||||||
|
return { ...result, LANG: 'C', LC_ALL: 'C' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remoteShellCommand(command: string): string {
|
||||||
|
const script = [
|
||||||
|
'selected_locale=C',
|
||||||
|
// Probe under C so an inherited, unavailable locale cannot break the probe.
|
||||||
|
'available_locales=$(locale -a 2>/dev/null) || available_locales=',
|
||||||
|
'for candidate in $available_locales; do',
|
||||||
|
' case "$candidate" in C.utf8|C.UTF-8) selected_locale=$candidate; break ;;',
|
||||||
|
' *utf8|*UTF-8|*utf-8) if [ "$selected_locale" = C ]; then selected_locale=$candidate; fi ;;',
|
||||||
|
' esac',
|
||||||
|
'done',
|
||||||
|
`exec env LANG="$selected_locale" LC_ALL="$selected_locale" LANGUAGE=C /bin/bash --noprofile --norc -c ${quote(command)}`,
|
||||||
|
].join('\n')
|
||||||
|
return `env LANG=C LC_ALL=C LANGUAGE=C /bin/sh -c ${quote(script)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
function loadFunction(name: string, dependencies: Record<string, unknown>) {
|
||||||
|
const declaration = source.statements.find((item) => ts.isFunctionDeclaration(item) && item.name?.text === name)
|
||||||
|
assert.ok(declaration)
|
||||||
|
const code = ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 })
|
||||||
|
return new Function(...Object.keys(dependencies), `${code}; return ${name};`)(...Object.values(dependencies))
|
||||||
|
}
|
||||||
|
|
||||||
|
function command(executable: string, args: string[], cwd: string) {
|
||||||
|
const result = spawnSync(executable, args, {
|
||||||
|
cwd, encoding: 'utf8', timeout: 15000,
|
||||||
|
env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.com' },
|
||||||
|
})
|
||||||
|
assert.equal(result.status, 0, result.stderr || String(result.error))
|
||||||
|
return result.stdout.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'lms-preserve-'))
|
||||||
|
const template = join(root, 'template')
|
||||||
|
mkdirSync(template)
|
||||||
|
command('git', ['init', '-b', 'main'], template)
|
||||||
|
writeFileSync(join(template, 'node.py'), 'template source\n')
|
||||||
|
writeFileSync(join(template, '.gitignore'), 'build/\ninstall/\nlog/\n')
|
||||||
|
command('git', ['add', '.'], template)
|
||||||
|
command('git', ['commit', '-m', 'template'], template)
|
||||||
|
const install = loadFunction('installRemoteWorkspaceModule', {
|
||||||
|
normalizeRemoteTarget: (target: unknown) => target,
|
||||||
|
remoteRepositoryUrl: (url: string) => url,
|
||||||
|
withGitCredential: (url: string) => url,
|
||||||
|
remotePathJoin: join,
|
||||||
|
safeArtifactFileName: () => 'unused.tar',
|
||||||
|
quoteShellSingle: (value: string) => "'" + value.replaceAll("'", "'\\''") + "'",
|
||||||
|
encodeBase64: (value: string) => Buffer.from(value).toString('base64'),
|
||||||
|
expandRemotePathVar: () => [],
|
||||||
|
runSsh: async (_target: unknown, script: string) => command('bash', ['-c', script], root),
|
||||||
|
parseModuleFolderName: () => ({ name: 'Test', category: 'Control' }),
|
||||||
|
})
|
||||||
|
const target = { workspaceRoot: join(root, 'workspace') }
|
||||||
|
return { root, template, target, install, modulePath: join(target.workspaceRoot, 'module') }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('reinstall preserves saved source, local commits, untracked files and build outputs despite changed repository URLs', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
try {
|
||||||
|
await f.install(f.target, 'Test', 'module', f.template)
|
||||||
|
writeFileSync(join(f.modulePath, 'node.py'), 'student commit\n')
|
||||||
|
command('git', ['add', 'node.py'], f.modulePath)
|
||||||
|
command('git', ['commit', '-m', 'student work'], f.modulePath)
|
||||||
|
const head = command('git', ['rev-parse', 'HEAD'], f.modulePath)
|
||||||
|
writeFileSync(join(f.modulePath, 'node.py'), 'saved but not committed\n')
|
||||||
|
writeFileSync(join(f.modulePath, 'new.py'), 'new student file\n')
|
||||||
|
for (const dir of ['build', 'install', 'log']) {
|
||||||
|
mkdirSync(join(f.modulePath, dir))
|
||||||
|
writeFileSync(join(f.modulePath, dir, 'keep'), dir)
|
||||||
|
}
|
||||||
|
const status = command('git', ['status', '--porcelain'], f.modulePath)
|
||||||
|
await f.install(f.target, 'Test', 'module', '/missing/new-template', '/missing/new-student', {
|
||||||
|
prebuiltArtifacts: [{ url: 'https://invalid.example/overwrite.tar', required: true }],
|
||||||
|
})
|
||||||
|
assert.equal(command('git', ['rev-parse', 'HEAD'], f.modulePath), head)
|
||||||
|
assert.equal(command('git', ['status', '--porcelain'], f.modulePath), status)
|
||||||
|
assert.equal(readFileSync(join(f.modulePath, 'node.py'), 'utf8'), 'saved but not committed\n')
|
||||||
|
assert.equal(readFileSync(join(f.modulePath, 'new.py'), 'utf8'), 'new student file\n')
|
||||||
|
for (const dir of ['build', 'install', 'log']) assert.equal(readFileSync(join(f.modulePath, dir, 'keep'), 'utf8'), dir)
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('first install restores the student repository instead of replacing it with template source', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
try {
|
||||||
|
const student = join(f.root, 'student')
|
||||||
|
command('git', ['clone', f.template, student], f.root)
|
||||||
|
writeFileSync(join(student, 'node.py'), 'submitted student work\n')
|
||||||
|
command('git', ['add', '.'], student)
|
||||||
|
command('git', ['commit', '-m', 'submission'], student)
|
||||||
|
await f.install(f.target, 'Test', 'module', f.template, student)
|
||||||
|
assert.equal(readFileSync(join(f.modulePath, 'node.py'), 'utf8'), 'submitted student work\n')
|
||||||
|
assert.equal(command('git', ['branch', '--show-current'], f.modulePath), 'student-work')
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('first install initializes an empty student repository', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
try {
|
||||||
|
const student = join(f.root, 'student.git')
|
||||||
|
command('git', ['init', '--bare', student], f.root)
|
||||||
|
await f.install(f.target, 'Test', 'module', f.template, student)
|
||||||
|
assert.equal(command('git', ['--git-dir', student, 'show', 'main:node.py'], f.root), 'template source')
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy session reset only stops modules and returns stop failures', async () => {
|
||||||
|
const target = { workspaceRoot: '/student/workspace' }
|
||||||
|
const modules = [{ moduleName: 'Test', folderName: 'module', templateRepositoryUrl: '/template' }]
|
||||||
|
const result = { ok: false, message: 'stop failed' }
|
||||||
|
let calls = 0
|
||||||
|
const reset = loadFunction('resetRemoteWorkspaceSession', {
|
||||||
|
stopRemoteWorkspaceModules: async (actualTarget: unknown, actualModules: unknown) => {
|
||||||
|
calls++
|
||||||
|
assert.equal(actualTarget, target)
|
||||||
|
assert.equal(actualModules, modules)
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(await reset(target, modules), result)
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
|
|
||||||
|
export const visualizationProgress = new AsyncLocalStorage<(message: string) => void>()
|
||||||
|
|
||||||
|
export function reportVisualizationProgress(message: string): void {
|
||||||
|
visualizationProgress.getStore()?.(message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import ts from 'typescript'
|
||||||
|
const { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } = createRequire(import.meta.url)('./yahboomRuntime.ts') as typeof import('./yahboomRuntime')
|
||||||
|
|
||||||
|
test('simulation is an explicit opt-in and excludes physical device modules', () => {
|
||||||
|
for (const role of ['vehicle', 'slam', 'map', 'localization', 'planning', 'control']) {
|
||||||
|
const folder = `[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`
|
||||||
|
assert.equal(yahboomSimulation(folder, ''), false)
|
||||||
|
assert.equal(yahboomSimulation(folder, 'sim_mode:=false'), false)
|
||||||
|
assert.equal(yahboomSimulation(folder, 'foo:=bar sim_mode:=true'), true)
|
||||||
|
}
|
||||||
|
for (const role of ['agent', 'camera']) {
|
||||||
|
assert.equal(yahboomSimulation(`[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`, 'sim_mode:=true'), false)
|
||||||
|
}
|
||||||
|
assert.equal(yahboomSimulation('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', 'sim_mode:=true'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('local simulation overrides ARM metadata and downloads only the PC image', async () => {
|
||||||
|
const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const declaration = source.statements.find((item): item is ts.FunctionDeclaration => ts.isFunctionDeclaration(item) && item.name?.text === 'resolveLocalModuleRuntime')
|
||||||
|
assert.ok(declaration)
|
||||||
|
const artifact = { image: 'future-nav2-gazebo:humble', url: 'https://example.com/gazebo.tar.zst' }
|
||||||
|
const resolver = new Function('yahboomSimulation', 'defaultNav2GazeboRuntimeImage', 'resolveCustomModuleRuntimeImage',
|
||||||
|
ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 }) + '; return resolveLocalModuleRuntime;'
|
||||||
|
)(yahboomSimulation, artifact, () => { throw new Error('ARM metadata must not override simulation') })
|
||||||
|
const result = await resolver('/module', '[lms-nav2-yahboom-vehicle]yahboom_vehicle_v1.0.0_ws', 'sim_mode:=true',
|
||||||
|
[{ image: 'future-nav2:humble-jetson_orin', url: 'https://example.com/arm.tar.zst' }])
|
||||||
|
assert.deepEqual(result, { image: artifact.image, artifacts: [artifact] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remote Yahboom downloads only its own image and preserves explicit overrides', () => {
|
||||||
|
const configured = [{ image: 'future-nav2:humble-jetson_orin', url: 'https://example.com/jetson.tar.zst' }]
|
||||||
|
assert.equal(yahboomRuntimeArtifacts('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', configured[0].image, configured), configured)
|
||||||
|
const folder = '[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws'
|
||||||
|
const image = 'future-nav2:humble-yahboom-agent'
|
||||||
|
const selected = yahboomRuntimeArtifacts(folder, image, configured)
|
||||||
|
assert.equal(selected?.length, 1)
|
||||||
|
assert.equal(selected?.[0].image, image)
|
||||||
|
assert.match(selected?.[0].checksumSha256 ?? '', /^[a-f0-9]{64}$/)
|
||||||
|
const override = { image, url: 'https://example.com/override.tar.zst' }
|
||||||
|
assert.deepEqual(yahboomRuntimeArtifacts(folder, image, [...configured, override]), [override])
|
||||||
|
assert.deepEqual(yahboomRuntimeArtifacts(folder, 'custom:tag', configured), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
function runtimeScript(name: string): string {
|
||||||
|
const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const declaration = source.statements.find((item): item is ts.FunctionDeclaration => ts.isFunctionDeclaration(item) && item.name?.text === name)
|
||||||
|
assert.ok(declaration)
|
||||||
|
const code = ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 })
|
||||||
|
return new Function('yahboomModuleCases', 'yahboomDevicePreflight', `${code}; return ${name}();`)(yahboomModuleCases, yahboomDevicePreflight)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Yahboom image routing does not change MKMini, Gazebo or custom modules', () => {
|
||||||
|
assert.equal(yahboomRuntimeImage('[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws'), 'future-nav2:humble-yahboom-agent')
|
||||||
|
assert.equal(yahboomRuntimeImage('[lms-nav2-yahboom-slam]yahboom_slam_v1.0.0_ws'), 'future-nav2:humble-yahboom')
|
||||||
|
for (const name of ['[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', '[lms-nav2-gazebo-map]nav2-map_v1.0.0_ws', '[lms-nav2-custom]yahboom_agent_v1.0.0_ws', '[lms-nav2-yahboom-agent]other_v1.0.0_ws']) {
|
||||||
|
assert.equal(yahboomRuntimeImage(name), null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('all eight templates map to their own package in both runtime scripts', () => {
|
||||||
|
for (const build of [false, true]) {
|
||||||
|
for (const role of ['agent', 'vehicle', 'slam', 'map', 'localization', 'planning', 'control', 'camera']) {
|
||||||
|
const script = ['case "$1" in', ...yahboomModuleCases(build), 'esac', build ? 'printf "%s" "$TARGET"' : 'printf "%s %s" "$PACKAGE" "$LAUNCH"'].join('\n')
|
||||||
|
const result = spawnSync('bash', ['-c', script, '_', `[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`], { encoding: 'utf8' })
|
||||||
|
assert.equal(result.status, 0, result.stderr)
|
||||||
|
assert.equal(result.stdout, build ? `yahboom_${role}` : `yahboom_${role} ${role}.launch.py`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generated build and run shell scripts are syntactically valid', () => {
|
||||||
|
for (const name of ['buildNav2DockerRunScript', 'buildNav2DockerBuildScript']) {
|
||||||
|
const result = spawnSync('bash', ['-n'], { input: runtimeScript(name), encoding: 'utf8' })
|
||||||
|
assert.equal(result.status, 0, result.stderr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalid hardware path fails before Docker is called; MKMini still launches without extra device', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'yahboom-runtime-'))
|
||||||
|
try {
|
||||||
|
const bin = join(root, 'bin')
|
||||||
|
mkdirSync(bin)
|
||||||
|
writeFileSync(join(bin, 'docker'), '#!/bin/bash\nprintf "%s\\n" "$@"\n', { mode: 0o755 })
|
||||||
|
const script = join(root, 'docker_run_nav2.sh')
|
||||||
|
writeFileSync(script, runtimeScript('buildNav2DockerRunScript'))
|
||||||
|
for (const [folder, parameter] of [
|
||||||
|
['[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws', 'serial_device:=/etc/passwd'],
|
||||||
|
['[lms-nav2-yahboom-camera]yahboom_camera_v1.0.0_ws', 'video_device:=/dev/../etc/passwd'],
|
||||||
|
['[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', ''],
|
||||||
|
]) {
|
||||||
|
mkdirSync(join(root, folder, 'install'), { recursive: true })
|
||||||
|
writeFileSync(join(root, folder, 'install/setup.bash'), '')
|
||||||
|
const result = spawnSync('bash', [script, 'test-container', root, folder, 'test-image', '77', 'false', parameter], {
|
||||||
|
encoding: 'utf8', env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, DISPLAY: '', FASTDDS_CONFIG_PATH: '' },
|
||||||
|
})
|
||||||
|
if (parameter) {
|
||||||
|
assert.equal(result.status, 64)
|
||||||
|
assert.equal(result.stdout, '')
|
||||||
|
} else {
|
||||||
|
assert.equal(result.status, 0, result.stderr)
|
||||||
|
assert.match(result.stdout, /vehicle_pkg\nvehicle.launch.py/)
|
||||||
|
assert.doesNotMatch(result.stdout, /ttyUSB|video0|privileged/)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const roles = ['agent', 'vehicle', 'slam', 'map', 'localization', 'planning', 'control', 'camera'] as const
|
||||||
|
|
||||||
|
export function yahboomSimulation(folderName: string | null | undefined, rosParams: string): boolean {
|
||||||
|
return Boolean(yahboomRuntimeImage(folderName))
|
||||||
|
&& !/yahboom-(agent|camera)\]/.test(folderName ?? '')
|
||||||
|
&& /(?:^|\s)sim_mode:=true(?:\s|$)/.test(rosParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeArtifact = {
|
||||||
|
image?: string | null
|
||||||
|
url?: string | null
|
||||||
|
fileName?: string | null
|
||||||
|
checksumSha256?: string | null
|
||||||
|
required?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifactRoot = 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/yahboom/arm64'
|
||||||
|
const artifacts: RuntimeArtifact[] = [
|
||||||
|
['future-nav2:humble-yahboom', 'future-nav2-humble-yahboom.tar.zst', 'b2f168c45b961ab96badeeb7df68d1c997996c75dcb5e0cc677b0b6669b1ffd2'],
|
||||||
|
['future-nav2:humble-yahboom-agent', 'future-nav2-humble-yahboom-agent.tar.zst', 'e30404506c320af1d53c8733177eaf27997dcb2864019b8fcc6888874f02d191'],
|
||||||
|
].map(([image, fileName, checksumSha256]) => ({
|
||||||
|
image, fileName, checksumSha256, required: true,
|
||||||
|
url: `${artifactRoot}/${checksumSha256.slice(0, 16)}/${fileName}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function yahboomRuntimeArtifacts(
|
||||||
|
folderName: string,
|
||||||
|
runtimeImage: string | null,
|
||||||
|
configured?: RuntimeArtifact[] | null,
|
||||||
|
): RuntimeArtifact[] | null | undefined {
|
||||||
|
if (!yahboomRuntimeImage(folderName)) return configured
|
||||||
|
const selected = configured?.find((item) => item.image?.trim() === runtimeImage && item.url?.trim())
|
||||||
|
?? artifacts.find((item) => item.image === runtimeImage)
|
||||||
|
// Yahboom must not download unrelated Jetson images from an existing profile.
|
||||||
|
return selected ? [selected] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yahboomRuntimeImage(folderName?: string | null): string | null {
|
||||||
|
const role = folderName?.match(/^\[lms-nav2-yahboom-([a-z]+)\]yahboom_\1_v[^/]+_ws$/)?.[1]
|
||||||
|
if (!roles.some((value) => value === role)) return null
|
||||||
|
return role === 'agent' ? 'future-nav2:humble-yahboom-agent' : 'future-nav2:humble-yahboom'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yahboomModuleCases(build = false): string[] {
|
||||||
|
return roles.map((role) => (
|
||||||
|
` "[lms-nav2-yahboom-${role}]yahboom_${role}_v"*_ws) ${build ? `TARGET=yahboom_${role}` : `PACKAGE=yahboom_${role}; LAUNCH=${role}.launch.py`} ;;`
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep hardware exposure limited to the two hardware-facing Yahboom templates.
|
||||||
|
export function yahboomDevicePreflight(): string[] {
|
||||||
|
return [
|
||||||
|
'YAHBOOM_DEVICE=""',
|
||||||
|
'case "$MODULE_FOLDER" in',
|
||||||
|
' "[lms-nav2-yahboom-agent]yahboom_agent_v"*_ws)',
|
||||||
|
' YAHBOOM_DEVICE=/dev/ttyUSB0',
|
||||||
|
' for arg in "$@"; do case "$arg" in serial_device:=*) YAHBOOM_DEVICE=${arg#serial_device:=} ;; esac; done',
|
||||||
|
' case "$YAHBOOM_DEVICE" in /dev/ttyUSB*|/dev/ttyACM*|/dev/serial/by-id/*) ;; *) echo "Invalid Yahboom serial device" >&2; exit 64 ;; esac',
|
||||||
|
' ;;',
|
||||||
|
' "[lms-nav2-yahboom-camera]yahboom_camera_v"*_ws)',
|
||||||
|
' YAHBOOM_DEVICE=/dev/video0',
|
||||||
|
' for arg in "$@"; do case "$arg" in video_device:=*) YAHBOOM_DEVICE=${arg#video_device:=} ;; esac; done',
|
||||||
|
' [[ "$YAHBOOM_DEVICE" =~ ^/dev/video[0-9]+$ ]] || { echo "Invalid Yahboom camera device" >&2; exit 64; }',
|
||||||
|
' ;;',
|
||||||
|
'esac',
|
||||||
|
'if [ -n "$YAHBOOM_DEVICE" ]; then',
|
||||||
|
' [[ "$YAHBOOM_DEVICE" != *".."* ]] && [ -c "$YAHBOOM_DEVICE" ] || { echo "Yahboom device not found: $YAHBOOM_DEVICE" >&2; exit 1; }',
|
||||||
|
'fi',
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,6 +2,20 @@ import { contextBridge, ipcRenderer } from 'electron'
|
|||||||
import { electronAPI } from '@electron-toolkit/preload'
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
import { readFileSync, rmSync } from 'fs'
|
import { readFileSync, rmSync } from 'fs'
|
||||||
|
|
||||||
|
let visualizationRequestId = 0
|
||||||
|
async function invokeVisualizationStart(channel: string, args: unknown[], onProgress?: (message: string) => void) {
|
||||||
|
const requestId = `${Date.now()}:${++visualizationRequestId}`
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, id: string, message: string) => {
|
||||||
|
if (id === requestId) onProgress?.(message)
|
||||||
|
}
|
||||||
|
ipcRenderer.on('visualization:progress', listener)
|
||||||
|
try {
|
||||||
|
return await ipcRenderer.invoke(channel, ...args, requestId)
|
||||||
|
} finally {
|
||||||
|
ipcRenderer.removeListener('visualization:progress', listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type LauncherAuthPayload = {
|
type LauncherAuthPayload = {
|
||||||
appUrl?: string | null
|
appUrl?: string | null
|
||||||
handoffToken?: string | null
|
handoffToken?: string | null
|
||||||
@@ -21,6 +35,7 @@ type ModulePrebuiltArtifact = {
|
|||||||
fileName?: string | null
|
fileName?: string | null
|
||||||
checksumSha256?: string | null
|
checksumSha256?: string | null
|
||||||
extractPath?: string | null
|
extractPath?: string | null
|
||||||
|
architecture?: string | null
|
||||||
required?: boolean | null
|
required?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +52,25 @@ type ModuleInstallOptions = {
|
|||||||
runtimeImages?: RuntimeImageArtifact[] | null
|
runtimeImages?: RuntimeImageArtifact[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RosNetworkConfig = {
|
||||||
|
enabled?: boolean | null
|
||||||
|
allowExternal?: boolean
|
||||||
|
peer?: RemoteWorkspaceTarget | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type SavedMapArtifactName = 'yaml' | 'pgm' | 'database'
|
||||||
|
|
||||||
|
type SavedMapArtifactPath = {
|
||||||
|
name: SavedMapArtifactName
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SavedMapArtifactDownload = {
|
||||||
|
name: SavedMapArtifactName
|
||||||
|
fileName: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
const decodeArgValue = (prefix: string): string | null => {
|
const decodeArgValue = (prefix: string): string | null => {
|
||||||
const rawValue = process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
|
const rawValue = process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
|
||||||
if (!rawValue) return null
|
if (!rawValue) return null
|
||||||
@@ -83,6 +117,11 @@ console.info('[lms-preload] launcher auth resolved', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||||
|
contextBridge.exposeInMainWorld('chatTextMenu', {
|
||||||
|
show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options),
|
||||||
|
readClipboardState: () => ipcRenderer.invoke('chat-text:clipboard-state'),
|
||||||
|
perform: (action: import('../shared/chatTextMenu').ChatClipboardAction) => ipcRenderer.invoke('chat-text:clipboard-action', action)
|
||||||
|
})
|
||||||
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
|
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
|
||||||
contextBridge.exposeInMainWorld('lmsBootSplash', {
|
contextBridge.exposeInMainWorld('lmsBootSplash', {
|
||||||
status: (message: string) => ipcRenderer.send('lms-boot-status', message),
|
status: (message: string) => ipcRenderer.send('lms-boot-status', message),
|
||||||
@@ -91,13 +130,21 @@ contextBridge.exposeInMainWorld('lmsBootSplash', {
|
|||||||
contextBridge.exposeInMainWorld('titlebar', {
|
contextBridge.exposeInMainWorld('titlebar', {
|
||||||
minimize: () => ipcRenderer.send('titlebar-minimize'),
|
minimize: () => ipcRenderer.send('titlebar-minimize'),
|
||||||
maximize: () => ipcRenderer.send('titlebar-maximize'),
|
maximize: () => ipcRenderer.send('titlebar-maximize'),
|
||||||
|
openDevTools: () => ipcRenderer.invoke('titlebar-open-devtools'),
|
||||||
close: () => ipcRenderer.send('titlebar-close')
|
close: () => ipcRenderer.send('titlebar-close')
|
||||||
})
|
})
|
||||||
|
contextBridge.exposeInMainWorld('appDiagnostics', {
|
||||||
|
read: () => ipcRenderer.invoke('app-diagnostics:read')
|
||||||
|
})
|
||||||
|
contextBridge.exposeInMainWorld('appLifecycle', {
|
||||||
|
quit: () => ipcRenderer.send('app-quit')
|
||||||
|
})
|
||||||
contextBridge.exposeInMainWorld('learningVideo', {
|
contextBridge.exposeInMainWorld('learningVideo', {
|
||||||
open: (url: string, title?: string) => ipcRenderer.invoke('learning-video:open', { url, title })
|
open: (url: string, title?: string) => ipcRenderer.invoke('learning-video:open', { url, title })
|
||||||
})
|
})
|
||||||
contextBridge.exposeInMainWorld('moduleFiles', {
|
contextBridge.exposeInMainWorld('moduleFiles', {
|
||||||
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:read', moduleName, workspaceRoot, folderName),
|
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:read', moduleName, workspaceRoot, folderName),
|
||||||
|
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:image', moduleName, path, workspaceRoot, folderName),
|
||||||
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:create', moduleName, path, kind, workspaceRoot, folderName),
|
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:create', moduleName, path, kind, workspaceRoot, folderName),
|
||||||
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:write', moduleName, path, content, workspaceRoot, folderName),
|
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:write', moduleName, path, content, workspaceRoot, folderName),
|
||||||
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:copy', moduleName, fromPath, toPath, mode, workspaceRoot, folderName),
|
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:copy', moduleName, fromPath, toPath, mode, workspaceRoot, folderName),
|
||||||
@@ -108,18 +155,20 @@ contextBridge.exposeInMainWorld('modules', {
|
|||||||
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('modules:list', workspaceRoot, modules),
|
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('modules:list', workspaceRoot, modules),
|
||||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||||
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:delete', moduleName, workspaceRoot, folderName),
|
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:delete', moduleName, workspaceRoot, folderName),
|
||||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
|
||||||
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages),
|
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => ipcRenderer.invoke('modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams),
|
||||||
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:stop', moduleName, workspaceRoot, folderName),
|
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:stop', moduleName, workspaceRoot, folderName),
|
||||||
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('modules:stop-many', modules, workspaceRoot),
|
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('modules:stop-many', modules, workspaceRoot),
|
||||||
stopRunning: () => ipcRenderer.invoke('modules:stop-running'),
|
stopRunning: () => ipcRenderer.invoke('modules:stop-running'),
|
||||||
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
|
||||||
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:logs', moduleName, workspaceRoot, folderName),
|
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:logs', moduleName, workspaceRoot, folderName),
|
||||||
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams)
|
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams)
|
||||||
})
|
})
|
||||||
contextBridge.exposeInMainWorld('git', {
|
contextBridge.exposeInMainWorld('git', {
|
||||||
status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('git:status', moduleName, workspaceRoot, folderName, selectedFilePath),
|
status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('git:status', moduleName, workspaceRoot, folderName, selectedFilePath),
|
||||||
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId),
|
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl),
|
||||||
|
teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('git:team-publish', moduleName, workspaceRoot, folderName, message, authorName, authorEmail),
|
||||||
|
teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('git:team-pull', moduleName, workspaceRoot, folderName),
|
||||||
submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('git:submissions', modules, workspaceRoot),
|
submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('git:submissions', modules, workspaceRoot),
|
||||||
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
|
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
|
||||||
})
|
})
|
||||||
@@ -149,45 +198,86 @@ contextBridge.exposeInMainWorld('terminal', {
|
|||||||
})
|
})
|
||||||
contextBridge.exposeInMainWorld('visualization', {
|
contextBridge.exposeInMainWorld('visualization', {
|
||||||
status: () => ipcRenderer.invoke('visualization:status'),
|
status: () => ipcRenderer.invoke('visualization:status'),
|
||||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile),
|
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork),
|
||||||
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
|
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
|
||||||
})
|
})
|
||||||
contextBridge.exposeInMainWorld('localWorkspace', {
|
contextBridge.exposeInMainWorld('localWorkspace', {
|
||||||
|
onModuleProgress: (callback: (folderName: string, message: string) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, folderName: string, message: string) => callback(folderName, message)
|
||||||
|
ipcRenderer.on('module:progress', listener)
|
||||||
|
return () => ipcRenderer.removeListener('module:progress', listener)
|
||||||
|
},
|
||||||
|
docker: {
|
||||||
|
status: () => ipcRenderer.invoke('local-workspace:docker:status'),
|
||||||
|
openGuide: () => ipcRenderer.invoke('local-workspace:docker:guide'),
|
||||||
|
},
|
||||||
moduleFiles: {
|
moduleFiles: {
|
||||||
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:read', moduleName, workspaceRoot, folderName),
|
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:read', moduleName, workspaceRoot, folderName),
|
||||||
|
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:image', moduleName, path, workspaceRoot, folderName),
|
||||||
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:create', moduleName, path, kind, workspaceRoot, folderName),
|
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:create', moduleName, path, kind, workspaceRoot, folderName),
|
||||||
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:write', moduleName, path, content, workspaceRoot, folderName),
|
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:write', moduleName, path, content, workspaceRoot, folderName),
|
||||||
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:copy', moduleName, fromPath, toPath, mode, workspaceRoot, folderName),
|
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:copy', moduleName, fromPath, toPath, mode, workspaceRoot, folderName),
|
||||||
rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:rename', moduleName, fromPath, toPath, workspaceRoot, folderName),
|
rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:rename', moduleName, fromPath, toPath, workspaceRoot, folderName),
|
||||||
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName)
|
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName)
|
||||||
},
|
},
|
||||||
|
agent: {
|
||||||
|
readTool: (request: import('../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-tool:read', request, workspaceRoot, folderName),
|
||||||
|
applyFiles: (changes: import('../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:agent-files:apply', changes, workspaceRoot),
|
||||||
|
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-command:run', moduleName, command, workspaceRoot, folderName)
|
||||||
|
},
|
||||||
modules: {
|
modules: {
|
||||||
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules),
|
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules),
|
||||||
|
listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'),
|
||||||
|
listGitSources: (root: string) => ipcRenderer.invoke('local-workspace:modules:git-sources', root),
|
||||||
|
inspectGitSource: (root: string, folder: string) => ipcRenderer.invoke('local-workspace:modules:git-source', root, folder),
|
||||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||||
|
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input),
|
||||||
|
selectImportSource: () => ipcRenderer.invoke('local-workspace:modules:select-import-source'),
|
||||||
|
importCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string; sourcePath: string }) => ipcRenderer.invoke('local-workspace:modules:import-custom', workspaceRoot, input),
|
||||||
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName),
|
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName),
|
||||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
|
||||||
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages),
|
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams),
|
||||||
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:stop', moduleName, workspaceRoot, folderName),
|
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:stop', moduleName, workspaceRoot, folderName),
|
||||||
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:modules:stop-many', modules, workspaceRoot),
|
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:modules:stop-many', modules, workspaceRoot),
|
||||||
stopRunning: () => ipcRenderer.invoke('local-workspace:modules:stop-running'),
|
stopRunning: () => ipcRenderer.invoke('local-workspace:modules:stop-running'),
|
||||||
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
|
||||||
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:logs', moduleName, workspaceRoot, folderName),
|
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:logs', moduleName, workspaceRoot, folderName),
|
||||||
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams)
|
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams)
|
||||||
},
|
},
|
||||||
|
rosGraph: {
|
||||||
|
snapshot: () => ipcRenderer.invoke('local-workspace:ros-graph')
|
||||||
|
},
|
||||||
|
nav2: {
|
||||||
|
setSimulatorPose: (pose: { x: number; y: number; yaw: number }) => ipcRenderer.invoke('local-workspace:nav2:set-simulator-pose', pose),
|
||||||
|
addRuntimeObstacle: (obstacle: { id: string; x: number; y: number; yaw: number; length: number; depth: number; height: number }) => ipcRenderer.invoke('local-workspace:nav2:add-runtime-obstacle', obstacle),
|
||||||
|
clearRuntimeObstacles: () => ipcRenderer.invoke('local-workspace:nav2:clear-runtime-obstacles')
|
||||||
|
},
|
||||||
|
worldLayout: {
|
||||||
|
read: (workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:world-layout:read', workspaceRoot),
|
||||||
|
write: (workspaceRoot: string | undefined, layout: unknown) => ipcRenderer.invoke('local-workspace:world-layout:write', workspaceRoot, layout)
|
||||||
|
},
|
||||||
|
maps: {
|
||||||
|
readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('local-workspace:maps:read-artifacts', workspaceRoot, artifacts),
|
||||||
|
sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('local-workspace:maps:sync', workspaceRoot, artifacts),
|
||||||
|
generateCollisionLayout: (workspaceRoot: string | undefined, yamlPath: string, pgmPath: string) => ipcRenderer.invoke('local-workspace:maps:generate-collision-layout', workspaceRoot, yamlPath, pgmPath),
|
||||||
|
generatePointCloudCollisionLayout: (workspaceRoot: string | undefined, databasePath: string) => ipcRenderer.invoke('local-workspace:maps:generate-point-cloud-collision-layout', workspaceRoot, databasePath)
|
||||||
|
},
|
||||||
visualization: {
|
visualization: {
|
||||||
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
|
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
|
||||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile),
|
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null, onProgress?: (message: string) => void) => invokeVisualizationStart('local-workspace:visualization:start', [stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath], onProgress),
|
||||||
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
|
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
|
||||||
},
|
},
|
||||||
git: {
|
git: {
|
||||||
status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('local-workspace:git:status', moduleName, workspaceRoot, folderName, selectedFilePath),
|
status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('local-workspace:git:status', moduleName, workspaceRoot, folderName, selectedFilePath),
|
||||||
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('local-workspace:git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId),
|
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('local-workspace:git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl),
|
||||||
|
teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('local-workspace:git:team-publish', moduleName, workspaceRoot, folderName, message, authorName, authorEmail),
|
||||||
|
teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:git:team-pull', moduleName, workspaceRoot, folderName),
|
||||||
restoreFile: (moduleName: string, workspaceRoot?: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('local-workspace:git:restore-file', moduleName, workspaceRoot, folderName, filePath, status),
|
restoreFile: (moduleName: string, workspaceRoot?: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('local-workspace:git:restore-file', moduleName, workspaceRoot, folderName, filePath, status),
|
||||||
submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:git:submissions', modules, workspaceRoot),
|
submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:git:submissions', modules, workspaceRoot),
|
||||||
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
|
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:start', moduleName, cols, rows, sessionId, workspaceRoot, folderName),
|
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string, mode?: 'host' | 'container') => ipcRenderer.invoke('local-workspace:terminal:start', moduleName, cols, rows, sessionId, workspaceRoot, folderName, mode),
|
||||||
resize: (cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('local-workspace:terminal:resize', cols, rows, sessionId),
|
resize: (cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('local-workspace:terminal:resize', cols, rows, sessionId),
|
||||||
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:change-directory', moduleName, sessionId, workspaceRoot, folderName),
|
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:change-directory', moduleName, sessionId, workspaceRoot, folderName),
|
||||||
write: (data: string, sessionId?: string) => ipcRenderer.send('local-workspace:terminal:write', data, sessionId),
|
write: (data: string, sessionId?: string) => ipcRenderer.send('local-workspace:terminal:write', data, sessionId),
|
||||||
@@ -211,32 +301,63 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
contextBridge.exposeInMainWorld('remoteWorkspace', {
|
contextBridge.exposeInMainWorld('remoteWorkspace', {
|
||||||
moduleFiles: {
|
moduleFiles: {
|
||||||
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName),
|
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName),
|
||||||
|
content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:content', target, moduleName, path, folderName),
|
||||||
|
search: (target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:search', target, moduleName, query, folderName),
|
||||||
|
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:image', target, moduleName, path, folderName),
|
||||||
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:create', target, moduleName, path, kind, folderName),
|
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:create', target, moduleName, path, kind, folderName),
|
||||||
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:write', target, moduleName, path, content, folderName),
|
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:write', target, moduleName, path, content, folderName),
|
||||||
copy: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:copy', target, moduleName, fromPath, toPath, mode, folderName),
|
copy: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:copy', target, moduleName, fromPath, toPath, mode, folderName),
|
||||||
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName),
|
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName),
|
||||||
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
|
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
|
||||||
},
|
},
|
||||||
|
agent: {
|
||||||
|
readTool: (target: RemoteWorkspaceTarget, request: import('../shared/agentTools').AgentToolRequest, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-tool:read', target, request, folderName),
|
||||||
|
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../shared/agentTools').AgentCheckedChange[]) => ipcRenderer.invoke('remote-workspace:agent-files:apply', target, changes),
|
||||||
|
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-command:run', target, moduleName, command, folderName)
|
||||||
|
},
|
||||||
|
maps: {
|
||||||
|
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
|
||||||
|
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath),
|
||||||
|
uploadRtabmapDatabase: (target: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null) => ipcRenderer.invoke('remote-workspace:maps:upload-rtabmap-database', target, uploadUrl, databasePath),
|
||||||
|
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
|
||||||
|
},
|
||||||
modules: {
|
modules: {
|
||||||
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules),
|
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules),
|
||||||
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
listDockerImages: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:modules:docker-images', target),
|
||||||
|
importGit: (target: RemoteWorkspaceTarget, root: string, input: import('../shared/gitModuleImport').GitModuleImportInput & { folderName: string }) => ipcRenderer.invoke('remote-workspace:modules:import-git', target, root, input),
|
||||||
|
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('remote-workspace:modules:create-custom', target, input),
|
||||||
|
transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:transfer', target, sourceWorkspaceRoot, moduleName, folderName),
|
||||||
|
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
|
||||||
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages),
|
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages),
|
||||||
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName),
|
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName),
|
||||||
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules),
|
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules),
|
||||||
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules),
|
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules),
|
||||||
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
|
||||||
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName)
|
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName)
|
||||||
},
|
},
|
||||||
|
rosGraph: {
|
||||||
|
snapshot: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:ros-graph', target)
|
||||||
|
},
|
||||||
|
ros: {
|
||||||
|
resetSession: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:ros:reset-session', target)
|
||||||
|
},
|
||||||
|
visualization: {
|
||||||
|
status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target),
|
||||||
|
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, onProgress?: (message: string) => void) => invokeVisualizationStart('remote-workspace:visualization:start', [target, stack, rosDomainId, environmentProfile, rosNetwork], onProgress),
|
||||||
|
stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target)
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('remote-workspace:git:status', target, moduleName, folderName, selectedFilePath),
|
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('remote-workspace:git:status', target, moduleName, folderName, selectedFilePath),
|
||||||
submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('remote-workspace:git:submit', target, moduleName, folderName, message, authorName, authorEmail, submissionId),
|
submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('remote-workspace:git:submit', target, moduleName, folderName, message, authorName, authorEmail, submissionId, repositoryUrl),
|
||||||
|
teamPublish: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('remote-workspace:git:team-publish', target, moduleName, folderName, message, authorName, authorEmail),
|
||||||
|
teamPull: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:git:team-pull', target, moduleName, folderName),
|
||||||
restoreFile: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('remote-workspace:git:restore-file', target, moduleName, folderName, filePath, status),
|
restoreFile: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('remote-workspace:git:restore-file', target, moduleName, folderName, filePath, status),
|
||||||
submissions: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:git:submissions', target, modules),
|
submissions: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:git:submissions', target, modules),
|
||||||
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash)
|
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash)
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:start', target, moduleName, cols, rows, sessionId, folderName),
|
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string, mode?: 'host' | 'container') => ipcRenderer.invoke('remote-workspace:terminal:start', target, moduleName, cols, rows, sessionId, folderName, mode),
|
||||||
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('remote-workspace:terminal:resize', target, cols, rows, sessionId),
|
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('remote-workspace:terminal:resize', target, cols, rows, sessionId),
|
||||||
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:change-directory', target, moduleName, sessionId, folderName),
|
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:change-directory', target, moduleName, sessionId, folderName),
|
||||||
write: (data: string, sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:write', data, sessionId),
|
write: (data: string, sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:write', data, sessionId),
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 961 KiB |
|
Before Width: | Height: | Size: 961 KiB After Width: | Height: | Size: 628 KiB |
@@ -0,0 +1,113 @@
|
|||||||
|
export function createCameraPreview({ root, baseUrl, getState, onAvailable }) {
|
||||||
|
const cameras = new Map();
|
||||||
|
let enabled = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function sources() {
|
||||||
|
const { state, receivedAt } = getState();
|
||||||
|
if (!enabled || Date.now() - receivedAt >= 3000) return [];
|
||||||
|
const list = Array.isArray(state?.cameras) ? state.cameras
|
||||||
|
: state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : [];
|
||||||
|
const seen = new Set();
|
||||||
|
return list.filter(item => {
|
||||||
|
if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false;
|
||||||
|
seen.add(item.id);
|
||||||
|
return true;
|
||||||
|
}).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFrame(camera) {
|
||||||
|
if (camera.url) URL.revokeObjectURL(camera.url);
|
||||||
|
camera.url = null;
|
||||||
|
camera.image.removeAttribute('src');
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id, camera) {
|
||||||
|
cameras.delete(id);
|
||||||
|
camera.controller?.abort();
|
||||||
|
clearFrame(camera);
|
||||||
|
camera.element.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frame(id, camera) {
|
||||||
|
if (camera.controller || Date.now() < camera.retryAt || document.hidden) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
camera.controller = controller;
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), 2000);
|
||||||
|
try {
|
||||||
|
const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`;
|
||||||
|
const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal });
|
||||||
|
if (!response.ok) throw new Error('Camera frame unavailable');
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return;
|
||||||
|
const previous = camera.url;
|
||||||
|
camera.url = URL.createObjectURL(blob);
|
||||||
|
camera.image.src = camera.url;
|
||||||
|
camera.retryAt = 0;
|
||||||
|
if (previous) URL.revokeObjectURL(previous);
|
||||||
|
} catch (_) {
|
||||||
|
if (cameras.get(id) === camera) {
|
||||||
|
camera.retryAt = Date.now() + 5000;
|
||||||
|
clearFrame(camera);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
if (camera.controller === controller) camera.controller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
const live = sources();
|
||||||
|
const ids = new Set(live.map(item => item.id));
|
||||||
|
for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera);
|
||||||
|
for (const item of live) {
|
||||||
|
let camera = cameras.get(item.id);
|
||||||
|
if (!camera) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = 'camera-preview-item';
|
||||||
|
element.dataset.cameraId = item.id;
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'camera-preview-label';
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.className = 'camera-preview-image';
|
||||||
|
element.append(label, image);
|
||||||
|
root.append(element);
|
||||||
|
camera = { element, label, image, url: null, controller: null, retryAt: 0 };
|
||||||
|
image.addEventListener('error', () => {
|
||||||
|
if (cameras.get(item.id) === camera) {
|
||||||
|
clearFrame(camera);
|
||||||
|
camera.retryAt = Date.now() + 5000;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cameras.set(item.id, camera);
|
||||||
|
}
|
||||||
|
camera.label.textContent = item.name || item.id;
|
||||||
|
camera.image.alt = item.name || item.id;
|
||||||
|
void frame(item.id, camera);
|
||||||
|
}
|
||||||
|
onAvailable(enabled && cameras.size > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibilityChanged() {
|
||||||
|
if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort();
|
||||||
|
else refresh();
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', visibilityChanged);
|
||||||
|
|
||||||
|
function setEnabled(value) {
|
||||||
|
enabled = value;
|
||||||
|
if (timer) window.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
refresh();
|
||||||
|
if (enabled) timer = window.setInterval(refresh, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setEnabled,
|
||||||
|
refresh,
|
||||||
|
dispose() {
|
||||||
|
setEnabled(false);
|
||||||
|
document.removeEventListener('visibilitychange', visibilityChanged);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
const storageKey = 'autonomy-studio.lms.language';
|
||||||
|
let language = 'ko';
|
||||||
|
try { language = localStorage.getItem(storageKey) === 'en' ? 'en' : 'ko'; } catch {}
|
||||||
|
|
||||||
|
const english = {
|
||||||
|
'벽 배치': 'Place walls',
|
||||||
|
'맵 저장': 'Save map',
|
||||||
|
'로봇 경로 표시': 'Show robot path',
|
||||||
|
'센서 표시': 'Show sensors',
|
||||||
|
'카메라 영상 표시': 'Show camera preview',
|
||||||
|
'초기 위치 지정': 'Set initial pose',
|
||||||
|
'목표 위치 지정': 'Set goal',
|
||||||
|
'카메라 시점': 'Camera view',
|
||||||
|
'미니맵 접기/펼치기': 'Toggle minimap',
|
||||||
|
'미니맵 접기': 'Collapse minimap',
|
||||||
|
'미니맵 펼치기': 'Expand minimap',
|
||||||
|
'카메라 화면 접기/펼치기': 'Toggle camera preview',
|
||||||
|
'카메라 화면 접기': 'Collapse camera preview',
|
||||||
|
'카메라 화면 펼치기': 'Expand camera preview',
|
||||||
|
'실시간 카메라 화면': 'Live camera preview',
|
||||||
|
'i 전진 · , 후진 · j/l 회전 · k 정지': 'i Forward · , Reverse · j/l Turn · k Stop',
|
||||||
|
'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지': 'i Forward · , Reverse · j/l Steer while moving · k Stop',
|
||||||
|
'ROS 상태 접기/펼치기': 'Toggle ROS status',
|
||||||
|
'ROS 상태 접기': 'Collapse ROS status',
|
||||||
|
'ROS 상태 펼치기': 'Expand ROS status',
|
||||||
|
'ROS 상태': 'ROS status',
|
||||||
|
'응답을 기다리는 중입니다.': 'Waiting for a response.',
|
||||||
|
'맵을 저장하고 있습니다.': 'Saving the map.',
|
||||||
|
'가제보 로봇 위치를 적용하지 못했습니다.': 'Failed to set the Gazebo robot pose.',
|
||||||
|
'주행 시뮬레이션에서는 초기 위치를 한 번만 설정할 수 있습니다.': 'The initial pose can only be set once during navigation simulation.',
|
||||||
|
'로봇 초기 위치 설정': 'Set initial robot pose',
|
||||||
|
'3D 포인트클라우드를 표시 중입니다.': 'Displaying the 3D point cloud.',
|
||||||
|
'모듈을 연결해주세요.': 'Connect your modules.',
|
||||||
|
'맵 저장에 실패했습니다.': 'Failed to save the map.',
|
||||||
|
'기존 목표를 취소하지 못했습니다.': 'Failed to cancel the previous goal.',
|
||||||
|
'가제보 로봇 위치 적용 시간이 초과되었습니다.': 'Setting the Gazebo robot pose timed out.'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function t(key) { return language === 'en' ? (english[key] ?? key) : key; }
|
||||||
|
|
||||||
|
// Only explicitly marked viewer UI is translated. Topic names and telemetry stay intact.
|
||||||
|
export function setText(element, key) {
|
||||||
|
element.dataset.i18n = key;
|
||||||
|
element.textContent = t(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(element => {
|
||||||
|
element.textContent = t(element.dataset.i18n);
|
||||||
|
});
|
||||||
|
for (const attribute of ['title', 'aria-label', 'alt']) {
|
||||||
|
document.querySelectorAll(`[data-i18n-${attribute}]`).forEach(element => {
|
||||||
|
element.setAttribute(attribute, t(element.getAttribute(`data-i18n-${attribute}`)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('storage', event => {
|
||||||
|
if (event.key !== storageKey) return;
|
||||||
|
language = event.newValue === 'en' ? 'en' : 'ko';
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
// Embedded viewers may not share storage with the app (for example file URLs).
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
if (window.parent === window || event.source !== window.parent) return;
|
||||||
|
if (event.data?.type !== 'lms:language' || !['ko', 'en'].includes(event.data.language)) return;
|
||||||
|
language = event.data.language;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
if (window.parent !== window) window.parent.postMessage({ type: 'lms:language:ready' }, '*');
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
||||||
|
<asset>
|
||||||
|
<contributor>
|
||||||
|
<author>Cole</author>
|
||||||
|
<authoring_tool>OpenCOLLADA for 3ds Max; Version: 1.3.1; Revision: 847M; Platform: x64; Configuration: Release_Max2012_static</authoring_tool>
|
||||||
|
<source_data>file:///C:/Users/Cole/Desktop/Houses.max</source_data>
|
||||||
|
</contributor>
|
||||||
|
<created>2012-12-21T07:14:54</created>
|
||||||
|
<modified>2012-12-21T07:14:54</modified>
|
||||||
|
<unit name="inch" meter="0.0254"/>
|
||||||
|
<up_axis>Z_UP</up_axis>
|
||||||
|
</asset>
|
||||||
|
<library_effects>
|
||||||
|
<effect id="_1_-_Default">
|
||||||
|
<profile_COMMON>
|
||||||
|
<technique sid="common">
|
||||||
|
<blinn>
|
||||||
|
<emission>
|
||||||
|
<color>0 0 0 1</color>
|
||||||
|
</emission>
|
||||||
|
<ambient>
|
||||||
|
<color>1 1 1 1</color>
|
||||||
|
</ambient>
|
||||||
|
<diffuse>
|
||||||
|
<color>1 1 1 1</color>
|
||||||
|
</diffuse>
|
||||||
|
<specular>
|
||||||
|
<color>0.9 0.9 0.9 1</color>
|
||||||
|
</specular>
|
||||||
|
<shininess>
|
||||||
|
<float>9.999999</float>
|
||||||
|
</shininess>
|
||||||
|
<reflective>
|
||||||
|
<color>0 0 0 1</color>
|
||||||
|
</reflective>
|
||||||
|
<transparent opaque="A_ONE">
|
||||||
|
<color>1 1 1 1</color>
|
||||||
|
</transparent>
|
||||||
|
<transparency>
|
||||||
|
<float>1</float>
|
||||||
|
</transparency>
|
||||||
|
</blinn>
|
||||||
|
</technique>
|
||||||
|
</profile_COMMON>
|
||||||
|
<extra>
|
||||||
|
<technique profile="OpenCOLLADA3dsMax">
|
||||||
|
<extended_shader>
|
||||||
|
<apply_reflection_dimming>0</apply_reflection_dimming>
|
||||||
|
<dim_level>0</dim_level>
|
||||||
|
<falloff_type>0</falloff_type>
|
||||||
|
<index_of_refraction>1.5</index_of_refraction>
|
||||||
|
<opacity_type>0</opacity_type>
|
||||||
|
<reflection_level>3</reflection_level>
|
||||||
|
<wire_size>1</wire_size>
|
||||||
|
<wire_units>0</wire_units>
|
||||||
|
</extended_shader>
|
||||||
|
<shader>
|
||||||
|
<ambient_diffuse_lock>1</ambient_diffuse_lock>
|
||||||
|
<ambient_diffuse_texture_lock>1</ambient_diffuse_texture_lock>
|
||||||
|
<diffuse_specular_lock>0</diffuse_specular_lock>
|
||||||
|
<soften>0.1</soften>
|
||||||
|
<use_self_illum_color>0</use_self_illum_color>
|
||||||
|
</shader>
|
||||||
|
</technique>
|
||||||
|
</extra>
|
||||||
|
</effect>
|
||||||
|
</library_effects>
|
||||||
|
<library_materials>
|
||||||
|
<material id="_1_-_Default-material" name="_1_-_Default">
|
||||||
|
<instance_effect url="#_1_-_Default"/>
|
||||||
|
</material>
|
||||||
|
</library_materials>
|
||||||
|
<library_geometries>
|
||||||
|
<geometry id="geom-Cylinder001" name="Cylinder001">
|
||||||
|
<mesh>
|
||||||
|
<source id="geom-Cylinder001-positions">
|
||||||
|
<float_array id="geom-Cylinder001-positions-array" count="279">63.77863 0.3839193 -94.24056 55.23391 31.88931 -94.24056 31.88931 55.23391 -94.24056 1.33153e-6 63.77862 -94.24056 -31.88931 55.23391 -94.24056 -55.2339 31.88931 -94.24056 -63.77862 0.3839193 -94.24056 55.23391 31.88931 101.6574 31.88931 55.23391 101.6574 -2.78785e-6 63.77862 101.6574 -31.88931 55.23391 101.6574 -55.23391 31.88931 101.6574 -63.77862 0.3839242 101.6574 63.77862 0.3839242 101.6574 63.77863 -135.6524 -94.24055 63.77862 -135.6524 101.6575 -63.77863 -135.6524 101.6575 -63.77862 -135.6524 -94.24055 69.09421 0.3839242 101.6574 60.08233 34.68855 101.6574 34.68855 60.08234 101.6574 -3.13332e-6 69.3771 101.6574 -34.68855 60.08233 101.6574 -60.08233 34.68855 101.6574 -69.18634 0.3839242 101.6574 -69.18634 -141.0601 101.6575 69.18633 -141.0602 101.6575 60.08233 34.68855 108.9806 34.68855 60.08234 108.9806 -3.13258e-6 69.3771 108.9806 -34.68855 60.08233 108.9806 -60.08233 34.68855 108.9806 -69.18634 0.3839244 108.9806 -69.18634 -141.0601 108.9806 69.18633 -141.0602 108.9806 69.09421 0.3839244 108.9806 -23.34707 -979.6222 17.01899 23.34707 -979.6222 17.01899 -23.34707 -979.6222 -9.602058 23.34707 -979.6222 -9.602058 -23.34707 -121.6467 17.01897 23.34707 -121.6467 17.01897 -23.34707 -121.6467 -9.602079 23.34707 -121.6467 -9.602079 -65.29713 -90.30914 34.35137 -65.29715 -90.30914 60.09743 -65.29715 -39.25652 63.45726 -73.57328 -90.30915 34.35137 -73.57329 -84.41591 63.45725 -73.57329 -39.25653 39.67269 -73.5733 -39.25652 63.45725 -73.57329 -48.59029 34.35137 -65.29715 -93.29141 67.81843 -65.29715 -39.25652 67.81852 -65.29713 -99.18414 34.35137 -65.29715 -84.41591 63.45726 -73.57329 -90.30914 60.09743 -65.29715 -99.1846 64.45859 -5.55696 73.37229 122.3759 5.556962 70.81001 120.9151 -5.556961 70.81001 109.6903 5.556962 70.81001 109.6903 -5.55696 85.82349 122.3759 5.556962 85.82349 122.3759 -5.55696 70.81001 120.9151 5.556962 73.37229 122.3759 -5.556959 66.63747 122.3759 5.556964 69.19975 120.9151 -5.55696 69.19975 109.6903 5.556963 69.19975 109.6903 -5.556959 54.18628 122.3759 5.556964 54.18628 122.3759 -5.556959 69.19975 120.9151 5.556964 66.63747 122.3759 -66.63045 -79.03947 42.09508 -66.63046 -79.03947 57.59226 -66.63046 162.3586 42.09508 -66.63046 99.09541 42.09508 -66.63046 162.3586 55.67602 -66.63046 148.0616 12.03616 -66.63045 102.9279 10.30568 -5.556961 70.81001 100.9286 5.556961 70.81001 100.9286 -5.556961 58.29042 100.9286 5.556961 58.29042 100.9286 -5.556961 70.81001 109.6903 5.556962 70.81001 109.6903 -5.55696 46.97038 109.6903 5.556962 46.97038 109.6903 -66.63046 101.0116 40.17884 -66.63046 160.4424 57.59225 -66.63046 145.3223 10.30569 -66.63045 101.0116 12.22192</float_array>
|
||||||
|
<technique_common>
|
||||||
|
<accessor source="#geom-Cylinder001-positions-array" count="93" stride="3">
|
||||||
|
<param name="X" type="float"/>
|
||||||
|
<param name="Y" type="float"/>
|
||||||
|
<param name="Z" type="float"/>
|
||||||
|
</accessor>
|
||||||
|
</technique_common>
|
||||||
|
</source>
|
||||||
|
<source id="geom-Cylinder001-normals">
|
||||||
|
<float_array id="geom-Cylinder001-normals-array" count="783">0.9912451 0.132035 2.4983e-8 0.8652635 0.5013175 1.68492e-8 0.8652634 0.5013174 1.38252e-8 0.9912451 0.132035 1.60082e-8 0.5000001 0.8660254 9.92266e-9 0.5 0.8660254 9.73644e-9 -5.64708e-8 1 -9.79561e-10 -5.89261e-8 1 -1.8623e-10 -0.5 0.8660254 -1.14197e-8 -0.5 0.8660254 -1.36251e-8 -0.8652635 0.5013175 -1.98732e-8 -0.8652635 0.5013175 -2.00329e-8 -0.9912451 0.132035 -2.25966e-8 -0.9912451 0.132035 -2.56756e-8 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 1 -1.72061e-8 2.69974e-8 1 2.18421e-15 3.89457e-8 0 5.39393e-8 1 0 5.39393e-8 1 0 5.39393e-8 1 5.94806e-15 5.39393e-8 1 5.94806e-15 5.39393e-8 1 5.94806e-15 5.39393e-8 1 -1 1.08357e-8 -2.69974e-8 -1 2.80417e-8 -3.89457e-8 0 -5.60835e-8 -1 0 -5.60835e-8 -1 0 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 5.60835e-8 -5.60835e-8 -1 5.60835e-8 -5.60835e-8 -1 5.60835e-8 -5.60835e-8 -1 -2.97263e-16 -5.39393e-8 -1 -2.97263e-16 -5.39393e-8 -1 -2.97263e-16 -5.39393e-8 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 -3.14437e-16 -5.60835e-8 -1 -3.14437e-16 -5.60835e-8 -1 -3.14437e-16 -5.60835e-8 -1 -5.39745e-8 -5.39745e-8 -1 -5.39745e-8 -5.39745e-8 -1 -5.39745e-8 -5.39745e-8 -1 0.99172 0.1284185 -3.18533e-9 0.8672485 0.4978755 0 0.8672485 0.4978756 -4.85969e-10 0.99172 0.1284184 -3.65799e-9 0.5000001 0.8660254 -1.74247e-12 0.5 0.8660254 0 -1.08031e-7 1 -1.74247e-12 -1.37494e-7 1 1.74249e-12 -0.5 0.8660254 0 -0.5000001 0.8660254 1.74248e-12 -0.8666227 0.498964 -4.20111e-10 -0.8666227 0.498964 0 -0.9916003 0.1293404 -3.1582e-9 -0.9916003 0.1293404 -2.7491e-9 -1 0 0 -1 0 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 0.9999998 6.51317e-4 -6.11024e-13 0.9999998 6.51317e-4 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 2.44539e-8 1 0 2.44539e-8 1 0 2.44539e-8 1 0 2.44539e-8 1 1 0 0 1 0 0 1 0 0 1 0 0 0 -2.44539e-8 -1 0 -2.44539e-8 -1 0 -2.44539e-8 -1 0 -2.44539e-8 -1 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 -1.30567e-7 -2.28558e-7 -1 1.68944e-7 -2.96333e-7 -1 -5.90983e-8 -2.5016e-7 -1 -1.06023e-7 -2.34713e-7 -1 1.82876e-7 -3.20771e-7 -1 -7.93843e-8 -2.36668e-7 7.39325e-7 -1 2.37658e-7 0 -1 0 1.82529e-7 -1 5.86744e-8 9.21853e-7 -1 2.96333e-7 -4.60927e-7 -6.88853e-14 1 -4.60927e-7 0 1 -4.60927e-7 -8.98548e-15 1 -4.60927e-7 -7.78708e-14 1 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 -5.92665e-7 -1 0 -5.92665e-7 -1 0 -5.92665e-7 -1 -2.49037e-7 -5.06819e-7 -1 -2.49037e-7 -5.06819e-7 -1 -2.49037e-7 -5.06819e-7 -4.00423e-7 -0.4952793 0.8687338 -4.00423e-7 -0.4952793 0.8687338 -4.00423e-7 -0.4952793 0.8687338 0 -0.4952789 0.868734 0 -0.4952789 0.8687341 0 -0.4952789 0.868734 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 0 1 0 0 1 0 0 1 0 0 1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 -0.4952782 -0.8687345 0 -0.4952782 -0.8687344 0 -0.4952781 -0.8687344 0 -0.4952782 -0.8687345 0 -0.4952782 -0.8687344 0 -0.4952781 -0.8687344 0 1 0 0 1 0 0 1 0 0 1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 -1 -2.99963e-8 -3.58181e-7 -1 -1.02221e-7 -4.58289e-8 -1 -3.99212e-8 -1.73411e-7 -1 -3.23093e-8 -4.85334e-7 -1 -3.18579e-8 -4.92309e-7 -1 -9.30545e-8 -1.27403e-7 -1 -5.76775e-8 -2.63987e-7 -1 -4.88709e-8 -1.95574e-7 -1 -3.36656e-8 -3.5904e-7 -1 -1.73765e-7 1.23235e-7 -1 -1.79962e-7 2.84877e-7</float_array>
|
||||||
|
<technique_common>
|
||||||
|
<accessor source="#geom-Cylinder001-normals-array" count="261" stride="3">
|
||||||
|
<param name="X" type="float"/>
|
||||||
|
<param name="Y" type="float"/>
|
||||||
|
<param name="Z" type="float"/>
|
||||||
|
</accessor>
|
||||||
|
</technique_common>
|
||||||
|
</source>
|
||||||
|
<source id="geom-Cylinder001-map1">
|
||||||
|
<float_array id="geom-Cylinder001-map1-array" count="450">0.1889011 0.2758261 0 0.2256371 0.2758261 0 0.2256371 0.6486552 0 0.188901 0.6486552 0 0.2623734 0.2758261 0 0.2623734 0.6486552 0 0.2991094 0.2758261 0 0.2991095 0.6486552 0 0.3358451 0.275826 0 0.3358452 0.6486552 0 0.3725807 0.275826 0 0.3725807 0.6486552 0 0.9257745 0.70301 0 0.8734617 0.7549164 0 0.802001 0.7739153 0 0.7305403 0.7549164 0 0.6782273 0.7030097 0 0.6594725 0.6335595 0 0.4093161 0.2758261 0 0.4093161 0.6486552 0 0.03795872 0.6486552 0 0.03795872 0.2758261 0 0.5602581 0.2758261 0 0.5602581 0.6486552 0 0.9443399 0.6335346 0 0.061661 0.8492339 0 0.09246806 0.8492339 0 0.09246806 0.8621775 0 0.061661 0.8621775 0 0.1245703 0.8492339 0 0.4158128 0.7749463 0 0.4288802 0.7265559 0 0.4645816 0.6911321 0 0.5133504 0.6781658 0 0.5621194 0.6911321 0 0.5978206 0.7265559 0 0.6108883 0.7749462 0 0.1245703 0.8621775 0 0.1566722 0.8492339 0 0.1566722 0.8621775 0 0.1887741 0.8492339 0 0.1887741 0.8621775 0 0.2208759 0.8492339 0 0.2208759 0.8621775 0 0.2523189 0.8492339 0 0.2523189 0.8621775 0 0.3790478 0.8492339 0 0.3790478 0.8621775 0 0.2097653 0.9498755 0 0.3783371 0.826761 0 0.6594725 0.3437682 0 0.06166129 0.8267609 0 0.9445293 0.3437679 0 0.3783363 0.702438 0 0.6108882 0.9807906 0 0.0836773 0.9498755 0 0.0836773 0.9369324 0 0.2097653 0.9369324 0 0.061661 0.7024379 0 0.4158128 0.9807907 0 0.637657 0.6559129 0 0.6597634 0.7377743 0 0.6472197 0.7449604 0 0.6234784 0.6557912 0 0.7201594 0.7977016 0 0.7129173 0.8101482 0 0.802662 0.8196367 0 0.802662 0.8340079 0 0.8851648 0.7977016 0 0.8924065 0.8101479 0 0.2192692 0.9691411 0 0.0836773 0.9691411 0 0.9455608 0.7377743 0 0.9581041 0.7449604 0 0.9676672 0.6559129 0 0.9816576 0.6577407 0 0.9676669 0.3076856 0 0.9816576 0.2938034 0 0.637657 0.3076856 0 0.0836773 0.9573665 0 0.2192692 0.9573665 0 0.6236663 0.2938034 0 0.637657 0.6559129 0 0.973251 0.1941192 0 0.945253 0.1941192 0 0.945253 0.1408443 0 0.973251 0.1408443 0 0.04288544 0.1408443 0 0.04288544 0.194119 0 0.945253 0.03266054 0 0.945253 0.08657608 0 0.04288544 0.08657608 0 0.04288544 0.03266054 0 0.945253 0.2533531 0 0.04288544 0.253353 0 0.8747699 0.9740806 0 0.8747699 0.9228441 0 0.886499 0.9161604 0 0.9763682 0.91616 0 0.8582996 0.9740796 0 0.8582998 0.9228438 0 0.878347 0.9018502 0 0.6516011 0.9556062 0 0.7323457 0.9556062 0 0.748215 0.9695692 0 0.8864987 0.8996912 0 0.9763682 0.8996903 0 0.9763678 0.9634923 0 0.6516011 0.9695697 0 0.7428828 0.9448491 0 0.7428828 0.8624189 0 0.7587513 0.8624189 0 0.9577932 0.9740818 0 0.7587519 0.9588121 0 0.6609319 0.8997087 0 0.6875241 0.8997087 0 0.6875241 0.8657955 0 0.6875241 0.8926538 0 0.6875241 0.9294978 0 0.6609319 0.9294978 0 0.6609319 0.8926538 0 0.701803 0.8955842 0 0.7283945 0.9294978 0 0.701803 0.9294978 0 0.701803 0.90264 0 0.701803 0.8657949 0 0.7283945 0.8657949 0 0.7283945 0.90264 0 0.7283945 0.8955842 0 0.304611 0.9144163 0 0.2467109 0.9144163 0 0.304611 0.8910367 0 0.0836773 0.9144163 0 0.0836773 0.8877379 0 0.291526 0.9661621 0 0.2502185 0.9691411 0 0.6609319 0.8657955 0 0.8666169 0.9085327 0 0.9434043 0.8599022 0 0.9434043 0.8377851 0 0.9683194 0.8377851 0 0.9683194 0.8599022 0 0.9259688 0.8377851 0 0.9259688 0.8599022 0 0.878527 0.8599022 0 0.878527 0.8377851 0 0.2484647 0.9177151 0 0.3028572 0.8877379 0 0.289019 0.9691411 0 0.2484647 0.9658423 0</float_array>
|
||||||
|
<technique_common>
|
||||||
|
<accessor source="#geom-Cylinder001-map1-array" count="150" stride="3">
|
||||||
|
<param name="S" type="float"/>
|
||||||
|
<param name="T" type="float"/>
|
||||||
|
<param name="P" type="float"/>
|
||||||
|
</accessor>
|
||||||
|
</technique_common>
|
||||||
|
</source>
|
||||||
|
<vertices id="geom-Cylinder001-vertices">
|
||||||
|
<input semantic="POSITION" source="#geom-Cylinder001-positions"/>
|
||||||
|
</vertices>
|
||||||
|
<triangles material="_1_-_Default_1" count="121">
|
||||||
|
<input semantic="VERTEX" source="#geom-Cylinder001-vertices" offset="0"/>
|
||||||
|
<input semantic="NORMAL" source="#geom-Cylinder001-normals" offset="1"/>
|
||||||
|
<input semantic="TEXCOORD" source="#geom-Cylinder001-map1" offset="2" set="0"/>
|
||||||
|
<p>0 0 0 1 1 1 7 2 2 7 2 2 13 3 3 0 0 0 1 1 1 2 4 4 8 5 5 8 5 5 7 2 2 1 1 1 2 4 4 3 6 6 9 7 7 9 7 7 8 5 5 2 4 4 3 6 6 4 8 8 10 9 9 10 9 9 9 7 7 3 6 6 4 8 8 5 10 10 11 11 11 11 11 11 10 9 9 4 8 8 5 10 10 6 12 18 12 13 19 12 13 19 11 11 11 5 10 10 27 14 12 28 15 13 29 16 14 29 17 14 30 18 15 31 19 16 27 20 12 29 21 14 31 22 16 27 23 12 31 24 16 32 25 17 27 26 12 32 27 17 35 28 24 5 29 31 4 30 32 3 31 33 3 32 33 2 33 34 1 34 35 3 35 33 1 36 35 0 37 36 5 38 31 3 39 33 0 40 36 6 41 30 5 42 31 0 43 36 0 0 0 13 3 3 15 44 20 15 44 20 14 45 21 0 0 0 35 46 24 32 47 17 33 48 50 33 49 50 34 50 52 35 51 24 12 13 19 6 12 18 17 52 22 17 52 22 16 53 23 12 13 19 6 54 30 0 55 36 14 56 54 14 57 54 17 58 59 6 59 30 14 60 49 15 61 51 17 62 53 15 63 51 16 64 58 17 65 53 13 66 60 7 67 61 19 68 62 19 69 62 18 70 63 13 71 60 7 72 61 8 73 64 20 74 65 20 75 65 19 76 62 7 77 61 8 78 64 9 79 66 21 80 67 21 81 67 20 82 65 8 83 64 9 84 66 10 85 68 22 86 69 22 87 69 21 88 67 9 89 66 10 90 68 11 91 72 23 92 73 23 93 73 22 94 69 10 95 68 11 96 72 12 97 74 24 98 75 24 99 75 23 100 73 11 101 72 12 102 74 16 103 76 25 104 77 25 105 77 24 106 75 12 107 74 16 108 76 15 109 78 26 110 81 26 111 81 25 112 77 16 113 76 15 114 78 13 115 82 18 116 63 18 117 63 26 118 81 15 119 78 18 120 25 19 121 26 27 122 27 27 122 27 35 123 28 18 120 25 19 121 26 20 124 29 28 125 37 28 125 37 27 122 27 19 121 26 20 124 29 21 126 38 29 127 39 29 127 39 28 125 37 20 124 29 21 126 38 22 128 40 30 129 41 30 129 41 29 127 39 21 126 38 22 128 40 23 130 42 31 131 43 31 131 43 30 129 41 22 128 40 23 130 42 24 132 44 32 133 45 32 133 45 31 131 43 23 130 42 24 132 44 25 134 46 33 135 47 33 135 47 32 133 45 24 132 44 25 136 70 26 137 71 34 138 79 34 139 79 33 140 80 25 141 70 26 142 48 18 120 55 35 123 56 35 123 56 34 143 57 26 142 48 36 144 83 38 145 84 39 146 85 39 146 85 37 147 86 36 144 83 36 148 89 37 149 90 41 150 91 41 150 91 40 151 92 36 148 89 37 152 90 39 153 85 43 154 87 43 154 87 41 155 91 37 152 90 39 156 85 38 157 84 42 158 88 42 158 88 43 159 87 39 156 85 38 160 84 36 161 93 40 162 94 40 162 94 42 163 88 38 160 84 47 164 95 56 165 96 48 166 97 47 164 95 48 166 97 50 167 98 50 167 98 49 168 107 51 169 112 47 164 95 50 167 98 51 169 112 44 170 99 45 171 100 56 172 96 56 172 96 47 173 95 44 170 99 55 174 105 46 175 106 50 176 98 50 176 98 48 177 97 55 174 105 46 178 102 55 179 103 52 180 104 52 181 104 53 182 108 46 183 102 45 184 109 44 185 110 54 186 111 54 187 111 57 188 113 45 189 109 45 190 137 55 191 101 48 192 97 48 193 97 56 194 96 45 195 137 55 196 103 45 197 109 57 198 113 57 199 113 52 200 104 55 201 103 64 202 117 59 203 120 61 204 136 61 204 136 60 205 116 64 202 117 65 206 114 58 207 115 62 208 118 62 209 118 63 210 119 65 211 114 64 212 117 58 213 115 65 214 114 65 215 114 59 216 120 64 217 117 72 218 127 68 219 122 69 220 123 69 220 123 67 221 124 72 218 127 73 222 121 71 223 125 70 224 126 70 225 126 66 226 128 73 227 121 72 228 127 67 229 124 73 230 121 73 231 121 66 232 128 72 233 127 85 234 143 86 235 142 82 236 139 82 236 139 81 237 138 85 234 143 81 238 138 82 239 139 84 240 140 84 241 140 83 242 141 81 243 138 86 244 142 85 245 143 87 246 144 87 247 144 88 248 145 86 249 142 76 250 129 80 251 135 92 252 149 74 253 132 75 254 133 90 255 147 90 255 147 77 256 130 74 253 132 89 257 146 77 256 130 90 255 147 90 255 147 78 258 131 89 257 146 89 257 146 78 258 131 76 250 129 76 250 129 92 252 149 89 257 146 79 259 134 91 260 148 80 251 135 80 251 135 76 250 129 79 259 134</p>
|
||||||
|
</triangles>
|
||||||
|
</mesh>
|
||||||
|
</geometry>
|
||||||
|
</library_geometries>
|
||||||
|
<library_lights>
|
||||||
|
<light id="EnvironmentAmbientLight" name="EnvironmentAmbientLight">
|
||||||
|
<technique_common>
|
||||||
|
<ambient>
|
||||||
|
<color>0 0 0</color>
|
||||||
|
</ambient>
|
||||||
|
</technique_common>
|
||||||
|
</light>
|
||||||
|
</library_lights>
|
||||||
|
<library_visual_scenes>
|
||||||
|
<visual_scene id="MaxScene">
|
||||||
|
<node name="EnvironmentAmbientLight">
|
||||||
|
<instance_light url="#EnvironmentAmbientLight"/>
|
||||||
|
</node>
|
||||||
|
<node id="node-Cylinder001" name="Cylinder001">
|
||||||
|
<rotate>5.33851e-8 0.7071068 0.7071068 -180</rotate>
|
||||||
|
<scale>0.0367934 0.0367934 0.06453677</scale>
|
||||||
|
<node>
|
||||||
|
<matrix>1 0 0 0 0 1 0 979.6222 0 0 1 -3.708471 0 0 0 1</matrix>
|
||||||
|
<instance_geometry url="#geom-Cylinder001">
|
||||||
|
<bind_material>
|
||||||
|
<technique_common>
|
||||||
|
<instance_material symbol="_1_-_Default_1" target="#_1_-_Default-material"/>
|
||||||
|
</technique_common>
|
||||||
|
</bind_material>
|
||||||
|
</instance_geometry>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</visual_scene>
|
||||||
|
</library_visual_scenes>
|
||||||
|
<scene>
|
||||||
|
<instance_visual_scene url="#MaxScene"/>
|
||||||
|
</scene>
|
||||||
|
</COLLADA>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #07111f;
|
background: #ffffff;
|
||||||
font-family: NanumSquare, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: NanumSquare, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: radial-gradient(circle at 50% 35%, #11243e 0%, #07111f 54%, #03070d 100%);
|
background: #ffffff;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,22 +31,53 @@
|
|||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fallback-title {
|
.brand-logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 45%;
|
top: 116px;
|
||||||
|
left: calc(50% - 3px);
|
||||||
|
z-index: 1;
|
||||||
|
width: 80px;
|
||||||
|
height: 70px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 187px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
z-index: 1;
|
||||||
color: #4d5d76;
|
width: 290px;
|
||||||
text-shadow: 0 1px 12px rgba(255, 255, 255, 0.28);
|
transform: translateX(-50%);
|
||||||
font-size: 28px;
|
text-align: center;
|
||||||
font-weight: 800;
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
color: #252d39;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
letter-spacing: 6px;
|
letter-spacing: 6px;
|
||||||
|
text-indent: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
margin-top: 9px;
|
||||||
|
color: #7a8495;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 26px;
|
right: 26px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
|
z-index: 2;
|
||||||
color: #64738a;
|
color: #64738a;
|
||||||
text-shadow: 0 1px 10px rgba(255, 255, 255, 0.24);
|
text-shadow: 0 1px 10px rgba(255, 255, 255, 0.24);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -58,6 +89,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
|
z-index: 2;
|
||||||
color: #7a8495;
|
color: #7a8495;
|
||||||
text-shadow: 0 1px 8px rgba(255, 255, 255, 0.2);
|
text-shadow: 0 1px 8px rgba(255, 255, 255, 0.2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -68,21 +100,35 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="splash">
|
<div class="splash">
|
||||||
<div class="fallback-title">LMS</div>
|
<img class="splash-image" src="./assets/splash.png" alt="" />
|
||||||
<img class="splash-image" id="splash-image" alt="" />
|
<img class="brand-logo" src="./assets/splash-logo.png" alt="" />
|
||||||
|
<div class="brand-copy">
|
||||||
|
<div class="brand-title">LMS</div>
|
||||||
|
<div class="brand-subtitle">Learning Management System</div>
|
||||||
|
</div>
|
||||||
<div class="copyright">© 2026 Autonomy Studio. All rights reserved.</div>
|
<div class="copyright">© 2026 Autonomy Studio. All rights reserved.</div>
|
||||||
<div class="loading" id="loading-text">학습 환경 준비 중...</div>
|
<div class="loading" id="loading-text">학습 환경 준비 중...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('splash-image').src = './assets/splash.png'
|
|
||||||
|
|
||||||
const el = document.getElementById('loading-text')
|
const el = document.getElementById('loading-text')
|
||||||
|
const translations = {
|
||||||
|
'학습 환경 준비 중...': 'Preparing your learning environment...',
|
||||||
|
'로그인 세션 확인 중...': 'Checking your session...',
|
||||||
|
'강좌 정보 불러오는 중...': 'Loading courses...',
|
||||||
|
'커리큘럼 및 플랫폼 정보 가져오는 중...': 'Loading curriculum and platforms...'
|
||||||
|
}
|
||||||
|
let language = 'ko'
|
||||||
|
try {
|
||||||
|
language = localStorage.getItem('autonomy-studio.lms.language') === 'en' ? 'en' : 'ko'
|
||||||
|
} catch {}
|
||||||
|
document.documentElement.lang = language
|
||||||
window.setSplashStatus = (message) => {
|
window.setSplashStatus = (message) => {
|
||||||
if (typeof message === 'string' && message.trim()) {
|
if (typeof message === 'string' && message.trim()) {
|
||||||
el.textContent = message
|
el.textContent = language === 'en' ? (translations[message] ?? message) : message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
window.setSplashStatus(el.textContent)
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { FileCode2, Loader2 } from 'lucide-react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
import type { AgentCodeReference, AgentCodeReferenceState } from '../features/workspace/useAiAgent'
|
||||||
|
|
||||||
|
export function AgentCodeReferences({ references, states, dark, busy, onOpen }: {
|
||||||
|
references: AgentCodeReference[]
|
||||||
|
states?: Record<string, AgentCodeReferenceState>
|
||||||
|
dark: boolean
|
||||||
|
busy?: boolean
|
||||||
|
onOpen?: (id: string) => void
|
||||||
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
|
return <div className="grid min-w-0 gap-3">
|
||||||
|
{references.map(reference => {
|
||||||
|
const state = states?.[reference.id]
|
||||||
|
const opening = state?.status === 'opening'
|
||||||
|
return <div key={reference.id} data-agent-code-reference={reference.id} className={['min-w-0 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||||
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
|
<FileCode2 size={14} className="mt-0.5 shrink-0 text-[#8b5cf6]" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="break-all font-bold">{reference.moduleName}/{reference.path}</p>
|
||||||
|
<p className={dark ? 'text-white/48' : 'text-[#64748b]'}>{t('{{start}}~{{end}}줄', { start: reference.startLine, end: reference.endLine })}</p>
|
||||||
|
<p className="mt-1 break-words">{reference.reason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{state?.error && <p role="alert" className="mt-2 break-words text-[11px] text-[#ef4444]">{t(state.error)}</p>}
|
||||||
|
<div className="mt-2 flex min-w-0 items-center justify-end gap-2">
|
||||||
|
<button type="button" disabled={busy || opening || !onOpen} onClick={() => onOpen?.(reference.id)} className="inline-flex h-8 min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-bold leading-none text-white hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:opacity-40">{opening ? <Loader2 size={13} className="shrink-0 animate-spin" /> : <FileCode2 size={13} className="shrink-0" />}<span className="relative top-px leading-none">{opening ? t('파일 여는 중...') : t('코드에서 보기')}</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Bot, Check } from 'lucide-react'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels'
|
||||||
|
|
||||||
|
export function AgentModelSelector({ models, provider, disabled, dark, onChange }: {
|
||||||
|
models: AgentModelOption[]
|
||||||
|
provider: string | null
|
||||||
|
disabled: boolean
|
||||||
|
dark: boolean
|
||||||
|
onChange: (provider: string) => void
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const selected = models.find(model => model.provider === provider)
|
||||||
|
const blocked = disabled || models.length === 0
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [position, setPosition] = useState({ left: 8, bottom: 8, width: 280, maxHeight: 320 })
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const menuId = useId()
|
||||||
|
const visible = open && !blocked
|
||||||
|
const close = (restoreFocus = false): void => {
|
||||||
|
setOpen(false)
|
||||||
|
if (restoreFocus) buttonRef.current?.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (blocked) setOpen(false)
|
||||||
|
}, [blocked])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!visible || !buttonRef.current) return
|
||||||
|
const bounds = buttonRef.current.getBoundingClientRect()
|
||||||
|
const width = Math.min(320, window.innerWidth - 16)
|
||||||
|
setPosition({
|
||||||
|
left: Math.max(8, Math.min(bounds.left, window.innerWidth - width - 8)),
|
||||||
|
bottom: window.innerHeight - bounds.top + 6,
|
||||||
|
width,
|
||||||
|
maxHeight: Math.max(0, Math.min(320, bounds.top - 14))
|
||||||
|
})
|
||||||
|
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
|
||||||
|
const index = Math.max(0, models.findIndex(model => model.provider === provider))
|
||||||
|
items?.[index]?.focus({ preventScroll: true })
|
||||||
|
}, [visible, models, provider])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
const outside = (event: Event): void => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false)
|
||||||
|
}
|
||||||
|
const dismiss = (): void => setOpen(false)
|
||||||
|
window.addEventListener('pointerdown', outside, true)
|
||||||
|
window.addEventListener('contextmenu', outside)
|
||||||
|
window.addEventListener('scroll', outside, true)
|
||||||
|
window.addEventListener('resize', dismiss)
|
||||||
|
window.addEventListener('blur', dismiss)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', outside, true)
|
||||||
|
window.removeEventListener('contextmenu', outside)
|
||||||
|
window.removeEventListener('scroll', outside, true)
|
||||||
|
window.removeEventListener('resize', dismiss)
|
||||||
|
window.removeEventListener('blur', dismiss)
|
||||||
|
}
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
aria-label={t('에이전트 모델 선택')}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={visible}
|
||||||
|
aria-controls={visible ? menuId : undefined}
|
||||||
|
title={selected ? agentModelLabel(selected) : t('사용 가능한 모델 없음')}
|
||||||
|
disabled={blocked}
|
||||||
|
onClick={() => setOpen(current => !current)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setOpen(true) }
|
||||||
|
else if (event.key === 'Escape') close()
|
||||||
|
}}
|
||||||
|
className={['grid size-7 shrink-0 cursor-pointer place-items-center rounded outline-none transition focus-visible:ring-1 focus-visible:ring-[#8b5cf6] disabled:cursor-default disabled:opacity-35',
|
||||||
|
dark ? 'text-white/60 hover:bg-white/10 hover:text-white/90' : 'text-[#64748b] hover:bg-[#eef1f7] hover:text-[#111520]',
|
||||||
|
visible ? dark ? 'bg-white/10 text-white/90' : 'bg-[#eef1f7] text-[#111520]' : ''].join(' ')}
|
||||||
|
>
|
||||||
|
<Bot size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{visible && createPortal(
|
||||||
|
<div ref={menuRef} id={menuId} role="menu" aria-label={t('에이전트 모델 선택')}
|
||||||
|
className={['fixed z-[160] overflow-y-auto overscroll-contain rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
|
||||||
|
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'].join(' ')}
|
||||||
|
style={position}
|
||||||
|
onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); return }
|
||||||
|
if (event.key === 'Tab') { close(true); return }
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
|
||||||
|
event.preventDefault()
|
||||||
|
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
|
||||||
|
const current = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||||
|
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
|
||||||
|
: (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
|
||||||
|
items[next]?.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{models.map(model => <button key={model.provider} type="button" role="menuitemradio" aria-checked={model.provider === provider}
|
||||||
|
title={agentModelLabel(model)} tabIndex={-1}
|
||||||
|
onClick={() => { if (!blocked) { onChange(model.provider); close(true) } }}
|
||||||
|
className={['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left outline-none transition',
|
||||||
|
dark ? 'hover:bg-[#37373d] hover:text-white focus:bg-[#37373d] focus:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520] focus:bg-[#eef1f7] focus:text-[#111520]'].join(' ')}>
|
||||||
|
<Check size={13} className={['shrink-0', model.provider === provider ? '' : 'opacity-0'].join(' ')} aria-hidden="true" />
|
||||||
|
<span className="min-w-0 truncate">{agentModelLabel(model)}</span>
|
||||||
|
</button>)}
|
||||||
|
</div>, document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { CheckCircle2, CircleAlert, XCircle } from 'lucide-react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
import type { AgentProgressStep } from '../features/workspace/agentContext'
|
||||||
|
import { AgentWorkDuration } from './AgentWorkDuration'
|
||||||
|
|
||||||
|
function stepLabel(step: AgentProgressStep): string {
|
||||||
|
if (step.kind === 'analysis') return [
|
||||||
|
t('{{round}}차 분석 완료', { round: step.round ?? 1 }),
|
||||||
|
step.requests ? t('추가 조회 {{count}}건', { count: step.requests }) : ''
|
||||||
|
].filter(Boolean).join(' · ')
|
||||||
|
if (step.kind === 'image') return `${t('이미지 업로드 완료')} · ${step.name}`
|
||||||
|
const tools: Record<string, string> = {
|
||||||
|
'modules.list': t('모듈 목록'),
|
||||||
|
'files.list': t('파일 목록'),
|
||||||
|
'files.search': t('파일 검색'),
|
||||||
|
'files.read': t('파일 읽기'),
|
||||||
|
'logs.read': t('로그 조회'),
|
||||||
|
'network.inspect': t('네트워크 조회'),
|
||||||
|
'ros.inspect': t('ROS 상태 조회'),
|
||||||
|
'practice.inspect': t('실습 구성 조회')
|
||||||
|
}
|
||||||
|
const status = step.status === 'error' ? t('조회 실패') : step.status === 'partial' ? t('일부 결과') : step.status === 'empty' ? t('조회 결과 없음') : step.status === 'missing' ? t('대상 없음') : t('조회 완료')
|
||||||
|
return [
|
||||||
|
step.moduleName,
|
||||||
|
step.kind === 'files' ? t('소스 파일') : step.kind === 'logs' ? t('로그 조회') : tools[step.name ?? ''] ?? step.name,
|
||||||
|
step.path,
|
||||||
|
status,
|
||||||
|
step.count !== undefined ? t('{{count}}건', { count: step.count }) : ''
|
||||||
|
].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentProgressBubble({ dark, steps, activity, startedAt }: { dark: boolean; steps: AgentProgressStep[]; activity?: string; startedAt?: number | null }): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
|
const listRef = useRef<HTMLOListElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const list = listRef.current
|
||||||
|
if (list) list.scrollTop = list.scrollHeight
|
||||||
|
}, [steps.length])
|
||||||
|
return (
|
||||||
|
<div data-agent-progress="true" className={['min-w-0 flex-1 overflow-hidden rounded-lg border px-3 py-3 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||||
|
<AgentWorkDuration dark={dark} startedAt={startedAt} />
|
||||||
|
<p className="font-bold">{t('진행 상황')}</p>
|
||||||
|
{steps.length > 0 && <ol ref={listRef} role="log" aria-label={t('진행 상황')} aria-live="polite" aria-relevant="additions" className="thin-scrollbar mt-2 grid max-h-64 gap-2 overflow-y-auto">
|
||||||
|
{steps.map(step => <li key={step.id} data-agent-progress-step={step.kind} className="agent-response-fade flex min-w-0 items-start gap-2">
|
||||||
|
{step.status === 'error' ? <XCircle size={13} className="mt-0.5 shrink-0 text-[#ef4444]" /> : step.status !== 'ok' ? <CircleAlert size={13} className="mt-0.5 shrink-0 text-[#d97706]" /> : <CheckCircle2 size={13} className="mt-0.5 shrink-0 text-[#22c55e]" />}
|
||||||
|
<span className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">{stepLabel(step)}</span>
|
||||||
|
</li>)}
|
||||||
|
</ol>}
|
||||||
|
<p role="status" className={['mt-2 break-words text-[11px]', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>{activity ? t(activity) : t('요청 분석 중')}</p>
|
||||||
|
<div className="mt-3 grid w-[220px] max-w-full animate-pulse gap-2 motion-reduce:animate-none" aria-hidden="true">
|
||||||
|
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
|
||||||
|
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
|
||||||
|
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
|
||||||
|
export function AgentWorkDuration({ startedAt, durationMs, dark }: { startedAt?: number | null; durationMs?: number; dark: boolean }): React.JSX.Element | null {
|
||||||
|
useLocaleRender()
|
||||||
|
const [now, setNow] = useState(Date.now)
|
||||||
|
const completed = typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0
|
||||||
|
const running = !completed && typeof startedAt === 'number' && Number.isFinite(startedAt)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return
|
||||||
|
const update = (): void => setNow(Date.now())
|
||||||
|
update()
|
||||||
|
const timer = window.setInterval(update, 1000)
|
||||||
|
document.addEventListener('visibilitychange', update)
|
||||||
|
window.addEventListener('focus', update)
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
document.removeEventListener('visibilitychange', update)
|
||||||
|
window.removeEventListener('focus', update)
|
||||||
|
}
|
||||||
|
}, [running, startedAt])
|
||||||
|
if (!running && !completed) return null
|
||||||
|
const seconds = Math.floor(Math.max(0, completed ? durationMs : now - startedAt!) / 1000)
|
||||||
|
return <p data-agent-work-duration={completed ? 'completed' : 'running'} aria-live="off" className={['mb-2 text-left text-[11px] font-normal leading-relaxed tabular-nums', dark ? 'text-white/45' : 'text-[#64748b]'].join(' ')}>
|
||||||
|
{completed ? t('{{seconds}}초 동안 작업함', { seconds }) : t('{{seconds}}초 동안 작업 중입니다', { seconds })}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
import { AlertCircle, CheckCircle2, Clock3, LogOut, Power, Trash2 } from 'lucide-react'
|
import { AlertCircle, CheckCircle2, Clock3, LogOut, Power, Trash2 } from 'lucide-react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ function strongClass(dark: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dimClass(dark: boolean): string {
|
function dimClass(dark: boolean): string {
|
||||||
return dark ? 'text-white/45' : 'text-[#727b8c]'
|
return dark ? 'text-white/45' : 'text-[#475569]'
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelButtonClass(dark: boolean): string {
|
function cancelButtonClass(dark: boolean): string {
|
||||||
@@ -60,6 +61,8 @@ export function StatusNoticeModal({
|
|||||||
message,
|
message,
|
||||||
detail,
|
detail,
|
||||||
children,
|
children,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
onConfirm
|
onConfirm
|
||||||
}: AppModalTheme & {
|
}: AppModalTheme & {
|
||||||
tone: ModalTone
|
tone: ModalTone
|
||||||
@@ -67,27 +70,34 @@ export function StatusNoticeModal({
|
|||||||
message: string
|
message: string
|
||||||
detail?: string
|
detail?: string
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
actionLabel?: string
|
||||||
|
onAction?: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
|
const hasAction = Boolean(actionLabel && onAction)
|
||||||
return (
|
return (
|
||||||
<div className={overlayClassName}>
|
<div className={overlayClassName}>
|
||||||
<section className={modalClass(dark)}>
|
<section className={modalClass(dark)}>
|
||||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
||||||
{toneIcon(tone)}
|
{toneIcon(tone)}
|
||||||
<strong className="text-[13px] font-bold">{title}</strong>
|
<strong className="text-[13px] font-bold">{t(title)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{message}</p>
|
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{t(message)}</p>
|
||||||
{children}
|
{children}
|
||||||
{detail && (
|
{detail && (
|
||||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
||||||
{detail}
|
{t(detail)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>
|
<button type="button" onClick={onConfirm} className={hasAction ? cancelButtonClass(dark) : confirmButtonClass('primary')}>{t("확인")}</button>
|
||||||
확인
|
{actionLabel && onAction && (
|
||||||
</button>
|
<button type="button" onClick={onAction} className={confirmButtonClass('primary')}>
|
||||||
|
{t(actionLabel)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -108,29 +118,28 @@ export function WeekSwitchBlockedModal({
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
return (
|
return (
|
||||||
<div className={overlayClassName}>
|
<div className={overlayClassName}>
|
||||||
<section className={modalClass(dark, true)}>
|
<section className={modalClass(dark, true)}>
|
||||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
||||||
<AlertCircle size={15} className="text-[#f59e0b]" />
|
<AlertCircle size={15} className="text-[#f59e0b]" />
|
||||||
<strong className="text-[13px] font-bold">이미 실행 중인 스튜디오가 있습니다</strong>
|
<strong className="text-[13px] font-bold">{t("이미 실행 중인 스튜디오가 있습니다")}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{[
|
{[
|
||||||
['실행 중', runningLabel],
|
[t("실행 중"), runningLabel],
|
||||||
['새로 시작', nextLabel]
|
[t("새로 시작"), nextLabel]
|
||||||
].map(([label, value], index) => (
|
].map(([label, value], index) => (
|
||||||
<div key={label} className={[index ? 'mt-2' : '', 'rounded-md border px-3 py-2', dark ? 'border-white/10 bg-white/5' : 'border-[#e5e8ef] bg-[#f8fafc]'].join(' ')}>
|
<div key={label} className={[index ? 'mt-2' : '', 'rounded-md border px-3 py-2', dark ? 'border-white/10 bg-white/5' : 'border-[#e5e8ef] bg-[#f8fafc]'].join(' ')}>
|
||||||
<p className={['text-[11px] font-bold', dimClass(dark)].join(' ')}>{label}</p>
|
<p className={['text-[11px] font-bold', dimClass(dark)].join(' ')}>{label}</p>
|
||||||
<p className={['mt-1 text-[13px] font-bold', strongClass(dark)].join(' ')}>{value}</p>
|
<p className={['mt-1 text-[13px] font-bold', strongClass(dark)].join(' ')}>{value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<p className={['mt-3 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
<p className={['mt-3 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>{t("현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다.")}</p>
|
||||||
현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>취소</button>
|
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{t("취소")}</button>
|
||||||
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>종료하고 새로 시작</button>
|
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{t("종료하고 새로 시작")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -149,15 +158,16 @@ export function RunningPlatformCloseModal({
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
return (
|
return (
|
||||||
<ConfirmStatusModal
|
<ConfirmStatusModal
|
||||||
overlayClassName={overlayClassName}
|
overlayClassName={overlayClassName}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
icon={<Power size={15} className="text-[#e87979]" />}
|
icon={<Power size={15} className="text-[#e87979]" />}
|
||||||
title="개발 스튜디오 종료"
|
title={t("개발 스튜디오 종료")}
|
||||||
message={`${platformTitle} 개발 스튜디오를 종료할까요?`}
|
message={t("{{v0}} 개발 스튜디오를 종료할까요?", { v0: platformTitle === '로컬 PC' ? t('로컬 PC') : platformTitle })}
|
||||||
detail="현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다."
|
detail={t("현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.")}
|
||||||
confirmLabel="종료"
|
confirmLabel={t("확인")}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
/>
|
/>
|
||||||
@@ -177,58 +187,136 @@ export function SessionConfirmModal({
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
const release = action === 'release'
|
const release = action === 'release'
|
||||||
return (
|
return (
|
||||||
<ConfirmStatusModal
|
<ConfirmStatusModal
|
||||||
overlayClassName={overlayClassName}
|
overlayClassName={overlayClassName}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
icon={release ? <Trash2 size={15} className="text-[#e87979]" /> : <LogOut size={15} className="text-[#e87979]" />}
|
icon={release ? <Trash2 size={15} className="text-[#e87979]" /> : <LogOut size={15} className="text-[#e87979]" />}
|
||||||
title={release ? '반납' : '연결종료'}
|
title={release ? t("반납") : t("연결종료")}
|
||||||
message={release ? '제어권을 반납할까요?' : '연결을 종료할까요?'}
|
message={release ? t("제어권을 반납할까요?") : t("연결을 종료할까요?")}
|
||||||
detail={release
|
detail={release
|
||||||
? `${platformName} 제어권을 반납하면 대기 중인 다음 수강생에게 제어권이 넘어갈 수 있습니다.`
|
? t("{{v0}} 제어권을 반납하면 대기 중인 다음 수강생에게 제어권이 넘어갈 수 있습니다.", { v0: platformName })
|
||||||
: `${platformName} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.`}
|
: t("{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.", { v0: platformName === '로컬 PC' ? t('로컬 PC') : platformName })}
|
||||||
confirmLabel={release ? '반납' : '연결종료'}
|
confirmLabel={release ? t("반납") : t("연결종료")}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfirmStatusModal({
|
export function ModuleShutdownConfirmModal({
|
||||||
overlayClassName,
|
overlayClassName,
|
||||||
dark,
|
dark,
|
||||||
icon,
|
action,
|
||||||
title,
|
moduleNames,
|
||||||
message,
|
onKeepRunning,
|
||||||
detail,
|
onStopModules,
|
||||||
confirmLabel,
|
|
||||||
onCancel,
|
|
||||||
onConfirm
|
|
||||||
}: AppModalTheme & {
|
}: AppModalTheme & {
|
||||||
icon: React.ReactNode
|
action: 'disconnect' | 'release'
|
||||||
title: string
|
moduleNames: string[]
|
||||||
message: string
|
onKeepRunning: () => void
|
||||||
detail: string
|
onStopModules: () => void
|
||||||
confirmLabel: string
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
|
const release = action === 'release'
|
||||||
|
return (
|
||||||
|
<ConfirmStatusModal
|
||||||
|
overlayClassName={overlayClassName}
|
||||||
|
dark={dark}
|
||||||
|
icon={<AlertCircle size={15} className="text-[#f59e0b]" />}
|
||||||
|
title={t("실행 중인 모듈")}
|
||||||
|
message={t("{{v0}}개의 모듈이 실행 중입니다. 모듈을 종료할까요?", { v0: moduleNames.length })}
|
||||||
|
detail={moduleNames.join(', ')}
|
||||||
|
cancelLabel={t("아니오")}
|
||||||
|
confirmLabel={t("예")}
|
||||||
|
onCancel={onKeepRunning}
|
||||||
|
onConfirm={onStopModules}
|
||||||
|
>
|
||||||
|
<p className={['mt-3 text-[11px] font-medium', dimClass(dark)].join(' ')}>
|
||||||
|
{release ? t("아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.") : t("아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.")}
|
||||||
|
</p>
|
||||||
|
</ConfirmStatusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ControlExtensionConfirmModal({
|
||||||
|
overlayClassName,
|
||||||
|
dark,
|
||||||
|
platformName,
|
||||||
|
submitting = false,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: AppModalTheme & {
|
||||||
|
platformName: string
|
||||||
|
submitting?: boolean
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
return (
|
return (
|
||||||
<div className={overlayClassName}>
|
<div className={overlayClassName}>
|
||||||
<section className={modalClass(dark)}>
|
<section className={modalClass(dark)}>
|
||||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
||||||
{icon}
|
<Clock3 size={15} className="text-[#f59e0b]" />
|
||||||
<strong className="text-[13px] font-bold">{title}</strong>
|
<strong className="text-[13px] font-bold">{t("제어 시간 연장")}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{message}</p>
|
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{platformName}{t("제어 시간을 연장할까요?")}</p>
|
||||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>{t("대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.")}</p>
|
||||||
{detail}
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>취소</button>
|
<button type="button" disabled={submitting} onClick={onCancel} className={cancelButtonClass(dark)}>{t("취소")}</button>
|
||||||
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{confirmLabel}</button>
|
<button type="button" disabled={submitting} onClick={onConfirm} className={confirmButtonClass('primary')}>
|
||||||
|
{submitting ? t("연장 중") : t("연장")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmStatusModal({
|
||||||
|
overlayClassName,
|
||||||
|
dark,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
detail,
|
||||||
|
cancelLabel = '취소',
|
||||||
|
confirmLabel,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
children
|
||||||
|
}: AppModalTheme & {
|
||||||
|
icon: React.ReactNode
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
detail: string
|
||||||
|
cancelLabel?: string
|
||||||
|
confirmLabel: string
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
children?: React.ReactNode
|
||||||
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
|
return (
|
||||||
|
<div className={overlayClassName}>
|
||||||
|
<section className={modalClass(dark)}>
|
||||||
|
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
||||||
|
{icon}
|
||||||
|
<strong className="text-[13px] font-bold">{t(title)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{t(message)}</p>
|
||||||
|
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
||||||
|
{t(detail)}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{t(cancelLabel)}</button>
|
||||||
|
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{t(confirmLabel)}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Check, Copy, FileCode2, X } from 'lucide-react'
|
||||||
|
import { common, createLowlight } from 'lowlight'
|
||||||
|
import type React from 'react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
|
||||||
|
const highlighter = createLowlight(common)
|
||||||
|
const languageLabels: Record<string, string> = {
|
||||||
|
python: 'Python', py: 'Python', yaml: 'YAML', yml: 'YAML', json: 'JSON',
|
||||||
|
cpp: 'C++', 'c++': 'C++', c: 'C', javascript: 'JavaScript', js: 'JavaScript',
|
||||||
|
typescript: 'TypeScript', ts: 'TypeScript', bash: 'Bash', sh: 'Bash', shell: 'Shell',
|
||||||
|
xml: 'XML', html: 'HTML', css: 'CSS', sql: 'SQL', markdown: 'Markdown', md: 'Markdown',
|
||||||
|
}
|
||||||
|
type HighlightNode = ReturnType<typeof highlighter.highlight>['children'][number]
|
||||||
|
|
||||||
|
function renderToken(node: HighlightNode, key: string): React.ReactNode {
|
||||||
|
if (node.type === 'text') return node.value
|
||||||
|
if (node.type !== 'element') return null
|
||||||
|
const classes = node.properties.className
|
||||||
|
return <span key={key} className={Array.isArray(classes) ? classes.join(' ') : undefined}>
|
||||||
|
{node.children.map((child, index) => renderToken(child, `${key}-${index}`))}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatCodeBlock({ source, language = '', dark, fadeClass = '' }: {
|
||||||
|
source: string
|
||||||
|
language?: string
|
||||||
|
dark: boolean
|
||||||
|
fadeClass?: string
|
||||||
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
|
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle')
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
useEffect(() => () => clearTimeout(timer.current), [])
|
||||||
|
useEffect(() => { setCopyState('idle'); clearTimeout(timer.current) }, [source])
|
||||||
|
const normalizedLanguage = language.toLowerCase()
|
||||||
|
const tokens = useMemo(() => {
|
||||||
|
if (source.length > 50_000 || !highlighter.registered(normalizedLanguage)) return source
|
||||||
|
try { return highlighter.highlight(normalizedLanguage, source).children.map((node, index) => renderToken(node, `${index}`)) }
|
||||||
|
catch { return source }
|
||||||
|
}, [source, normalizedLanguage])
|
||||||
|
const copy = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(source)
|
||||||
|
setCopyState('copied')
|
||||||
|
} catch {
|
||||||
|
setCopyState('error')
|
||||||
|
}
|
||||||
|
clearTimeout(timer.current)
|
||||||
|
timer.current = setTimeout(() => setCopyState('idle'), 1800)
|
||||||
|
}
|
||||||
|
const copyLabel = copyState === 'copied' ? t('복사됨') : copyState === 'error' ? t('코드를 복사하지 못했습니다.') : t('코드 복사')
|
||||||
|
return <div data-chat-code-block data-code-theme={dark ? 'dark' : 'light'} className={['chat-code-block my-2 min-w-0 max-w-full overflow-hidden rounded-md border', dark ? 'border-white/12 bg-[#191919] text-[#dedede]' : 'border-[#d7dfeb] bg-[#f8fafc] text-[#253247]', fadeClass].join(' ')}>
|
||||||
|
<div className={['flex h-9 min-w-0 items-center justify-between gap-2 border-b px-2.5', dark ? 'border-white/10 bg-[#262626]' : 'border-[#e2e8f0] bg-[#eef2f7]'].join(' ')}>
|
||||||
|
<span title={language || undefined} className="flex min-w-0 items-center gap-1.5 text-[11px] font-bold"><FileCode2 size={13} className="shrink-0" /><span className="truncate">{languageLabels[normalizedLanguage] ?? (language ? language.slice(0, 40) : t('일반 텍스트'))}</span></span>
|
||||||
|
<button type="button" title={copyLabel} aria-label={copyLabel} onClick={event => { event.stopPropagation(); void copy() }} className={['grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded transition focus-visible:outline-2 focus-visible:outline-[#60a5fa]', copyState === 'error' ? 'text-[#ef4444]' : dark ? 'text-white/65 hover:bg-white/10 hover:text-white' : 'text-[#526079] hover:bg-[#dbe3ee] hover:text-[#182337]'].join(' ')}>{copyState === 'copied' ? <Check size={14} /> : copyState === 'error' ? <X size={14} /> : <Copy size={14} />}</button>
|
||||||
|
<span role="status" className="sr-only">{copyState === 'idle' ? '' : copyLabel}</span>
|
||||||
|
</div>
|
||||||
|
<pre tabIndex={0} aria-label={t('코드')} className="thin-scrollbar m-0 max-h-80 max-w-full overflow-auto p-3 text-[11px] leading-relaxed outline-offset-[-2px] [overflow-wrap:normal] [tab-size:4] [word-break:normal]">
|
||||||
|
<code className="block w-max min-w-full whitespace-pre font-mono font-normal [overflow-wrap:normal] [word-break:normal]">{tokens}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
import { displayAttachmentName } from './chatAttachments'
|
||||||
|
|
||||||
|
type ImageAttachment = { name: string; url: string }
|
||||||
|
|
||||||
|
export function ChatImageAttachments({ images, variant = 'draft', dark = true, onRemove, removeDisabled = false }: {
|
||||||
|
images: ImageAttachment[]
|
||||||
|
variant?: 'draft' | 'sent'
|
||||||
|
dark?: boolean
|
||||||
|
onRemove?: (image: ImageAttachment) => void
|
||||||
|
removeDisabled?: boolean
|
||||||
|
}): React.JSX.Element | null {
|
||||||
|
useLocaleRender()
|
||||||
|
const [preview, setPreview] = useState<ImageAttachment | null>(null)
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (preview) dialogRef.current?.showModal()
|
||||||
|
else dialogRef.current?.close()
|
||||||
|
}, [preview])
|
||||||
|
if (!images.length) return null
|
||||||
|
return <>
|
||||||
|
<div data-chat-image-attachments={variant} className={['flex min-w-0 flex-wrap gap-2', variant === 'sent' ? 'justify-end' : 'mt-2'].join(' ')}>
|
||||||
|
{images.map((source, index) => {
|
||||||
|
const image = { ...source, name: displayAttachmentName(source.name) }
|
||||||
|
return variant === 'draft' ? <div key={`${image.url}-${index}`} data-chat-image-draft="true" className={['flex h-16 w-56 min-w-0 max-w-full items-center gap-1 overflow-hidden rounded-md border p-1', dark ? 'border-white/10 bg-white/[0.03] text-white/70' : 'border-[#d8dee9] bg-[#f8f9fc] text-[#475569]'].join(' ')}>
|
||||||
|
<button type="button" title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="flex h-full min-w-0 flex-1 cursor-zoom-in items-center gap-2 overflow-hidden text-left">
|
||||||
|
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-14 shrink-0 rounded bg-black/10 object-contain" />
|
||||||
|
<span className="min-w-0 truncate text-[11px]">{image.name}</span>
|
||||||
|
</button>
|
||||||
|
{onRemove && <button type="button" disabled={removeDisabled} title={t('첨부 제거')} aria-label={`${t('첨부 제거')}: ${image.name}`} onClick={() => onRemove(image)} className={['grid size-6 shrink-0 cursor-pointer place-items-center rounded disabled:cursor-default disabled:opacity-40', dark ? 'hover:bg-white/10' : 'hover:bg-black/5'].join(' ')}><X size={12} /></button>}
|
||||||
|
</div> : <button type="button" key={`${image.url}-${index}`} title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="h-16 w-20 shrink-0 cursor-zoom-in overflow-hidden rounded-md border border-current/20 bg-black/10">
|
||||||
|
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-full object-contain" />
|
||||||
|
</button>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{preview && createPortal(<dialog ref={dialogRef} aria-label={preview.name} onCancel={() => setPreview(null)} onClick={event => { if (event.target === event.currentTarget) setPreview(null) }} className="fixed inset-0 m-auto max-h-[90vh] max-w-[94vw] overflow-auto rounded-lg border border-white/15 bg-[#202020] p-3 text-white shadow-xl backdrop:bg-black/75">
|
||||||
|
<div className="mb-2 flex min-w-0 items-center justify-between gap-3"><span className="min-w-0 break-all text-[12px]">{preview.name}</span><button type="button" title={t('닫기')} aria-label={t('닫기')} onClick={() => setPreview(null)} className="grid size-8 shrink-0 cursor-pointer place-items-center rounded hover:bg-white/10"><X size={16} /></button></div>
|
||||||
|
<img src={preview.url} alt={preview.name} referrerPolicy="no-referrer" className="max-h-[78vh] max-w-full object-contain" />
|
||||||
|
</dialog>, document.body)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { ClipboardPaste, Copy, Trash2 } from 'lucide-react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
|
||||||
|
export type ChatTextContextMenuState = {
|
||||||
|
kind: 'message' | 'input'
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
text: string
|
||||||
|
input?: HTMLTextAreaElement
|
||||||
|
selectionStart?: number
|
||||||
|
selectionEnd?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatTextContextMenu({ dark, menu, canClear = false, onClose, onClear, onPasteText }: {
|
||||||
|
dark: boolean
|
||||||
|
menu: ChatTextContextMenuState | null
|
||||||
|
canClear?: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onClear?: () => void
|
||||||
|
onPasteText?: (text: string, menu: ChatTextContextMenuState) => void
|
||||||
|
}): React.JSX.Element | null {
|
||||||
|
useLocaleRender()
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [clipboardState, setClipboardState] = useState<{ menu: ChatTextContextMenuState; hasText: boolean } | null>(null)
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||||
|
const closeRef = useRef(onClose)
|
||||||
|
closeRef.current = onClose
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!menu || !menuRef.current) return
|
||||||
|
const bounds = menuRef.current.getBoundingClientRect()
|
||||||
|
setPosition({ x: Math.max(8, Math.min(menu.x, window.innerWidth - bounds.width - 8)), y: Math.max(8, Math.min(menu.y, window.innerHeight - bounds.height - 8)) })
|
||||||
|
}, [menu])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return
|
||||||
|
let disposed = false
|
||||||
|
if (menu.kind === 'input') {
|
||||||
|
const read = Promise.resolve().then(() => window.chatTextMenu?.readClipboardState
|
||||||
|
? window.chatTextMenu.readClipboardState()
|
||||||
|
: navigator.clipboard.readText().then(text => ({ hasText: text.length > 0 })))
|
||||||
|
void read.then(state => { if (!disposed) setClipboardState({ menu, hasText: state.hasText }) })
|
||||||
|
.catch(() => { if (!disposed) setClipboardState({ menu, hasText: false }) })
|
||||||
|
}
|
||||||
|
const outside = (event: Event): void => {
|
||||||
|
if (!menuRef.current?.contains(event.target as Node)) closeRef.current()
|
||||||
|
}
|
||||||
|
const keydown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Escape') { event.preventDefault(); closeRef.current() }
|
||||||
|
}
|
||||||
|
const close = (): void => closeRef.current()
|
||||||
|
window.addEventListener('pointerdown', outside, true)
|
||||||
|
window.addEventListener('contextmenu', outside)
|
||||||
|
window.addEventListener('scroll', outside, true)
|
||||||
|
window.addEventListener('resize', close)
|
||||||
|
window.addEventListener('blur', close)
|
||||||
|
window.addEventListener('keydown', keydown)
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
window.removeEventListener('pointerdown', outside, true)
|
||||||
|
window.removeEventListener('contextmenu', outside)
|
||||||
|
window.removeEventListener('scroll', outside, true)
|
||||||
|
window.removeEventListener('resize', close)
|
||||||
|
window.removeEventListener('blur', close)
|
||||||
|
window.removeEventListener('keydown', keydown)
|
||||||
|
}
|
||||||
|
}, [menu])
|
||||||
|
|
||||||
|
if (!menu) return null
|
||||||
|
const pasteEnabled = menu.kind === 'input' && !menu.input?.disabled && !menu.input?.readOnly && clipboardState?.menu === menu && clipboardState.hasText
|
||||||
|
const itemClass = ['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left transition disabled:cursor-default disabled:opacity-35', dark ? 'enabled:hover:bg-[#37373d] enabled:hover:text-white' : 'enabled:hover:bg-[#eef1f7] enabled:hover:text-[#111520]'].join(' ')
|
||||||
|
const copy = async (): Promise<void> => {
|
||||||
|
if (!menu.text) return
|
||||||
|
onClose()
|
||||||
|
if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'copy', text: menu.text })
|
||||||
|
else await navigator.clipboard.writeText(menu.text)
|
||||||
|
}
|
||||||
|
const paste = async (): Promise<void> => {
|
||||||
|
if (!pasteEnabled || !menu.input?.isConnected || menu.input.disabled || menu.input.readOnly) return
|
||||||
|
const input = menu.input
|
||||||
|
input.focus({ preventScroll: true })
|
||||||
|
input.setSelectionRange(menu.selectionStart ?? input.selectionStart, menu.selectionEnd ?? input.selectionEnd)
|
||||||
|
onClose()
|
||||||
|
if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'paste' })
|
||||||
|
else {
|
||||||
|
const text = await navigator.clipboard.readText()
|
||||||
|
if (text) onPasteText?.(text, menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createPortal(
|
||||||
|
<div ref={menuRef} role="menu" aria-label={t('질의응답')} className={[
|
||||||
|
'fixed z-[160] w-[160px] overflow-hidden rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
|
||||||
|
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'
|
||||||
|
].join(' ')} style={{ left: position.x, top: position.y }} onMouseDown={event => event.preventDefault()} onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}>
|
||||||
|
<button type="button" role="menuitem" disabled={!menu.text} onClick={() => void copy().catch(error => console.error('[chat] copy failed', error))} className={itemClass}><Copy size={13} />{t('복사')}</button>
|
||||||
|
{menu.kind === 'input' ? (
|
||||||
|
<button type="button" role="menuitem" disabled={!pasteEnabled} onClick={() => void paste().catch(error => console.error('[chat] paste failed', error))} className={itemClass}><ClipboardPaste size={13} />{t('붙여넣기')}</button>
|
||||||
|
) : onClear ? <>
|
||||||
|
<div role="separator" className={['my-1 border-t', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')} />
|
||||||
|
<button type="button" role="menuitem" disabled={!canClear} onClick={() => { onClose(); onClear() }} className={itemClass}><Trash2 size={13} />{t('대화내역 삭제')}</button>
|
||||||
|
</> : null}
|
||||||
|
</div>, document.body
|
||||||
|
)
|
||||||
|
}
|
||||||