diff --git a/AGENTS.md b/AGENTS.md index 2239e29..e012a8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,24 @@ 이 문서는 새 에이전트가 현재 작업을 안전하게 이어가기 위한 저장소 수준 안내서다. 작업 시작 전 이 파일과 수정 대상 아래의 `prototype/AGENTS.md`를 모두 읽는다. +## 0. 최우선 계약: 프로토타입이 아닌 프로덕션 제품 + +Study Builder의 개발 목표는 화면 시연용 프로토타입이 아니라 실제 사용자 데이터, +로컬 파일, 프로세스와 AI Provider를 안전하게 다루는 배포 가능한 Electron 제품이다. +`prototype/`은 역사적인 디렉터리 이름일 뿐 품질 기준이 아니다. + +작업 시작 전 반드시 [`docs/production-development-contract.md`](docs/production-development-contract.md)를 +읽고 따른다. 특히 다음을 금지한다. + +- Renderer에 고정된 학습서, 진행률, 파일, 실행 결과 또는 AI 답변을 실제 데이터처럼 넣기 +- 파일·PTY·Provider를 호출하지 않고 `setTimeout`이나 로컬 상태로 성공을 연출하기 +- 브라우저 fallback이나 테스트 mock을 Electron 제품 기능의 완료 증거로 사용하기 +- 저장·실행 실패를 성공 Toast나 빈 결과로 숨기기 + +기능 완료는 실제 Renderer → Preload → Main → 저장소·Workspace·프로세스·Provider +왕복과 실패·취소·재시작 복원을 검증한 뒤에만 선언한다. 현재 공개 배포에 남은 서명, +공증 등의 작업은 숨기거나 가짜로 대체하지 않는다. + ## 1. 현재 제품 방향 Study Builder는 로컬 우선 Electron 학습 애플리케이션이다. @@ -15,6 +33,7 @@ Study Builder는 로컬 우선 Electron 학습 애플리케이션이다. 상세 제품 결정과 반응형 규칙은 다음 문서가 기준이다. +- `docs/production-development-contract.md` - `prototype/AGENTS.md` - `design.md` - `README.md` diff --git a/INSTALLER_FIX.md b/INSTALLER_FIX.md new file mode 100644 index 0000000..44939f9 --- /dev/null +++ b/INSTALLER_FIX.md @@ -0,0 +1,6 @@ +# Practice Runtime installer hotfix + +이 수정본은 설치 스크립트가 존재하지 않는 `prototype/tests/unit/ipc-contract.test.ts`를 찾던 문제를 수정합니다. +실제 파일인 `prototype/tests/unit/ipc-contracts.test.ts`를 사용합니다. + +저장소 루트에서 압축을 덮어쓴 뒤 기존 적용 스크립트를 다시 실행하세요. diff --git a/PRACTICE_RUNTIME_UPDATE.md b/PRACTICE_RUNTIME_UPDATE.md new file mode 100644 index 0000000..5a2e490 --- /dev/null +++ b/PRACTICE_RUNTIME_UPDATE.md @@ -0,0 +1,94 @@ +# Study Builder 실습 런타임 통합 수정본 + +현재 적용된 위키 UI 통합, Java·Spring 파일 생성, 브라우저식 탭 이동, VS Code 스타일 Explorer를 유지하면서 실습 화면의 런타임 문제를 정리합니다. + +## 반영 내용 + +### 바로 실습 + +- Spring Boot 위키 페이지 상단에 연결된 실습의 `바로 실습하기` 버튼을 표시합니다. +- 실습 가이드에는 현재 단계의 목표 파일을 바로 열거나 생성하는 버튼을 표시합니다. +- 목표 Java 파일이 없으면 상위 package 폴더를 순서대로 만들고, 경로와 파일명을 분석해 package·class·Spring 골격까지 생성합니다. + +### 메시지 수명 주기 + +- 파일 생성, 저장, 폴더 연결 등의 성공 알림은 자동으로 닫힙니다. +- 오류 알림은 자동으로 사라지지 않으며 사용자가 닫을 수 있습니다. +- `이전 실습 노트가 저장되어 있습니다` 고정 토스트를 제거하고 가이드 하단의 작은 상태 정보로 변경합니다. +- Markdown 가져오기와 출처 복사 알림도 일정 시간 후 닫힙니다. + +### 상호작용 터미널 + +- 하단 `터미널`을 클릭하면 현재 실습 폴더를 작업 디렉터리로 사용하는 실제 PTY 셸을 시작합니다. +- macOS는 사용자의 기본 셸을 우선 사용하고, 사용할 수 없으면 zsh·bash·sh 순서로 확인합니다. +- Windows는 `ComSpec`, Linux는 기본 셸 환경을 사용합니다. +- 키 입력, Ctrl+C, 방향키, ANSI 출력, 창 크기 변경을 PTY로 전달합니다. +- 테스트 실행과 상호작용 셸은 동시에 점유하지 않으며 서로 전환할 때 기존 세션을 정리합니다. +- 터미널 세션은 현재 Electron 창과 현재 승인된 실습 폴더에만 연결됩니다. + +### 대화형 AI 학습 도우미 + +- `테스트 실패`를 전제로 하던 패널을 일반 Java·Spring 학습 도우미로 변경합니다. +- 현재 코드의 역할, 호출 흐름, 다음 구현 순서, 터미널 오류 등 자유로운 질문을 입력할 수 있습니다. +- 답변 뒤 같은 패널에서 후속 질문을 계속할 수 있습니다. +- 현재 파일 외에 관련성이 높은 텍스트 파일을 최대 8개까지 문맥 후보로 선택합니다. +- 최근 대화 8개 메시지와 실행 중이거나 최근 종료된 터미널 출력도 선택적으로 포함합니다. +- `.env`, credential·secret·token 파일, 개인 키·인증서, `.git`, `.gradle`, `build`, `node_modules` 등은 관련 파일 후보에서 제외합니다. +- 매 질문마다 전송 파일, 대화 수, 터미널 포함 여부, 전체 전송량을 먼저 확인합니다. +- 수정안은 기존 Diff Review를 거쳐 편집기 버퍼에만 적용되며, 디스크 저장은 별도로 승인해야 합니다. + +### 실습 폴더 복원 + +- 이전에 승인한 실습 폴더 복원 기능을 그대로 유지합니다. +- 앱 재실행, 홈 이동, 위키 전환, 실습 탭 재진입 후에도 최근 유효한 폴더를 다시 연결합니다. +- 폴더가 이동되거나 삭제된 경우에만 폴더 선택 화면을 표시합니다. + +## 적용 전 + +현재 정상 동작하는 상태를 커밋해 두는 것을 권장합니다. + +```bash +git add . +git commit -m "chore: checkpoint before practice runtime update" +``` + +## 적용 + +ZIP을 저장소 루트에 덮어쓴 뒤 실행합니다. + +```bash +cd /Users/wars/Github/study-builder +unzip -o ~/Downloads/study-builder-practice-runtime-update-final.zip -d . +chmod +x apply-study-builder-practice-runtime-update.sh +./apply-study-builder-practice-runtime-update.sh +``` + +적용 스크립트는 여러 번 실행해도 같은 IPC 항목이나 상태 전이를 중복 추가하지 않습니다. + +## 검증 + +```bash +cd prototype +npm run typecheck +npm test +npm run build:desktop +``` + +실제 앱을 실행합니다. + +```bash +npm run dev:desktop +``` + +## 수동 확인 순서 + +1. 위키 페이지에서 `바로 실습하기`를 눌러 현재 탭에서 실습 화면으로 이동합니다. +2. 실습 목표 파일이 없으면 package 폴더와 Java 골격이 함께 생성되는지 확인합니다. +3. 파일 생성 성공 메시지가 자동으로 사라지는지 확인합니다. +4. 하단 `터미널`을 누르고 `pwd`, `ls`, `./gradlew tasks`를 직접 입력합니다. +5. 터미널이 열린 상태에서 `테스트`를 누르면 셸을 정리한 뒤 승인 화면으로 전환되는지 확인합니다. +6. AI 패널을 열고 테스트 실패와 무관한 질문을 입력합니다. +7. 문맥 미리보기에서 현재 파일, 관련 파일, 대화 기록, 터미널 전송량을 확인합니다. +8. 첫 답변 뒤 후속 질문을 입력해 대화가 이어지는지 확인합니다. +9. 수정안이 있는 응답만 Diff Review를 표시하고, 적용 뒤에도 저장 전까지 디스크가 바뀌지 않는지 확인합니다. +10. 앱을 완전히 종료하고 다시 실행해 이전 실습 폴더가 자동 복원되는지 확인합니다. diff --git a/README.md b/README.md index 62f8b68..d76b9a4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ AI는 선택 기능입니다. AI를 사용하지 않아도 위키, 파일 편집 현재 MVP의 원칙은 **로컬 우선**, **사용자 승인**, **기존 파일 비파괴**, **BYOP(Bring Your Own Provider)**입니다. 클라우드 계정, 동기화, 협업, 결제와 자동 업데이트는 포함하지 않습니다. +## 개발 문서 + +이 저장소의 `prototype/`은 역사적인 디렉터리 이름입니다. 개발 대상은 시연용 +프로토타입이 아니라 실제 파일·저장 데이터·프로세스·Provider를 사용하는 Electron +제품입니다. 새 기능을 만들기 전에 [프로덕션 개발 계약](docs/production-development-contract.md)을 +읽고, 런타임 하드코딩 금지·실제 IPC 경계·실패 처리·재시작 복원·완료 증거 규칙을 +따르십시오. + ## 현재 MVP 기능 - 홈, 위키 문서, 실습 화면을 누적하는 커스텀 Electron 상단 탭 Bar @@ -148,7 +156,7 @@ npm run dev ## 학습서 콘텐츠 만들기 -현재 상용 UI의 배포 학습서는 읽기 전용입니다. 앱 안에서 새 문서를 만들거나 Markdown을 가져오고 원문을 수정하지 않습니다. 콘텐츠 저자는 `prototype/resources/course-sources/` 아래 Markdown과 manifest를 수정한 뒤 `npm run build:course`로 Renderer 콘텐츠와 Electron seed를 함께 생성합니다. +배포 학습서 원문은 읽기 전용으로 보호합니다. 사용자는 읽기 전용 원문을 덮어쓰지 않고 개인 학습서에서 새 문서 생성, UTF-8 Markdown 가져오기, 블록 편집과 저장을 수행할 수 있습니다. 콘텐츠 저자는 `prototype/resources/course-sources/` 아래 Markdown과 manifest를 수정한 뒤 `npm run build:course`로 Renderer 콘텐츠와 Electron seed를 함께 생성합니다. 실습 계약은 `practice-manifest.json`, 시작 프로젝트는 `prototype/resources/template-sources/spring-boot-rest/`에서 관리합니다. 자세한 스키마와 생성물 경계는 [강의 자료 제작 방법](prototype/docs/course-authoring.md)을 참고하십시오. diff --git a/TYPECHECK_FIX.md b/TYPECHECK_FIX.md new file mode 100644 index 0000000..b2e6112 --- /dev/null +++ b/TYPECHECK_FIX.md @@ -0,0 +1,8 @@ +# Practice Runtime Typecheck Fix + +`ContextManifest.terminal`에 `included` 필드가 추가됐지만 기존 `assistant-proposal.test.ts` fixture가 갱신되지 않은 문제를 수정합니다. + +```bash +chmod +x apply-practice-runtime-typecheck-fix.sh +./apply-practice-runtime-typecheck-fix.sh +``` diff --git a/apply-practice-runtime-typecheck-fix.sh b/apply-practice-runtime-typecheck-fix.sh new file mode 100755 index 0000000..b38d3aa --- /dev/null +++ b/apply-practice-runtime-typecheck-fix.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" +node scripts/apply-practice-runtime-typecheck-fix.mjs diff --git a/apply-study-builder-practice-runtime-update.sh b/apply-study-builder-practice-runtime-update.sh new file mode 100755 index 0000000..62a64fc --- /dev/null +++ b/apply-study-builder-practice-runtime-update.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ! -f "prototype/package.json" ]]; then + echo "study-builder 저장소 루트에서 실행해 주세요." >&2 + exit 1 +fi + +required_files=( + "prototype/src/practice/java-file-template.js" + "prototype/src/practice/java-editor-setup.js" + "prototype/src/practice/ProjectFileTree.jsx" + "prototype/src/hooks/useWorkspaceActions.js" + "prototype/src/wiki/CourseWorkspaceHeader.jsx" + "prototype/src/wiki/SyntaxHighlightedCode.jsx" + "prototype/electron/terminal/course-approved-commands.ts" +) + +for required_file in "${required_files[@]}"; do + if [[ ! -f "$required_file" ]]; then + echo "필수 선행 파일이 없습니다: $required_file" >&2 + echo "앞서 적용한 Java/Spring 편집기 및 VS Code Explorer 수정본이 반영된 저장소에서 실행해 주세요." >&2 + exit 1 + fi +done + +node ./scripts/apply-study-builder-practice-runtime-update.mjs diff --git a/docs/development-plan.md b/docs/development-plan.md index db80293..60c8d51 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -1,8 +1,8 @@ # Study Builder Electron MVP 개발 계획 -> 상태: **피드백 P0/P1 구현 및 회귀 QA 완료, unsigned macOS 릴리스 후보 검증 완료** +> 상태: **피드백 P0/P1 구현·실습 검증 계약·회귀 QA 완료, unsigned macOS 릴리스 후보 검증 완료** > -> 기준일: 2026-07-31 · 대상: macOS 우선의 로컬 Electron 제품 +> 기준일: 2026-08-06 · 대상: macOS 우선의 로컬 Electron 제품 ### 2026-08-02 피드백 구현 결과 @@ -11,12 +11,13 @@ | 위키 | 경제학과 Spring 학습서를 저장 데이터로 렌더링하고 배포 콘텐츠는 읽기 전용으로 통일했다. 경제학 선택 문장 질문은 설정된 Provider의 실제 스트리밍 응답을 사용한다. | | 학습 UI | 기본 가이드 집중 화면, `01`~`18` 축약 내비게이션과 hover/focus 설명, 단일 관련 파일 동선, Terminal 접기·닫기 구분을 반영했다. | | 파일 | 탐색기의 새 파일과 실습 대상 파일은 답안 템플릿 없이 빈 파일로 생성한다. | -| 실습 계약 | 18개 단위에 산출물, 최소 2개 요구사항과 최소 2개 완료 기준을 정의하고 course build에서 검증한다. | +| 개인 위키 | 배포 강의 원문은 읽기 전용으로 유지하고, 사용자 문서는 실제 revisioned IPC를 통해 Markdown 가져오기·블록 편집·저장·삭제·재시작 복원을 제공한다. | +| 실습 계약 | 18개 단위에 산출물, 최소 2개 요구사항과 최소 2개 완료 기준을 정의하고 course build에서 검증한다. 16개 command 단계에는 실제 JUnit 검증 클래스가 시작 템플릿에 포함되며, 참조 완성 프로젝트에서 모두 통과한다. | | Provider | Main에서 모델 목록을 조회하고 Provider metadata에 추론 강도를 저장한다. API Key는 Renderer로 반환하지 않는다. | -| 회귀 QA | TypeScript 통과, Vitest 46 files·240 tests, Sites 4 tests, Electron E2E 54 tests를 retry 0으로 통과했다. 1440·1280·1024 실제 Electron 캡처를 현재 기본 화면으로 갱신했다. | +| 회귀 QA | TypeScript 통과, Vitest 46 files·240 tests, Sites 4 tests, Electron E2E 56 tests를 retry 0으로 통과했다. 1440·1280·1024 실제 Electron 캡처를 현재 기본 화면으로 갱신했다. | | 패키징 | macOS arm64 unsigned `.app`·DMG·ZIP 생성, ASAR 6,521 entries와 native `node-pty`·Spring 템플릿 감사, 패키지 실행·재시작 Smoke Test 1개를 통과했다. | -릴리스 전 남은 핵심 작업은 manifest가 참조하는 단계별 Spring 검증 클래스 제공이다. 현재 시작 템플릿에는 `StudymateApplicationTests`만 있으므로 후반 실습 명령은 해당 테스트 소스가 추가되기 전에는 성공할 수 없다. classpath 기반 Eclipse JDT Language Server 통합도 아직 없으며 현재는 Monaco의 정적 Java 지원과 템플릿의 VS Code 확장 권장으로 제한한다. +manifest가 참조하는 16개 단계별 Spring 검증 클래스는 시작 템플릿에 포함되어 있다. 시작 템플릿에서 아직 구현하지 않은 단계가 실패하는 것은 의도된 학습 계약이며, 학습자가 코드를 작성한 뒤 해당 검증 명령으로 완료를 확인한다. 2단계 관찰 실습은 명령 성공과 비어 있지 않은 실습 노트 저장을 함께 요구한다. classpath 기반 Eclipse JDT Language Server 통합은 아직 없으며 현재는 Monaco의 정적 Java 지원과 템플릿의 VS Code 확장 권장으로 제한한다. ### 2026-07-31 실행 결과 diff --git a/docs/production-development-contract.md b/docs/production-development-contract.md new file mode 100644 index 0000000..b057c48 --- /dev/null +++ b/docs/production-development-contract.md @@ -0,0 +1,283 @@ +# Study Builder 프로덕션 개발 계약 + +이 문서는 Study Builder를 이어서 개발하는 사람과 AI 에이전트가 가장 먼저 따라야 하는 제품 개발 계약이다. + +Study Builder의 개발 목표는 화면 시연용 프로토타입이 아니라 사용자의 실제 학습 자료, 로컬 파일, 실행 프로세스와 AI Provider를 안전하게 다루는 배포 가능한 Electron 데스크톱 제품이다. 저장소의 애플리케이션 경로가 역사적인 이유로 `prototype/`이라는 이름을 사용하지만, 이 이름은 구현 품질이나 완료 기준을 낮추는 근거가 아니다. + +현재 빌드가 곧바로 공개 상용 배포 준비를 마쳤다는 뜻은 아니다. 서명·공증, 자동 업데이트 등 남은 릴리스 작업은 사실대로 미완료라고 기록한다. 다만 앞으로 추가하거나 수정하는 기능은 처음부터 실제 제품 경로, 실패 처리, 데이터 보존, 보안과 패키징을 기준으로 구현한다. + +## 1. 최우선 원칙 + +1. 사용자가 보는 데이터와 상태는 실제 저장소, 선택한 Workspace, 실행 결과 또는 사용자가 입력한 값에서 나온다. +2. 버튼은 실제 기능을 실행하거나 실행할 수 없는 이유를 설명한다. 클릭 후 성공한 것처럼 보이기만 하는 동작을 만들지 않는다. +3. 개발 편의를 위해 런타임 데이터를 하드코딩하거나 `setTimeout`으로 성공을 연출하지 않는다. +4. Renderer는 파일 시스템, 프로세스, Electron 객체, raw IPC와 Provider Secret에 직접 접근하지 않는다. +5. 저장·삭제·덮어쓰기·명령 실행·AI 전송은 사용자의 명시적인 선택과 검증된 Main Process 경계를 거친다. +6. 기능 완료는 실제 Electron에서의 동작, 실패 경로, 재시작 복원과 패키지 환경을 검증한 뒤에만 선언한다. +7. 검증하지 않은 동작, 공개 배포 준비, 외부 서비스 연결을 완료했다고 표현하지 않는다. + +## 2. 하드코딩 금지의 정확한 의미 + +금지 대상은 런타임에서 실제 사용자 상태처럼 보이는 고정 데이터와 가짜 실행 결과다. + +### 금지되는 구현 + +- 고정된 학습서 목록, 최근 학습 기록, 진행률 또는 완료 체크를 Renderer 컴포넌트에 직접 넣기 +- 특정 문서 ID나 챕터 번호에만 반응하는 별도 화면을 만들고 저장 데이터를 우회하기 +- Workspace를 선택하지 않았는데 파일 트리, 파일 내용 또는 터미널 출력을 예시 값으로 표시하기 +- 실제 파일을 읽거나 쓰지 않고 편집·저장 성공 Toast만 표시하기 +- 실제 프로세스를 실행하지 않고 타이머로 빌드·테스트 성공 상태 만들기 +- 테스트 종료 코드와 검증 결과 없이 단계 완료 또는 학습 진도 올리기 +- Provider 요청 없이 미리 작성한 AI 답변을 스트리밍처럼 표시하기 +- Provider API Key를 소스, `.env` 예시 값, localStorage, 일반 JSON 또는 로그에 넣기 +- 오류를 삼키고 빈 배열, 빈 문자열 또는 성공 응답으로 바꾸기 +- 브라우저 fallback에서 파일, PTY, Secret 저장 같은 Electron 전용 기능을 성공한 것처럼 흉내 내기 +- 테스트용 fixture나 mock server를 production bundle의 런타임 분기로 사용하기 + +### 허용되는 고정 값 + +아래 값은 제품 규칙이나 빌드 입력이므로 하드코딩된 사용자 데이터와 다르다. + +- IPC payload 크기, 파일 크기, timeout과 같은 검증 상한 +- Provider 유형, 오류 코드, 허용 명령 ID와 같은 프로토콜 상수 +- 디자인 토큰, 접근성 label과 사용자 안내 문구 +- `course-manifest.json`과 Markdown 원문에서 빌드된 배포 학습 콘텐츠 +- 버전이 있는 첫 실행 seed. 단, 실제 사용자 데이터와 구분되고 이후 저장소가 소유해야 한다. +- 테스트 파일 안에만 존재하며 production import graph에 포함되지 않는 fixture와 mock + +새 상수를 추가할 때는 “제품 규칙인가, 실제 사용자 상태를 대신하는 값인가?”를 판단한다. 두 번째라면 저장소나 실제 서비스 흐름으로 옮긴다. + +## 3. 브라우저 fallback은 제품 구현이 아니다 + +`prototype/src/services/app-service.js`에는 브라우저에서 레이아웃과 읽기 흐름을 확인하기 위한 메모리 기반 fallback이 있다. 이 경로는 Electron 제품 기능의 완료 증거가 아니다. + +- 새 privileged 기능을 browser memory API에 구현하지 않는다. +- Workspace, 파일, 환경 진단, 터미널, Provider와 AI는 브라우저에서 `UNAVAILABLE`을 반환하거나 데스크톱 앱이 필요하다고 안내한다. +- 브라우저에서 동작한 UI만으로 파일 저장, Secret 보관, PTY 실행 또는 재시작 복원을 검증했다고 주장하지 않는다. +- 기존 메모리 sample을 확장하기보다 실제 Electron 경로와 temp userData 기반 E2E를 우선한다. +- 브라우저 검토 경로가 필요하면 “샘플”임을 분명히 표시하고 제품 데이터와 혼동되지 않게 한다. + +## 4. 제품 아키텍처와 책임 + +```text +Renderer (React) + │ 사용자 입력과 화면 상태 + │ window.studyBuilder의 명시적 도메인 함수만 호출 + ▼ +Preload (contextBridge) + │ 고정 IPC 채널 + │ 요청·이벤트 검증과 listener 소유권 + ▼ +Electron Main + ├─ Repository userData 저장·백업·복구·migration + ├─ Workspace 승인 root 내부 파일 CRUD·watch·revision + ├─ Template 빈 폴더 확인·archive 검증·rollback + ├─ Environment 실제 실행 파일과 버전 진단 + ├─ Terminal 승인 토큰·PTY·출력·종료 정리 + ├─ Provider 설정·Secret·연결 테스트·응답 정규화 + └─ Assistant 승인 문맥·스트리밍·취소·Diff 제안 +``` + +| 영역 | Source of Truth | 실제 구현 경로 | 완료 증거 | +| --- | --- | --- | --- | +| 앱·학습 위치 | versioned app state | `electron/storage/`, `src/hooks/useStudyBuilderState.js` | 재시작 후 같은 상태 복원 | +| 위키 | 저장된 문서와 빌드된 강의 source | `electron/storage/`, `src/wiki/` | CRUD 또는 읽기 전용 정책과 충돌 검증 | +| Workspace | 사용자가 시스템 dialog에서 승인한 canonical root | `electron/workspace/`, `src/services/workspace-service.js` | 실제 임시 폴더 왕복과 root 이탈 차단 | +| 편집기 | 디스크 파일 content와 revision | `src/practice/CodeWorkspace.jsx`, Workspace IPC | 외부 변경 충돌 시 원본 보존 | +| 실습 환경 | 실제 JDK·Gradle·템플릿 파일 | `electron/environment/`, `electron/workspace/workspace-template.ts` | 빈 폴더 생성, 실행, 실패 rollback | +| 터미널 | Main이 소유한 실제 PTY | `electron/terminal/` | 승인, 출력, 종료 코드, 취소와 자식 정리 | +| 진도 | 해당 단계 검증 명령의 성공 결과 | course practice 계약과 app repository | 화면 이동만으로 증가하지 않음 | +| Provider | 사용자가 저장한 redacted metadata와 Main의 Secret | `electron/provider/` | 연결 성공·실패·취소, Renderer key 비노출 | +| AI | 사용자가 승인한 context manifest와 Provider stream | `electron/assistant/`, `src/services/assistant-service.js` | 미리보기, revision 재검증, 스트리밍과 취소 | + +## 5. 실제 데이터와 저장 정책 + +### 앱 내부 데이터 + +Electron `app.getPath("userData")/study-builder/` 아래 versioned JSON 저장소를 사용한다. 저장 계층은 임시 파일 기록, flush, backup rotation과 atomic rename을 담당한다. 손상된 데이터는 덮어쓰지 않고 별도 파일로 보존한 뒤 유효한 backup 또는 seed로 복구한다. + +Schema를 바꿀 때는 다음을 함께 구현한다. + +1. 이전 버전 fixture +2. 명시적 migration +3. migration 실패 시 원본 보존 +4. 재시작 복원 테스트 +5. downgrade 또는 미지원 version 처리 + +### Workspace 데이터 + +Workspace 파일은 앱의 `userData`로 복사하지 않는다. 사용자가 선택한 원본 폴더에서만 작업한다. + +- 모든 Renderer 경로는 상대 경로다. +- Main에서 canonical root, parent, symlink와 traversal을 다시 검증한다. +- 텍스트·크기·민감 파일 정책을 적용한다. +- 쓰기와 이름 변경은 revision 충돌을 확인한다. +- 삭제는 확인 후 OS 휴지통을 우선한다. +- 최근 Workspace에는 복원에 필요한 최소 정보만 저장하고 Renderer에 절대 경로를 불필요하게 노출하지 않는다. + +### Provider Secret + +- API Key는 Main에서만 사용한다. +- macOS `safeStorage`가 사용 가능할 때만 암호화해 영구 저장한다. +- 암호화할 수 없으면 평문 저장으로 fallback하지 않는다. +- 사용자가 명시적으로 승인한 경우에만 process memory의 session-only key를 사용한다. +- Provider 목록 응답에는 key 원문 대신 `hasKey` 같은 등록 여부만 포함한다. + +## 6. 기능을 구현하는 방법 + +### 6.1 실제 흐름을 먼저 추적한다 + +UI를 수정하기 전에 다음 왕복 경로를 끝까지 확인한다. + +```text +사용자 이벤트 +→ React component/hook +→ Renderer service +→ window.studyBuilder +→ Preload method +→ IPC contract와 Main validator +→ domain service/repository +→ 실제 파일·프로세스·Provider +→ 정규화된 성공 또는 오류 +→ 사용자에게 다음 행동을 설명하는 UI +``` + +같은 문제를 여러 컴포넌트에서 막지 말고 모든 호출이 지나는 가장 좁은 공통 경계에서 원인을 수정한다. + +### 6.2 정상·실패·복원을 한 작업으로 다룬다 + +새 기능에는 최소한 다음 상태가 필요하다. + +- 시작 전 또는 데이터 없음 +- 진행 중 +- 실제 성공 +- 취소 +- 사용자가 해결할 수 있는 실패 +- 외부 변경 또는 revision 충돌 +- 앱 종료·재실행 후 복원 +- Workspace나 외부 의존성이 사라진 상태 + +성공 UI부터 만들고 실패와 복원을 나중으로 미루지 않는다. + +### 6.3 UI는 실제 서비스 결과를 렌더링한다 + +React 컴포넌트는 파일 시스템, fetch, child process나 저장 형식을 알지 않는다. 서비스에서 반환된 typed result와 normalized error만 사용한다. 임시 UI 상태는 허용하지만 디스크 저장 성공, Provider 연결, 테스트 완료 같은 사실을 대신하면 안 된다. + +### 6.4 테스트 mock은 경계 안에서만 사용한다 + +실제 API Key나 인터넷 연결은 자동 테스트에 넣지 않는다. 대신 local fake SSE server, 임시 userData와 임시 Workspace를 사용해 실제 production Adapter와 IPC 경계를 통과시킨다. + +Mock 사용이 허용되는 조건은 다음과 같다. + +- 테스트 파일 또는 명시적인 dependency injection에만 존재한다. +- production 기본값으로 선택되지 않는다. +- 사용자가 보는 성공을 미리 결정하지 않는다. +- 실제 parser, timeout, cancellation과 error normalization을 검증한다. + +## 7. 변경 시작 체크리스트 + +```bash +git status --short +git branch --show-current +git log -8 --oneline --decorate +``` + +다음 문서를 순서대로 읽는다. + +1. `AGENTS.md` +2. `docs/production-development-contract.md` +3. `prototype/AGENTS.md` +4. `README.md` +5. `design.md` +6. 수정 영역의 source와 관련 테스트 + +미커밋 파일은 소유자가 명확하지 않으면 수정하거나 stage하지 않는다. ZIP이나 외부 patch는 임시 디렉터리에 풀어 diff를 확인하고 필요한 파일만 반영한다. + +## 8. 검증 기준 + +### 작은 변경 + +```bash +cd prototype +npm run typecheck +npx vitest run path/to/related.test.ts +npx playwright test path/to/related.spec.ts --workers=1 --retries=0 +git diff --check +``` + +### 제품 단위 변경 + +```bash +cd prototype +npm run build:templates +npm run build:course +npm run typecheck +npm test +npm run test:sites +npm run build:desktop +npm run test:e2e -- --workers=1 --retries=0 +``` + +### 릴리스 경계 변경 + +```bash +cd prototype +npm run package:dir +npm run package:mac +npm run inspect:package +npm run test:packaged +``` + +privileged 기능의 완료 증거는 실제 Electron 또는 packaged app에서 확보한다. 브라우저 DOM assertion, Unit Test 또는 service 직접 호출만으로 Renderer → Preload → Main 왕복을 검증했다고 표현하지 않는다. + +## 9. 기능별 완료 조건 + +기능은 아래 질문에 모두 답할 수 있을 때 완료다. + +- 데이터가 어디에서 왔는가? +- 앱을 재실행해도 필요한 상태가 복원되는가? +- 실제 실패를 성공처럼 숨기지 않는가? +- 사용자의 파일과 기존 데이터가 실패 후에도 보존되는가? +- Renderer가 권한·Secret을 직접 얻지 않는가? +- 입력 크기, 경로, revision, timeout과 취소를 검증하는가? +- 외부 파일 변경, 네트워크 실패와 프로세스 종료를 처리하는가? +- 실제 Electron 화면에서 버튼을 눌러 확인했는가? +- 관련 Unit·Integration·E2E와 production build가 통과했는가? +- 패키징 경로가 달라져도 필요한 resource를 찾는가? +- 구현한 범위와 검증하지 못한 범위를 문서에 정확히 기록했는가? + +하나라도 답할 수 없다면 기능은 “완료”가 아니라 “부분 구현” 또는 “미검증”이다. + +## 10. 새 AI 에이전트의 보고 형식 + +작업 완료 보고에는 다음을 포함한다. + +1. 사용자가 겪은 증상과 근본 원인 +2. 실제 데이터·파일·프로세스 흐름에서 수정한 경계 +3. 주요 변경 파일 +4. 실행한 검증 명령과 정확한 통과·실패 수 +5. 실제 Electron 캡처 또는 패키지 검증 위치 +6. 남은 미완료 기능, 외부 의존성과 기술 부채 +7. 생성한 커밋 hash 또는 커밋하지 않은 이유 + +“동작합니다”, “상용화되었습니다”, “안전합니다” 같은 결론은 위 근거가 있을 때만 사용한다. + +## 11. 현재 알려진 제품 경계 + +- 공개 macOS 배포에는 Developer ID 서명과 notarization이 아직 필요하다. +- 자동 업데이트 서버는 아직 없다. +- 앱 내부 Java 지원은 Monaco 기반이며 classpath-aware JDT Language Server는 아직 없다. +- 브라우저 fallback은 디자인·읽기 검토용이며 Electron 기능을 대체하지 않는다. +- course source와 manifest를 수정한 뒤에는 생성물을 직접 고치지 말고 `build:course`와 `build:templates`를 실행한다. + +이 제한을 우회하는 가짜 구현을 추가하지 않는다. 필요한 범위를 실제로 구현하거나, 미완료 상태와 다음 작업을 정확히 남긴다. + +## 관련 문서 + +- [저장소 에이전트 지침](../AGENTS.md) +- [실행·아키텍처·보안](../README.md) +- [Renderer 제품 결정](../prototype/AGENTS.md) +- [개발 계획과 검증 기록](development-plan.md) +- [개인정보 처리 기준](privacy.md) +- [릴리스 운영 가이드](release.md) +- [강의 자료 제작 방법](../prototype/docs/course-authoring.md) diff --git a/prototype/AGENTS.md b/prototype/AGENTS.md index 9201ceb..070230a 100644 --- a/prototype/AGENTS.md +++ b/prototype/AGENTS.md @@ -1,4 +1,23 @@ -# Prototype Instructions +# Study Builder Product Instructions + +## Production product rule + +`prototype/` is a legacy directory name. Work in this directory targets the real +Study Builder Electron product, not a disposable UI prototype. Read +[`../docs/production-development-contract.md`](../docs/production-development-contract.md) +before changing runtime behavior. + +- Do not hardcode user books, progress, Workspace files, command results, provider + responses, or AI answers into Renderer components. +- Browser-only memory data is a review fallback, not a production implementation or + completion proof. Do not extend it for privileged features. +- File, persistence, PTY, environment, Provider, and assistant behavior must use the + real Preload/Main boundary and include failure, cancellation, cleanup, and restart + verification. +- Test fixtures and mock providers must remain test-only. They must exercise the real + production adapter and IPC path instead of becoming a runtime fallback. +- If a production dependency is missing, show an actionable unavailable/error state. + Never fake success to keep the flow moving. Run the local server yourself and open the preview in the browser available to this environment. Do not give the user server-start instructions when you can run it. @@ -6,11 +25,11 @@ Before making substantial visual changes, use the Product Design plugin's `get-c When implementing from a selected generated mock, treat that image as the source of truth for layout, component anatomy, density, spacing, color, typography, visible content, and hierarchy. -Build app UI in `src/`. Keep `.openai/hosting.json`, `worker/index.js`, `scripts/prepare-sites-build.mjs`, and `tests/sites-worker.test.mjs` intact so the same local prototype can be handed to Sites. Before a Sites handoff, run `npm run build` and `npm run test:sites`; the build must leave `dist/client/index.html`, `dist/server/index.js`, and `dist/.openai/hosting.json`. +Build app UI in `src/`. Keep `.openai/hosting.json`, `worker/index.js`, `scripts/prepare-sites-build.mjs`, and `tests/sites-worker.test.mjs` intact so the same product UI can be handed to Sites for review. Before a Sites handoff, run `npm run build` and `npm run test:sites`; the build must leave `dist/client/index.html`, `dist/server/index.js`, and `dist/.openai/hosting.json`. ## Durable Product Decisions -- Treat the local React prototype as the primary review artifact; do not make Figma the only interactive deliverable. +- Treat the local Electron-backed React application as the primary review artifact; do not make Figma the only interactive deliverable. - The home screen is a personal learning-book library, not a course marketplace or progress dashboard. - Model books as either `위키형 학습서` or `실습형 학습서`. Every book uses the same shared wiki renderer, header, table of contents, and reading canvas. Only practice-enabled books expose the Wiki/Learning workspace switch or open Learning Mode; economics, interest-rate, writing, and similar reading-first books hide those controls rather than using a separate legacy View. - In Wiki edit mode, paragraph blocks must support Notion-like mouse reordering from the block handle while preserving direct text editing and the existing block menu. diff --git a/prototype/electron/storage/app-repository.ts b/prototype/electron/storage/app-repository.ts index adb4e6b..486255b 100644 --- a/prototype/electron/storage/app-repository.ts +++ b/prototype/electron/storage/app-repository.ts @@ -353,7 +353,7 @@ function externalDocument( ...noteBlocks, ], kind: metadata === undefined ? "user" : "course", - readOnly: true, + readOnly: metadata !== undefined, parentId: metadata?.parentId ?? null, order: metadata?.order ?? Number.MAX_SAFE_INTEGER, chapterLabel: metadata?.chapterLabel ?? null, @@ -497,7 +497,7 @@ export class AppRepository { bookId, title, kind: metadata === undefined ? "user" as const : "course" as const, - readOnly: true, + readOnly: metadata !== undefined, parentId: metadata?.parentId ?? null, order: metadata?.order ?? Number.MAX_SAFE_INTEGER, chapterLabel: metadata?.chapterLabel ?? null, @@ -551,6 +551,9 @@ export class AppRepository { updateWikiDocument(input: WikiWrite): Promise { return this.write(input.expectedRevision, (current) => { this.findDocument(current, input.documentId); + if (SPRING_COURSE_DOCUMENT_IDS.has(input.documentId)) { + throw new IpcContractError("PERMISSION_DENIED"); + } return { ...current, wikiDocuments: current.wikiDocuments.map((document) => diff --git a/prototype/resources/template-sources/spring-boot-rest/docs/LEARNING.md b/prototype/resources/template-sources/spring-boot-rest/docs/LEARNING.md index f7ef056..f1c026f 100644 --- a/prototype/resources/template-sources/spring-boot-rest/docs/LEARNING.md +++ b/prototype/resources/template-sources/spring-boot-rest/docs/LEARNING.md @@ -9,18 +9,19 @@ - `src/main/java/com/ducami/studymate/StudymateApplication.java`: 애플리케이션 시작점입니다. - `src/main/resources/application.yaml`: 실행 환경의 기본 설정입니다. - `src/test/java/com/ducami/studymate/StudymateApplicationTests.java`: Spring Context 시작 여부를 확인합니다. +- `src/test/java/com/ducami/studymate/practice/PracticeVerificationTests.java`: 각 Chapter의 계약을 확인하는 단계별 검증 테스트입니다. ## 실행 명령 ```bash -./gradlew test +./gradlew test --tests '*StudymateApplicationTests' ./gradlew bootRun ``` Windows PowerShell 또는 명령 프롬프트에서는 다음 명령을 사용합니다. ```powershell -.\gradlew.bat test +.\gradlew.bat test --tests "*StudymateApplicationTests" .\gradlew.bat bootRun ``` @@ -28,4 +29,7 @@ Windows PowerShell 또는 명령 프롬프트에서는 다음 명령을 사용 API의 요청과 응답을 먼저 정한 뒤 Entity, Repository, DTO, Service, Controller를 필요한 순서로 작성합니다. 현재 Chapter의 테스트가 통과하기 전에는 다음 Chapter의 완성 코드를 먼저 복사하지 않습니다. +각 실습 명령은 현재 단계의 검증 클래스만 실행합니다. 시작 프로젝트에서 아직 만들지 않은 +클래스를 참조하는 단계는 의도적으로 실패하므로, 실패 로그를 구현할 계약의 목록으로 사용합니다. + 장문 설명은 Study Builder 위키의 `spring-chapter-00`부터 `spring-chapter-06` 문서에서 확인합니다. diff --git a/prototype/resources/template-sources/spring-boot-rest/src/test/java/com/ducami/studymate/practice/PracticeVerificationTests.java b/prototype/resources/template-sources/spring-boot-rest/src/test/java/com/ducami/studymate/practice/PracticeVerificationTests.java new file mode 100644 index 0000000..1cc4eac --- /dev/null +++ b/prototype/resources/template-sources/spring-boot-rest/src/test/java/com/ducami/studymate/practice/PracticeVerificationTests.java @@ -0,0 +1,789 @@ +package com.ducami.studymate.practice; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManagerFactory; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +final class PracticeContracts { + + private PracticeContracts() { + } + + static String source(String relativePath) { + Path path = Path.of(relativePath); + try { + assertTrue(Files.isRegularFile(path), "필수 구현 파일이 없습니다: " + relativePath); + return Files.readString(path); + } catch (IOException exception) { + fail("구현 파일을 읽지 못했습니다: " + relativePath, exception); + return ""; + } + } + + static void contains(String relativePath, String... fragments) { + String source = source(relativePath); + for (String fragment : fragments) { + assertTrue(source.contains(fragment), + relativePath + "에 다음 계약이 없습니다: " + fragment); + } + } + + static void any(String relativePath, String... fragments) { + String source = source(relativePath); + for (String fragment : fragments) { + if (source.contains(fragment)) { + return; + } + } + fail(relativePath + "에 필요한 구현 중 하나가 없습니다: " + String.join(", ", fragments)); + } + + static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + static String accessToken(ObjectMapper objectMapper, MvcResult result) throws Exception { + JsonNode token = objectMapper.readTree(result.getResponse().getContentAsString()) + .path("data") + .path("accessToken"); + assertFalse(token.isMissingNode() || token.asText().isBlank()); + return token.asText(); + } + + static long dataId(ObjectMapper objectMapper, MvcResult result) throws Exception { + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()) + .path("data"); + assertTrue(data.isNumber()); + return data.longValue(); + } + + static String login(MockMvc mockMvc, ObjectMapper objectMapper, String email) throws Exception { + MvcResult result = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"%s","password":"password123"} + """.formatted(email))) + .andExpect(status().isOk()) + .andReturn(); + return accessToken(objectMapper, result); + } + + static String signupAndLogin(MockMvc mockMvc, ObjectMapper objectMapper, String email) + throws Exception { + mockMvc.perform(post("/api/v1/users/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"practice-user","email":"%s","password":"password123"} + """.formatted(email))) + .andExpect(status().isCreated()); + return login(mockMvc, objectMapper, email); + } + + record StudySeed(long id, String token) { + } + + static StudySeed createStudy(MockMvc mockMvc, ObjectMapper objectMapper, String email) + throws Exception { + var request = post("/api/v1/studies") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"practice study","content":"practice content"} + """); + MvcResult result = mockMvc.perform(request).andReturn(); + if (result.getResponse().getStatus() == 401) { + String token = signupAndLogin(mockMvc, objectMapper, email); + result = mockMvc.perform(post("/api/v1/studies") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"practice study","content":"practice content"} + """)) + .andExpect(status().isCreated()) + .andReturn(); + return new StudySeed(dataId(objectMapper, result), token); + } + assertEquals(201, result.getResponse().getStatus()); + return new StudySeed(dataId(objectMapper, result), ""); + } + + static Object responseBody(Object response) throws Exception { + assertTrue(response instanceof ResponseEntity); + Object body = ((ResponseEntity) response).getBody(); + assertNotNull(body); + return body; + } +} + +@SpringBootTest +class PersistenceSmokeTest { + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void studyEntityAndRepositoryAreManagedByTheRunningApplication() throws Exception { + Class entityClass = Class.forName( + "com.ducami.studymate.domain.study.entity.StudyEntity"); + var entityType = entityManagerFactory.getMetamodel().entity(entityClass); + assertTrue(entityType.getAttributes().stream() + .anyMatch(attribute -> attribute.getName().equals("id"))); + assertTrue(entityType.getAttributes().stream() + .anyMatch(attribute -> attribute.getName().equals("title"))); + assertTrue(entityType.getAttributes().stream() + .anyMatch(attribute -> attribute.getName().equals("content"))); + + Integer rowCount = jdbcTemplate.queryForObject( + "select count(*) from tb_studies", Integer.class); + assertTrue(rowCount != null && rowCount >= 0); + + Class repositoryClass = Class.forName( + "com.ducami.studymate.domain.study.repository.StudyRepository"); + assertTrue(Arrays.stream(repositoryClass.getInterfaces()) + .anyMatch(parent -> parent.getSimpleName().equals("JpaRepository"))); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/entity/StudyEntity.java", + "@Entity", "@Table", "tb_studies", "@Id", "GenerationType.IDENTITY", + "title", "content"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/repository/StudyRepository.java", + "JpaRepository", "StudyEntity"); + } +} + +@SpringBootTest +@AutoConfigureMockMvc +class StudyQueryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void studyQueryReturnsDtosAndNotFoundForMissingStudy() throws Exception { + mockMvc.perform(get("/api/v1/studies")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + mockMvc.perform(get("/api/v1/studies/999999999")) + .andExpect(status().isNotFound()); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/controller/StudyController.java", + "@RestController", "@GetMapping", "StudyService", "StudyResponse"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/service/StudyService.java", + "findAll", "findById", "StudyRepository"); + PracticeContracts.any( + "src/main/java/com/ducami/studymate/domain/study/dto/response/StudyResponse.java", + "record", "class"); + } +} + +@SpringBootTest +@AutoConfigureMockMvc +class StudyCreateValidationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void studyCreationRejectsBlankTitleAndReturnsCreatedId() throws Exception { + MvcResult unauthenticated = mockMvc.perform(post("/api/v1/studies") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":" ","content":"내용"} + """)) + .andReturn(); + String token = ""; + if (unauthenticated.getResponse().getStatus() == 401) { + mockMvc.perform(post("/api/v1/users/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"study-owner","email":"study-owner@example.com","password":"password123"} + """)) + .andExpect(status().isCreated()); + token = PracticeContracts.login(mockMvc, objectMapper, "study-owner@example.com"); + } else { + assertEquals(400, unauthenticated.getResponse().getStatus()); + } + var blankRequest = post("/api/v1/studies") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":" ","content":"내용"} + """); + if (!token.isBlank()) blankRequest.header("Authorization", "Bearer " + token); + mockMvc.perform(blankRequest).andExpect(status().isBadRequest()); + var validRequest = post("/api/v1/studies") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"첫 스터디","content":"내용"} + """); + if (!token.isBlank()) validRequest.header("Authorization", "Bearer " + token); + mockMvc.perform(validRequest) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data").isNumber()); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/dto/request/CreateStudyRequest.java", + "@NotBlank", "title"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/controller/StudyController.java", + "@PostMapping", "@Valid", "CreateStudyRequest"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/service/StudyService.java", + "save", "StudyEntity"); + } +} + +@SpringBootTest +@AutoConfigureMockMvc +class StudyUpdateControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void studyUpdateChangesOnlyTheProvidedFields() throws Exception { + PracticeContracts.StudySeed study = PracticeContracts.createStudy( + mockMvc, objectMapper, "study-update@example.com"); + var request = put("/api/v1/studies/{id}", study.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"content":"updated content"} + """); + if (!study.token().isBlank()) { + request.header("Authorization", "Bearer " + study.token()); + } + mockMvc.perform(request).andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/studies/{id}", study.id())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("practice study")) + .andExpect(jsonPath("$.data.content").value("updated content")); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/dto/request/UpdateStudyRequest.java", + "title", "content"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/service/StudyService.java", + "@Transactional", "update", "StudyEntity"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/controller/StudyController.java", + "@PutMapping", "UpdateStudyRequest"); + } +} + +@SpringBootTest +@AutoConfigureMockMvc +class StudyDeleteTransactionTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void studyDeleteRemovesAnExistingStudyAndRejectsTheNextLookup() throws Exception { + PracticeContracts.StudySeed study = PracticeContracts.createStudy( + mockMvc, objectMapper, "study-delete@example.com"); + var request = org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .delete("/api/v1/studies/{id}", study.id()); + if (!study.token().isBlank()) { + request.header("Authorization", "Bearer " + study.token()); + } + mockMvc.perform(request).andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/studies/{id}", study.id())) + .andExpect(status().isNotFound()); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/service/StudyService.java", + "@Transactional", "delete", "studyRepository.delete"); + PracticeContracts.any( + "src/main/java/com/ducami/studymate/domain/study/service/StudyService.java", + "StudyNotFoundException", "orElseThrow"); + } +} + +@SpringBootTest +class TodoRelationshipTest { + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Test + void todoEntityIsManagedWithStudyAndStringStatusAttributes() throws Exception { + Class entityClass = Class.forName( + "com.ducami.studymate.domain.todo.entity.TodoEntity"); + var entityType = entityManagerFactory.getMetamodel().entity(entityClass); + assertTrue(entityType.getAttributes().stream() + .anyMatch(attribute -> attribute.getName().equals("study"))); + assertTrue(entityType.getAttributes().stream() + .anyMatch(attribute -> attribute.getName().equals("status"))); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/todo/entity/TodoEntity.java", + "@Entity", "tb_todos", "@ManyToOne", "FetchType.LAZY", "@JoinColumn", + "@Enumerated", "EnumType.STRING", "PENDING"); + } +} + +@SpringBootTest +@AutoConfigureMockMvc +class TodoQueryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void todoQueryReturnsAStudyScopedList() throws Exception { + MvcResult firstCreate = mockMvc.perform(post("/api/v1/studies") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"todo study","content":"content"} + """)) + .andReturn(); + String token = ""; + long studyId; + if (firstCreate.getResponse().getStatus() == 401) { + mockMvc.perform(post("/api/v1/users/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"todo-owner","email":"todo-owner@example.com","password":"password123"} + """)) + .andExpect(status().isCreated()); + token = PracticeContracts.login(mockMvc, objectMapper, "todo-owner@example.com"); + var createRequest = post("/api/v1/studies") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"todo study","content":"content"} + """); + studyId = PracticeContracts.dataId(objectMapper, + mockMvc.perform(createRequest).andExpect(status().isCreated()).andReturn()); + } else { + assertEquals(201, firstCreate.getResponse().getStatus()); + studyId = PracticeContracts.dataId(objectMapper, firstCreate); + } + mockMvc.perform(get("/api/v1/studies/{studyId}/todos", studyId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/todo/controller/TodoController.java", + "@GetMapping", "studyId", "TodoService"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/todo/repository/TodoRepository.java", + "findAllByStudyIdOrderByIdAsc", "findByIdAndStudyId"); + } +} + +@SpringBootTest +@AutoConfigureMockMvc +class TodoMutationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void todoMutationUpdatesContentStatusAndDeleteSeparately() throws Exception { + PracticeContracts.StudySeed study = PracticeContracts.createStudy( + mockMvc, objectMapper, "todo-mutation@example.com"); + String token = study.token(); + var createRequest = post("/api/v1/studies/{studyId}/todos", study.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"content":"first content"} + """); + if (!token.isBlank()) { + createRequest.header("Authorization", "Bearer " + token); + } + MvcResult created = mockMvc.perform(createRequest).andReturn(); + if (created.getResponse().getStatus() == 401) { + token = PracticeContracts.signupAndLogin( + mockMvc, objectMapper, "todo-mutation-author@example.com"); + created = mockMvc.perform(post("/api/v1/studies/{studyId}/todos", study.id()) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"content":"first content"} + """)) + .andExpect(status().isCreated()) + .andReturn(); + } + assertEquals(201, created.getResponse().getStatus()); + long todoId = PracticeContracts.dataId(objectMapper, created); + + var updateRequest = put("/api/v1/studies/{studyId}/todos/{todoId}", study.id(), todoId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"content":"updated content"} + """); + if (!token.isBlank()) updateRequest.header("Authorization", "Bearer " + token); + mockMvc.perform(updateRequest).andExpect(status().isOk()); + + var statusRequest = patch("/api/v1/studies/{studyId}/todos/{todoId}/status", study.id(), todoId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"status":"COMPLETED"} + """); + if (!token.isBlank()) statusRequest.header("Authorization", "Bearer " + token); + mockMvc.perform(statusRequest).andExpect(status().isOk()); + mockMvc.perform(get("/api/v1/studies/{studyId}/todos/{todoId}", study.id(), todoId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").value("updated content")) + .andExpect(jsonPath("$.data.status").value("COMPLETED")); + + var deleteRequest = delete("/api/v1/studies/{studyId}/todos/{todoId}", study.id(), todoId); + if (!token.isBlank()) deleteRequest.header("Authorization", "Bearer " + token); + mockMvc.perform(deleteRequest).andExpect(status().isOk()); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/todo/service/TodoService.java", + "update", "status", "delete", "findByIdAndStudyId"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/todo/controller/TodoController.java", + "@PutMapping", "@PatchMapping", "@DeleteMapping"); + } +} + +class DomainExceptionTest { + @Test + void domainExceptionsCarryTypedStatusInformation() throws Exception { + Class statusType = Class.forName( + "com.ducami.studymate.domain.study.exception.StudyStatusCode"); + Object notFoundStatus = Arrays.stream(statusType.getEnumConstants()) + .filter(value -> ((Enum) value).name().equals("STUDY_NOT_FOUND")) + .findFirst() + .orElseThrow(); + Class applicationException = Class.forName( + "com.ducami.studymate.global.exception.ApplicationException"); + Class notFoundException = Class.forName( + "com.ducami.studymate.domain.study.exception.StudyNotFoundException"); + Object exception = notFoundException.getDeclaredConstructor().newInstance(); + Method getStatusCode = applicationException.getMethod("getStatusCode"); + assertTrue(applicationException.isAssignableFrom(notFoundException)); + assertEquals(notFoundStatus, getStatusCode.invoke(exception)); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/global/exception/ApplicationException.java", + "StatusCode", "statusCode"); + PracticeContracts.any( + "src/main/java/com/ducami/studymate/domain/study/exception/StudyNotFoundException.java", + "class", "record"); + PracticeContracts.any( + "src/main/java/com/ducami/studymate/domain/study/exception/StudyStatusCode.java", + "STUDY_NOT_FOUND", "enum"); + } +} + +@SpringBootTest +class GlobalExceptionHandlerTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void globalHandlerIsRegisteredForDomainAndValidationErrors() throws Exception { + Class handlerClass = Class.forName( + "com.ducami.studymate.global.exception.handler.GlobalExceptionHandler"); + Object handler = applicationContext.getBean(handlerClass); + assertTrue(handlerClass.isInstance(handler)); + assertTrue(handlerClass.isAnnotationPresent(RestControllerAdvice.class)); + assertTrue(Arrays.stream(handlerClass.getDeclaredMethods()) + .anyMatch(method -> method.isAnnotationPresent(ExceptionHandler.class))); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/global/exception/handler/GlobalExceptionHandler.java", + "@RestControllerAdvice", "@ExceptionHandler", "MethodArgumentNotValidException"); + PracticeContracts.any( + "src/main/java/com/ducami/studymate/global/exception/handler/GlobalExceptionHandler.java", + "StudyNotFoundException", "ApplicationException"); + } +} + +class ApiResponseContractTest { + @Test + void createdFactoryKeepsHttpStatusAndBodyStatusInSync() throws Exception { + Class responseType = Class.forName( + "com.ducami.studymate.global.data.ApiResponse"); + Method created = responseType.getMethod("created", String.class, Object.class); + Object response = created.invoke(null, "created", 42L); + ResponseEntity entity = (ResponseEntity) response; + assertEquals(201, entity.getStatusCode().value()); + Object body = PracticeContracts.responseBody(response); + Method status = body.getClass().getMethod("status"); + Method data = body.getClass().getMethod("data"); + assertEquals(201, status.invoke(body)); + assertEquals(42L, data.invoke(body)); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/global/data/ApiResponse.java", + "record", "created", "data"); + PracticeContracts.any( + "src/main/java/com/ducami/studymate/global/data/ApiResponse.java", + "message", "status", "code"); + } +} + +@SpringBootTest +@AutoConfigureMockMvc +class SignupControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void signupReturnsCreatedUserData() throws Exception { + mockMvc.perform(post("/api/v1/users/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"tester","email":"tester@example.com","password":"password123"} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.name").value("tester")) + .andExpect(jsonPath("$.data.email").value("tester@example.com")) + .andExpect(jsonPath("$.data.role").value("USER")); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/user/controller/UserController.java", + "@PostMapping", "UserService"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/user/service/UserService.java", + "PasswordEncoder", "save"); + } +} + +@SpringBootTest +@AutoConfigureMockMvc +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void validCredentialsReturnATokenAndInvalidCredentialsAreRejected() throws Exception { + mockMvc.perform(post("/api/v1/users/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"tester","email":"auth@example.com","password":"password123"} + """)) + .andExpect(status().isCreated()); + PracticeContracts.accessToken(new ObjectMapper(), mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"auth@example.com","password":"password123"} + """)) + .andExpect(status().isOk()) + .andReturn()); + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"auth@example.com","password":"wrong-password"} + """)) + .andExpect(status().isUnauthorized()); + } + + @Test + void loginDelegatesCredentialVerificationToAuthService() { + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/auth/controller/AuthController.java", + "@PostMapping", "AuthService"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/auth/service/AuthService.java", + "PasswordEncoder", "JwtProvider"); + } +} + +@SpringBootTest +class JwtProviderTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void jwtProviderRoundTripsUserIdWithoutPasswordClaims() throws Exception { + Class providerClass = Class.forName( + "com.ducami.studymate.global.security.jwt.JwtProvider"); + Object provider = applicationContext.getBean(providerClass); + Class userClass = Class.forName( + "com.ducami.studymate.domain.user.entity.UserEntity"); + var userConstructor = userClass.getDeclaredConstructor(); + userConstructor.setAccessible(true); + Object user = userConstructor.newInstance(); + Class roleClass = Class.forName( + "com.ducami.studymate.domain.user.enums.UserRole"); + Object userRole = Arrays.stream(roleClass.getEnumConstants()) + .filter(value -> ((Enum) value).name().equals("USER")) + .findFirst() + .orElseThrow(); + PracticeContracts.setField(user, "id", 42L); + PracticeContracts.setField(user, "email", "jwt@example.com"); + PracticeContracts.setField(user, "role", userRole); + Object token = providerClass.getMethod("generateToken", userClass).invoke(provider, user); + String accessToken = (String) token.getClass().getMethod("accessToken").invoke(token); + assertFalse(accessToken.isBlank()); + assertEquals(42L, providerClass.getMethod("getUserId", String.class) + .invoke(provider, accessToken)); + Object claims = providerClass.getMethod("getClaims", String.class) + .invoke(provider, accessToken); + assertFalse(claims.toString().contains("password")); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/global/security/jwt/JwtProvider.java", + "generate", "parse", "validate"); + PracticeContracts.any( + "src/main/java/com/ducami/studymate/global/security/jwt/JwtProvider.java", + "SecretKey", "signWith", "Jwts"); + } +} + +@SpringBootTest +@AutoConfigureMockMvc +class SecurityAuthenticationFlowTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void protectedUserInfoRequiresAndAcceptsBearerAuthentication() throws Exception { + mockMvc.perform(get("/api/v1/users/me")) + .andExpect(status().isUnauthorized()); + mockMvc.perform(post("/api/v1/users/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"security-user","email":"security@example.com","password":"password123"} + """)) + .andExpect(status().isCreated()); + String token = PracticeContracts.login(mockMvc, objectMapper, "security@example.com"); + mockMvc.perform(get("/api/v1/users/me") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/global/security/jwt/filter/JwtAuthenticationFilter.java", + "OncePerRequestFilter", "Authorization", "Bearer"); + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/global/security/config/SecurityConfig.java", + "SecurityFilterChain", "addFilterBefore", "JwtAuthenticationFilter"); + } +} + +@SpringBootTest +@AutoConfigureMockMvc +class AuthorizationFlowTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void protectedStudyMutationsDistinguishAuthenticationAndOwnership() throws Exception { + mockMvc.perform(post("/api/v1/users/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"owner","email":"owner@example.com","password":"password123"} + """)) + .andExpect(status().isCreated()); + mockMvc.perform(post("/api/v1/users/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"other","email":"other@example.com","password":"password123"} + """)) + .andExpect(status().isCreated()); + String ownerToken = PracticeContracts.login(mockMvc, objectMapper, "owner@example.com"); + String otherToken = PracticeContracts.login(mockMvc, objectMapper, "other@example.com"); + MvcResult created = mockMvc.perform(post("/api/v1/studies") + .header("Authorization", "Bearer " + ownerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"owner study","content":"content"} + """)) + .andExpect(status().isCreated()) + .andReturn(); + long studyId = PracticeContracts.dataId(objectMapper, created); + mockMvc.perform(put("/api/v1/studies/{id}", studyId) + .header("Authorization", "Bearer " + otherToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"hijack"} + """)) + .andExpect(status().isForbidden()); + mockMvc.perform(put("/api/v1/studies/{id}", studyId) + .header("Authorization", "Bearer " + ownerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"updated"} + """)) + .andExpect(status().isOk()); + mockMvc.perform(put("/api/v1/studies/{id}", studyId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"anonymous"} + """)) + .andExpect(status().isUnauthorized()); + + PracticeContracts.contains( + "src/main/java/com/ducami/studymate/domain/study/controller/StudyController.java", + "AuthenticationPrincipal", "UserPrincipal"); + PracticeContracts.any( + "src/main/java/com/ducami/studymate/domain/study/service/StudyService.java", + "validateOwner", "ownerId", "getOwner"); + PracticeContracts.any( + "src/main/java/com/ducami/studymate/domain/study/exception/StudyStatusCode.java", + "FORBIDDEN", "UNAUTHORIZED"); + } +} diff --git a/prototype/resources/templates/spring-boot-rest.zip b/prototype/resources/templates/spring-boot-rest.zip index cbd5d2e..12e4f9e 100644 Binary files a/prototype/resources/templates/spring-boot-rest.zip and b/prototype/resources/templates/spring-boot-rest.zip differ diff --git a/prototype/scripts/build-course-content.mjs b/prototype/scripts/build-course-content.mjs index b0624bb..e0fc64a 100644 --- a/prototype/scripts/build-course-content.mjs +++ b/prototype/scripts/build-course-content.mjs @@ -10,6 +10,13 @@ const manifest = JSON.parse( const practiceManifest = JSON.parse( await readFile(join(courseRoot, "practice-manifest.json"), "utf8"), ); +const practiceVerifierSource = await readFile( + join( + root, + "resources/template-sources/spring-boot-rest/src/test/java/com/ducami/studymate/practice/PracticeVerificationTests.java", + ), + "utf8", +); const documentIdByPageId = new Map( manifest.documents.map((document) => [document.sourcePageId, document.id]), ); @@ -22,6 +29,7 @@ const practiceIds = new Set(); const commandIds = new Set(); for (const unit of practiceManifest.units) { const commandId = unit.verification?.commandId; + const testClass = unit.verification?.args?.at(-1)?.match(/^\*(\w+)$/u)?.[1]; const stringList = (value) => Array.isArray(value) && value.length >= 2 @@ -40,6 +48,9 @@ for (const unit of practiceManifest.units) { || typeof unit.file !== "string" || unit.file.startsWith("/") || unit.file.split("/").includes("..") + || (unit.verification.kind === "command" + && testClass !== "StudymateApplicationTests" + && !practiceVerifierSource.includes(`class ${testClass}`)) ) { throw new Error(`실습 계약이 올바르지 않습니다: ${unit.unitId ?? "unknown"}`); } diff --git a/prototype/scripts/build-template-archives.mjs b/prototype/scripts/build-template-archives.mjs index 8ecb5ee..242ad8e 100644 --- a/prototype/scripts/build-template-archives.mjs +++ b/prototype/scripts/build-template-archives.mjs @@ -9,6 +9,7 @@ const sourceRoot = join(prototypeRoot, "resources", "template-sources", "spring- const outputPath = join(prototypeRoot, "resources", "templates", "spring-boot-rest.zip"); const DOS_TIME = 0; const DOS_DATE = 33; // 1980-01-01 +const IGNORED_TEMPLATE_DIRECTORIES = new Set([".gradle", "build", "out", "target", "node_modules"]); const crcTable = new Uint32Array(256); for (let index = 0; index < 256; index += 1) { @@ -33,7 +34,9 @@ async function collectFiles(directory) { entries.sort((left, right) => left.name.localeCompare(right.name, "en")); for (const entry of entries) { const absolutePath = join(directory, entry.name); - if (entry.isDirectory()) output.push(...await collectFiles(absolutePath)); + if (entry.isDirectory() && !IGNORED_TEMPLATE_DIRECTORIES.has(entry.name)) { + output.push(...await collectFiles(absolutePath)); + } else if (entry.isFile()) output.push(absolutePath); else throw new Error(`Unsupported template entry: ${absolutePath}`); } diff --git a/prototype/src/StudyBuilderApp.jsx b/prototype/src/StudyBuilderApp.jsx index a25ca32..086cd46 100644 --- a/prototype/src/StudyBuilderApp.jsx +++ b/prototype/src/StudyBuilderApp.jsx @@ -40,6 +40,7 @@ export function StudyBuilderApp() { const [learning, setLearning] = useState(createLearningState); const [tabSession, setTabSession] = useState(createAppTabSession); const [providerSettingsOpen, setProviderSettingsOpen] = useState(false); + const [wikiDocumentMode, setWikiDocumentMode] = useState("read"); const learningRef = useRef(learning); const tabSessionRef = useRef(tabSession); const wikiCleanupRef = useRef(null); @@ -371,16 +372,23 @@ export function StudyBuilderApp() { documentId, { newTab: options.newTab === true }, ); + if (opened && options.newTab !== true) setWikiDocumentMode("read"); return opened ? (target ?? { id: documentId }) : null; }; const createDocument = async (title, blocks) => { const created = await builder.createDocument(title, blocks); if (created) { + setWikiDocumentMode("edit"); replaceTabSession( updateActiveAppTab( tabSessionRef.current, - createTabFor("spring-wiki", created.bookId, created.id, created.title), + createTabFor( + created.bookId === "economics" ? "legacy" : "spring-wiki", + created.bookId, + created.id, + created.title, + ), { recordHistory: false }, ), ); @@ -402,7 +410,20 @@ export function StudyBuilderApp() { return saved; }; - const removeDocument = async () => builder.removeDocument(); + const removeDocument = async () => { + const removed = await builder.removeDocument(); + if (removed) setWikiDocumentMode("read"); + return removed; + }; + + const selectLegacyDocument = async (documentId, options = {}) => { + const target = builder.documents.find((item) => item.id === documentId); + const opened = await open("legacy", "economics", documentId, { + newTab: options.newTab === true, + }); + if (opened && options.newTab !== true) setWikiDocumentMode("read"); + return opened ? (target ?? { id: documentId }) : null; + }; const savePracticeNote = async (note, runSummary) => builder.savePracticeNote(note, runSummary); @@ -460,11 +481,16 @@ export function StudyBuilderApp() { document={builder.document} documents={builder.documents} bookTitle={activeBook?.title} - onSelectDocument={(documentId, options = {}) => - open("legacy", "economics", documentId, { - newTab: options.newTab === true, - }) + documentMode={wikiDocumentMode} + onDocumentModeChange={setWikiDocumentMode} + onDirtyChange={(dirty) => + send({ type: dirty ? "EDIT_WIKI_BUFFER" : "SAVE_WIKI_DOCUMENT" }) } + registerCleanup={registerWikiCleanup} + onSelectDocument={selectLegacyDocument} + onCreateDocument={createDocument} + onSaveDocument={saveDocument} + onRemoveDocument={removeDocument} onHome={() => void open("home")} /> @@ -478,6 +504,8 @@ export function StudyBuilderApp() { bookTitle={activeBook?.title} operation={builder.operation} error={builder.error} + documentMode={wikiDocumentMode} + onDocumentModeChange={setWikiDocumentMode} onDirtyChange={(dirty) => send({ type: dirty ? "EDIT_WIKI_BUFFER" : "SAVE_WIKI_DOCUMENT" }) } diff --git a/prototype/src/wiki/SpringWikiScreen.jsx b/prototype/src/wiki/SpringWikiScreen.jsx index d836e17..f809640 100644 --- a/prototype/src/wiki/SpringWikiScreen.jsx +++ b/prototype/src/wiki/SpringWikiScreen.jsx @@ -983,11 +983,18 @@ export function SpringWikiScreen({ const [assistantStatus, setAssistantStatus] = useState("idle"); const [answer, setAnswer] = useState(""); const requestIdRef = useRef(null); - const editMode = documentMode === "edit"; + const markdownInputRef = useRef(null); + const canAuthor = + document?.readOnly === false && + typeof onCreateDocument === "function" && + typeof onSaveDocument === "function" && + typeof onRemoveDocument === "function"; + const activeDocumentMode = + canAuthor ? documentMode : "read"; const hasPractice = documents.some( (candidate) => (candidate.practiceUnitIds?.length ?? 0) > 0, ); - const sourceCount = (editMode ? blocks : document?.blocks ?? []) + const sourceCount = (activeDocumentMode === "edit" ? blocks : document?.blocks ?? []) .filter((block) => block.type === "source").length; useEffect(() => { @@ -1132,13 +1139,14 @@ export function SpringWikiScreen({ }; const changeEditMode = (next) => { - if (!next && !confirmDiscard()) return; - if (!next) { + const edit = next === "edit"; + if (!edit && !confirmDiscard()) return; + if (!edit) { setTitle(document?.title ?? ""); setBlocks(document?.blocks ?? []); setDirty(false); } - onDocumentModeChange(next ? "edit" : "read"); + onDocumentModeChange(edit ? "edit" : "read"); }; const changeTitle = (value) => { @@ -1153,6 +1161,8 @@ export function SpringWikiScreen({ setSaveState("idle"); }; + const openMarkdownPicker = () => markdownInputRef.current?.click(); + const openSources = () => { globalThis.document.getElementById("wiki-sources")?.scrollIntoView({ behavior: "smooth", @@ -1233,7 +1243,35 @@ export function SpringWikiScreen({ onOpenSources={openSources} onOpenQuestions={openQuestion} onHome={onHome} - /> + documentMode={ + canAuthor ? activeDocumentMode : null + } + onDocumentModeChange={changeEditMode} + > + {canAuthor ? ( + <> + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void importMarkdown(file); + }} + /> + + + + ) : null} +
표시할 학습 문서가 없습니다.

