diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ee988b3..7448cc8 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -31,5 +31,6 @@ jobs: ELECTRON_SKIP_BINARY_DOWNLOAD: '1' - run: npm run lint - run: npm test + - run: npm run test:demo - run: npm run build - run: npm run electron:build-main diff --git a/docs/BROWSER_QA.md b/docs/BROWSER_QA.md index f025eac..978586f 100644 --- a/docs/BROWSER_QA.md +++ b/docs/BROWSER_QA.md @@ -39,3 +39,19 @@ - OBS 테스트 환경: 캐릭터·오버레이 동기화 이번 결과는 브라우저 UI 확인이며 Electron 설치 파일, 실서버, 마이크, OBS 동작을 보장하지 않습니다. 테스트 계정이나 운영 계정의 비밀번호는 문서에 기록하지 않습니다. + + +## 로컬 샘플 환경 추가 확인 (2026-10-08) + +대상: `npm run dev:demo`, `http://127.0.0.1:5174`, Codex 내장 브라우저. + +- 고정 테스트 사용자로 로그인 후 대시보드 이동 확인 +- 방송 미진행 안내, 캐릭터 0개 안내, 채팅 분석의 비활성 방송 안내 확인 +- 방송 통계의 0회 기록 및 달력 표시 확인 +- 통계 화면 새로고침 후 로그인 상태·페이지 유지 확인 +- 계정 메뉴의 로그아웃 후 로그인 화면 복귀 확인 +- 오류 로그 조회 시 수집된 error 없음 + +API 응답은 모두 로컬 샘플. 실제 서버의 사용자 인증·데이터 정확성·캐릭터 CRUD·방송 중 이동·마이크·OBS는 미검증. 샘플 HTTP 경계 테스트 1개 및 기존 테스트 9개 통과, 프론트 빌드·Electron 컴파일 통과, 린트 오류 0개와 기존 경고 7개 확인. + +2026-10-09 재개 확인: 샘플 안내 영역과 앱 화면 분리, 최종 로그인·대시보드 표시 확인. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d3adc7a..4c7081c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -103,3 +103,28 @@ npm run electron:preview # STT 준비 + 빌드 후 로컬 실행 ## PR 자동 검사 `Frontend checks`는 PR과 main push에서 Node 20 환경의 설치·린트·테스트·프론트 빌드·Electron 컴파일을 실행합니다. 기존 린트 경고 7개는 오류로 승격하지 않았습니다. 실제 데스크톱 실행과 STT 모델 패키징은 배포·통합 확인에서 다룹니다. + + +## 백엔드 없이 화면 확인 + +```bash +cd swproject +npm run dev:demo +``` + +주소: `http://127.0.0.1:5174` — 이메일 `demo@example.com`, 비밀번호 `demo1234` 입력. + +- 화면 하단의 **로컬 샘플** 표시로 일반 개발 환경과 구분 +- 실제 계정 등록이나 비밀번호 검증 없음. 입력값을 서버에 저장하지 않고 고정된 테스트 사용자 반환. 실제 비밀번호 입력 금지 +- `5174` 전용 origin 사용으로 일반 개발 서버 `5173`의 브라우저 저장소와 분리 +- 로그인·로그아웃, 캐릭터 빈 목록, 방송 전 대시보드·채팅 분석, 빈 방송 통계 지원 +- 캐릭터 생성·수정·삭제, 방송 시작, 치지직 연동, 서버 설정 저장 미지원. 미지원 API는 501 응답 +- 모든 `/api/` 요청은 로컬 fixture에서 처리. 실제 백엔드 프록시 사용 안 함 +- 화면 설정 등 기존 로컬 저장소 기능은 해당 origin에 남을 수 있음. 초기화는 해당 사이트 데이터 삭제 +- 실행 종료: 터미널에서 Ctrl+C + +`npm run test:demo`는 HTTP 요청으로 샘플 로그인·인증 없는 접근·빈 목록·미지원 변경 요청의 경계를 확인. PR 자동 검사에도 포함. + +이 모드는 실제 백엔드 대체 구현이 아니라 로그인 후 빈 상태를 확인하는 개발 도구. 프로덕션 빌드·Electron 패키징에는 샘플 서버와 표시를 주입하지 않음. `scripts/dev-demo.mjs`는 Vite 개발 서버에서만 실행. + +실제 백엔드는 현재 develop 설정 기준 Java 21, MySQL, Redis 및 외부 서비스 설정이 필요. 백엔드 담당자와 로컬 전용 설정·테스트 데이터·외부 서비스 대체 범위를 맞춘 다음 통합 검증 진행. 샘플 화면 통과를 실제 인증·방송·음성 연동 통과로 기록하지 않음. diff --git a/swproject/package.json b/swproject/package.json index 31a8f70..0cca99a 100644 --- a/swproject/package.json +++ b/swproject/package.json @@ -14,7 +14,9 @@ "electron:build": "npm run stt:build && npm run electron:build-main && npm run build && electron-builder --publish never", "electron:preview": "npm run stt:build && npm run electron:build-main && npm run build && cross-env NODE_ENV=production electron dist-electron/main.js", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "dev:demo": "node scripts/dev-demo.mjs", + "test:demo": "node --test scripts/demo-api.test.mjs" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -110,7 +112,7 @@ "identity": null, "artifactName": "LiveBuddy-mac-${arch}.${ext}", "extendInfo": { - "NSMicrophoneUsageDescription": "음성 인식(STT)을 위해 마이크에 접근합니다." + "NSMicrophoneUsageDescription": "\uc74c\uc131 \uc778\uc2dd(STT)\uc744 \uc704\ud574 \ub9c8\uc774\ud06c\uc5d0 \uc811\uadfc\ud569\ub2c8\ub2e4." } }, "win": { diff --git a/swproject/scripts/demo-api.mjs b/swproject/scripts/demo-api.mjs new file mode 100644 index 0000000..c23dc44 --- /dev/null +++ b/swproject/scripts/demo-api.mjs @@ -0,0 +1,44 @@ +/** Local-only fixtures for signed-in, inactive-broadcast UI checks. No upstream requests. */ +export const demoSession = { + userId: 1, email: 'demo@example.com', name: '로컬 테스트', + accessToken: 'local-demo-access', refreshToken: 'local-demo-refresh', +}; + +export function demoResponse(method, rawUrl, authorization) { + const url = new URL(rawUrl, 'http://localhost'); + const path = url.pathname; + const ok = body => ({ status: 200, body }); + const error = (status, message) => ({ status, body: { message } }); + // Credentials are intentionally not read, stored, or validated. This is not authentication. + if (method === 'POST' && path === '/api/v1/auth/login/email') return ok(demoSession); + if (method === 'POST' && path === '/api/v1/auth/refresh') { + return ok({ accessToken: demoSession.accessToken, refreshToken: demoSession.refreshToken }); + } + if (authorization !== `Bearer ${demoSession.accessToken}`) return error(401, '로컬 샘플 로그인이 필요합니다.'); + if (method === 'POST' && path === '/api/v1/auth/logout') return ok({}); + if (method === 'GET' && path === '/api/v1/user/chzzk') { + return ok({ authorized: false, accessTokenExpired: false, refreshTokenExpired: false }); + } + if (method === 'GET' && path === '/api/v1/characters') { + return ok({ content: [], page: 1, size: 10, hasNext: false }); + } + if (method === 'GET' && path === '/api/v1/characters/settings') { + return ok({ characterImages: [], vrmPresets: [], personaPresetTypes: [] }); + } + if (method === 'GET' && ['/api/v1/stream/info', '/api/v1/stream/chat/stats'].includes(path)) { + return error(404, '진행 중인 방송이 없습니다. (로컬 샘플)'); + } + if (method === 'GET' && path === '/api/v1/broadcast/settings') return ok({ aiProactiveToChat: false }); + if (method === 'GET' && path === '/api/v1/broadcast/stats/month') { + return ok({ broadcastMonthInfoList: [], broadcastYear: Number(url.searchParams.get('year')), broadcastMonth: Number(url.searchParams.get('month')) }); + } + return error(501, '로컬 샘플에서 지원하지 않는 기능입니다. 실제 백엔드 연결이 필요합니다.'); +} + +export function demoMiddleware(req, res, next) { + if (!req.url?.startsWith('/api/')) return next(); + const result = demoResponse(req.method, req.url, req.headers.authorization); + req.resume(); + res.writeHead(result.status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(result.body)); +} diff --git a/swproject/scripts/demo-api.test.mjs b/swproject/scripts/demo-api.test.mjs new file mode 100644 index 0000000..ffb83ab --- /dev/null +++ b/swproject/scripts/demo-api.test.mjs @@ -0,0 +1,26 @@ +/** Verify fixture boundaries using a real local HTTP server, without external services. */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { demoMiddleware } from './demo-api.mjs'; + +test('local demo handles login, empty state, unsupported writes and fallback', async t => { + const server = createServer((req, res) => demoMiddleware(req, res, () => { res.writeHead(418); res.end(); })); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise(resolve => server.close(resolve))); + const origin = `http://127.0.0.1:${server.address().port}`; + const login = await fetch(`${origin}/api/v1/auth/login/email`, { method: 'POST', body: '{}' }); + const session = await login.json(); + assert.equal(login.status, 200); + assert.equal(session.email, 'demo@example.com'); + const headers = { Authorization: `Bearer ${session.accessToken}` }; + const characters = await fetch(`${origin}/api/v1/characters?page=1`, { headers }); + assert.deepEqual((await characters.json()).content, []); + assert.equal((await fetch(`${origin}/api/v1/characters`)).status, 401); + assert.equal((await fetch(`${origin}/api/v1/stream/info`, { headers })).status, 404); + for (const path of ['stream/start', 'characters', 'auth/register/email']) { + assert.equal((await fetch(`${origin}/api/v1/${path}`, { method: 'POST', headers })).status, 501); + } + assert.equal((await fetch(`${origin}/api/v1/unknown`, { headers })).status, 501); + assert.equal((await fetch(`${origin}/icon.png`)).status, 418); +}); diff --git a/swproject/scripts/dev-demo.mjs b/swproject/scripts/dev-demo.mjs new file mode 100644 index 0000000..6ed76e0 --- /dev/null +++ b/swproject/scripts/dev-demo.mjs @@ -0,0 +1,29 @@ +/** Start the existing UI with local fixtures on a separate loopback origin. */ +import { createServer } from 'vite'; +import { demoMiddleware } from './demo-api.mjs'; + +const server = await createServer({ + mode: 'demo', + define: { + 'import.meta.env.VITE_WS_URL': JSON.stringify('ws://127.0.0.1:5174'), + 'import.meta.env.VITE_IMAGE_BASE_URL': JSON.stringify('http://127.0.0.1:5174'), + }, + server: { host: '127.0.0.1', port: 5174, strictPort: true, open: false }, + plugins: [{ + name: 'local-demo-fixtures', + apply: 'serve', + configureServer(vite) { + // Replace the normal API proxy. Unknown endpoints must never reach a real backend. + vite.config.server.proxy = {}; + vite.middlewares.use(demoMiddleware); + }, + transformIndexHtml() { + return [{ tag: 'style', children: '#root .h-screen{height:calc(100dvh - 52px)} #root .min-h-screen{min-height:calc(100dvh - 52px)} body{padding-bottom:52px}', injectTo: 'head' }, { tag: 'aside', attrs: { role: 'note', style: 'position:fixed;bottom:0;left:0;right:0;z-index:99999;height:52px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;padding:8px 12px;background:#fff3cd;color:#302600;font:13px sans-serif;text-align:center' }, children: '로컬 샘플 · demo@example.com / demo1234 · 실제 비밀번호 입력 금지 · 계정 생성·방송·서버 저장 미지원', injectTo: 'body' }]; + }, + }], +}); +await server.listen(); +server.printUrls(); +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, async () => { await server.close(); process.exit(0); }); +}