careermate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +256 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/apps/mcp/src/index.ts +66 -0
  4. package/apps/web/DESIGN_GUIDE.md +105 -0
  5. package/apps/web/UI_CONTRACT.md +44 -0
  6. package/apps/web/public/app.js +118 -0
  7. package/apps/web/public/fonts/PretendardVariable.woff2 +0 -0
  8. package/apps/web/public/index.html +41 -0
  9. package/apps/web/public/lib.js +282 -0
  10. package/apps/web/public/pages/applications.js +98 -0
  11. package/apps/web/public/pages/documents.js +446 -0
  12. package/apps/web/public/pages/home.js +263 -0
  13. package/apps/web/public/pages/interview.js +230 -0
  14. package/apps/web/public/pages/jobs.js +494 -0
  15. package/apps/web/public/pages/profile.js +576 -0
  16. package/apps/web/public/pages/settings.js +233 -0
  17. package/apps/web/public/styles.css +426 -0
  18. package/apps/web/src/exports.ts +68 -0
  19. package/apps/web/src/http.ts +180 -0
  20. package/apps/web/src/index.ts +49 -0
  21. package/apps/web/src/info.ts +50 -0
  22. package/apps/web/src/routes.ts +350 -0
  23. package/apps/web/src/security.ts +102 -0
  24. package/apps/web/src/server.ts +141 -0
  25. package/apps/web/src/settings.ts +88 -0
  26. package/bin/careermate.mjs +74 -0
  27. package/dist/careermate.mcpb +0 -0
  28. package/dist/install-page/index.html +474 -0
  29. package/dist/install-page/style.css +391 -0
  30. package/dist/install-page/vercel.json +20 -0
  31. package/dist/mcp-smoke.err +3 -0
  32. package/dist/mcp.mjs +23704 -0
  33. package/dist/mcpb-stage/README.md +219 -0
  34. package/dist/mcpb-stage/dist/install-page/index.html +434 -0
  35. package/dist/mcpb-stage/dist/install-page/style.css +407 -0
  36. package/dist/mcpb-stage/dist/install-page/vercel.json +20 -0
  37. package/dist/mcpb-stage/dist/mcp.mjs +23704 -0
  38. package/dist/mcpb-stage/dist/public/app.js +118 -0
  39. package/dist/mcpb-stage/dist/public/fonts/PretendardVariable.woff2 +0 -0
  40. package/dist/mcpb-stage/dist/public/index.html +41 -0
  41. package/dist/mcpb-stage/dist/public/lib.js +282 -0
  42. package/dist/mcpb-stage/dist/public/pages/applications.js +98 -0
  43. package/dist/mcpb-stage/dist/public/pages/documents.js +446 -0
  44. package/dist/mcpb-stage/dist/public/pages/home.js +263 -0
  45. package/dist/mcpb-stage/dist/public/pages/interview.js +230 -0
  46. package/dist/mcpb-stage/dist/public/pages/jobs.js +494 -0
  47. package/dist/mcpb-stage/dist/public/pages/profile.js +576 -0
  48. package/dist/mcpb-stage/dist/public/pages/settings.js +233 -0
  49. package/dist/mcpb-stage/dist/public/styles.css +420 -0
  50. package/dist/mcpb-stage/dist/web.mjs +7240 -0
  51. package/dist/mcpb-stage/manifest.json +40 -0
  52. package/dist/public/app.js +118 -0
  53. package/dist/public/fonts/PretendardVariable.woff2 +0 -0
  54. package/dist/public/index.html +41 -0
  55. package/dist/public/lib.js +282 -0
  56. package/dist/public/pages/applications.js +98 -0
  57. package/dist/public/pages/documents.js +446 -0
  58. package/dist/public/pages/home.js +263 -0
  59. package/dist/public/pages/interview.js +230 -0
  60. package/dist/public/pages/jobs.js +494 -0
  61. package/dist/public/pages/profile.js +576 -0
  62. package/dist/public/pages/settings.js +233 -0
  63. package/dist/public/styles.css +426 -0
  64. package/dist/web.mjs +7240 -0
  65. package/docs/ARCHITECTURE.md +208 -0
  66. package/docs/CHANGES_V1.md +103 -0
  67. package/docs/DATA_MODEL.md +460 -0
  68. package/docs/DECISIONS.md +277 -0
  69. package/docs/DEMO.md +242 -0
  70. package/docs/INSTALL.md +148 -0
  71. package/docs/INSTALL_AND_USAGE.md +99 -0
  72. package/docs/MCP_TOOLS.md +233 -0
  73. package/docs/ROADMAP.md +134 -0
  74. package/docs/START_WORKFLOW.md +125 -0
  75. package/docs/SUPPORTED_AI_APPS.md +60 -0
  76. package/docs/TODO.md +57 -0
  77. package/docs/UX_NOTES.md +247 -0
  78. package/docs/WORKFLOWS.md +200 -0
  79. package/install-page/index.html +474 -0
  80. package/install-page/style.css +391 -0
  81. package/install-page/vercel.json +20 -0
  82. package/package.json +68 -0
  83. package/packages/core/src/context.ts +74 -0
  84. package/packages/core/src/index.ts +8 -0
  85. package/packages/core/src/onboarding.ts +81 -0
  86. package/packages/core/src/services.ts +146 -0
  87. package/packages/core/src/summary.ts +104 -0
  88. package/packages/db/src/connection.ts +46 -0
  89. package/packages/db/src/index.ts +22 -0
  90. package/packages/db/src/paths.ts +41 -0
  91. package/packages/db/src/repositories.ts +828 -0
  92. package/packages/db/src/runtime.ts +58 -0
  93. package/packages/db/src/schema.ts +189 -0
  94. package/packages/exporters/src/html.ts +113 -0
  95. package/packages/exporters/src/index.ts +364 -0
  96. package/packages/exporters/src/markdown.ts +178 -0
  97. package/packages/mcp-tools/src/bridge.ts +83 -0
  98. package/packages/mcp-tools/src/index.ts +8 -0
  99. package/packages/mcp-tools/src/result.ts +49 -0
  100. package/packages/mcp-tools/src/tools.ts +455 -0
  101. package/packages/parsers/src/html.ts +86 -0
  102. package/packages/parsers/src/index.ts +228 -0
  103. package/packages/parsers/src/keywords.ts +151 -0
  104. package/packages/prompts/src/humanize.ts +59 -0
  105. package/packages/prompts/src/index.ts +82 -0
  106. package/packages/prompts/src/install.ts +43 -0
  107. package/packages/prompts/src/onboarding.ts +35 -0
  108. package/packages/prompts/src/system.ts +53 -0
  109. package/packages/shared/src/enums.ts +103 -0
  110. package/packages/shared/src/index.ts +18 -0
  111. package/packages/shared/src/schemas.ts +398 -0
  112. package/packages/workflows/src/definitions.ts +107 -0
  113. package/packages/workflows/src/index.ts +39 -0
  114. package/scripts/build-dist.mjs +62 -0
  115. package/scripts/build-mcpb.mjs +70 -0
  116. package/scripts/doctor.ts +81 -0
  117. package/scripts/init.ts +342 -0
  118. package/scripts/mcp-probe.ts +55 -0
  119. package/scripts/migrate.ts +6 -0
  120. package/scripts/run.mjs +33 -0
  121. package/scripts/seed.ts +129 -0
  122. package/scripts/test.ts +117 -0
  123. package/scripts/ui-smoke.ts +73 -0
  124. package/tsconfig.json +29 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `npm run seed` — populate the database with realistic demo data so a new user