학습서 콘텐츠가 설치되어 있는지 확인해 주세요.

+ ) : activeDocumentMode === "edit" && document.readOnly === false ? ( + void save()} + onDelete={() => void remove()} + /> ) : ( { - const current = await window.studyBuilder?.wiki.get({ documentId: "spring-chapter-00" }); - if (!current?.ok) return current; - return window.studyBuilder?.wiki.update({ - documentId: current.value.document.id, - expectedRevision: current.value.revision, + const course = await window.studyBuilder?.wiki.get({ documentId: "spring-chapter-00" }); + if (!course?.ok) return course; + const forbidden = await window.studyBuilder?.wiki.update({ + documentId: course.value.document.id, + expectedRevision: course.value.revision, + title: "강의 원문을 덮어쓰면 안 됩니다", + blocks: course.value.document.blocks, + }); + const created = await window.studyBuilder?.wiki.create({ + bookId: "spring", title: "Controller CRUD 검증", - blocks: current.value.document.blocks.map((block, index) => index === 0 - ? { ...block, content: "재시작 뒤에도 남는 본문" } - : block), + blocks: [{ id: "persisted_block", type: "paragraph", content: "재시작 뒤에도 남는 본문" }], + }); + if (!created?.ok) return created; + const personal = await window.studyBuilder?.wiki.update({ + documentId: created.value.document.id, + expectedRevision: created.value.revision, + title: created.value.document.title, + blocks: created.value.document.blocks, + }); + if (!personal?.ok) return personal; + const state = await window.studyBuilder?.appState.get(); + if (!state?.ok) return state; + await window.studyBuilder?.appState.update({ + expectedRevision: state.value.revision, + patch: { screen: "spring-wiki", mode: "wiki", page: personal.value.document.id }, }); + return { forbidden: forbidden?.ok ? null : forbidden?.error.code }; }); - expect(updated?.ok).toBe(true); + expect(updated).toEqual({ forbidden: "PERMISSION_DENIED" }); } finally { await app.close(); } @@ -177,8 +195,13 @@ test("persists wiki CRUD and rejects stale writes across restart", async ({}, te confirmed: true, }); const listedAfter = await window.studyBuilder?.wiki.list({ bookId: "spring" }); + const listed = await window.studyBuilder?.wiki.list({ bookId: "spring" }); + const currentItem = listed?.ok + ? listed.value.find((item) => item.title === "Controller CRUD 검증") + : undefined; + if (currentItem === undefined) return { error: "personal-document-not-found" }; const current = await window.studyBuilder?.wiki.get({ - documentId: "spring-chapter-00", + documentId: currentItem.id, }); if (!current?.ok) return current; const failed = await window.studyBuilder?.wiki.update({ @@ -188,7 +211,7 @@ test("persists wiki CRUD and rejects stale writes across restart", async ({}, te blocks: current.value.document.blocks, }); const after = await window.studyBuilder?.wiki.get({ - documentId: "spring-chapter-00", + documentId: current.value.document.id, }); return { createdWasListed: listedBefore?.ok && listedBefore.value.some((item) => item.id === created.value.document.id), diff --git a/prototype/tests/e2e/unified-wiki-ui.spec.ts b/prototype/tests/e2e/unified-wiki-ui.spec.ts index a146aa7..9ead53f 100644 --- a/prototype/tests/e2e/unified-wiki-ui.spec.ts +++ b/prototype/tests/e2e/unified-wiki-ui.spec.ts @@ -100,3 +100,65 @@ test("renders wiki-only and practice-enabled books through the same wiki view", await fixture.close(); } }); + +test("edits and imports a personal wiki document through the Electron repository", async ({}, testInfo) => { + let fixture = await launchElectron(testInfo); + + try { + await fixture.page + .locator(".library-item") + .filter({ hasText: "경제 뉴스를 읽기 위한 기초 경제학" }) + .getByRole("button") + .click(); + await expect(fixture.page.getByLabel("읽기 또는 편집 모드")).toBeVisible(); + await expect( + fixture.page.getByRole("button", { name: "Markdown 가져오기" }), + ).toBeVisible(); + + await fixture.page.locator('input[type="file"]').setInputFiles({ + name: "개인 학습 메모.md", + mimeType: "text/markdown", + buffer: Buffer.from("# 개인 학습 메모\n\n직접 저장한 내용입니다.", "utf8"), + }); + await expect( + fixture.page.getByRole("heading", { name: "개인 학습 메모" }), + ).toBeVisible(); + await expect(fixture.page.getByText("직접 저장한 내용입니다.")).toBeVisible(); + + await fixture.page.getByRole("button", { name: "편집", exact: true }).click(); + await fixture.page.getByLabel("문서 제목").fill("수정한 개인 학습 메모"); + await fixture.page + .getByLabel("블록 내용") + .last() + .fill("편집 후에도 실제 저장소에 남는 내용입니다."); + await fixture.page.getByRole("button", { name: "저장", exact: true }).click(); + await expect(fixture.page.getByText("저장되었습니다.")).toBeVisible(); + const readButton = fixture.page.getByRole("button", { name: "읽기", exact: true }); + await readButton.click(); + await expect(readButton).toHaveClass(/active/u); + await expect(fixture.page.locator(".spring-wiki-editor")).toHaveCount(0); + await expect( + fixture.page.getByRole("heading", { name: "수정한 개인 학습 메모" }), + ).toBeVisible(); + await expect( + fixture.page.getByText("편집 후에도 실제 저장소에 남는 내용입니다."), + ).toBeVisible(); + + const sandbox = { + root: fixture.root, + userData: fixture.userData, + workspace: fixture.workspace, + trash: fixture.trash, + }; + await fixture.close({ removeRoot: false }); + fixture = await launchElectron(testInfo, { sandbox }); + await expect( + fixture.page.getByRole("heading", { name: "수정한 개인 학습 메모" }), + ).toBeVisible(); + await expect( + fixture.page.getByText("편집 후에도 실제 저장소에 남는 내용입니다."), + ).toBeVisible(); + } finally { + await fixture.close(); + } +}); diff --git a/prototype/tests/unit/app-repository.test.ts b/prototype/tests/unit/app-repository.test.ts index 295b23b..e2c391e 100644 --- a/prototype/tests/unit/app-repository.test.ts +++ b/prototype/tests/unit/app-repository.test.ts @@ -32,6 +32,15 @@ afterEach(async () => { }); describe("AppRepository", () => { + it("keeps bundled course pages read-only while exposing personal pages as editable", async () => { + const { repository: subject } = await repository(); + + expect((await subject.getWikiDocument("spring-chapter-00")).document.readOnly) + .toBe(true); + expect((await subject.getWikiDocument("economics-inflation")).document.readOnly) + .toBe(false); + }); + it("persists app position and restores it from a new repository", async () => { // Given const { root, repository: first } = await repository(); @@ -154,7 +163,7 @@ describe("AppRepository", () => { expect(completed.state.library.books[0]?.progress).toBe(6); }); - it("updates built-in course documents in place and preserves them after restart", async () => { + it("protects built-in course documents from writes and preserves them after restart", async () => { const { root, repository: subject } = await repository(); const course = await subject.getWikiDocument("spring-chapter-01"); const blocks = course.document.blocks.map((block, index) => @@ -163,22 +172,15 @@ describe("AppRepository", () => { : block, ); - const updated = await subject.updateWikiDocument({ + await expect(subject.updateWikiDocument({ documentId: course.document.id, expectedRevision: course.revision, title: "Chapter 01. 직접 편집한 조회 API", blocks, - }); - - expect(updated.document).toMatchObject({ - id: course.document.id, - title: "Chapter 01. 직접 편집한 조회 API", - kind: "course", - readOnly: true, - }); + })).rejects.toMatchObject({ code: "PERMISSION_DENIED" }); await expect(subject.removeWikiDocument({ documentId: course.document.id, - expectedRevision: updated.revision, + expectedRevision: course.revision, })).rejects.toMatchObject({ code: "PERMISSION_DENIED" }); const restored = new AppRepository( @@ -187,7 +189,7 @@ describe("AppRepository", () => { ); expect((await restored.getWikiDocument(course.document.id)).document) .toMatchObject({ - title: "Chapter 01. 직접 편집한 조회 API", + title: course.document.title, readOnly: true, }); }); @@ -223,7 +225,7 @@ describe("AppRepository", () => { bookId: "spring", title: "수정한 문서", kind: "user", - readOnly: true, + readOnly: false, })); await expect(subject.getWikiDocument(created.document.id)).rejects.toMatchObject({ code: "NOT_FOUND", diff --git a/prototype/tests/unit/workspace-template.test.ts b/prototype/tests/unit/workspace-template.test.ts index 5efa693..03f926b 100644 --- a/prototype/tests/unit/workspace-template.test.ts +++ b/prototype/tests/unit/workspace-template.test.ts @@ -45,7 +45,7 @@ describe("WorkspaceTemplateService", () => { guideFile: "docs/LEARNING.md", testCommand: "./gradlew test", }) - expect(result.value.createdFiles).toBe(20) + expect(result.value.createdFiles).toBe(21) expect(result.value.archiveSha256).toMatch(/^[a-f0-9]{64}$/u) expect(await readFile(join(root, "build.gradle"), "utf8")).toContain("JavaLanguageVersion.of(21)") expect(await readFile(join(root, result.value.entryFile), "utf8")).toContain("@SpringBootApplication") diff --git a/scripts/apply-practice-runtime-typecheck-fix.mjs b/scripts/apply-practice-runtime-typecheck-fix.mjs new file mode 100644 index 0000000..bb86b92 --- /dev/null +++ b/scripts/apply-practice-runtime-typecheck-fix.mjs @@ -0,0 +1,20 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const target = resolve(process.cwd(), "prototype/tests/unit/assistant-proposal.test.ts"); +const source = await readFile(target, "utf8"); +const fixed = "terminal: { byteCount: 0, truncated: false, included: false },"; + +if (source.includes(fixed)) { + console.log("assistant-proposal 테스트 fixture는 이미 수정되어 있습니다."); + process.exit(0); +} + +const legacy = "terminal: { byteCount: 0, truncated: false },"; +if (!source.includes(legacy)) { + throw new Error("assistant-proposal 테스트 fixture의 적용 위치를 찾지 못했습니다."); +} + +await writeFile(target, source.replace(legacy, fixed), "utf8"); +console.log("assistant-proposal 테스트 fixture에 terminal.included=false를 추가했습니다."); +console.log("검증: cd prototype && npm run typecheck && npm test && npm run build:desktop"); diff --git a/scripts/apply-study-builder-practice-runtime-update.mjs b/scripts/apply-study-builder-practice-runtime-update.mjs new file mode 100644 index 0000000..4befe67 --- /dev/null +++ b/scripts/apply-study-builder-practice-runtime-update.mjs @@ -0,0 +1,259 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const root = process.cwd(); +const feedbackMarker = "study-builder-practice-runtime: transient feedback"; + +async function patch(relativePath, transform) { + const path = resolve(root, relativePath); + const source = await readFile(path, "utf8"); + const next = transform(source); + if (next !== source) await writeFile(path, next, "utf8"); +} + +function replaceRequired(source, before, after, label) { + if (source.includes(after)) return source; + const index = source.indexOf(before); + if (index < 0) throw new Error(`${label} 적용 위치를 찾지 못했습니다.`); + if (source.indexOf(before, index + before.length) >= 0) { + throw new Error(`${label} 적용 위치가 둘 이상입니다.`); + } + return `${source.slice(0, index)}${after}${source.slice(index + before.length)}`; +} + +function insertAfter(source, anchor, insertion, label) { + if (source.includes(insertion.trim())) return source; + const index = source.indexOf(anchor); + if (index < 0) throw new Error(`${label} 적용 위치를 찾지 못했습니다.`); + return `${source.slice(0, index + anchor.length)}${insertion}${source.slice(index + anchor.length)}`; +} + +await patch("prototype/src/main.jsx", (source) => { + if (source.includes('import "./practice/runtime-fixes.css";')) return source; + const anchor = 'import "./practice/practice-agent.css";\n'; + return replaceRequired( + source, + anchor, + `${anchor}import "./practice/runtime-fixes.css";\n`, + "실습 런타임 스타일 import", + ); +}); + +await patch("prototype/src/hooks/useWorkspaceEditor.js", (source) => { + let next = source; + if (!next.includes(feedbackMarker)) { + const effect = `\n // ${feedbackMarker}\n useEffect(() => {\n if (message === null || error !== null) return undefined\n const timer = window.setTimeout(() => setMessage(null), 4200)\n return () => window.clearTimeout(timer)\n }, [message, error])\n`; + next = insertAfter( + next, + ` useEffect(() => {\n dirtyChangeRef.current?.(dirty)\n }, [dirty])\n`, + effect, + "작업공간 성공 메시지 자동 닫기", + ); + } + if (!next.includes("clearFeedback:")) { + next = replaceRequired( + next, + ` message,\n error,\n connect,`, + ` message,\n error,\n clearFeedback: () => {\n setMessage(null)\n setError(null)\n },\n connect,`, + "작업공간 피드백 닫기 API", + ); + } + return next; +}); + +await patch("prototype/src/state/learning-state.js", (source) => { + let next = source; + const broadenedStart = ` case "START_ASSISTANT":\n return state.agent !== "closed" && state.assistant === "inactive"\n ? accepted(state, { assistant: "streaming", agent: "summary" })\n : rejected(state);`; + if (!next.includes(broadenedStart)) { + next = replaceRequired( + next, + ` case "START_ASSISTANT":\n return state.agent === "summary" && state.assistant === "inactive"\n ? accepted(state, { assistant: "streaming" })\n : rejected(state);`, + broadenedStart, + "AI 후속 질문 상태 전이", + ); + } + if (!next.includes('case "OPEN_TERMINAL"')) { + next = replaceRequired( + next, + ` case "REQUEST_RUN":`, + ` case "OPEN_TERMINAL":\n return state.terminal === "inactive"\n ? accepted(state, { terminal: "active" })\n : accepted(state, {});\n case "CLOSE_TERMINAL":\n return accepted(state, { terminal: "inactive" });\n case "REQUEST_RUN":`, + "상호작용 터미널 상태 전이", + ); + } + return next; +}); + +await patch("prototype/electron/ipc/validate.ts", (source) => { + let next = source; + if (!next.includes("export function validateTerminalInput")) { + next = replaceRequired( + next, + `export function validateTerminalResize(`, + `export function validateTerminalInput(\n value: unknown,\n): Result<{ readonly sessionId: string; readonly data: string }> {\n const record = exactRecord(value, ["sessionId", "data"])\n if (!record.ok) return record\n const sessionId = identifier(record.value["sessionId"])\n const data = boundedBytes(record.value["data"], 1, 64 * 1_024)\n if (!sessionId.ok || !data.ok) return failure("INVALID_INPUT")\n return success({ sessionId: sessionId.value, data: data.value })\n}\n\nexport function validateTerminalResize(`, + "터미널 입력 검증 함수", + ); + } + if (!next.includes('"terminal.input": validateTerminalInput')) { + next = replaceRequired( + next, + ` "terminal.start": validateTerminalStart,\n "terminal.resize": validateTerminalResize,`, + ` "terminal.start": validateTerminalStart,\n "terminal.input": validateTerminalInput,\n "terminal.resize": validateTerminalResize,`, + "터미널 입력 validator 등록", + ); + } + return next; +}); + +await patch("prototype/electron/ipc/register-handlers.ts", (source) => { + let next = source; + if (!next.includes('"terminal.input": "sb:terminal:input"')) { + next = replaceRequired( + next, + ` "terminal.start": "sb:terminal:start",\n "terminal.resize": "sb:terminal:resize",`, + ` "terminal.start": "sb:terminal:start",\n "terminal.input": "sb:terminal:input",\n "terminal.resize": "sb:terminal:resize",`, + "터미널 입력 IPC 채널", + ); + } + if (!next.includes(' "terminal.input",')) { + next = replaceRequired( + next, + ` "terminal.start",\n "terminal.resize",`, + ` "terminal.start",\n "terminal.input",\n "terminal.resize",`, + "터미널 입력 IPC 멤버", + ); + } + if (!next.includes('"terminal.input": INPUT_VALIDATORS["terminal.input"]')) { + next = replaceRequired( + next, + ` "terminal.start": INPUT_VALIDATORS["terminal.start"],\n "terminal.resize": INPUT_VALIDATORS["terminal.resize"],`, + ` "terminal.start": INPUT_VALIDATORS["terminal.start"],\n "terminal.input": INPUT_VALIDATORS["terminal.input"],\n "terminal.resize": INPUT_VALIDATORS["terminal.resize"],`, + "터미널 입력 IPC validator", + ); + } + return next; +}); + +await patch("prototype/electron/services/create-services.ts", (source) => { + if (source.includes('terminalHandlers?.handlers["terminal.input"]')) return source; + return replaceRequired( + source, + ` "terminal.start": terminalHandlers?.handlers["terminal.start"] ?? handler,\n "terminal.resize": terminalHandlers?.handlers["terminal.resize"] ?? handler,`, + ` "terminal.start": terminalHandlers?.handlers["terminal.start"] ?? handler,\n "terminal.input": terminalHandlers?.handlers["terminal.input"] ?? handler,\n "terminal.resize": terminalHandlers?.handlers["terminal.resize"] ?? handler,`, + "터미널 입력 서비스 등록", + ); +}); + +await patch("prototype/electron/preload.cts", (source) => { + if (source.includes('input: (input: unknown) => ipcRenderer.invoke("sb:terminal:input"')) { + return source; + } + return replaceRequired( + source, + ` start: startTerminal,\n resize: (input: unknown) => ipcRenderer.invoke("sb:terminal:resize", input),`, + ` start: startTerminal,\n input: (input: unknown) => ipcRenderer.invoke("sb:terminal:input", input),\n resize: (input: unknown) => ipcRenderer.invoke("sb:terminal:resize", input),`, + "터미널 입력 preload API", + ); +}); + +await patch("prototype/src/electron-api.d.ts", (source) => { + let next = source; + if (!next.includes("readonly input: (input: { readonly sessionId: string; readonly data: string })")) { + next = replaceRequired( + next, + ` readonly start: (input: { readonly approvalToken: string; readonly cols: number; readonly rows: number }) => Promise>\n readonly resize:`, + ` readonly start: (input: { readonly approvalToken: string; readonly cols: number; readonly rows: number }) => Promise>\n readonly input: (input: { readonly sessionId: string; readonly data: string }) => Promise>\n readonly resize:`, + "터미널 입력 renderer 타입", + ); + } + if (!next.includes("readonly relatedFiles:")) { + next = replaceRequired( + next, + ` readonly terminal: {\n readonly byteCount: number\n readonly truncated: boolean\n }\n}`, + ` readonly relatedFiles: readonly {\n readonly relativePath: string\n readonly byteCount: number\n readonly includedByteCount: number\n readonly revision: number\n readonly truncated: boolean\n }[]\n readonly history: {\n readonly messageCount: number\n readonly byteCount: number\n }\n readonly terminal: {\n readonly byteCount: number\n readonly truncated: boolean\n readonly included: boolean\n }\n}`, + "확장된 AI 문맥 타입", + ); + } + return next; +}); + +await patch("prototype/electron/main.mts", (source) => { + if (source.includes("terminalService.contextSummary(ownerId)")) return source; + return replaceRequired( + source, + "terminalSummary: (ownerId) => terminalService.latestSummary(ownerId),", + "terminalSummary: (ownerId) => terminalService.contextSummary(ownerId),", + "실행 중 터미널 문맥 연결", + ); +}); + +await patch("prototype/tests/e2e/shell.spec.ts", (source) => { + let next = source + .replace("only 43 capabilities exist", "only 44 capabilities exist") + .replace("only 42 capabilities exist", "only 44 capabilities exist"); + if (!next.includes('"terminal.input"')) { + next = replaceRequired( + next, + ` "terminal.approve", "terminal.cancel", "terminal.onData", "terminal.onExit", "terminal.resize", "terminal.start",`, + ` "terminal.approve", "terminal.cancel", "terminal.input", "terminal.onData", "terminal.onExit", "terminal.resize", "terminal.start",`, + "shell capability 목록", + ); + } + return next; +}); + +await patch("prototype/electron/ipc/contracts.cts", (source) => { + let next = source; + if (!next.includes('member: "terminal.input"')) { + next = replaceRequired( + next, + ' { member: "terminal.start", channel: "sb:terminal:start", kind: "invoke", payload: ["approvalToken", "cols", "rows"], success: ["sessionId"], prerequisite: "matching unexpired one-use approval", failures: ["INVALID_INPUT", "PERMISSION_DENIED", "CONFLICT", "PROCESS_ERROR"], cancellation: "none" },\n', + ' { member: "terminal.start", channel: "sb:terminal:start", kind: "invoke", payload: ["approvalToken", "cols", "rows"], success: ["sessionId"], prerequisite: "matching unexpired one-use approval", failures: ["INVALID_INPUT", "PERMISSION_DENIED", "CONFLICT", "PROCESS_ERROR"], cancellation: "none" },\n { member: "terminal.input", channel: "sb:terminal:input", kind: "invoke", payload: ["sessionId", "data"], success: ["sessionId", "accepted"], prerequisite: "owned interactive terminal session", failures: ["INVALID_INPUT", "PERMISSION_DENIED", "PROCESS_ERROR"], cancellation: "none" },\n', + "터미널 입력 계약", + ); + } + if (!next.includes("terminalInputBytes:")) { + next = replaceRequired( + next, + " terminalArg: 1_024,\n", + " terminalArg: 1_024,\n terminalInputBytes: 64 * 1_024,\n", + "터미널 입력 크기 계약", + ); + } + return next; +}); + +await patch("prototype/tests/unit/ipc-contracts.test.ts", (source) => { + let next = source; + if (!next.includes('["terminal.input", "sb:terminal:input"')) { + next = replaceRequired( + next, + ' ["terminal.start", "sb:terminal:start", ["approvalToken", "cols", "rows"], ["sessionId"]],\n', + ' ["terminal.start", "sb:terminal:start", ["approvalToken", "cols", "rows"], ["sessionId"]],\n ["terminal.input", "sb:terminal:input", ["sessionId", "data"], ["sessionId", "accepted"]],\n', + "터미널 입력 예상 계약", + ); + } + if (!next.includes('["terminal.input", "owned interactive terminal session"')) { + next = replaceRequired( + next, + ' ["terminal.start", "matching unexpired one-use approval", ["INVALID_INPUT", "PERMISSION_DENIED", "CONFLICT", "PROCESS_ERROR"], "none"],\n', + ' ["terminal.start", "matching unexpired one-use approval", ["INVALID_INPUT", "PERMISSION_DENIED", "CONFLICT", "PROCESS_ERROR"], "none"],\n ["terminal.input", "owned interactive terminal session", ["INVALID_INPUT", "PERMISSION_DENIED", "PROCESS_ERROR"], "none"],\n', + "터미널 입력 예상 정책", + ); + } + next = next + .replace("expect(new Set(channels).size).toBe(44)", "expect(new Set(channels).size).toBe(45)") + .replace("toHaveLength(39)", "toHaveLength(40)") + .replace("expect(policyCount).toBe(44)", "expect(policyCount).toBe(45)"); + if (!next.includes("terminalInputBytes: 65_536")) { + next = replaceRequired( + next, + " terminalArg: 1_024,\n", + " terminalArg: 1_024,\n terminalInputBytes: 65_536,\n", + "터미널 입력 바운드 예상값", + ); + } + return next; +}); + +console.log("Study Builder 실습 런타임 통합 수정본을 적용했습니다."); +console.log("검증: cd prototype && npm run typecheck && npm test && npm run build:desktop");