Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions docs/BROWSER_QA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 재개 확인: 샘플 안내 영역과 앱 화면 분리, 최종 로그인·대시보드 표시 확인.
25 changes: 25 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 및 외부 서비스 설정이 필요. 백엔드 담당자와 로컬 전용 설정·테스트 데이터·외부 서비스 대체 범위를 맞춘 다음 통합 검증 진행. 샘플 화면 통과를 실제 인증·방송·음성 연동 통과로 기록하지 않음.
6 changes: 4 additions & 2 deletions swproject/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
44 changes: 44 additions & 0 deletions swproject/scripts/demo-api.mjs
Original file line number Diff line number Diff line change
@@ -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));
}
26 changes: 26 additions & 0 deletions swproject/scripts/demo-api.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
29 changes: 29 additions & 0 deletions swproject/scripts/dev-demo.mjs
Original file line number Diff line number Diff line change
@@ -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); });
}
Loading