3
+ * (or the demo walkthrough) immediately sees a populated dashboard. Refuses to
4
+ * run if real data already exists, unless `--force` is passed. Tip: seed into a
5
+ * throwaway location with `CAREERMATE_DATA_DIR=./demo-data npm run seed`.
6
+ */
7
+ import {
8
+ profileRepo, experienceRepo, projectRepo, skillRepo, documentRepo, getDataDir,
9
+ } from '@careermate/db';
10
+ import {
11
+ saveProfile, saveJobPosting, saveFitAnalysis, saveCoverLetterVersion,
12
+ updateApplicationStatus, saveInterviewPrep, addResume,
13
+ } from '@careermate/core';
14
+
15
+ const force = process.argv.includes('--force');
16
+
17
+ function main(): void {
18
+ if (profileRepo.get()?.name && !force) {
19
+ console.log('⚠️ 이미 프로필 데이터가 있습니다. 데모 데이터를 덮어쓰지 않습니다.');
20
+ console.log(' 별도 폴더에 넣으려면: CAREERMATE_DATA_DIR=./demo-data npm run seed');
21
+ console.log(' 그래도 진행하려면: npm run seed -- --force');
22
+ process.exit(0);
23
+ }
24
+
25
+ saveProfile({
26
+ name: '이서연',
27
+ email: 'seoyeon.lee@example.com',
28
+ phone: '010-1234-5678',
29
+ location: '서울',
30
+ headline: '5년 차 백엔드 엔지니어 · 결제/정산 도메인',
31
+ summary: '대규모 트래픽 환경에서 결제·정산 시스템을 설계·운영했습니다. 안정성과 데이터 정합성을 중시하며, 문제를 정량적으로 정의하고 개선하는 것을 좋아합니다.',
32
+ desired_roles: ['백엔드 엔지니어', '서버 개발자', '플랫폼 엔지니어'],
33
+ desired_conditions: '서울/판교, 정규직, 원격 일부 가능, 결제/금융 도메인 선호',
34
+ preferred_tone: '담백하고 구체적이며, 성과를 숫자로 보여주는 문체',
35
+ emphasis_points: ['대규모 트래픽 처리 경험', '데이터 정합성', '장애 대응', '동료와의 협업'],
36
+ links: [
37
+ { label: 'GitHub', url: 'https://github.com/example' },
38
+ { label: '기술 블로그', url: 'https://blog.example.com' },
39
+ ],
40
+ });
41
+
42
+ experienceRepo.add({
43
+ company: '페이테크', role: '백엔드 엔지니어', employment_type: '정규직',
44
+ start_date: '2021-03', is_current: true,
45
+ description: '결제 게이트웨이와 정산 시스템 개발·운영',
46
+ achievements: ['결제 승인 API p99 응답시간 320ms→110ms 개선', '정산 배치 처리 시간 6시간→40분 단축', '장애 0건으로 연간 거래액 1.2조 처리'],
47
+ tech: ['Java', 'Spring Boot', 'Kafka', 'MySQL', 'Redis', 'Kubernetes'],
48
+ });
49
+ experienceRepo.add({
50
+ company: '커머스랩', role: '주니어 백엔드 개발자', employment_type: '정규직',
51
+ start_date: '2019-01', end_date: '2021-02',
52
+ description: '커머스 주문/재고 서비스 개발',
53
+ achievements: ['주문 동시성 이슈 해결로 재고 오차 99% 감소', '검색 응답속도 2배 개선'],
54
+ tech: ['Python', 'Django', 'PostgreSQL', 'Elasticsearch'],
55
+ });
56
+
57
+ projectRepo.add({
58
+ name: '실시간 정산 파이프라인', role: '설계 및 리드',
59
+ description: 'Kafka 기반 이벤트 스트리밍으로 준실시간 정산 파이프라인 구축',
60
+ highlights: ['정산 지연 6시간→실시간', '재처리 가능한 멱등 설계'],
61
+ tech: ['Kafka', 'Spring', 'MySQL'], url: 'https://github.com/example/settlement',
62
+ });
63
+ projectRepo.add({
64
+ name: '결제 장애 자동 복구 시스템', role: '개발',
65
+ description: 'PG사 장애 시 자동 우회 라우팅 및 알림',
66
+ highlights: ['장애 평균 복구시간 15분→2분'], tech: ['Java', 'Spring', 'Redis'],
67
+ });
68
+
69
+ for (const s of [
70
+ { name: 'Java', category: '언어', level: '상', years: 5 },
71
+ { name: 'Spring Boot', category: '프레임워크', level: '상', years: 5 },
72
+ { name: 'Kafka', category: '인프라', level: '중', years: 3 },
73
+ { name: 'MySQL', category: '데이터베이스', level: '상', years: 5 },
74
+ { name: 'Kubernetes', category: '인프라', level: '중', years: 2 },
75
+ { name: 'Python', category: '언어', level: '중', years: 3 },
76
+ ]) skillRepo.add(s);
77
+
78
+ addResume({
79
+ kind: 'resume', title: '이서연 이력서 (백엔드)', is_primary: true, source: 'manual',
80
+ content: `# 이서연 — 백엔드 엔지니어\n\n## 경력 요약\n결제·정산 도메인 5년. 대규모 트래픽 안정화와 데이터 정합성에 강점.\n\n## 핵심 성과\n- 결제 승인 API p99 320ms→110ms\n- 정산 배치 6시간→40분\n- 연간 거래액 1.2조 무중단 처리`,
81
+ });
82
+
83
+ // A job with full analysis + cover letter + status + interview prep
84
+ const { job } = saveJobPosting({
85
+ company: '토스', position: '백엔드 엔지니어 (결제)',
86
+ url: 'https://example.com/toss/backend', location: '서울 강남', employment_type: '정규직',
87
+ description: '토스 결제 시스템의 안정성과 확장성을 책임질 백엔드 엔지니어를 찾습니다. 대규모 트래픽 환경에서의 결제/정산 경험을 우대합니다.',
88
+ requirements: ['Java/Kotlin 백엔드 개발 경험', '대규모 트래픽 처리 경험', '결제/정산 도메인 이해'],
89
+ keywords: ['Java', 'Kotlin', 'Spring', 'Kafka', '결제', 'MSA'], deadline: '2026-07-15', source: '데모',
90
+ });
91
+ saveFitAnalysis({
92
+ job_id: job.id, score: 88,
93
+ summary: '결제·정산 도메인 경험과 대규모 트래픽 처리 이력이 공고 요구사항과 강하게 일치합니다. Kotlin 경험만 보완하면 최상위 적합도입니다.',
94
+ strengths: ['결제/정산 도메인 5년 경험', '대규모 트래픽 안정화 성과(p99 개선)', 'Kafka 기반 이벤트 처리 경험'],
95
+ gaps: ['Kotlin 실무 경험 부족(Java는 능숙)', 'MSA 전환 리딩 경험은 제한적'],
96
+ matched_keywords: ['Java', 'Spring', 'Kafka', '결제'], missing_keywords: ['Kotlin', 'MSA'],
97
+ recommendations: ['지원동기에 결제 도메인 애정과 정합성 경험을 구체 수치로 강조', 'Kotlin은 학습 의지+Java 전이 가능성으로 보완'],
98
+ });
99
+ const { coverLetter } = saveCoverLetterVersion({
100
+ job_id: job.id, title: '토스 백엔드 자기소개서',
101
+ content: '저는 결제 시스템의 "한 푼도 틀리면 안 된다"는 긴장감 속에서 성장했습니다...(데모 자기소개서 v1)', note: '초안', source: 'ai',
102
+ });
103
+ saveCoverLetterVersion({
104
+ cover_letter_id: coverLetter.id,
105
+ content: '저는 결제 시스템의 "한 푼도 틀리면 안 된다"는 긴장감 속에서 성장했습니다. 페이테크에서 결제 승인 API의 p99 응답시간을 320ms에서 110ms로 줄이며...(데모 자기소개서 v2 — 지원동기·성과 보강)',
106
+ note: '지원동기와 정량 성과 보강', source: 'edit',
107
+ });
108
+ updateApplicationStatus(job.id, 'document_passed', '데모: 서류 합격 처리');
109
+ saveInterviewPrep({
110
+ job_id: job.id,
111
+ questions: [
112
+ { question: '결제 승인 API 응답시간을 어떻게 개선했나요?', intent: '성능 개선의 깊이와 접근법 확인', followups: ['병목은 어떻게 찾았나요?', '트레이드오프는 없었나요?'], answer_outline: '프로파일링으로 DB 커넥션 풀 병목 발견 → 캐시 계층 도입 → p99 320→110ms. 정합성 유지 위해 캐시 무효화 전략 설명.' },
113
+ { question: '데이터 정합성이 깨질 뻔한 경험과 해결 방법은?', intent: '장애 대응·정합성 사고력', followups: ['멱등성은 어떻게 보장했나요?'], answer_outline: '정산 재처리 시 중복 지급 위험 → 멱등키 + 분산락 도입으로 해결.' },
114
+ ],
115
+ star_guides: [
116
+ { question: '가장 큰 기술적 성과', situation: '정산 배치가 6시간 소요되어 익일 지연', task: '처리 시간 단축', action: 'Kafka 이벤트 스트리밍으로 준실시간 파이프라인 재설계', result: '6시간→40분, 정산 지연 0건' },
117
+ ],
118
+ self_introduction: '안녕하세요. 결제·정산 도메인에서 5년간 "정확함"을 지켜온 백엔드 엔지니어 이서연입니다...(데모 1분 자기소개)',
119
+ notes: '결제 도메인 질문에 집중 예상. 정량 수치 암기.',
120
+ });
121
+
122
+ console.log('✅ 데모 데이터를 생성했습니다.');
123
+ console.log(` 위치: ${getDataDir()}`);
124
+ console.log(` 프로필 1 · 경력 ${experienceRepo.list().length} · 프로젝트 ${projectRepo.list().length} · 기술 ${skillRepo.list().length} · 문서 ${documentRepo.list().length}`);
125
+ console.log(' 공고 1(토스, 적합도 88, 서류 합격) · 자기소개서 1(2버전) · 면접 준비 1');
126
+ console.log('\n `npm start` 로 대시보드를 열어 확인하세요.');
127
+ }
128
+
129
+ main();
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `npm test` — end-to-end smoke test of the whole system in one run:
3
+ * 1. core + DB use-cases
4
+ * 2. HTTP API + security gate (CSRF token, Host allow-list, cross-origin)
5
+ * 3. MCP stdio server with a real MCP client (via a spawnSync probe)
6
+ * 4. THE key invariant: the dashboard (API) and the MCP server (separate
7
+ * process) read & write the SAME local database.
8
+ *
9
+ * Run it through the plain-Node runner: `npm test` (→ scripts/run.mjs), which
10
+ * reads the TEST_VERDICT line below. HTTP calls use node:http with agent:false
11
+ * (no keep-alive pooling) because the spawnSync probe blocks the event loop,
12
+ * which would otherwise leave a stale pooled socket → ECONNRESET.
13
+ *
14
+ * Runs entirely against a throwaway data directory; never touches real data.
15
+ */
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import fs from 'node:fs';
19
+ import net from 'node:net';
20
+ import http from 'node:http';
21
+ import { spawnSync } from 'node:child_process';
22
+
23
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cf-e2e-'));
24
+ process.env.CAREERMATE_DATA_DIR = tmp;
25
+ process.env.CAREERMATE_NO_OPEN = '1';
26
+
27
+ const { startServer } = await import('../apps/web/src/server.ts');
28
+ const { SESSION_TOKEN } = await import('../apps/web/src/security.ts');
29
+ const here = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
30
+
31
+ let pass = 0;
32
+ let fail = 0;
33
+ const ok = (name: string, cond: boolean, extra = '') => {
34
+ if (cond) { pass++; console.log(` ✅ ${name}`); }
35
+ else { fail++; console.log(` ❌ ${name} ${extra}`); }
36
+ };
37
+ const section = (s: string) => console.log(`\n${s}`);
38
+
39
+ const { port, server } = await startServer(46070);
40
+ const ORIGIN = `127.0.0.1:${port}`;
41
+
42
+ /** Fresh-socket HTTP (no keep-alive) so a blocking spawnSync can't stale a pool. */
43
+ function reqRaw(method: string, p: string, body?: unknown, headers: Record<string, string> = {}): Promise<{ status: number; text: string; headers: http.IncomingHttpHeaders }> {
44
+ return new Promise((resolve, reject) => {
45
+ const payload = body !== undefined ? JSON.stringify(body) : undefined;
46
+ const r = http.request(
47
+ { host: '127.0.0.1', port, path: p, method, agent: false, headers: { 'content-type': 'application/json', ...headers } },
48
+ (res) => { let d = ''; res.on('data', (c) => (d += c)); res.on('end', () => resolve({ status: res.statusCode!, text: d, headers: res.headers })); },
49
+ );
50
+ r.on('error', reject);
51
+ if (payload) r.write(payload);
52
+ r.end();
53
+ });
54
+ }
55
+ const authed = (m: string, p: string, b?: unknown) => reqRaw(m, p, b, { 'x-careermate-token': SESSION_TOKEN });
56
+ const json = async (m: string, p: string, b?: unknown, h?: Record<string, string>) => JSON.parse((await reqRaw(m, p, b, h)).text);
57
+
58
+ /* ---------------------------------------------------------- 1. API + security */
59
+ section('1) HTTP API + 보안');
60
+ ok('헬스 체크', (await json('GET', '/api/health')).ok === true);
61
+ ok('토큰 없는 변경요청 차단(CSRF)', (await reqRaw('PUT', '/api/profile', {})).status === 403);
62
+ ok('외부 Origin 차단', (await reqRaw('GET', '/api/health', undefined, { origin: 'https://attacker.example' })).status === 403);
63
+
64
+ const spoof = await new Promise<string>((res) => {
65
+ const s = net.connect(port, '127.0.0.1', () => s.write(`GET /api/health HTTP/1.1\r\nHost: attacker.example\r\nConnection: close\r\n\r\n`));
66
+ let b = ''; s.on('data', (d) => (b += d)); s.on('end', () => res(b.split('\r\n')[0]!));
67
+ });
68
+ ok('Host 스푸핑 차단(DNS 리바인딩)', spoof.includes('403'));
69
+ ok('zod 입력검증', (await authed('POST', '/api/jobs', { company: '' })).status === 400);
70
+
71
+ /* ------------------------------------------------------------- 2. API 업무 흐름 */
72
+ section('2) 대시보드 업무 흐름 (API)');
73
+ await authed('PUT', '/api/profile', { name: '김커리어', desired_roles: ['백엔드'], preferred_tone: '담백' });
74
+ ok('프로필 저장/조회', (await json('GET', '/api/profile')).profile.name === '김커리어');
75
+ ok('온보딩 상태 반영', (await json('GET', '/api/onboarding')).has_profile === true);
76
+
77
+ const apiJob = (await json('POST', '/api/jobs', { company: '라인', position: '서버 개발자', keywords: ['Java'] }, { 'x-careermate-token': SESSION_TOKEN })).job;
78
+ await authed('PUT', `/api/jobs/${apiJob.id}/fit`, { score: 77, strengths: ['s'] });
79
+ const statusRes = await json('PUT', `/api/applications/${apiJob.id}/status`, { status: 'document_passed' }, { 'x-careermate-token': SESSION_TOKEN });
80
+ ok('상태 변경 + 면접 힌트', statusRes.application.status === 'document_passed' && !!statusRes.hint);
81
+ const ctx = await json('GET', `/api/context?job_id=${apiJob.id}`);
82
+ ok('get_application_context 집계', ctx.job?.id === apiJob.id && ctx.fit_analysis?.score === 77 && ctx.writing_preferences.preferred_tone === '담백');
83
+ const cl = (await json('POST', '/api/cover-letters', { title: '라인 자소서', job_id: apiJob.id, content: 'v1' }, { 'x-careermate-token': SESSION_TOKEN })).cover_letter;
84
+ await authed('POST', `/api/cover-letters/${cl.id}/versions`, { content: 'v2', note: '보강' });
85
+ ok('자소서 버전 관리', (await json('GET', `/api/cover-letters/${cl.id}`)).cover_letter.version_count === 2);
86
+ const exp = await reqRaw('GET', `/api/export/cover-letter/${cl.id}?format=md`);
87
+ ok('자소서 내보내기(MD)', String(exp.headers['content-disposition'] || '').includes('attachment'));
88
+
89
+ /* ------------------------------------------------- 3 & 4. MCP + DB 공유 불변식 */
90
+ section('3) MCP 서버 (stdio) + 4) 핵심 불변식: 대시보드 ↔ MCP 동일 DB');
91
+ await authed('PUT', '/api/profile', { headline: 'API가 추가한 한 줄' });
92
+
93
+ // Run the MCP stdio session as a fully-reaped child; read its verdict from stdout.
94
+ const probe = spawnSync(process.execPath, ['--no-warnings', '--import', 'tsx', path.join(here, 'mcp-probe.ts')], {
95
+ cwd: path.resolve(here, '..'),
96
+ env: { ...process.env },
97
+ encoding: 'utf8',
98
+ timeout: 60000,
99
+ });
100
+ const probeLine = (probe.stdout || '').split('\n').find((l) => l.startsWith('RESULT '));
101
+ const pr = probeLine ? JSON.parse(probeLine.slice('RESULT '.length)) : { ok: false, error: probe.stderr?.slice(0, 200) };
102
+
103
+ ok('MCP 서버 연결 + 도구 20개 이상 노출', pr.ok === true && pr.toolCount >= 20, `(${pr.toolCount ?? 0})`);
104
+ ok('get_application_context 존재', pr.hasContext === true);
105
+ ok('MCP가 API가 쓴 프로필 이름을 읽음 (DB 공유 ←)', pr.profileName === '김커리어');
106
+ ok('MCP가 API의 수정(한 줄 소개)을 즉시 봄 (←)', pr.profileHeadline === 'API가 추가한 한 줄');
107
+ ok('MCP save_job_posting', !!pr.jobId);
108
+ ok('대시보드(API)가 MCP가 저장한 공고를 봄 (→)', (await json('GET', '/api/jobs')).jobs.some((j: any) => j.id === pr.jobId));
109
+
110
+ console.log(`\n${'='.repeat(40)}`);
111
+ console.log(`결과: ${pass} 통과 · ${fail} 실패`);
112
+ console.log(fail === 0 ? '✅ 전체 통과' : '❌ 실패 있음');
113
+ console.log(fail === 0 ? 'TEST_VERDICT PASS' : `TEST_VERDICT FAIL ${fail}`);
114
+
115
+ await new Promise<void>((r) => server.close(() => r()));
116
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
117
+ process.exit(fail === 0 ? 0 : 1);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `npm run test:ui` — render every dashboard page in a real browser and assert
3
+ * it mounts with no console/page errors. Optional: requires Playwright + a
4
+ * Chromium build (`npm i -D playwright && npx playwright install chromium`).
5
+ *
6
+ * Uses a throwaway data directory so it never touches the user's real data.
7
+ */
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import fs from 'node:fs';
11
+
12
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cf-uismoke-'));
13
+ process.env.CAREERMATE_DATA_DIR = tmp;
14
+ process.env.CAREERMATE_NO_OPEN = '1';
15
+
16
+ const { startServer } = await import('../apps/web/src/server.ts');
17
+ const { saveProfile, saveJobPosting, saveFitAnalysis, saveCoverLetterVersion, updateApplicationStatus, saveInterviewPrep } =
18
+ await import('@careermate/core');
19
+
20
+ // minimal data so every page has something to render
21
+ saveProfile({ name: '테스트', desired_roles: ['개발자'], preferred_tone: '담백하게' });
22
+ const { job } = saveJobPosting({ company: '테스트회사', position: '개발자', keywords: ['JS'] });
23
+ saveFitAnalysis({ job_id: job.id, score: 80, strengths: ['강점'], gaps: ['보완'] });
24
+ saveCoverLetterVersion({ job_id: job.id, title: '자소서', content: '내용 v1', note: '초안' });
25
+ updateApplicationStatus(job.id, 'document_passed');
26
+ saveInterviewPrep({ job_id: job.id, questions: [{ question: '질문?' }], self_introduction: '소개' });
27
+
28
+ let chromium: any;
29
+ try {
30
+ ({ chromium } = await import('playwright'));
31
+ } catch {
32
+ console.log('⚠️ Playwright가 설치되어 있지 않습니다. 건너뜁니다.');
33
+ console.log(' 설치: npm i -D playwright && npx playwright install chromium');
34
+ process.exit(0);
35
+ }
36
+
37
+ const { port, server } = await startServer(46050);
38
+ const base = `http://127.0.0.1:${port}`;
39
+ const browser = await chromium.launch();
40
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
41
+
42
+ const errors: string[] = [];
43
+ let route = '';
44
+ page.on('console', (m: any) => { if (m.type() === 'error') errors.push(`[${route}] ${m.text()}`); });
45
+ page.on('pageerror', (e: any) => errors.push(`[${route}] PAGEERROR ${e.message}`));
46
+
47
+ const routes: [string, string][] = [
48
+ ['home', '#/'], ['profile', '#/profile'], ['jobs', '#/jobs'], ['job-detail', `#/jobs/${job.id}`],
49
+ ['applications', '#/applications'], ['documents', '#/documents'], ['interview', '#/interview'], ['settings', '#/settings'],
50
+ ];
51
+
52
+ let failures = 0;
53
+ for (const [name, hash] of routes) {
54
+ route = name;
55
+ await page.goto(base + '/' + hash, { waitUntil: 'networkidle' });
56
+ await page.waitForTimeout(400);
57
+ const mounted = await page.$eval('#view', (node: any) => node.children.length > 0).catch(() => false);
58
+ const ok = mounted;
59
+ if (!ok) failures++;
60
+ console.log(` ${ok ? '✅' : '❌'} ${name.padEnd(13)} mounted=${mounted}`);
61
+ }
62
+
63
+ if (errors.length) { console.log('\n콘솔/페이지 오류:'); errors.forEach((e) => console.log(' ' + e)); }
64
+ failures += errors.length;
65
+ console.log(`\n${failures === 0 ? '✅ UI 스모크 테스트 통과' : `❌ 실패 ${failures}건`}`);
66
+ console.log(failures === 0 ? 'UI_VERDICT PASS' : `UI_VERDICT FAIL ${failures}`);
67
+
68
+ await browser.close();
69
+ await new Promise((r) => setTimeout(r, 200));
70
+ await new Promise<void>((r) => server.close(() => r()));
71
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
72
+ process.exitCode = failures === 0 ? 0 : 1;
73
+ setTimeout(() => process.exit(process.exitCode), 1500).unref();
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2023"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": false,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "resolveJsonModule": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "allowImportingTsExtensions": true,
15
+ "noEmit": true,
16
+ "baseUrl": ".",
17
+ "paths": {
18
+ "@careermate/shared": ["packages/shared/src/index.ts"],
19
+ "@careermate/db": ["packages/db/src/index.ts"],
20
+ "@careermate/core": ["packages/core/src/index.ts"],
21
+ "@careermate/mcp-tools": ["packages/mcp-tools/src/index.ts"],
22
+ "@careermate/exporters": ["packages/exporters/src/index.ts"],
23
+ "@careermate/parsers": ["packages/parsers/src/index.ts"],
24
+ "@careermate/prompts": ["packages/prompts/src/index.ts"],
25
+ "@careermate/workflows": ["packages/workflows/src/index.ts"]
26
+ }
27
+ },
28
+ "include": ["packages/**/*.ts", "apps/**/*.ts", "scripts/**/*.ts"]
29
+ }