diff --git a/.github/scripts/bundle-size-report.js b/.github/scripts/bundle-size-report.js
deleted file mode 100644
index 96da2910..00000000
--- a/.github/scripts/bundle-size-report.js
+++ /dev/null
@@ -1,205 +0,0 @@
-'use strict';
-
-const fs = require('fs');
-const path = require('path');
-const zlib = require('zlib');
-
-const BUILD_DIR = path.join(process.cwd(), 'apps/timo-web/.next');
-
-const formatBytes = (bytes) => {
- if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
- if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} kB`;
- return `${bytes} B`;
-};
-
-const toKb = (bytes) => bytes / 1024;
-
-const safeGzipSize = (filePath) => {
- try {
- const content = fs.readFileSync(filePath);
- return zlib.gzipSync(content).length;
- } catch (err) {
- throw new Error(`번들 파일 처리 실패: ${filePath} — ${err.message}`);
- }
-};
-
-const readJson = (filePath) => {
- try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
-};
-
-const sumDirGzipSize = (dirPath) => {
- if (!fs.existsSync(dirPath)) return 0;
- return fs.readdirSync(dirPath).reduce((sum, file) => {
- const full = path.join(dirPath, file);
- const stat = fs.statSync(full);
- return sum + (stat.isFile() ? safeGzipSize(full) : 0);
- }, 0);
-};
-
-const analyzeBuild = (buildDir) => {
- if (!fs.existsSync(buildDir)) return { routes: [], sharedSize: null };
-
- const buildManifest = readJson(path.join(buildDir, 'build-manifest.json'));
- const routesManifest = readJson(path.join(buildDir, 'routes-manifest.json'));
- // webpack 빌드에서만 생성됨
- const appBuildManifest = readJson(path.join(buildDir, 'app-build-manifest.json'));
-
- if (!buildManifest) return { routes: [], sharedSize: null };
-
- // 내부 Next.js 라우트 필터 (사용자 정의 라우트가 아님)
- const INTERNAL_ROUTES = new Set(['/_not-found', '/_global-error']);
-
- // 공유 번들 gzip 크기 (rootMainFiles)
- const rootMainFiles = buildManifest.rootMainFiles ?? [];
- const sharedBytes = rootMainFiles.reduce(
- (sum, chunk) => sum + safeGzipSize(path.join(buildDir, chunk)),
- 0
- );
-
- let routes = [];
-
- if (appBuildManifest) {
- // webpack 모드: app-build-manifest.json으로 라우트별 청크 매핑
- const pages = appBuildManifest.pages ?? {};
- routes = Object.entries(pages)
- .filter(([route]) => !INTERNAL_ROUTES.has(route))
- .map(([route, chunks]) => {
- const pageBytes = chunks.reduce(
- (sum, chunk) => sum + safeGzipSize(path.join(buildDir, chunk)),
- 0
- );
- const firstLoadBytes = pageBytes + sharedBytes;
- return {
- path: route,
- size: formatBytes(pageBytes),
- firstLoad: formatBytes(firstLoadBytes),
- firstLoadKb: toKb(firstLoadBytes),
- };
- });
- } else {
- // Turbopack 모드: routes-manifest.json으로 라우트 목록 구성
- const staticRoutes = [
- ...(routesManifest?.staticRoutes ?? []),
- ...(routesManifest?.dynamicRoutes ?? []),
- ];
-
- const appChunksDir = path.join(buildDir, 'static', 'chunks', 'app');
-
- routes = staticRoutes
- .filter(({ page }) => !INTERNAL_ROUTES.has(page))
- .map(({ page }) => {
- const segment = page === '/' ? '' : page;
- const pageChunkDir = path.join(appChunksDir, segment);
- const pageBytes = sumDirGzipSize(pageChunkDir);
-
- const firstLoadBytes = pageBytes + sharedBytes;
- return {
- path: page,
- size: formatBytes(pageBytes),
- firstLoad: formatBytes(firstLoadBytes),
- firstLoadKb: toKb(firstLoadBytes),
- };
- });
-
- // 라우트가 없으면 공유 번들만이라도 / 라우트로 표시
- if (routes.length === 0 && sharedBytes > 0) {
- routes = [{
- path: '/',
- size: '0 B',
- firstLoad: formatBytes(sharedBytes),
- firstLoadKb: toKb(sharedBytes),
- }];
- }
- }
-
- return { routes, sharedSize: formatBytes(sharedBytes) };
-};
-
-const WARN_THRESHOLD_KB = 200;
-const ERROR_THRESHOLD_KB = 350;
-
-const sizeIcon = (firstLoadKb) => {
- if (firstLoadKb >= ERROR_THRESHOLD_KB) return '🔴';
- if (firstLoadKb >= WARN_THRESHOLD_KB) return '🟡';
- return '🟢';
-};
-
-const formatRouteTable = (appLabel, { routes, sharedSize }) => {
- if (!routes.length) {
- return `### ${appLabel}\n\n> ⚠️ 번들 크기 데이터를 가져오지 못했습니다 (.next 디렉토리를 확인하세요).\n`;
- }
-
- const rows = routes
- .map(
- (r) =>
- `| \`${r.path}\` | ${r.size} | ${r.firstLoad} | ${sizeIcon(r.firstLoadKb)} |`
- )
- .join('\n');
-
- const sharedLine = sharedSize ? `\n> 공유 번들: **${sharedSize}**\n` : '';
-
- return `### ${appLabel}
-
-| 라우트 | 크기 | First Load JS | 상태 |
-|--------|------|---------------|------|
-${rows}
-${sharedLine}`;
-};
-
-module.exports = async ({ github, context, core }) => {
- const prNumber = context.payload.pull_request?.number;
- if (!prNumber) {
- core.warning('PR 컨텍스트를 찾을 수 없습니다.');
- return;
- }
-
- const { owner, repo } = context.repo;
-
- core.info(`번들 분석 경로: ${BUILD_DIR}`);
- const timoWebBuild = analyzeBuild(BUILD_DIR);
-
- if (!timoWebBuild.routes.length) {
- core.warning(`번들 크기 데이터를 파싱하지 못했습니다. ${BUILD_DIR} 를 확인하세요.`);
- return;
- }
-
- const legend = `> 🟢 정상 (<${WARN_THRESHOLD_KB}kB) 🟡 주의 (<${ERROR_THRESHOLD_KB}kB) 🔴 초과 (≥${ERROR_THRESHOLD_KB}kB) — First Load JS 기준 (gzip 크기)`;
-
- const body = `## 📦 번들 사이즈 리포트
-
-${formatRouteTable('🕐 Timo Web', timoWebBuild)}
-
----
-
-${legend}
-
-*빌드 커밋: \`${context.sha.slice(0, 7)}\`*`;
-
- const { data: comments } = await github.rest.issues.listComments({
- owner,
- repo,
- issue_number: prNumber,
- });
-
- const existing = comments.find(
- (c) => c.user?.type === 'Bot' && c.body?.includes('번들 사이즈 리포트')
- );
-
- if (existing) {
- await github.rest.issues.updateComment({
- owner,
- repo,
- comment_id: existing.id,
- body,
- });
- } else {
- await github.rest.issues.createComment({
- owner,
- repo,
- issue_number: prNumber,
- body,
- });
- }
-
- core.info('번들 사이즈 리포트 PR 코멘트 게시 완료');
-};
diff --git a/.github/scripts/performance-report.js b/.github/scripts/performance-report.js
new file mode 100644
index 00000000..c663d95f
--- /dev/null
+++ b/.github/scripts/performance-report.js
@@ -0,0 +1,242 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const zlib = require('zlib');
+
+// ─── Utilities ────────────────────────────────────────────────────────────────
+
+const readJson = (filePath) => {
+ if (!filePath || !fs.existsSync(filePath)) return null;
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
+ catch { return null; }
+};
+
+const gzipSize = (filePath) => {
+ try { return zlib.gzipSync(fs.readFileSync(filePath)).length; }
+ catch { return 0; }
+};
+
+const formatBytes = (bytes) => {
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} kB`;
+ return `${bytes} B`;
+};
+
+// ─── Bundle ───────────────────────────────────────────────────────────────────
+
+const BUILD_DIR = path.join(process.cwd(), 'apps/timo-web/.next');
+const INTERNAL_ROUTES = new Set(['/_not-found', '/_global-error']);
+const BUNDLE_WARN_KB = 200;
+const BUNDLE_ERROR_KB = 350;
+
+const sumDirGzipSize = (dir) => {
+ if (!fs.existsSync(dir)) return 0;
+ return fs.readdirSync(dir).reduce((sum, file) => {
+ const full = path.join(dir, file);
+ return sum + (fs.statSync(full).isFile() ? gzipSize(full) : 0);
+ }, 0);
+};
+
+const analyzeBundle = () => {
+ const buildManifest = readJson(path.join(BUILD_DIR, 'build-manifest.json'));
+ if (!buildManifest) return null;
+
+ const routesManifest = readJson(path.join(BUILD_DIR, 'routes-manifest.json'));
+ const appBuildManifest = readJson(path.join(BUILD_DIR, 'app-build-manifest.json'));
+
+ const sharedBytes = (buildManifest.rootMainFiles ?? []).reduce(
+ (sum, chunk) => sum + gzipSize(path.join(BUILD_DIR, chunk)),
+ 0
+ );
+
+ let routes;
+ if (appBuildManifest) {
+ routes = Object.entries(appBuildManifest.pages ?? {})
+ .filter(([route]) => !INTERNAL_ROUTES.has(route))
+ .map(([route, chunks]) => {
+ const pageBytes = chunks.reduce((sum, c) => sum + gzipSize(path.join(BUILD_DIR, c)), 0);
+ const firstLoad = pageBytes + sharedBytes;
+ return { path: route, size: formatBytes(pageBytes), firstLoad: formatBytes(firstLoad), firstLoadKb: firstLoad / 1024 };
+ });
+ } else {
+ const allRoutes = [
+ ...(routesManifest?.staticRoutes ?? []),
+ ...(routesManifest?.dynamicRoutes ?? []),
+ ];
+ const chunksDir = path.join(BUILD_DIR, 'static', 'chunks', 'app');
+ routes = allRoutes
+ .filter(({ page }) => !INTERNAL_ROUTES.has(page))
+ .map(({ page }) => {
+ const pageBytes = sumDirGzipSize(path.join(chunksDir, page === '/' ? '' : page));
+ const firstLoad = pageBytes + sharedBytes;
+ return { path: page, size: formatBytes(pageBytes), firstLoad: formatBytes(firstLoad), firstLoadKb: firstLoad / 1024 };
+ });
+
+ if (routes.length === 0 && sharedBytes > 0) {
+ routes = [{ path: '/', size: '0 B', firstLoad: formatBytes(sharedBytes), firstLoadKb: sharedBytes / 1024 }];
+ }
+ }
+
+ return { routes, sharedSize: formatBytes(sharedBytes) };
+};
+
+const renderBundle = (data) => {
+ const wrap = (content) => `\nBundle Size — timo-web
\n${content}\n `;
+
+ if (!data?.routes?.length) return wrap('\n> ⚠️ 번들 데이터를 가져오지 못했습니다.\n');
+
+ const rows = data.routes.map(({ path: p, size, firstLoad, firstLoadKb }) => {
+ const icon = firstLoadKb >= BUNDLE_ERROR_KB ? '🔴' : firstLoadKb >= BUNDLE_WARN_KB ? '🟡' : '🟢';
+ return `| \`${p}\` | ${size} | ${icon} ${firstLoad} |`;
+ }).join('\n');
+
+ return wrap(`
+| 라우트 | 크기 | First Load JS |
+|--------|-----:|:-------------|
+${rows}
+
+> 공유 번들: **${data.sharedSize}**
+> 🟢 < ${BUNDLE_WARN_KB}kB | 🟡 < ${BUNDLE_ERROR_KB}kB | 🔴 ≥ ${BUNDLE_ERROR_KB}kB (First Load JS · gzip)
+`);
+};
+
+// ─── Lighthouse ───────────────────────────────────────────────────────────────
+
+const LHCI_DIR = path.join(process.cwd(), '.lighthouseci');
+
+const analyzeLighthouse = () => {
+ const manifest = readJson(path.join(LHCI_DIR, 'manifest.json'));
+ if (!manifest) return null;
+
+ return manifest
+ .filter((r) => r.isRepresentativeRun && r.summary)
+ .map(({ url, summary, jsonPath }) => {
+ let pathname;
+ try { pathname = new URL(url).pathname || '/'; }
+ catch { pathname = url; }
+
+ const audits = readJson(jsonPath)?.audits ?? {};
+ return {
+ pathname,
+ perf: summary.performance,
+ a11y: summary.accessibility,
+ lcp: audits['largest-contentful-paint']?.numericValue,
+ cls: audits['cumulative-layout-shift']?.numericValue,
+ tbt: audits['total-blocking-time']?.numericValue,
+ };
+ });
+};
+
+const fmtScore = (v, threshold) => {
+ const icon = v >= threshold + 0.1 ? '🟢' : v >= threshold ? '🟡' : '🔴';
+ return `${icon} ${Math.round(v * 100)}`;
+};
+
+const renderLighthouse = (data) => {
+ const wrap = (content) => `\nLighthouse — timo-web
\n${content}\n `;
+
+ if (!data?.length) return wrap('\n> ⚠️ Lighthouse 결과를 가져오지 못했습니다.\n');
+
+ const rows = data.map(({ pathname, perf, a11y, lcp, cls, tbt }) => {
+ const lcpFmt = lcp != null ? `${lcp < 2500 ? '🟢' : lcp < 4000 ? '🟡' : '🔴'} ${(lcp / 1000).toFixed(1)}s` : '-';
+ const clsFmt = cls != null ? `${cls < 0.1 ? '🟢' : cls < 0.25 ? '🟡' : '🔴'} ${cls.toFixed(3)}` : '-';
+ const tbtFmt = tbt != null ? `${tbt < 200 ? '🟢' : tbt < 600 ? '🟡' : '🔴'} ${Math.round(tbt)}ms` : '-';
+ return `| \`${pathname}\` | ${fmtScore(perf, 0.7)} | ${fmtScore(a11y, 0.85)} | ${lcpFmt} | ${clsFmt} | ${tbtFmt} |`;
+ }).join('\n');
+
+ return wrap(`
+| URL | Perf | A11y | LCP | CLS | TBT |
+|-----|:----:|:----:|----:|----:|----:|
+${rows}
+
+> **Perf** ≥ 70 / **A11y** ≥ 85 목표
+> **LCP** 🟢 < 2.5s 🟡 < 4s 🔴 ≥ 4s | **CLS** 🟢 < 0.1 🟡 < 0.25 🔴 ≥ 0.25 | **TBT** 🟢 < 200ms 🟡 < 600ms 🔴 ≥ 600ms
+`);
+};
+
+// ─── Images ───────────────────────────────────────────────────────────────────
+
+const PUBLIC_DIR = path.join(process.cwd(), 'apps/timo-web/public');
+const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.svg', '.bmp', '.tiff']);
+const MODERN_EXTS = new Set(['.webp', '.avif', '.svg']);
+const IMG_WARN = 200 * 1024;
+const IMG_ERROR = 500 * 1024;
+
+const scanImages = (dir, base = dir) => {
+ if (!fs.existsSync(dir)) return [];
+ return fs.readdirSync(dir).flatMap((file) => {
+ const full = path.join(dir, file);
+ if (fs.statSync(full).isDirectory()) return scanImages(full, base);
+ const ext = path.extname(file).toLowerCase();
+ if (!IMAGE_EXTS.has(ext)) return [];
+ return [{ rel: path.relative(base, full), bytes: fs.statSync(full).size, ext }];
+ });
+};
+
+const analyzeImages = () => {
+ const images = scanImages(PUBLIC_DIR);
+ return images.length > 0 ? images : null;
+};
+
+const renderImages = (data) => {
+ const wrap = (content) => `\nImage Optimization — timo-web
\n${content}\n `;
+
+ if (!data) return wrap('\n> public/ 디렉토리에 이미지가 없습니다.\n');
+
+ const rows = data.map(({ rel, bytes, ext }) => {
+ const sizeIcon = bytes >= IMG_ERROR ? '🔴' : bytes >= IMG_WARN ? '🟡' : '🟢';
+ const fmtBadge = MODERN_EXTS.has(ext) ? '✅' : '⚠️';
+ return `| \`${rel}\` | ${formatBytes(bytes)} | ${ext.slice(1).toUpperCase()} ${fmtBadge} | ${sizeIcon} |`;
+ }).join('\n');
+
+ const total = formatBytes(data.reduce((s, i) => s + i.bytes, 0));
+ const nonModern = data.filter((i) => !MODERN_EXTS.has(i.ext)).length;
+ const formatNote = nonModern > 0
+ ? `⚠️ ${nonModern}개 파일 WebP/AVIF 변환 권장`
+ : '✅ 모든 이미지가 최적화된 포맷';
+
+ return wrap(`
+| 파일 | 크기 | 포맷 | 상태 |
+|------|-----:|:----:|:----:|
+${rows}
+
+> 총 ${data.length}개 · ${total} | 🟢 < 200KB | 🟡 < 500KB | 🔴 ≥ 500KB
+> ${formatNote}
+`);
+};
+
+// ─── Main ─────────────────────────────────────────────────────────────────────
+
+const COMMENT_MARKER = '';
+
+module.exports = async ({ github, context, core }) => {
+ const prNumber = context.payload.pull_request?.number;
+ if (!prNumber) { core.warning('PR 컨텍스트를 찾을 수 없습니다.'); return; }
+
+ const { owner, repo } = context.repo;
+
+ const body = [
+ COMMENT_MARKER,
+ '## Timo Performance Report',
+ '',
+ renderBundle(analyzeBundle()),
+ '',
+ renderLighthouse(analyzeLighthouse()),
+ '',
+ renderImages(analyzeImages()),
+ '',
+ `*측정 커밋: \`${context.sha.slice(0, 7)}\`*`,
+ ].join('\n');
+
+ const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: prNumber });
+ const existing = comments.find((c) => c.user?.type === 'Bot' && c.body?.includes(COMMENT_MARKER));
+
+ if (existing) {
+ await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
+ core.info('성능 리포트 업데이트 완료');
+ } else {
+ await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
+ core.info('성능 리포트 게시 완료');
+ }
+};
diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml
index 75617d8c..a1153e67 100644
--- a/.github/workflows/auto-assign.yml
+++ b/.github/workflows/auto-assign.yml
@@ -1,4 +1,4 @@
-name: Auto Assign
+name: 👽 Auto Assign
on:
pull_request:
diff --git a/.github/workflows/auto-issue.yml b/.github/workflows/auto-issue.yml
index 4b7b6d25..79b9e849 100644
--- a/.github/workflows/auto-issue.yml
+++ b/.github/workflows/auto-issue.yml
@@ -1,4 +1,4 @@
-name: Auto Issue
+name: 👽 Auto Issue
on:
issues:
diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml
index d7d37157..e18118b7 100644
--- a/.github/workflows/auto-label.yml
+++ b/.github/workflows/auto-label.yml
@@ -1,4 +1,4 @@
-name: Auto Label
+name: 👽 Auto Label
on:
pull_request:
diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml
deleted file mode 100644
index 7b971078..00000000
--- a/.github/workflows/bundle-size.yml
+++ /dev/null
@@ -1,59 +0,0 @@
-name: 📦 번들 사이즈 리포트
-
-on:
- pull_request:
- types: [opened, synchronize, reopened]
- branches: [main, develop]
-
-permissions:
- contents: read
- pull-requests: write
-
-concurrency:
- group: bundle-size-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- bundle-size:
- name: Bundle Size Report
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - uses: pnpm/action-setup@v4
-
- - uses: actions/setup-node@v4
- with:
- node-version: 24
- cache: 'pnpm'
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Build timo-web
- id: build
- env:
- TURBO_UI: false
- NO_COLOR: '1'
- run: pnpm turbo run build --filter=timo-web --output-logs=full 2>&1 | tee /tmp/timo-web-build.txt
- continue-on-error: true
-
- - name: Debug - 빌드 결과 확인
- if: steps.build.outcome == 'failure'
- run: |
- echo "=== .next 디렉토리 ==="
- ls apps/timo-web/.next/ 2>/dev/null || echo "(.next 없음)"
- echo "=== routes-manifest staticRoutes ==="
- node -e "const m=require('./apps/timo-web/.next/routes-manifest.json');console.log(JSON.stringify(m.staticRoutes,null,2))" 2>/dev/null || echo "(없음)"
- echo "=== build-manifest rootMainFiles ==="
- node -e "const m=require('./apps/timo-web/.next/build-manifest.json');console.log(JSON.stringify(m.rootMainFiles,null,2))" 2>/dev/null || echo "(없음)"
- echo "=== static/chunks/app 디렉토리 ==="
- ls apps/timo-web/.next/static/chunks/app/ 2>/dev/null || echo "(없음)"
-
- - name: 번들 사이즈 PR 코멘트 게시
- uses: actions/github-script@v7
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const fn = require('./.github/scripts/bundle-size-report.js')
- await fn({ github, context, core })
diff --git a/.github/workflows/discord-notification.yml b/.github/workflows/discord-notification.yml
index 03edf28f..2433b55f 100644
--- a/.github/workflows/discord-notification.yml
+++ b/.github/workflows/discord-notification.yml
@@ -1,4 +1,4 @@
-name: Automated Discord Notification
+name: 🎵 Automated Discord Notification
on:
pull_request:
diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml
new file mode 100644
index 00000000..ba8c2cd9
--- /dev/null
+++ b/.github/workflows/performance.yml
@@ -0,0 +1,65 @@
+name: ✨ Performance Report
+
+on:
+ pull_request:
+ types: [opened, reopened]
+ branches: [main, develop]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: performance-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ performance:
+ name: 성능 분석
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: pnpm/action-setup@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: 'pnpm'
+
+ - name: 의존성 설치
+ run: pnpm install --frozen-lockfile
+
+ - name: timo-web 빌드
+ env:
+ TURBO_UI: false
+ NO_COLOR: '1'
+ run: pnpm turbo run build --filter=timo-web
+
+ - name: Next.js 서버 시작
+ run: pnpm --filter=timo-web start &
+
+ - name: 서버 준비 대기
+ run: npx wait-on@8 http://localhost:3000 --timeout 60000
+
+ - name: Lighthouse CI 실행
+ run: npx @lhci/cli@0.14.x autorun --config=apps/timo-web/lighthouserc.cjs
+ continue-on-error: true
+
+ - name: 성능 리포트 PR 코멘트 게시
+ if: always()
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const fn = require('./.github/scripts/performance-report.js')
+ await fn({ github, context, core })
+
+ - name: Lighthouse 결과 아티팩트 업로드
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: lighthouse-results
+ path: .lighthouseci/
+ retention-days: 30
diff --git a/.gitignore b/.gitignore
index 96fab4fe..ee778085 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,9 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
+# Lighthouse CI
+.lighthouseci/
+
# Misc
.DS_Store
*.pem
diff --git a/apps/timo-web/lighthouserc.cjs b/apps/timo-web/lighthouserc.cjs
new file mode 100644
index 00000000..bd0fd5ec
--- /dev/null
+++ b/apps/timo-web/lighthouserc.cjs
@@ -0,0 +1,30 @@
+/* global module */
+'use strict';
+
+module.exports = {
+ ci: {
+ collect: {
+ url: [
+ 'http://localhost:3000',
+ ],
+ numberOfRuns: 3,
+ settings: {
+ chromeFlags: '--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage',
+ },
+ },
+ assert: {
+ preset: 'lighthouse:no-pwa',
+ assertions: {
+ 'categories:performance': ['warn', { minScore: 0.7 }],
+ 'categories:accessibility': ['warn', { minScore: 0.85 }],
+ 'categories:best-practices': ['warn', { minScore: 0.8 }],
+ 'categories:seo': ['warn', { minScore: 0.8 }],
+ },
+ },
+ upload: {
+ target: 'filesystem',
+ outputDir: '.lighthouseci',
+ reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%',
+ },
+ },
+};