From 024b2944a2aface0cccb9e6ea201b86cd37d57a9 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 16:52:55 +0900 Subject: [PATCH 1/9] =?UTF-8?q?docs:=20=EC=A0=95=EA=B8=B0=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=20=ED=9B=84=EC=86=8D=20=EC=A0=95=EB=B9=84=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-07-25-routine-health-hardening-design.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-25-routine-health-hardening-design.md diff --git a/docs/superpowers/specs/2026-07-25-routine-health-hardening-design.md b/docs/superpowers/specs/2026-07-25-routine-health-hardening-design.md new file mode 100644 index 0000000..b369e5b --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-routine-health-hardening-design.md @@ -0,0 +1,99 @@ +# 정기 점검 후속 정비 설계 + +## 목표 + +2026년 7월 25일 정기 점검에서 확인된 개발 의존성 취약점, 450줄 임계 파일, +반복 실패 알림의 부재, External Smoke 러너 취소 가능성을 한 번에 정비한다. +공개 API와 MCP 응답 계약은 바꾸지 않으며, 운영 비용을 추가하지 않는다. + +## 검토한 접근 + +### 최소 패치 + +취약한 lockfile과 GitHub secret만 수정하고 나머지는 그대로 둔다. 변경 위험은 가장 +낮지만 450줄 임계 파일, 자동 줄 수 검사 부재, 15개 Smoke 러너의 중복 설치와 취소 +가능성이 남는다. + +### 균형형 정비 + +호환 가능한 의존성을 갱신하고, 임계 파일을 책임별로 분리하며, 줄 수 검사를 CI에 +추가한다. External Smoke는 한 러너에서 순차 실행하고 실패를 한 번만 알린다. +Health 알림은 일일 full 점검으로 제한한다. 현재 문제를 직접 해결하면서 별도 저장소나 +유료 서비스를 만들지 않으므로 이 방식을 선택한다. + +### 장애 상태 저장 + +Durable Object나 별도 데이터베이스에 이전 장애 상태를 저장해 상태 전환 때만 알린다. +정교한 중복 억제가 가능하지만 새 운영 상태와 비용, 장애 복구 로직이 필요해 현재 +규모에는 과하다. + +## 설계 + +### 의존성 안전성 + +- `brace-expansion`을 취약하지 않은 5.0.8 이상으로 고정되는 lockfile로 갱신한다. +- `@cloudflare/workers-types`, ESLint, Hono, Playwright, Wrangler는 현재 호환 범위의 + 최신 패치 또는 마이너 버전으로 올린다. +- TypeScript 7은 `typescript-eslint`의 지원 범위를 벗어나므로 6.0.3을 유지한다. +- 전체 audit와 production-only audit를 모두 실행해 개발·운영 의존성을 구분한다. + +### 450줄 제한 + +- `src/pages/openapiSpecActions.ts`의 쿼리 파라미터 정의를 별도 모듈로 옮긴다. +- `src/cliHelp.ts`의 명령 데이터 정의를 별도 모듈로 옮기고 출력 로직만 남긴다. +- `src/services/lottemart/session.ts`의 raw socket 전송과 HTTP 응답 변환을 별도 + 전송 모듈로 옮긴다. +- `scripts/quality/check-source-line-limit.ts`가 모든 `src/**/*.ts` 파일을 재귀 + 탐색해 450줄 초과 파일을 경로와 줄 수로 보고하고 실패 종료한다. +- 경계값 450줄은 허용하고 451줄부터 실패한다. 자동 생성된 `src` 파일은 현재 + 없으므로 예외 목록을 만들지 않는다. +- npm `check:source-lines`, 통합 `check`, GitHub CI에 동일 검사를 연결한다. +- AGENTS와 CONTRIBUTING에는 제한이 CI로 강제된다는 사실을 명시한다. + +### External Smoke + +- 15개 매트릭스 작업을 단일 `external-smoke` 작업으로 바꾼다. +- 의존성 설치와 CLI 빌드는 한 번만 수행한다. +- 기존 서비스 목록을 CLI, MCP 순서로 한 개씩 실행한다. 각 서비스는 한 번 실패하면 + 15초 후 한 번 재시도한다. +- 한 서비스가 최종 실패해도 다음 서비스 점검을 계속하고, 마지막에 실패 서비스 + 목록을 `external-smoke-summary.txt`로 집계한 뒤 작업을 실패 처리한다. +- 작업에 명시적 제한 시간을 두고, 모든 명령은 순차 실행해 외부 서비스와 GitHub + 러너에 동시에 부하를 주지 않는다. +- 실패 알림은 예약 실행에서만 한 번 전송한다. 수동 실행은 작업 로그로 확인한다. + +### Health 알림 + +- quick와 full 점검의 실패 판정은 그대로 유지해 GitHub Actions 기록을 숨기지 않는다. +- Moshi 푸시는 한국시간 자정의 일일 full 예약 실행이 실패할 때만 전송한다. +- 반복 quick 실패와 수동 점검은 푸시하지 않는다. +- 구현과 배포 검증이 끝난 뒤 저장소 Actions secret에 `MOSHI_WEBHOOK_TOKEN`을 + 등록한다. 값은 로그와 문서에 기록하지 않는다. + +## 오류 처리 + +- 줄 수 검사기는 `src`를 읽지 못하면 성공으로 간주하지 않고 오류와 함께 실패한다. +- Smoke는 서비스별 종료 코드와 출력 일부를 보존해 실패 원인을 요약한다. +- Moshi token이 없거나 알림 전송이 실패해도 원래 Health/Smoke 실패 결과는 + 덮어쓰지 않는다. +- 알려진 Zyte 403과 외부 WAF 장애를 성공으로 바꾸지 않는다. GitHub 실패 상태는 + 실제 가용성 신호로 유지한다. + +## 테스트와 검증 + +- 줄 수 계산과 450/451 경계, 재귀 탐색, 정렬된 위반 보고를 단위 테스트한다. +- 저장소 설정 회귀 테스트로 npm script, CI, 순차 Smoke, 일일 full 알림 조건을 + 고정한다. +- 파일 분리 전후의 기존 OpenAPI, CLI 도움말, 롯데마트 세션 테스트를 그대로 + 통과시켜 공개 동작 보존을 확인한다. +- 모든 테스트는 `--maxWorkers=1 --no-file-parallelism`으로 순차 실행한다. +- 최종적으로 format, ESLint, Biome, TypeScript, 100% coverage, build, + `wrangler deploy --dry-run`, npm audit를 확인한다. +- PR CI가 통과하면 main에 병합하고 CI/CD 배포 완료 후 production `/health`와 + Worker 버전을 확인한다. + +## 범위 밖 + +- 외부 Zyte 계정의 결제 한도 상향과 새 유료 프록시는 추가하지 않는다. +- 알려진 외부 장애를 정상으로 분류하거나 Health 실패를 숨기지 않는다. +- TypeScript 7 전환과 Durable Object 기반 장애 상태 저장은 별도 작업으로 남긴다. From ffa05c3ae1deaaec18433c93efedf1110ba41a10 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 16:56:53 +0900 Subject: [PATCH 2/9] =?UTF-8?q?docs:=20=EC=A0=95=EA=B8=B0=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=20=ED=9B=84=EC=86=8D=20=EC=A0=95=EB=B9=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-25-routine-health-hardening.md | 488 ++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-25-routine-health-hardening.md diff --git a/docs/superpowers/plans/2026-07-25-routine-health-hardening.md b/docs/superpowers/plans/2026-07-25-routine-health-hardening.md new file mode 100644 index 0000000..885d561 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-routine-health-hardening.md @@ -0,0 +1,488 @@ +# Routine Health Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 정기 점검에서 확인된 개발 의존성 취약점, 450줄 임계 파일, 반복 알림, +External Smoke 러너 낭비를 공개 동작 변경 없이 정비한다. + +**Architecture:** 데이터 정의와 전송 계층을 기존 대형 파일에서 분리하고, 독립적인 +줄 수 검사기를 npm과 CI에 연결한다. External Smoke는 단일 러너에서 서비스별 명령을 +순차 실행해 결과를 모으며, 예약된 일일 점검만 Moshi로 한 번 알린다. + +**Tech Stack:** TypeScript 6, Vitest, ESLint, Biome, GitHub Actions, Cloudflare +Workers, npm + +--- + +### Task 1: 450줄 제한 자동 검사 + +**Files:** + +- Create: `scripts/quality/check-source-line-limit.ts` +- Create: `tests/scripts/source-line-limit.test.ts` +- Modify: `tests/scripts/repository-config.test.ts` +- Modify: `package.json` +- Modify: `.github/workflows/ci.yml` +- Modify: `AGENTS.md` +- Modify: `CONTRIBUTING.md` + +- [ ] **Step 1: 경계값과 재귀 탐색 실패 테스트 작성** + +`tests/scripts/source-line-limit.test.ts`에 임시 디렉터리를 만들고 다음 동작을 검증한다. + +```typescript +expect(countSourceLines(`${'line\n'.repeat(450)}`)).toBe(450); +expect(countSourceLines(`${'line\n'.repeat(451)}`)).toBe(451); +await expect(findSourceLineViolations(sourceRoot, 450)).resolves.toEqual([ + { path: 'nested/too-long.ts', lines: 451 }, +]); +``` + +`tests/scripts/repository-config.test.ts`에는 `check:source-lines`, 통합 `check`, CI 단계, +AGENTS와 CONTRIBUTING의 CI 강제 문구를 기대하는 테스트를 추가한다. + +- [ ] **Step 2: 새 테스트가 올바른 이유로 실패하는지 확인** + +Run: + +```bash +npx vitest run tests/scripts/source-line-limit.test.ts tests/scripts/repository-config.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: `scripts/quality/check-source-line-limit.ts`가 없어 import 또는 설정 기대값이 +실패한다. + +- [ ] **Step 3: 최소 줄 수 검사기 구현** + +검사기는 다음 API와 CLI 동작을 제공한다. + +```typescript +export const MAX_SOURCE_FILE_LINES = 450; + +export interface SourceLineViolation { + path: string; + lines: number; +} + +export function countSourceLines(source: string): number; + +export async function findSourceLineViolations( + sourceRoot?: string, + maxLines?: number, +): Promise; +``` + +`findSourceLineViolations`는 디렉터리를 이름순으로 재귀 탐색하고 `.ts` 파일만 읽는다. +CLI 실행 시 위반이 없으면 검사 파일 수와 제한을 출력하고 0으로 종료한다. 위반이 +있으면 `path: lines lines`를 출력하고 `process.exitCode = 1`로 설정한다. `src`를 +읽지 못한 오류는 잡아 성공으로 바꾸지 않는다. + +`package.json`은 다음처럼 연결한다. + +```json +"check:source-lines": "npx tsx scripts/quality/check-source-line-limit.ts", +"check": "npm run format:check && npm run lint && npm run lint:biome && npm run typecheck && npm run check:source-lines && npm test" +``` + +CI의 Type check 다음에 `npm run check:source-lines` 단계를 추가하고 두 문서에는 +450줄 제한이 CI에서 강제된다고 명시한다. + +- [ ] **Step 4: 대상 테스트와 실제 저장소 검사 통과 확인** + +Run: + +```bash +npx vitest run tests/scripts/source-line-limit.test.ts tests/scripts/repository-config.test.ts --maxWorkers=1 --no-file-parallelism +npm run check:source-lines +``` + +Expected: 두 명령 모두 PASS, 현재 `src/**/*.ts` 위반 0개. + +- [ ] **Step 5: 커밋** + +```bash +git add scripts/quality/check-source-line-limit.ts tests/scripts/source-line-limit.test.ts tests/scripts/repository-config.test.ts package.json .github/workflows/ci.yml AGENTS.md CONTRIBUTING.md +git commit -m "ci: 소스 파일 450줄 제한 자동 검사" +``` + +### Task 2: OpenAPI와 CLI 도움말 책임 분리 + +**Files:** + +- Create: `src/pages/openapiSpecActionParameters.ts` +- Modify: `src/pages/openapiSpecActions.ts` +- Create: `src/cliHelpDefinitions.ts` +- Modify: `src/cliHelp.ts` +- Modify: `tests/pages/openapi.test.ts` +- Modify: `tests/app/cli.test.ts` + +- [ ] **Step 1: 새 모듈 계약을 사용하는 실패 테스트 작성** + +OpenAPI 테스트는 새 모듈에서 파라미터를 import해 `action`이 필수이며 enum이 +`ACTION_QUERY_ACTIONS`와 일치하는지 확인한다. CLI 테스트는 새 정의 모듈에서 +`COMMAND_LIST`, `COMMAND_SUMMARY`, `COMMAND_DETAIL`을 import해 모든 명령에 요약과 +상세 도움말이 존재하는지 확인한다. + +```typescript +expect(ACTION_QUERY_PARAMETERS.find((item) => item.name === 'action')).toMatchObject({ + required: true, +}); +for (const command of COMMAND_LIST) { + expect(COMMAND_SUMMARY[command]).toBeTruthy(); + expect(COMMAND_DETAIL[command].length).toBeGreaterThan(0); +} +``` + +- [ ] **Step 2: 새 모듈이 없어 실패하는지 확인** + +Run: + +```bash +npx vitest run tests/pages/openapi.test.ts tests/app/cli.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: 두 새 모듈을 찾지 못해 FAIL. + +- [ ] **Step 3: OpenAPI 파라미터 정의 이동** + +`ACTION_QUERY_PARAMETERS` 전체를 `openapiSpecActionParameters.ts`로 옮기고 export한다. +`openapiSpecActions.ts`는 이를 import해 기존 스펙 객체에 그대로 사용한다. 값, 순서, +설명, schema는 변경하지 않는다. + +- [ ] **Step 4: CLI 정의 이동과 기존 export 보존** + +`CommandName`, `COMMAND_LIST`, `COMMAND_SUMMARY`, `COMMAND_DETAIL`을 +`cliHelpDefinitions.ts`로 옮긴다. `cliHelp.ts`는 출력 함수만 유지하고 다음처럼 기존 +export를 보존한다. + +```typescript +import { + COMMAND_DETAIL, + COMMAND_LIST, + COMMAND_SUMMARY, + type CommandName, +} from './cliHelpDefinitions.js'; + +export { COMMAND_LIST } from './cliHelpDefinitions.js'; +export type { CommandName } from './cliHelpDefinitions.js'; +``` + +- [ ] **Step 5: 관련 테스트와 줄 수 검사 통과 확인** + +Run: + +```bash +npx vitest run tests/pages/openapi.test.ts tests/pages/openapi-entry.test.ts tests/app/cli.test.ts --maxWorkers=1 --no-file-parallelism +npm run check:source-lines +``` + +Expected: PASS, 분리된 모든 파일이 450줄 이하. + +- [ ] **Step 6: 커밋** + +```bash +git add src/pages/openapiSpecActionParameters.ts src/pages/openapiSpecActions.ts src/cliHelpDefinitions.ts src/cliHelp.ts tests/pages/openapi.test.ts tests/app/cli.test.ts +git commit -m "refactor: OpenAPI와 CLI 도움말 정의 분리" +``` + +### Task 3: 롯데마트 소켓 전송 계층 분리 + +**Files:** + +- Create: `src/services/lottemart/socketTransport.ts` +- Modify: `src/services/lottemart/session.ts` +- Modify: `tests/services/lottemart/session.test.ts` + +- [ ] **Step 1: 소켓 테스트 import를 새 모듈로 변경** + +다음 세 symbol을 새 모듈에서 import하도록 테스트를 먼저 바꾼다. + +```typescript +import { + __testOnlyCreateLotteMartSocketResponse, + __testOnlyFetchLotteMartSocketResponse, + withLotteMartSessionCookie, +} from '../../../src/services/lottemart/socketTransport.js'; +``` + +기존 raw HTTP 파싱, write/close/read timeout, 헤더와 body 직렬화 검증은 유지한다. + +- [ ] **Step 2: 새 모듈 부재로 실패하는지 확인** + +Run: + +```bash +npx vitest run tests/services/lottemart/session.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: `socketTransport.js` 모듈을 찾지 못해 FAIL. + +- [ ] **Step 3: 전송 계층 이동** + +다음 책임을 `socketTransport.ts`로 옮긴다. + +- `LotteMartSocketConnect` +- `withSocketTimeout` +- `__testOnlyCreateLotteMartSocketResponse` +- `__testOnlyFetchLotteMartSocketResponse` +- `fetchLotteMartSocketResponse` +- `withLotteMartSessionCookie` +- socket 요청 body 문자열 변환 + +Cloudflare socket dynamic import의 coverage ignore 경계는 유지한다. `session.ts`는 +`fetchLotteMartSocketResponse`와 `withLotteMartSessionCookie`를 import하고, 기존 +소비자를 위해 `withLotteMartSessionCookie`를 다시 export한다. + +- [ ] **Step 4: 롯데마트 회귀 테스트와 줄 수 검사 통과 확인** + +Run: + +```bash +npx vitest run tests/services/lottemart/session.test.ts tests/services/lottemart/debug.test.ts tests/services/lottemart/client.test.ts tests/services/lottemart/client.edge.test.ts --maxWorkers=1 --no-file-parallelism +npm run check:source-lines +``` + +Expected: PASS, `session.ts`와 새 전송 모듈 모두 450줄 이하. + +- [ ] **Step 5: 커밋** + +```bash +git add src/services/lottemart/socketTransport.ts src/services/lottemart/session.ts tests/services/lottemart/session.test.ts +git commit -m "refactor: 롯데마트 소켓 전송 계층 분리" +``` + +### Task 4: 의존성 취약점과 호환 업데이트 + +**Files:** + +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `tests/scripts/repository-config.test.ts` + +- [ ] **Step 1: 보안 버전 회귀 테스트 추가** + +repository config 테스트에서 lockfile의 `node_modules/brace-expansion.version`이 +5.0.8 이상인지 확인한다. + +```typescript +const version = lock.packages['node_modules/brace-expansion'].version; +const [major, minor, patch] = version.split('.').map(Number); +expect([major, minor]).toEqual([5, 0]); +expect(patch).toBeGreaterThanOrEqual(8); +``` + +- [ ] **Step 2: 현재 lockfile에서 실패하는지 확인** + +Run: + +```bash +npx vitest run tests/scripts/repository-config.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: 현재 5.0.7 때문에 FAIL. + +- [ ] **Step 3: 호환 의존성 갱신** + +Run: + +```bash +npm install hono@4.12.32 +npm install --save-dev @cloudflare/workers-types@5.20260724.1 eslint@10.8.0 playwright@1.62.0 wrangler@4.114.0 +npm update brace-expansion +``` + +TypeScript는 6.0.3을 유지한다. 명령이 만든 package와 lockfile 변경 외 수동 lockfile +편집은 하지 않는다. + +- [ ] **Step 4: 보안과 호환성 확인** + +Run: + +```bash +npx vitest run tests/scripts/repository-config.test.ts --maxWorkers=1 --no-file-parallelism +npm audit +npm audit --omit=dev +npm run typecheck +``` + +Expected: 테스트 PASS, audit 두 종류 모두 취약점 0개, typecheck PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add package.json package-lock.json tests/scripts/repository-config.test.ts +git commit -m "fix: 개발 의존성 취약점과 호환 버전 갱신" +``` + +### Task 5: 순차 External Smoke와 일일 단일 알림 + +**Files:** + +- Modify: `.github/workflows/external-smoke.yml` +- Modify: `.github/workflows/health-checks.yml` +- Modify: `tests/scripts/repository-config.test.ts` + +- [ ] **Step 1: 원하는 workflow 계약의 실패 테스트 작성** + +repository config 테스트가 다음을 요구하도록 바꾼다. + +```typescript +expect(smoke).not.toContain('matrix:'); +expect(smoke).not.toContain('max-parallel:'); +expect(smoke).toContain('run_smoke_with_retry'); +expect(smoke).toContain('CLI_SMOKE_SERVICES='); +expect(smoke).toContain('MCP_SMOKE_SERVICES='); +expect(smoke).toContain('external-smoke-summary.txt'); +expect(smoke).toContain("github.event_name == 'schedule'"); +expect(health).toContain("github.event.schedule == '10 15 * * *'"); +``` + +서비스 이름 전체와 한 번의 `npm run build`, Moshi token 누락 처리도 기대한다. + +- [ ] **Step 2: 기존 매트릭스 workflow에서 실패하는지 확인** + +Run: + +```bash +npx vitest run tests/scripts/repository-config.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: matrix와 `max-parallel: 4` 때문에 FAIL. + +- [ ] **Step 3: External Smoke를 단일 순차 작업으로 변경** + +workflow는 checkout, Node 24, `npm ci`, `npm run build`를 한 번씩 수행한다. +`run_smoke_with_retry`는 suite와 service를 받아 기존 smoke 스크립트를 실행하고 +15초 후 한 번 재시도한다. CLI 서비스 10개와 MCP 서비스 5개를 각각 `for` 루프로 +순차 실행한다. + +각 최종 실패는 `suite.service`와 출력의 마지막 부분을 summary 파일에 추가한다. +모든 서비스를 실행한 뒤 실패 수가 1 이상이면 종료 코드 1을 반환한다. job에는 +`timeout-minutes: 60`을 둔다. + +알림 step은 다음 조건으로 예약 실행에서 한 번만 동작한다. + +```yaml +if: failure() && github.event_name == 'schedule' +``` + +- [ ] **Step 4: Health 알림을 full 예약 실패로 제한** + +Health 검사와 재시도는 바꾸지 않고 알림 step 조건만 다음처럼 제한한다. + +```yaml +if: failure() && github.event_name == 'schedule' && github.event.schedule == '10 15 * * *' +``` + +Moshi `curl`에는 `--fail-with-body --silent --show-error`를 사용해 알림 전송 오류가 +로그에 드러나게 한다. + +- [ ] **Step 5: workflow 회귀 테스트와 format 확인** + +Run: + +```bash +npx vitest run tests/scripts/repository-config.test.ts --maxWorkers=1 --no-file-parallelism +npm run format:check +``` + +Expected: PASS. + +- [ ] **Step 6: 커밋** + +```bash +git add .github/workflows/external-smoke.yml .github/workflows/health-checks.yml tests/scripts/repository-config.test.ts +git commit -m "ci: 외부 점검 순차 실행과 일일 단일 알림" +``` + +### Task 6: 전체 검증, PR, 배포 + +**Files:** + +- Verify all changed files +- External configuration: GitHub Actions secret `MOSHI_WEBHOOK_TOKEN` + +- [ ] **Step 1: 정적 검사 순차 실행** + +Run each command separately: + +```bash +npm run format:check +npm run lint +npm run lint:biome +npm run typecheck +npm run check:source-lines +``` + +Expected: 모두 PASS, compiler warning과 lint warning 0개. + +- [ ] **Step 2: 전체 테스트와 100% coverage 순차 실행** + +Run: + +```bash +npx vitest run --maxWorkers=1 --no-file-parallelism +npx vitest run --coverage --maxWorkers=1 --no-file-parallelism +``` + +Expected: 전체 테스트 PASS, Statements/Branches/Functions/Lines 100%. + +- [ ] **Step 3: 빌드와 배포 dry-run, audit 실행** + +Run each command separately: + +```bash +npm run build +npx wrangler deploy --dry-run +npm audit +npm audit --omit=dev +``` + +Expected: 모두 PASS, 취약점 0개. + +- [ ] **Step 4: diff와 파일 길이 검토** + +Run: + +```bash +git diff main...HEAD --check +rg --files src -g '*.ts' | xargs wc -l | sort -nr | head -n 20 +git status --short +``` + +Expected: whitespace 오류 없음, 450줄 초과 없음, 의도하지 않은 파일 없음. + +- [ ] **Step 5: 브랜치 push와 PR 생성** + +```bash +git push -u origin chore/routine-health-hardening +gh pr create \ + --base main \ + --head chore/routine-health-hardening \ + --title "chore: 정기 점검 후속 품질과 운영 정비" \ + --body "## 요약 +- 450줄 제한 자동 검사와 임계 파일 분리 +- 의존성 보안 업데이트 +- External Smoke 순차 실행과 일일 단일 알림 + +## 검증 +- 단일 워커 전체 테스트와 100% coverage +- lint, typecheck, build, deploy dry-run +- npm audit 취약점 0개" +``` + +PR 본문에는 변경 요약, 순차 테스트 결과, audit 결과, TypeScript 7 보류 이유를 +기록한다. CI, Coverage, CodeQL, Deploy required check를 확인한다. + +- [ ] **Step 6: Moshi secret 등록과 main 병합** + +PR 검증이 통과한 뒤 token 값을 출력하지 않는 입력 방식으로 +`MOSHI_WEBHOOK_TOKEN` Actions secret을 등록한다. PR을 squash merge하고 최신 main을 +pull한다. + +- [ ] **Step 7: 배포와 운영 확인** + +Deploy workflow 완료를 확인하고 production `/health`가 200인지, Worker 최신 버전이 +100% 트래픽을 받는지 확인한다. External Smoke와 Health workflow를 수동 실행해 +workflow 문법과 순차 실행을 검증하되 수동 실행은 Moshi 푸시를 보내지 않는지 로그로 +확인한다. From d9f39aec65adbc421d657f6f3de13bf8067bb7fd Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 16:59:27 +0900 Subject: [PATCH 3/9] =?UTF-8?q?ci:=20=EC=86=8C=EC=8A=A4=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20450=EC=A4=84=20=EC=A0=9C=ED=95=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EA=B2=80=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 + AGENTS.md | 3 +- CONTRIBUTING.md | 7 +- package.json | 3 +- scripts/quality/check-source-line-limit.ts | 98 ++++++++++++++++++++++ tests/scripts/repository-config.test.ts | 16 ++++ tests/scripts/source-line-limit.test.ts | 41 +++++++++ 7 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 scripts/quality/check-source-line-limit.ts create mode 100644 tests/scripts/source-line-limit.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d564f6..fc4fca9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Type check run: npm run typecheck + - name: Source file line limit + run: npm run check:source-lines + - name: Unit tests run: npm test diff --git a/AGENTS.md b/AGENTS.md index dd46408..052af60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,8 @@ - **최대 줄 수**: 450줄 - **권장 줄 수**: 300-400줄 - **초과 시 조치**: 파일이 450줄을 초과하면 기능별로 분리하여 모듈화 -- **예외**: 자동 생성 파일(예: OpenAPI 생성 산출물)은 예외로 둘 수 있음 +- **자동 검사**: `src/**/*.ts`의 450줄 제한은 CI에서 자동으로 강제 +- **예외**: 현재 `src` 아래 예외는 없으며 OpenAPI 생성 산출물은 저장소 루트에 위치 #### 파일 분리 예시 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87206cd..abf1297 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,11 +62,8 @@ ci: PR 검증 워크플로우 추가 - TypeScript `strict` 모드를 유지합니다. - 비동기/외부 API 호출에는 에러 처리를 포함합니다. - 주석은 한국어로 작성합니다. -- 파일 길이는 450줄 내외를 권장합니다. - -예외: - -- `src/pages/openapi.ts`는 OpenAPI 생성 로직 특성상 길어질 수 있습니다. +- 모든 `src/**/*.ts` 파일은 450줄 이하로 유지합니다. +- 450줄 제한은 CI에서 자동으로 강제되며 `npm run check:source-lines`로 로컬 확인합니다. - 길이가 커지는 파일은 기능 단위 모듈 분리를 우선 검토하세요. ## 테스트 원칙 diff --git a/package.json b/package.json index 73696ed..8a6397d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "release:dry-run": "npm pack --dry-run", "publish:public": "npm publish --access public", "typecheck": "tsc --noEmit", + "check:source-lines": "npx tsx scripts/quality/check-source-line-limit.ts", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" \"vitest.config.ts\"", "lint:fix": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" \"vitest.config.ts\" --fix", "format": "prettier .github/**/*.md .github/**/*.yml README.md SECURITY.md AGENTS.md CONTRIBUTING.md package.json tsconfig.json .prettierrc.json .mcp.json eslint.config.ts --write", @@ -39,7 +40,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "check": "npm run format:check && npm run lint && npm run lint:biome && npm run typecheck && npm test", + "check": "npm run format:check && npm run lint && npm run lint:biome && npm run typecheck && npm run check:source-lines && npm test", "lint:biome": "biome lint" }, "keywords": [ diff --git a/scripts/quality/check-source-line-limit.ts b/scripts/quality/check-source-line-limit.ts new file mode 100644 index 0000000..39932a2 --- /dev/null +++ b/scripts/quality/check-source-line-limit.ts @@ -0,0 +1,98 @@ +/** + * TypeScript 소스 파일 줄 수 제한 검사 + */ + +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const MAX_SOURCE_FILE_LINES = 450; + +export interface SourceLineViolation { + path: string; + lines: number; +} + +interface SourceLineScan { + fileCount: number; + violations: SourceLineViolation[]; +} + +export function countSourceLines(source: string): number { + if (source.length === 0) { + return 0; + } + + const newlineCount = source.match(/\n/g)?.length ?? 0; + return newlineCount + (source.endsWith('\n') ? 0 : 1); +} + +async function listTypeScriptFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + + const files: string[] = []; + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await listTypeScriptFiles(entryPath))); + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + files.push(entryPath); + } + } + + return files; +} + +async function scanSourceLineLimit(sourceRoot: string, maxLines: number): Promise { + const files = await listTypeScriptFiles(sourceRoot); + const violations: SourceLineViolation[] = []; + + for (const file of files) { + const lines = countSourceLines(await readFile(file, 'utf8')); + if (lines > maxLines) { + violations.push({ + path: path.relative(sourceRoot, file).split(path.sep).join('/'), + lines, + }); + } + } + + return { fileCount: files.length, violations }; +} + +export async function findSourceLineViolations( + sourceRoot = path.resolve('src'), + maxLines = MAX_SOURCE_FILE_LINES, +): Promise { + return (await scanSourceLineLimit(sourceRoot, maxLines)).violations; +} + +async function main(): Promise { + const sourceRoot = path.resolve('src'); + const { fileCount, violations } = await scanSourceLineLimit( + sourceRoot, + MAX_SOURCE_FILE_LINES, + ); + + if (violations.length === 0) { + console.log( + `Source file line limit passed: ${fileCount} file(s), maximum ${MAX_SOURCE_FILE_LINES} lines`, + ); + return; + } + + console.error(`Source files exceeding ${MAX_SOURCE_FILE_LINES} lines:`); + for (const violation of violations) { + console.error(`- ${violation.path}: ${violation.lines} lines`); + } + process.exitCode = 1; +} + +const entryPath = process.argv[1]; +if (entryPath && import.meta.url === pathToFileURL(entryPath).href) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index 5b26557..d011818 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -101,6 +101,22 @@ describe('repository maintenance configuration', () => { expect(agents).toContain('수정 전 커밋의 과거 실패'); }); + it('450줄 제한은 npm과 CI에서 자동 검사한다', () => { + const pkg = JSON.parse(readText('package.json')) as { scripts: Record }; + const ci = readText('.github/workflows/ci.yml'); + const agents = readText('AGENTS.md'); + const contributing = readText('CONTRIBUTING.md'); + + expect(pkg.scripts['check:source-lines']).toContain( + 'scripts/quality/check-source-line-limit.ts', + ); + expect(pkg.scripts.check).toContain('npm run check:source-lines'); + expect(ci).toContain('Source file line limit'); + expect(ci).toContain('npm run check:source-lines'); + expect(agents).toContain('450줄 제한은 CI에서 자동으로 강제'); + expect(contributing).toContain('450줄 제한은 CI에서 자동으로 강제'); + }); + it('external smoke workflow는 수동 및 야간 실행으로 CLI smoke를 수행한다', () => { const workflow = readText('.github/workflows/external-smoke.yml'); diff --git a/tests/scripts/source-line-limit.test.ts b/tests/scripts/source-line-limit.test.ts new file mode 100644 index 0000000..014374e --- /dev/null +++ b/tests/scripts/source-line-limit.test.ts @@ -0,0 +1,41 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + countSourceLines, + findSourceLineViolations, +} from '../../scripts/quality/check-source-line-limit.js'; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((temporaryRoot) => rm(temporaryRoot, { force: true, recursive: true })), + ); +}); + +describe('source line limit', () => { + it('마지막 줄바꿈 유무와 빈 파일을 일관되게 계산한다', () => { + expect(countSourceLines('')).toBe(0); + expect(countSourceLines('first')).toBe(1); + expect(countSourceLines('first\nsecond')).toBe(2); + expect(countSourceLines('first\nsecond\n')).toBe(2); + }); + + it('450줄은 허용하고 중첩된 451줄 TypeScript 파일만 보고한다', async () => { + const sourceRoot = await mkdtemp(path.join(tmpdir(), 'daiso-source-lines-')); + temporaryRoots.push(sourceRoot); + await mkdir(path.join(sourceRoot, 'nested')); + await writeFile(path.join(sourceRoot, 'allowed.ts'), 'line\n'.repeat(450)); + await writeFile(path.join(sourceRoot, 'nested', 'too-long.ts'), 'line\n'.repeat(451)); + await writeFile(path.join(sourceRoot, 'nested', 'ignored.js'), 'line\n'.repeat(500)); + + await expect(findSourceLineViolations(sourceRoot, 450)).resolves.toEqual([ + { + path: 'nested/too-long.ts', + lines: 451, + }, + ]); + }); +}); From b5753bfdf0c495178f593266eb6930254d73d043 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 17:04:04 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20OpenAPI=EC=99=80=20CLI=20?= =?UTF-8?q?=EB=8F=84=EC=9B=80=EB=A7=90=20=EC=A0=95=EC=9D=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cliHelp.ts | 387 +--------------------- src/cliHelpDefinitions.ts | 378 +++++++++++++++++++++ src/pages/openapiSpecActionParameters.ts | 398 +++++++++++++++++++++++ src/pages/openapiSpecActions.ts | 395 +--------------------- tests/app/cli.test.ts | 12 + tests/pages/openapi.test.ts | 14 + 6 files changed, 812 insertions(+), 772 deletions(-) create mode 100644 src/cliHelpDefinitions.ts create mode 100644 src/pages/openapiSpecActionParameters.ts diff --git a/src/cliHelp.ts b/src/cliHelp.ts index 82b62c5..f2bfdd4 100644 --- a/src/cliHelp.ts +++ b/src/cliHelp.ts @@ -1,381 +1,14 @@ /** - * CLI 도움말 정의 + * CLI 도움말 출력 */ +import { + COMMAND_DETAIL, + COMMAND_LIST, + COMMAND_SUMMARY, + type CommandName, +} from './cliHelpDefinitions.js'; -export type CommandName = - | 'help' - | 'version' - | 'url' - | 'health' - | 'claude' - | 'get' - | 'products' - | 'product' - | 'stores' - | 'inventory' - | 'display-location' - | 'compare' - | 'places' - | 'cu-stores' - | 'cu-inventory' - | 'cgv-theaters' - | 'cgv-movies' - | 'cgv-timetable' - | 'lottecinema-theaters' - | 'lottecinema-movies' - | 'lottecinema-seats' - | 'lottemart-stores' - | 'lottemart-products' - | 'emart24-stores' - | 'emart24-products' - | 'emart24-inventory' - | 'gs25-stores' - | 'gs25-products' - | 'gs25-inventory' - | 'seveneleven-products' - | 'seveneleven-stores' - | 'seveneleven-inventory' - | 'seveneleven-popwords' - | 'seveneleven-catalog'; - -export const COMMAND_LIST: CommandName[] = [ - 'help', - 'version', - 'url', - 'health', - 'claude', - 'get', - 'products', - 'product', - 'stores', - 'inventory', - 'display-location', - 'compare', - 'places', - 'cu-stores', - 'cu-inventory', - 'cgv-theaters', - 'cgv-movies', - 'cgv-timetable', - 'lottecinema-theaters', - 'lottecinema-movies', - 'lottecinema-seats', - 'lottemart-stores', - 'lottemart-products', - 'emart24-stores', - 'emart24-products', - 'emart24-inventory', - 'gs25-stores', - 'gs25-products', - 'gs25-inventory', - 'seveneleven-products', - 'seveneleven-stores', - 'seveneleven-inventory', - 'seveneleven-popwords', - 'seveneleven-catalog', -]; - -const COMMAND_SUMMARY: Record = { - help: '도움말 출력', - version: 'CLI 버전 출력', - url: 'MCP 엔드포인트 URL 출력', - health: '원격 서버 상태 확인', - claude: 'Claude Code MCP 등록 명령 출력/실행', - get: '임의 API 경로 GET 호출', - products: '다이소 제품 검색', - product: '다이소 제품 상세 조회', - stores: '다이소 매장 검색', - inventory: '다이소 재고 조회', - 'display-location': '다이소 진열 위치 조회', - compare: '키 없는 통합 상품 가격 후보 비교', - places: '음식점/카페 등 주변 장소 검색', - 'cu-stores': 'CU 매장 검색', - 'cu-inventory': 'CU 재고 조회', - 'cgv-theaters': 'CGV 극장 검색', - 'cgv-movies': 'CGV 영화 검색', - 'cgv-timetable': 'CGV 시간표 조회', - 'lottecinema-theaters': '롯데시네마 주변 지점 조회', - 'lottecinema-movies': '롯데시네마 영화/회차 조회', - 'lottecinema-seats': '롯데시네마 잔여 좌석 조회', - 'lottemart-stores': '롯데마트 매장 검색', - 'lottemart-products': '롯데마트 상품 검색', - 'emart24-stores': '이마트24 매장 검색', - 'emart24-products': '이마트24 상품 검색', - 'emart24-inventory': '이마트24 재고 조회', - 'gs25-stores': 'GS25 매장 검색', - 'gs25-products': 'GS25 상품 검색', - 'gs25-inventory': 'GS25 재고 조회', - 'seveneleven-products': '세븐일레븐 상품 검색', - 'seveneleven-stores': '세븐일레븐 매장 검색', - 'seveneleven-inventory': '세븐일레븐 재고 조회', - 'seveneleven-popwords': '세븐일레븐 인기 검색어 조회', - 'seveneleven-catalog': '세븐일레븐 카탈로그 스냅샷 조회', -}; - -const COMMAND_DETAIL: Record = { - help: [ - '명령: help', - '설명: 전체 도움말 또는 특정 명령 상세 도움말을 출력합니다.', - '사용법: daiso help [command]', - '예시: daiso help products', - ], - version: ['명령: version', '설명: 설치된 daiso CLI 버전을 출력합니다.', '사용법: daiso version'], - url: [ - '명령: url', - '설명: MCP 엔드포인트 URL을 출력합니다.', - '사용법: daiso url', - '출력 예시: https://mcp.aka.page/mcp', - ], - health: [ - '명령: health', - '설명: 서버 /health API를 호출해 상태를 확인합니다.', - '사용법: daiso health', - '출력: status, endpoint, checkedAt(JSON)', - ], - claude: [ - '명령: claude', - '설명: Claude Code MCP 등록 명령을 출력하거나 실행합니다.', - '사용법: daiso claude [--exec]', - '옵션: --exec 실제 claude 명령 실행', - '예시: daiso claude --exec', - ], - get: [ - '명령: get', - '설명: 임의 API 경로를 GET으로 호출합니다.', - '사용법: daiso get [--queryKey value ...] [--json]', - '필수: ', - '옵션: --json 원본 JSON 출력', - '예시: daiso get /api/cgv/movies --playDate 20260307 --theaterCode 0056', - ], - products: [ - '명령: products', - '설명: 다이소 제품 검색 API를 호출합니다.', - '사용법: daiso products [--page N] [--pageSize N] [--json]', - '필수: ', - '옵션: --page, --pageSize, --json', - '예시: daiso products 수납박스 --page 1 --pageSize 30', - ], - product: [ - '명령: product', - '설명: 다이소 제품 상세 API를 호출합니다.', - '사용법: daiso product [--json]', - '필수: ', - '옵션: --json', - '예시: daiso product 1034604', - ], - stores: [ - '명령: stores', - '설명: 다이소 매장 검색 API를 호출합니다.', - '사용법: daiso stores [keyword] [--sido 값] [--gugun 값] [--dong 값] [--limit N] [--json]', - '필수: keyword 또는 --sido', - '옵션: --keyword, --sido, --gugun, --dong, --limit, --json', - '예시: daiso stores 강남역', - '예시: daiso stores --sido 서울 --gugun 강남구', - ], - inventory: [ - '명령: inventory', - '설명: 다이소 재고 API를 호출합니다.', - '사용법: daiso inventory [--keyword 값] [--lat 값] [--lng 값] [--page N] [--pageSize N] [--json]', - '필수: ', - '옵션: --keyword, --lat, --lng, --page, --pageSize, --json', - '예시: daiso inventory 1034604 --keyword 강남역', - ], - 'display-location': [ - '명령: display-location', - '설명: 다이소 매장 내 상품 진열 위치 API를 호출합니다.', - '사용법: daiso display-location [--json]', - '필수: , ', - '옵션: --json', - '예시: daiso display-location 1034604 04515', - ], - compare: [ - '명령: compare', - '설명: 새 외부 키 없이 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다.', - '사용법: daiso compare [--services daiso,gs25,seveneleven,emart24] [--limit N] [--json]', - '필수: ', - '옵션: --services, --limit, --json', - '예시: daiso compare 콜라 --limit 3', - '예시: daiso compare 컵라면 --services seveneleven,emart24 --json', - ], - places: [ - '명령: places', - '설명: 네이버 지역 검색으로 음식점, 카페, 디저트 가게 등 주변 장소를 검색합니다.', - '사용법: daiso places [--category cafe|restaurant|food|dessert|all] [--keyword 검색어] [--limit N] [--json]', - '필수: 또는 --keyword', - '옵션: --category, --keyword, --limit, --start, --sort, --json', - '예시: daiso places 강남역 --category cafe --limit 5', - '예시: daiso places 성수동 --keyword 브런치 --limit 5', - ], - 'cu-stores': [ - '명령: cu-stores', - '설명: CU 매장 검색 API를 호출합니다.', - '사용법: daiso cu-stores [keyword] [--lat 값] [--lng 값] [--limit N] [--json]', - '옵션: --keyword, --lat, --lng, --limit, --json', - '예시: daiso cu-stores 강남', - '예시: daiso cu-stores --lat 37.498 --lng 127.027 --limit 10', - ], - 'cu-inventory': [ - '명령: cu-inventory', - '설명: CU 재고 검색 API를 호출합니다.', - '사용법: daiso cu-inventory [--storeKeyword 값] [--lat 값] [--lng 값] [--size N] [--offset N] [--json]', - '필수: ', - '옵션: --storeKeyword, --lat, --lng, --size, --offset, --searchSort, --storeLimit, --json', - '예시: daiso cu-inventory 과자', - '예시: daiso cu-inventory 컵라면 --storeKeyword 강남 --size 10', - ], - 'cgv-theaters': [ - '명령: cgv-theaters', - '설명: CGV 극장 검색 API를 호출합니다.', - '사용법: daiso cgv-theaters [keyword] [--regionCode 값] [--playDate YYYYMMDD] [--lat 값] [--lng 값] [--limit N] [--json]', - '옵션: --keyword, --regionCode, --playDate, --lat, --lng, --limit, --json', - '예시: daiso cgv-theaters 강남 --limit 5', - '예시: daiso cgv-theaters --regionCode 01 --playDate 20260304', - ], - 'cgv-movies': [ - '명령: cgv-movies', - '설명: CGV 영화 검색 API를 호출합니다.', - '사용법: daiso cgv-movies [keyword] [--playDate YYYYMMDD] [--theaterCode 값] [--movieCode 값] [--lat 값] [--lng 값] [--json]', - '옵션: --keyword, --playDate, --theaterCode, --movieCode, --lat, --lng, --json', - '예시: daiso cgv-movies 강남 --playDate 20260304', - '예시: daiso cgv-movies --playDate 20260304 --theaterCode 0056', - ], - 'cgv-timetable': [ - '명령: cgv-timetable', - '설명: CGV 시간표 API를 호출합니다.', - '사용법: daiso cgv-timetable [keyword] [--playDate YYYYMMDD] [--theaterCode 값] [--movieCode 값] [--lat 값] [--lng 값] [--json]', - '옵션: --keyword, --playDate, --theaterCode, --movieCode, --lat, --lng, --json', - '예시: daiso cgv-timetable 강남 --playDate 20260304', - '예시: daiso cgv-timetable --playDate 20260304 --theaterCode 0056', - ], - 'lottecinema-theaters': [ - '명령: lottecinema-theaters', - '설명: 롯데시네마 주변 지점 API를 호출합니다.', - '사용법: daiso lottecinema-theaters [keyword] [--lat 값] [--lng 값] [--playDate YYYYMMDD] [--limit N] [--json]', - '옵션: --keyword, --lat, --lng, --playDate, --limit, --json', - '예시: daiso lottecinema-theaters 잠실 --limit 5', - '예시: daiso lottecinema-theaters --keyword "안산 중앙역" --limit 5', - ], - 'lottecinema-movies': [ - '명령: lottecinema-movies', - '설명: 롯데시네마 영화/회차 API를 호출합니다.', - '사용법: daiso lottecinema-movies [keyword] [--playDate YYYYMMDD] [--theaterId 값] [--movieId 값] [--lat 값] [--lng 값] [--json]', - '옵션: --playDate, --theaterId, --movieId, --keyword, --lat, --lng, --json', - '예시: daiso lottecinema-movies 잠실 --playDate 20260310', - '예시: daiso lottecinema-movies --playDate 20260310 --keyword "안산 중앙역"', - ], - 'lottecinema-seats': [ - '명령: lottecinema-seats', - '설명: 롯데시네마 잔여 좌석 API를 호출합니다.', - '사용법: daiso lottecinema-seats [keyword] [--playDate YYYYMMDD] [--theaterId 값] [--movieId 값] [--lat 값] [--lng 값] [--limit N] [--json]', - '옵션: --playDate, --theaterId, --movieId, --keyword, --lat, --lng, --limit, --json', - '예시: daiso lottecinema-seats 잠실 --playDate 20260310 --movieId 23816', - '예시: daiso lottecinema-seats --playDate 20260310 --keyword "안산 중앙역" --movieId 23816', - ], - 'lottemart-stores': [ - '명령: lottemart-stores', - '설명: 롯데마트 계열 매장 검색 API를 호출합니다.', - '사용법: daiso lottemart-stores [keyword] [--area 값] [--brandVariant 값] [--lat 값] [--lng 값] [--limit N] [--json]', - '옵션: --keyword, --area, --brandVariant, --lat, --lng, --limit, --json', - '예시: daiso lottemart-stores 잠실 --area 서울', - '예시: daiso lottemart-stores --area 경기 --brandVariant lottemart --limit 10', - ], - 'lottemart-products': [ - '명령: lottemart-products', - '설명: 롯데마트 매장 기준 상품 검색 API를 호출합니다.', - '사용법: daiso lottemart-products [--storeCode 값 | --storeName 값] [--area 값] [--pageLimit N] [--json]', - '필수: , --storeCode 또는 --storeName', - '옵션: --storeCode, --storeName, --area, --pageLimit, --json', - '예시: daiso lottemart-products 콜라 --storeName 강변점 --area 서울', - '예시: daiso lottemart-products 우유 --storeCode 2301 --pageLimit 2', - ], - 'emart24-stores': [ - '명령: emart24-stores', - '설명: 이마트24 매장 검색 API를 호출합니다.', - '사용법: daiso emart24-stores [keyword] [--area1 값] [--area2 값] [--lat 값] [--lng 값] [--service24h true|false] [--limit N] [--json]', - '옵션: --keyword, --area1, --area2, --lat, --lng, --service24h, --limit, --json', - '예시: daiso emart24-stores 강남', - '예시: daiso emart24-stores --area1 서울특별시 --area2 강남구 --service24h true', - ], - 'emart24-products': [ - '명령: emart24-products', - '설명: 이마트24 상품 검색 API를 호출합니다.', - '사용법: daiso emart24-products [--page N] [--pageSize N] [--sortType 값] [--saleProductYn Y|N] [--json]', - '필수: ', - '옵션: --page, --pageSize, --sortType, --saleProductYn, --json', - '예시: daiso emart24-products 두바이', - '예시: daiso emart24-products 도시락 --page 2 --pageSize 20', - ], - 'emart24-inventory': [ - '명령: emart24-inventory', - '설명: 이마트24 매장별 재고 API를 호출합니다.', - '사용법: daiso emart24-inventory --bizNoArr 코드1,코드2 [--json]', - '필수: , --bizNoArr', - '옵션: --bizNoArr, --json', - '예시: daiso emart24-inventory 8800244010504 --bizNoArr 28339,05015,23233', - ], - 'gs25-stores': [ - '명령: gs25-stores', - '설명: GS25 매장 검색 API를 호출합니다.', - '사용법: daiso gs25-stores [keyword] [--lat 값] [--lng 값] [--limit N] [--json]', - '옵션: --keyword, --lat, --lng, --serviceCode, --limit, --json', - '예시: daiso gs25-stores 강남', - '예시: daiso gs25-stores --lat 37.4982 --lng 127.0276 --limit 10', - ], - 'gs25-products': [ - '명령: gs25-products', - '설명: GS25 상품 키워드 검색 API를 호출합니다.', - '사용법: daiso gs25-products [--limit N] [--json]', - '필수: ', - '옵션: --serviceCode, --limit, --json', - '예시: daiso gs25-products 오감자', - ], - 'gs25-inventory': [ - '명령: gs25-inventory', - '설명: GS25 재고 검색 API를 호출합니다.', - '사용법: daiso gs25-inventory [--storeKeyword 값] [--lat 값] [--lng 값] [--storeLimit N] [--json]', - '필수: ', - '옵션: --storeKeyword, --lat, --lng, --serviceCode, --storeLimit, --json', - '예시: daiso gs25-inventory 오감자 --storeKeyword 강남', - ], - 'seveneleven-products': [ - '명령: seveneleven-products', - '설명: 세븐일레븐 상품 검색 API를 호출합니다.', - '사용법: daiso seveneleven-products [--page N] [--size N] [--sort 값] [--json]', - '필수: ', - '옵션: --page, --size, --sort, --json', - '예시: daiso seveneleven-products 삼각김밥 --size 20', - ], - 'seveneleven-stores': [ - '명령: seveneleven-stores', - '설명: 세븐일레븐 매장 검색 API를 호출합니다.', - '사용법: daiso seveneleven-stores [--limit N] [--json]', - '필수: ', - '옵션: --limit, --json', - '예시: daiso seveneleven-stores 안산 중앙역 --limit 10', - ], - 'seveneleven-inventory': [ - '명령: seveneleven-inventory', - '설명: 세븐일레븐 재고 API를 호출합니다.', - '사용법: daiso seveneleven-inventory [--storeKeyword 값] [--storeLimit N] [--page N] [--size N] [--json]', - '필수: ', - '옵션: --keyword, --storeKeyword, --storeLimit, --page, --size, --json', - '예시: daiso seveneleven-inventory 핫식스 --storeKeyword "안산 중앙역" --storeLimit 10', - ], - 'seveneleven-popwords': [ - '명령: seveneleven-popwords', - '설명: 세븐일레븐 인기 검색어 API를 호출합니다.', - '사용법: daiso seveneleven-popwords [--label 값] [--json]', - '옵션: --label, --json', - '예시: daiso seveneleven-popwords --label home', - ], - 'seveneleven-catalog': [ - '명령: seveneleven-catalog', - '설명: 세븐일레븐 카탈로그 스냅샷 API를 호출합니다.', - '사용법: daiso seveneleven-catalog [--includeIssues true|false] [--includeExhibition true|false] [--limit N] [--json]', - '옵션: --includeIssues, --includeExhibition, --limit, --json', - '예시: daiso seveneleven-catalog --includeIssues true --includeExhibition true --limit 10', - ], -}; +export { COMMAND_LIST, type CommandName } from './cliHelpDefinitions.js'; export function printHelp(writeOut: (message: string) => void): void { writeOut('daiso CLI'); @@ -435,9 +68,7 @@ export function printCommandHelp( ): number { if (!Object.hasOwn(COMMAND_DETAIL, command)) { writeErr(`도움말을 찾을 수 없는 명령어: ${command}`); - writeErr( - `사용 가능한 명령어: ${COMMAND_LIST.join(', ')}`, - ); + writeErr(`사용 가능한 명령어: ${COMMAND_LIST.join(', ')}`); return 1; } diff --git a/src/cliHelpDefinitions.ts b/src/cliHelpDefinitions.ts new file mode 100644 index 0000000..61d9bee --- /dev/null +++ b/src/cliHelpDefinitions.ts @@ -0,0 +1,378 @@ +/** + * CLI 도움말 정의 + */ + +export type CommandName = + | 'help' + | 'version' + | 'url' + | 'health' + | 'claude' + | 'get' + | 'products' + | 'product' + | 'stores' + | 'inventory' + | 'display-location' + | 'compare' + | 'places' + | 'cu-stores' + | 'cu-inventory' + | 'cgv-theaters' + | 'cgv-movies' + | 'cgv-timetable' + | 'lottecinema-theaters' + | 'lottecinema-movies' + | 'lottecinema-seats' + | 'lottemart-stores' + | 'lottemart-products' + | 'emart24-stores' + | 'emart24-products' + | 'emart24-inventory' + | 'gs25-stores' + | 'gs25-products' + | 'gs25-inventory' + | 'seveneleven-products' + | 'seveneleven-stores' + | 'seveneleven-inventory' + | 'seveneleven-popwords' + | 'seveneleven-catalog'; + +export const COMMAND_LIST: CommandName[] = [ + 'help', + 'version', + 'url', + 'health', + 'claude', + 'get', + 'products', + 'product', + 'stores', + 'inventory', + 'display-location', + 'compare', + 'places', + 'cu-stores', + 'cu-inventory', + 'cgv-theaters', + 'cgv-movies', + 'cgv-timetable', + 'lottecinema-theaters', + 'lottecinema-movies', + 'lottecinema-seats', + 'lottemart-stores', + 'lottemart-products', + 'emart24-stores', + 'emart24-products', + 'emart24-inventory', + 'gs25-stores', + 'gs25-products', + 'gs25-inventory', + 'seveneleven-products', + 'seveneleven-stores', + 'seveneleven-inventory', + 'seveneleven-popwords', + 'seveneleven-catalog', +]; + +export const COMMAND_SUMMARY: Record = { + help: '도움말 출력', + version: 'CLI 버전 출력', + url: 'MCP 엔드포인트 URL 출력', + health: '원격 서버 상태 확인', + claude: 'Claude Code MCP 등록 명령 출력/실행', + get: '임의 API 경로 GET 호출', + products: '다이소 제품 검색', + product: '다이소 제품 상세 조회', + stores: '다이소 매장 검색', + inventory: '다이소 재고 조회', + 'display-location': '다이소 진열 위치 조회', + compare: '키 없는 통합 상품 가격 후보 비교', + places: '음식점/카페 등 주변 장소 검색', + 'cu-stores': 'CU 매장 검색', + 'cu-inventory': 'CU 재고 조회', + 'cgv-theaters': 'CGV 극장 검색', + 'cgv-movies': 'CGV 영화 검색', + 'cgv-timetable': 'CGV 시간표 조회', + 'lottecinema-theaters': '롯데시네마 주변 지점 조회', + 'lottecinema-movies': '롯데시네마 영화/회차 조회', + 'lottecinema-seats': '롯데시네마 잔여 좌석 조회', + 'lottemart-stores': '롯데마트 매장 검색', + 'lottemart-products': '롯데마트 상품 검색', + 'emart24-stores': '이마트24 매장 검색', + 'emart24-products': '이마트24 상품 검색', + 'emart24-inventory': '이마트24 재고 조회', + 'gs25-stores': 'GS25 매장 검색', + 'gs25-products': 'GS25 상품 검색', + 'gs25-inventory': 'GS25 재고 조회', + 'seveneleven-products': '세븐일레븐 상품 검색', + 'seveneleven-stores': '세븐일레븐 매장 검색', + 'seveneleven-inventory': '세븐일레븐 재고 조회', + 'seveneleven-popwords': '세븐일레븐 인기 검색어 조회', + 'seveneleven-catalog': '세븐일레븐 카탈로그 스냅샷 조회', +}; + +export const COMMAND_DETAIL: Record = { + help: [ + '명령: help', + '설명: 전체 도움말 또는 특정 명령 상세 도움말을 출력합니다.', + '사용법: daiso help [command]', + '예시: daiso help products', + ], + version: ['명령: version', '설명: 설치된 daiso CLI 버전을 출력합니다.', '사용법: daiso version'], + url: [ + '명령: url', + '설명: MCP 엔드포인트 URL을 출력합니다.', + '사용법: daiso url', + '출력 예시: https://mcp.aka.page/mcp', + ], + health: [ + '명령: health', + '설명: 서버 /health API를 호출해 상태를 확인합니다.', + '사용법: daiso health', + '출력: status, endpoint, checkedAt(JSON)', + ], + claude: [ + '명령: claude', + '설명: Claude Code MCP 등록 명령을 출력하거나 실행합니다.', + '사용법: daiso claude [--exec]', + '옵션: --exec 실제 claude 명령 실행', + '예시: daiso claude --exec', + ], + get: [ + '명령: get', + '설명: 임의 API 경로를 GET으로 호출합니다.', + '사용법: daiso get [--queryKey value ...] [--json]', + '필수: ', + '옵션: --json 원본 JSON 출력', + '예시: daiso get /api/cgv/movies --playDate 20260307 --theaterCode 0056', + ], + products: [ + '명령: products', + '설명: 다이소 제품 검색 API를 호출합니다.', + '사용법: daiso products [--page N] [--pageSize N] [--json]', + '필수: ', + '옵션: --page, --pageSize, --json', + '예시: daiso products 수납박스 --page 1 --pageSize 30', + ], + product: [ + '명령: product', + '설명: 다이소 제품 상세 API를 호출합니다.', + '사용법: daiso product [--json]', + '필수: ', + '옵션: --json', + '예시: daiso product 1034604', + ], + stores: [ + '명령: stores', + '설명: 다이소 매장 검색 API를 호출합니다.', + '사용법: daiso stores [keyword] [--sido 값] [--gugun 값] [--dong 값] [--limit N] [--json]', + '필수: keyword 또는 --sido', + '옵션: --keyword, --sido, --gugun, --dong, --limit, --json', + '예시: daiso stores 강남역', + '예시: daiso stores --sido 서울 --gugun 강남구', + ], + inventory: [ + '명령: inventory', + '설명: 다이소 재고 API를 호출합니다.', + '사용법: daiso inventory [--keyword 값] [--lat 값] [--lng 값] [--page N] [--pageSize N] [--json]', + '필수: ', + '옵션: --keyword, --lat, --lng, --page, --pageSize, --json', + '예시: daiso inventory 1034604 --keyword 강남역', + ], + 'display-location': [ + '명령: display-location', + '설명: 다이소 매장 내 상품 진열 위치 API를 호출합니다.', + '사용법: daiso display-location [--json]', + '필수: , ', + '옵션: --json', + '예시: daiso display-location 1034604 04515', + ], + compare: [ + '명령: compare', + '설명: 새 외부 키 없이 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다.', + '사용법: daiso compare [--services daiso,gs25,seveneleven,emart24] [--limit N] [--json]', + '필수: ', + '옵션: --services, --limit, --json', + '예시: daiso compare 콜라 --limit 3', + '예시: daiso compare 컵라면 --services seveneleven,emart24 --json', + ], + places: [ + '명령: places', + '설명: 네이버 지역 검색으로 음식점, 카페, 디저트 가게 등 주변 장소를 검색합니다.', + '사용법: daiso places [--category cafe|restaurant|food|dessert|all] [--keyword 검색어] [--limit N] [--json]', + '필수: 또는 --keyword', + '옵션: --category, --keyword, --limit, --start, --sort, --json', + '예시: daiso places 강남역 --category cafe --limit 5', + '예시: daiso places 성수동 --keyword 브런치 --limit 5', + ], + 'cu-stores': [ + '명령: cu-stores', + '설명: CU 매장 검색 API를 호출합니다.', + '사용법: daiso cu-stores [keyword] [--lat 값] [--lng 값] [--limit N] [--json]', + '옵션: --keyword, --lat, --lng, --limit, --json', + '예시: daiso cu-stores 강남', + '예시: daiso cu-stores --lat 37.498 --lng 127.027 --limit 10', + ], + 'cu-inventory': [ + '명령: cu-inventory', + '설명: CU 재고 검색 API를 호출합니다.', + '사용법: daiso cu-inventory [--storeKeyword 값] [--lat 값] [--lng 값] [--size N] [--offset N] [--json]', + '필수: ', + '옵션: --storeKeyword, --lat, --lng, --size, --offset, --searchSort, --storeLimit, --json', + '예시: daiso cu-inventory 과자', + '예시: daiso cu-inventory 컵라면 --storeKeyword 강남 --size 10', + ], + 'cgv-theaters': [ + '명령: cgv-theaters', + '설명: CGV 극장 검색 API를 호출합니다.', + '사용법: daiso cgv-theaters [keyword] [--regionCode 값] [--playDate YYYYMMDD] [--lat 값] [--lng 값] [--limit N] [--json]', + '옵션: --keyword, --regionCode, --playDate, --lat, --lng, --limit, --json', + '예시: daiso cgv-theaters 강남 --limit 5', + '예시: daiso cgv-theaters --regionCode 01 --playDate 20260304', + ], + 'cgv-movies': [ + '명령: cgv-movies', + '설명: CGV 영화 검색 API를 호출합니다.', + '사용법: daiso cgv-movies [keyword] [--playDate YYYYMMDD] [--theaterCode 값] [--movieCode 값] [--lat 값] [--lng 값] [--json]', + '옵션: --keyword, --playDate, --theaterCode, --movieCode, --lat, --lng, --json', + '예시: daiso cgv-movies 강남 --playDate 20260304', + '예시: daiso cgv-movies --playDate 20260304 --theaterCode 0056', + ], + 'cgv-timetable': [ + '명령: cgv-timetable', + '설명: CGV 시간표 API를 호출합니다.', + '사용법: daiso cgv-timetable [keyword] [--playDate YYYYMMDD] [--theaterCode 값] [--movieCode 값] [--lat 값] [--lng 값] [--json]', + '옵션: --keyword, --playDate, --theaterCode, --movieCode, --lat, --lng, --json', + '예시: daiso cgv-timetable 강남 --playDate 20260304', + '예시: daiso cgv-timetable --playDate 20260304 --theaterCode 0056', + ], + 'lottecinema-theaters': [ + '명령: lottecinema-theaters', + '설명: 롯데시네마 주변 지점 API를 호출합니다.', + '사용법: daiso lottecinema-theaters [keyword] [--lat 값] [--lng 값] [--playDate YYYYMMDD] [--limit N] [--json]', + '옵션: --keyword, --lat, --lng, --playDate, --limit, --json', + '예시: daiso lottecinema-theaters 잠실 --limit 5', + '예시: daiso lottecinema-theaters --keyword "안산 중앙역" --limit 5', + ], + 'lottecinema-movies': [ + '명령: lottecinema-movies', + '설명: 롯데시네마 영화/회차 API를 호출합니다.', + '사용법: daiso lottecinema-movies [keyword] [--playDate YYYYMMDD] [--theaterId 값] [--movieId 값] [--lat 값] [--lng 값] [--json]', + '옵션: --playDate, --theaterId, --movieId, --keyword, --lat, --lng, --json', + '예시: daiso lottecinema-movies 잠실 --playDate 20260310', + '예시: daiso lottecinema-movies --playDate 20260310 --keyword "안산 중앙역"', + ], + 'lottecinema-seats': [ + '명령: lottecinema-seats', + '설명: 롯데시네마 잔여 좌석 API를 호출합니다.', + '사용법: daiso lottecinema-seats [keyword] [--playDate YYYYMMDD] [--theaterId 값] [--movieId 값] [--lat 값] [--lng 값] [--limit N] [--json]', + '옵션: --playDate, --theaterId, --movieId, --keyword, --lat, --lng, --limit, --json', + '예시: daiso lottecinema-seats 잠실 --playDate 20260310 --movieId 23816', + '예시: daiso lottecinema-seats --playDate 20260310 --keyword "안산 중앙역" --movieId 23816', + ], + 'lottemart-stores': [ + '명령: lottemart-stores', + '설명: 롯데마트 계열 매장 검색 API를 호출합니다.', + '사용법: daiso lottemart-stores [keyword] [--area 값] [--brandVariant 값] [--lat 값] [--lng 값] [--limit N] [--json]', + '옵션: --keyword, --area, --brandVariant, --lat, --lng, --limit, --json', + '예시: daiso lottemart-stores 잠실 --area 서울', + '예시: daiso lottemart-stores --area 경기 --brandVariant lottemart --limit 10', + ], + 'lottemart-products': [ + '명령: lottemart-products', + '설명: 롯데마트 매장 기준 상품 검색 API를 호출합니다.', + '사용법: daiso lottemart-products [--storeCode 값 | --storeName 값] [--area 값] [--pageLimit N] [--json]', + '필수: , --storeCode 또는 --storeName', + '옵션: --storeCode, --storeName, --area, --pageLimit, --json', + '예시: daiso lottemart-products 콜라 --storeName 강변점 --area 서울', + '예시: daiso lottemart-products 우유 --storeCode 2301 --pageLimit 2', + ], + 'emart24-stores': [ + '명령: emart24-stores', + '설명: 이마트24 매장 검색 API를 호출합니다.', + '사용법: daiso emart24-stores [keyword] [--area1 값] [--area2 값] [--lat 값] [--lng 값] [--service24h true|false] [--limit N] [--json]', + '옵션: --keyword, --area1, --area2, --lat, --lng, --service24h, --limit, --json', + '예시: daiso emart24-stores 강남', + '예시: daiso emart24-stores --area1 서울특별시 --area2 강남구 --service24h true', + ], + 'emart24-products': [ + '명령: emart24-products', + '설명: 이마트24 상품 검색 API를 호출합니다.', + '사용법: daiso emart24-products [--page N] [--pageSize N] [--sortType 값] [--saleProductYn Y|N] [--json]', + '필수: ', + '옵션: --page, --pageSize, --sortType, --saleProductYn, --json', + '예시: daiso emart24-products 두바이', + '예시: daiso emart24-products 도시락 --page 2 --pageSize 20', + ], + 'emart24-inventory': [ + '명령: emart24-inventory', + '설명: 이마트24 매장별 재고 API를 호출합니다.', + '사용법: daiso emart24-inventory --bizNoArr 코드1,코드2 [--json]', + '필수: , --bizNoArr', + '옵션: --bizNoArr, --json', + '예시: daiso emart24-inventory 8800244010504 --bizNoArr 28339,05015,23233', + ], + 'gs25-stores': [ + '명령: gs25-stores', + '설명: GS25 매장 검색 API를 호출합니다.', + '사용법: daiso gs25-stores [keyword] [--lat 값] [--lng 값] [--limit N] [--json]', + '옵션: --keyword, --lat, --lng, --serviceCode, --limit, --json', + '예시: daiso gs25-stores 강남', + '예시: daiso gs25-stores --lat 37.4982 --lng 127.0276 --limit 10', + ], + 'gs25-products': [ + '명령: gs25-products', + '설명: GS25 상품 키워드 검색 API를 호출합니다.', + '사용법: daiso gs25-products [--limit N] [--json]', + '필수: ', + '옵션: --serviceCode, --limit, --json', + '예시: daiso gs25-products 오감자', + ], + 'gs25-inventory': [ + '명령: gs25-inventory', + '설명: GS25 재고 검색 API를 호출합니다.', + '사용법: daiso gs25-inventory [--storeKeyword 값] [--lat 값] [--lng 값] [--storeLimit N] [--json]', + '필수: ', + '옵션: --storeKeyword, --lat, --lng, --serviceCode, --storeLimit, --json', + '예시: daiso gs25-inventory 오감자 --storeKeyword 강남', + ], + 'seveneleven-products': [ + '명령: seveneleven-products', + '설명: 세븐일레븐 상품 검색 API를 호출합니다.', + '사용법: daiso seveneleven-products [--page N] [--size N] [--sort 값] [--json]', + '필수: ', + '옵션: --page, --size, --sort, --json', + '예시: daiso seveneleven-products 삼각김밥 --size 20', + ], + 'seveneleven-stores': [ + '명령: seveneleven-stores', + '설명: 세븐일레븐 매장 검색 API를 호출합니다.', + '사용법: daiso seveneleven-stores [--limit N] [--json]', + '필수: ', + '옵션: --limit, --json', + '예시: daiso seveneleven-stores 안산 중앙역 --limit 10', + ], + 'seveneleven-inventory': [ + '명령: seveneleven-inventory', + '설명: 세븐일레븐 재고 API를 호출합니다.', + '사용법: daiso seveneleven-inventory [--storeKeyword 값] [--storeLimit N] [--page N] [--size N] [--json]', + '필수: ', + '옵션: --keyword, --storeKeyword, --storeLimit, --page, --size, --json', + '예시: daiso seveneleven-inventory 핫식스 --storeKeyword "안산 중앙역" --storeLimit 10', + ], + 'seveneleven-popwords': [ + '명령: seveneleven-popwords', + '설명: 세븐일레븐 인기 검색어 API를 호출합니다.', + '사용법: daiso seveneleven-popwords [--label 값] [--json]', + '옵션: --label, --json', + '예시: daiso seveneleven-popwords --label home', + ], + 'seveneleven-catalog': [ + '명령: seveneleven-catalog', + '설명: 세븐일레븐 카탈로그 스냅샷 API를 호출합니다.', + '사용법: daiso seveneleven-catalog [--includeIssues true|false] [--includeExhibition true|false] [--limit N] [--json]', + '옵션: --includeIssues, --includeExhibition, --limit, --json', + '예시: daiso seveneleven-catalog --includeIssues true --includeExhibition true --limit 10', + ], +}; diff --git a/src/pages/openapiSpecActionParameters.ts b/src/pages/openapiSpecActionParameters.ts new file mode 100644 index 0000000..5f0aa8c --- /dev/null +++ b/src/pages/openapiSpecActionParameters.ts @@ -0,0 +1,398 @@ +/** + * OpenAI Actions용 축약 OpenAPI 스펙 + */ +import { ACTION_QUERY_ACTIONS } from '../api/actionsProxy.js'; + +export const ACTION_QUERY_PARAMETERS = [ + { + name: 'action', + in: 'query', + required: true, + description: '실행할 액션 이름입니다. 사용 가능한 값은 enum 목록을 참고하세요.', + schema: { + type: 'string', + enum: ACTION_QUERY_ACTIONS, + }, + }, + { + name: 'q', + in: 'query', + required: false, + description: '다이소 제품 검색어', + schema: { type: 'string' }, + }, + { + name: 'query', + in: 'query', + required: false, + description: '세븐일레븐 상품 검색어', + schema: { type: 'string' }, + }, + { + name: 'itemCode', + in: 'query', + required: false, + description: 'GS25 상품 코드', + schema: { type: 'string' }, + }, + { + name: 'productId', + in: 'query', + required: false, + description: + '제품 ID 또는 상세 조회용 path 파라미터. 상품명만 알면 먼저 daisoSearchProducts 또는 /api/daiso/products로 productId를 확인하세요.', + schema: { type: 'string' }, + }, + { + name: 'keyword', + in: 'query', + required: false, + description: '공통 키워드 파라미터', + schema: { type: 'string' }, + }, + { + name: 'location', + in: 'query', + required: false, + description: '주변 장소 검색 지역/역/주소 키워드', + schema: { type: 'string' }, + }, + { + name: 'category', + in: 'query', + required: false, + description: '주변 장소 카테고리(restaurant, cafe, food, dessert, all)', + schema: { type: 'string' }, + }, + { + name: 'services', + in: 'query', + required: false, + description: '통합 비교 서비스 목록(daiso,gs25,seveneleven,emart24)', + schema: { type: 'string' }, + }, + { + name: 'type', + in: 'query', + required: false, + description: '개발자 요청 유형(bug, improvement, feature, docs, other)', + schema: { type: 'string' }, + }, + { + name: 'severity', + in: 'query', + required: false, + description: '개발자 요청 영향도(low, medium, high, critical)', + schema: { type: 'string' }, + }, + { + name: 'title', + in: 'query', + required: false, + description: '개발자 요청 제목', + schema: { type: 'string' }, + }, + { + name: 'description', + in: 'query', + required: false, + description: '개발자 요청 상세 설명', + schema: { type: 'string' }, + }, + { + name: 'toolName', + in: 'query', + required: false, + description: '문제가 발생했거나 개선이 필요한 MCP 도구 이름', + schema: { type: 'string' }, + }, + { + name: 'reproduction', + in: 'query', + required: false, + description: '오류 재현 방법', + schema: { type: 'string' }, + }, + { + name: 'expected', + in: 'query', + required: false, + description: '기대 결과', + schema: { type: 'string' }, + }, + { + name: 'actual', + in: 'query', + required: false, + description: '실제 결과', + schema: { type: 'string' }, + }, + { + name: 'source', + in: 'query', + required: false, + description: '요청 출처(mcp, chatgpt, claude, cli, api, other)', + schema: { type: 'string' }, + }, + { + name: 'sido', + in: 'query', + required: false, + description: '다이소 시/도', + schema: { type: 'string' }, + }, + { + name: 'gugun', + in: 'query', + required: false, + description: '다이소 구/군', + schema: { type: 'string' }, + }, + { + name: 'dong', + in: 'query', + required: false, + description: '다이소 동', + schema: { type: 'string' }, + }, + { + name: 'area', + in: 'query', + required: false, + description: '롯데마트 지역', + schema: { type: 'string' }, + }, + { + name: 'area1', + in: 'query', + required: false, + description: '이마트24 1차 지역', + schema: { type: 'string' }, + }, + { + name: 'area2', + in: 'query', + required: false, + description: '이마트24 2차 지역', + schema: { type: 'string' }, + }, + { + name: 'areaCode', + in: 'query', + required: false, + description: '메가박스 지역 코드', + schema: { type: 'string' }, + }, + { + name: 'brandVariant', + in: 'query', + required: false, + description: '롯데마트 브랜드 변형', + schema: { type: 'string' }, + }, + { + name: 'lat', + in: 'query', + required: false, + description: '위도', + schema: { type: 'number', format: 'float' }, + }, + { + name: 'lng', + in: 'query', + required: false, + description: '경도', + schema: { type: 'number', format: 'float' }, + }, + { + name: 'page', + in: 'query', + required: false, + description: '페이지 번호', + schema: { type: 'integer' }, + }, + { + name: 'pageIdx', + in: 'query', + required: false, + description: '올리브영 페이지 번호', + schema: { type: 'integer' }, + }, + { + name: 'pageLimit', + in: 'query', + required: false, + description: '롯데마트 추가 페이지 수', + schema: { type: 'integer' }, + }, + { + name: 'pageSize', + in: 'query', + required: false, + description: '페이지 크기', + schema: { type: 'integer' }, + }, + { + name: 'size', + in: 'query', + required: false, + description: '페이지 크기 또는 검색 크기', + schema: { type: 'integer' }, + }, + { + name: 'offset', + in: 'query', + required: false, + description: 'CU 검색 오프셋', + schema: { type: 'integer' }, + }, + { + name: 'limit', + in: 'query', + required: false, + description: '최대 결과 수', + schema: { type: 'integer' }, + }, + { + name: 'storeLimit', + in: 'query', + required: false, + description: '매장 결과 제한 수', + schema: { type: 'integer' }, + }, + { + name: 'storeCode', + in: 'query', + required: false, + description: + '매장 코드. 다이소 진열 위치는 /api/daiso/inventory 응답의 storeInventory.stores[].storeCode에서 확인하세요.', + schema: { type: 'string' }, + }, + { + name: 'storeName', + in: 'query', + required: false, + description: + '매장명. 롯데마트 상품 조회에서 storeCode가 없을 때 사용하며, 모르면 먼저 lottemartFindNearbyStores 또는 /api/lottemart/stores로 확인하세요.', + schema: { type: 'string' }, + }, + { + name: 'storeKeyword', + in: 'query', + required: false, + description: '매장 키워드', + schema: { type: 'string' }, + }, + { + name: 'playDate', + in: 'query', + required: false, + description: '상영일(YYYYMMDD)', + schema: { type: 'string' }, + }, + { + name: 'regionCode', + in: 'query', + required: false, + description: 'CGV 지역 코드', + schema: { type: 'string' }, + }, + { + name: 'theaterCode', + in: 'query', + required: false, + description: 'CGV 극장 코드', + schema: { type: 'string' }, + }, + { + name: 'theaterId', + in: 'query', + required: false, + description: '메가박스/롯데시네마 지점 ID', + schema: { type: 'string' }, + }, + { + name: 'movieCode', + in: 'query', + required: false, + description: 'CGV 영화 코드', + schema: { type: 'string' }, + }, + { + name: 'movieId', + in: 'query', + required: false, + description: '메가박스/롯데시네마 영화 ID', + schema: { type: 'string' }, + }, + { + name: 'pluCd', + in: 'query', + required: false, + description: '이마트24 상품 PLU 코드', + schema: { type: 'string' }, + }, + { + name: 'bizNoArr', + in: 'query', + required: false, + description: '이마트24 사업자번호 목록', + schema: { type: 'string' }, + }, + { + name: 'label', + in: 'query', + required: false, + description: '세븐일레븐 인기 검색어 라벨', + schema: { type: 'string' }, + }, + { + name: 'sort', + in: 'query', + required: false, + description: '정렬 기준', + schema: { type: 'string' }, + }, + { + name: 'searchSort', + in: 'query', + required: false, + description: 'CU 검색 정렬 기준', + schema: { type: 'string' }, + }, + { + name: 'service24h', + in: 'query', + required: false, + description: '이마트24 24시간 필터', + schema: { type: 'boolean' }, + }, + { + name: 'includeSoldOut', + in: 'query', + required: false, + description: '올리브영 품절 포함 여부', + schema: { type: 'boolean' }, + }, + { + name: 'includeIssues', + in: 'query', + required: false, + description: '세븐일레븐 이달의 행사지 포함 여부', + schema: { type: 'boolean' }, + }, + { + name: 'includeExhibition', + in: 'query', + required: false, + description: '세븐일레븐 전시상품 포함 여부', + schema: { type: 'boolean' }, + }, + { + name: 'timeoutMs', + in: 'query', + required: false, + description: '세븐일레븐 inventory 타임아웃', + schema: { type: 'integer' }, + }, +] as const; diff --git a/src/pages/openapiSpecActions.ts b/src/pages/openapiSpecActions.ts index 0bbaae7..e6b6768 100644 --- a/src/pages/openapiSpecActions.ts +++ b/src/pages/openapiSpecActions.ts @@ -1,401 +1,8 @@ /** * OpenAI Actions용 축약 OpenAPI 스펙 */ -import { ACTION_QUERY_ACTIONS } from '../api/actionsProxy.js'; +import { ACTION_QUERY_PARAMETERS } from './openapiSpecActionParameters.js'; import { OPENAPI_COMPONENTS } from './openapiSpecComponents.js'; -const ACTION_QUERY_PARAMETERS = [ - { - name: 'action', - in: 'query', - required: true, - description: '실행할 액션 이름입니다. 사용 가능한 값은 enum 목록을 참고하세요.', - schema: { - type: 'string', - enum: ACTION_QUERY_ACTIONS, - }, - }, - { - name: 'q', - in: 'query', - required: false, - description: '다이소 제품 검색어', - schema: { type: 'string' }, - }, - { - name: 'query', - in: 'query', - required: false, - description: '세븐일레븐 상품 검색어', - schema: { type: 'string' }, - }, - { - name: 'itemCode', - in: 'query', - required: false, - description: 'GS25 상품 코드', - schema: { type: 'string' }, - }, - { - name: 'productId', - in: 'query', - required: false, - description: - '제품 ID 또는 상세 조회용 path 파라미터. 상품명만 알면 먼저 daisoSearchProducts 또는 /api/daiso/products로 productId를 확인하세요.', - schema: { type: 'string' }, - }, - { - name: 'keyword', - in: 'query', - required: false, - description: '공통 키워드 파라미터', - schema: { type: 'string' }, - }, - { - name: 'location', - in: 'query', - required: false, - description: '주변 장소 검색 지역/역/주소 키워드', - schema: { type: 'string' }, - }, - { - name: 'category', - in: 'query', - required: false, - description: '주변 장소 카테고리(restaurant, cafe, food, dessert, all)', - schema: { type: 'string' }, - }, - { - name: 'services', - in: 'query', - required: false, - description: '통합 비교 서비스 목록(daiso,gs25,seveneleven,emart24)', - schema: { type: 'string' }, - }, - { - name: 'type', - in: 'query', - required: false, - description: '개발자 요청 유형(bug, improvement, feature, docs, other)', - schema: { type: 'string' }, - }, - { - name: 'severity', - in: 'query', - required: false, - description: '개발자 요청 영향도(low, medium, high, critical)', - schema: { type: 'string' }, - }, - { - name: 'title', - in: 'query', - required: false, - description: '개발자 요청 제목', - schema: { type: 'string' }, - }, - { - name: 'description', - in: 'query', - required: false, - description: '개발자 요청 상세 설명', - schema: { type: 'string' }, - }, - { - name: 'toolName', - in: 'query', - required: false, - description: '문제가 발생했거나 개선이 필요한 MCP 도구 이름', - schema: { type: 'string' }, - }, - { - name: 'reproduction', - in: 'query', - required: false, - description: '오류 재현 방법', - schema: { type: 'string' }, - }, - { - name: 'expected', - in: 'query', - required: false, - description: '기대 결과', - schema: { type: 'string' }, - }, - { - name: 'actual', - in: 'query', - required: false, - description: '실제 결과', - schema: { type: 'string' }, - }, - { - name: 'source', - in: 'query', - required: false, - description: '요청 출처(mcp, chatgpt, claude, cli, api, other)', - schema: { type: 'string' }, - }, - { - name: 'sido', - in: 'query', - required: false, - description: '다이소 시/도', - schema: { type: 'string' }, - }, - { - name: 'gugun', - in: 'query', - required: false, - description: '다이소 구/군', - schema: { type: 'string' }, - }, - { - name: 'dong', - in: 'query', - required: false, - description: '다이소 동', - schema: { type: 'string' }, - }, - { - name: 'area', - in: 'query', - required: false, - description: '롯데마트 지역', - schema: { type: 'string' }, - }, - { - name: 'area1', - in: 'query', - required: false, - description: '이마트24 1차 지역', - schema: { type: 'string' }, - }, - { - name: 'area2', - in: 'query', - required: false, - description: '이마트24 2차 지역', - schema: { type: 'string' }, - }, - { - name: 'areaCode', - in: 'query', - required: false, - description: '메가박스 지역 코드', - schema: { type: 'string' }, - }, - { - name: 'brandVariant', - in: 'query', - required: false, - description: '롯데마트 브랜드 변형', - schema: { type: 'string' }, - }, - { - name: 'lat', - in: 'query', - required: false, - description: '위도', - schema: { type: 'number', format: 'float' }, - }, - { - name: 'lng', - in: 'query', - required: false, - description: '경도', - schema: { type: 'number', format: 'float' }, - }, - { - name: 'page', - in: 'query', - required: false, - description: '페이지 번호', - schema: { type: 'integer' }, - }, - { - name: 'pageIdx', - in: 'query', - required: false, - description: '올리브영 페이지 번호', - schema: { type: 'integer' }, - }, - { - name: 'pageLimit', - in: 'query', - required: false, - description: '롯데마트 추가 페이지 수', - schema: { type: 'integer' }, - }, - { - name: 'pageSize', - in: 'query', - required: false, - description: '페이지 크기', - schema: { type: 'integer' }, - }, - { - name: 'size', - in: 'query', - required: false, - description: '페이지 크기 또는 검색 크기', - schema: { type: 'integer' }, - }, - { - name: 'offset', - in: 'query', - required: false, - description: 'CU 검색 오프셋', - schema: { type: 'integer' }, - }, - { - name: 'limit', - in: 'query', - required: false, - description: '최대 결과 수', - schema: { type: 'integer' }, - }, - { - name: 'storeLimit', - in: 'query', - required: false, - description: '매장 결과 제한 수', - schema: { type: 'integer' }, - }, - { - name: 'storeCode', - in: 'query', - required: false, - description: - '매장 코드. 다이소 진열 위치는 /api/daiso/inventory 응답의 storeInventory.stores[].storeCode에서 확인하세요.', - schema: { type: 'string' }, - }, - { - name: 'storeName', - in: 'query', - required: false, - description: - '매장명. 롯데마트 상품 조회에서 storeCode가 없을 때 사용하며, 모르면 먼저 lottemartFindNearbyStores 또는 /api/lottemart/stores로 확인하세요.', - schema: { type: 'string' }, - }, - { - name: 'storeKeyword', - in: 'query', - required: false, - description: '매장 키워드', - schema: { type: 'string' }, - }, - { - name: 'playDate', - in: 'query', - required: false, - description: '상영일(YYYYMMDD)', - schema: { type: 'string' }, - }, - { - name: 'regionCode', - in: 'query', - required: false, - description: 'CGV 지역 코드', - schema: { type: 'string' }, - }, - { - name: 'theaterCode', - in: 'query', - required: false, - description: 'CGV 극장 코드', - schema: { type: 'string' }, - }, - { - name: 'theaterId', - in: 'query', - required: false, - description: '메가박스/롯데시네마 지점 ID', - schema: { type: 'string' }, - }, - { - name: 'movieCode', - in: 'query', - required: false, - description: 'CGV 영화 코드', - schema: { type: 'string' }, - }, - { - name: 'movieId', - in: 'query', - required: false, - description: '메가박스/롯데시네마 영화 ID', - schema: { type: 'string' }, - }, - { - name: 'pluCd', - in: 'query', - required: false, - description: '이마트24 상품 PLU 코드', - schema: { type: 'string' }, - }, - { - name: 'bizNoArr', - in: 'query', - required: false, - description: '이마트24 사업자번호 목록', - schema: { type: 'string' }, - }, - { - name: 'label', - in: 'query', - required: false, - description: '세븐일레븐 인기 검색어 라벨', - schema: { type: 'string' }, - }, - { - name: 'sort', - in: 'query', - required: false, - description: '정렬 기준', - schema: { type: 'string' }, - }, - { - name: 'searchSort', - in: 'query', - required: false, - description: 'CU 검색 정렬 기준', - schema: { type: 'string' }, - }, - { - name: 'service24h', - in: 'query', - required: false, - description: '이마트24 24시간 필터', - schema: { type: 'boolean' }, - }, - { - name: 'includeSoldOut', - in: 'query', - required: false, - description: '올리브영 품절 포함 여부', - schema: { type: 'boolean' }, - }, - { - name: 'includeIssues', - in: 'query', - required: false, - description: '세븐일레븐 이달의 행사지 포함 여부', - schema: { type: 'boolean' }, - }, - { - name: 'includeExhibition', - in: 'query', - required: false, - description: '세븐일레븐 전시상품 포함 여부', - schema: { type: 'boolean' }, - }, - { - name: 'timeoutMs', - in: 'query', - required: false, - description: '세븐일레븐 inventory 타임아웃', - schema: { type: 'integer' }, - }, -] as const; export function generateOpenApiSpec(baseUrl: string): object { return { diff --git a/tests/app/cli.test.ts b/tests/app/cli.test.ts index 0730e3c..bcb5809 100644 --- a/tests/app/cli.test.ts +++ b/tests/app/cli.test.ts @@ -4,6 +4,11 @@ import { describe, expect, it, vi } from 'vitest'; import { isDirectExecution, runCli } from '../../src/cli.js'; +import { + COMMAND_DETAIL, + COMMAND_LIST, + COMMAND_SUMMARY, +} from '../../src/cliHelpDefinitions.js'; function createDeps() { const output: string[] = []; @@ -30,6 +35,13 @@ function createDeps() { } describe('CLI', () => { + it('모든 명령은 요약과 상세 도움말 정의를 가진다', () => { + for (const command of COMMAND_LIST) { + expect(COMMAND_SUMMARY[command]).toBeTruthy(); + expect(COMMAND_DETAIL[command].length).toBeGreaterThan(0); + } + }); + it('명령어 없이 TTY 환경이면 인터랙티브 모드를 실행한다', async () => { const { deps } = createDeps(); deps.isInteractiveTerminal = () => true; diff --git a/tests/pages/openapi.test.ts b/tests/pages/openapi.test.ts index b5a268d..4765931 100644 --- a/tests/pages/openapi.test.ts +++ b/tests/pages/openapi.test.ts @@ -12,8 +12,22 @@ import { createOpenApiYamlResponse, __testOnlyJsonToYaml, } from '../../src/pages/openapi.js'; +import { ACTION_QUERY_ACTIONS } from '../../src/api/actionsProxy.js'; +import { ACTION_QUERY_PARAMETERS } from '../../src/pages/openapiSpecActionParameters.js'; describe('OpenAPI 페이지', () => { + it('Actions 파라미터 모듈은 필수 action enum 계약을 제공한다', () => { + const actionParameter = ACTION_QUERY_PARAMETERS.find( + (parameter) => parameter.name === 'action', + ); + + expect(actionParameter).toMatchObject({ + in: 'query', + required: true, + }); + expect(actionParameter?.schema.enum).toEqual(ACTION_QUERY_ACTIONS); + }); + it('OpenAI Actions용 OpenAPI 스펙 객체를 생성한다', () => { const spec = generateOpenApiSpec('https://example.com') as { openapi: string; From e4830708ca34ffba1812afc65f6d4c807e270200 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 17:06:14 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=EB=A1=AF=EB=8D=B0=EB=A7=88?= =?UTF-8?q?=ED=8A=B8=20=EC=86=8C=EC=BC=93=20=EC=A0=84=EC=86=A1=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/lottemart/session.ts | 200 ++-------------------- src/services/lottemart/socketTransport.ts | 195 +++++++++++++++++++++ tests/services/lottemart/session.test.ts | 6 +- 3 files changed, 211 insertions(+), 190 deletions(-) create mode 100644 src/services/lottemart/socketTransport.ts diff --git a/src/services/lottemart/session.ts b/src/services/lottemart/session.ts index 7bc9658..a568fd0 100644 --- a/src/services/lottemart/session.ts +++ b/src/services/lottemart/session.ts @@ -5,6 +5,17 @@ import { HttpError, fetchWithTimeout } from '../../utils/http.js'; import { decodeBase64, requestByZyte } from '../../utils/zyte.js'; import { LOTTEMART_API } from './api.js'; +import { + fetchLotteMartSocketResponse, + toLotteMartBodyText, + withLotteMartSessionCookie, +} from './socketTransport.js'; + +export { + __testOnlyCreateLotteMartSocketResponse, + __testOnlyFetchLotteMartSocketResponse, + withLotteMartSessionCookie, +} from './socketTransport.js'; const SESSION_CACHE_TTL_MS = 5 * 60 * 1000; let sessionCache: { expiresAt: number; cookie: string } | null = null; @@ -32,173 +43,6 @@ export interface LotteMartProbeAttempt { sessionCookie: string | null; } -type LotteMartSocketConnect = ( - address: { hostname: string; port: number }, - options?: { allowHalfOpen: boolean }, -) => { - readable: ReadableStream; - writable: WritableStream; -}; - -function withSocketTimeout(operation: Promise, timeout: number): Promise { - const timeoutMs = Math.max(1, Math.trunc(timeout)); - - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => resolve(null), timeoutMs); - operation.then( - (value) => { - clearTimeout(timeoutId); - resolve(value); - }, - (error: unknown) => { - clearTimeout(timeoutId); - reject(error); - }, - ); - }); -} - -export function __testOnlyCreateLotteMartSocketResponse(raw: Uint8Array): Response | null { - const delimiter = new TextEncoder().encode('\r\n\r\n'); - let boundary = -1; - for (let index = 0; index <= raw.length - delimiter.length; index += 1) { - let matched = true; - for (let inner = 0; inner < delimiter.length; inner += 1) { - if (raw[index + inner] !== delimiter[inner]) { - matched = false; - break; - } - } - if (matched) { - boundary = index; - break; - } - } - - if (boundary < 0) { - return null; - } - - const headerText = new TextDecoder().decode(raw.slice(0, boundary)); - if (!headerText.startsWith('HTTP/')) { - return null; - } - - const bodyBytes = raw.slice(boundary + delimiter.length); - const headerLinesRaw = headerText.split('\r\n'); - const statusLine = headerLinesRaw.shift() as string; - const [, statusCodeText = '500', ...statusTextParts] = statusLine.split(' '); - const responseHeaders = new Headers(); - for (const line of headerLinesRaw) { - const separatorIndex = line.indexOf(':'); - if (separatorIndex < 0) { - continue; - } - responseHeaders.append(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim()); - } - - return new Response(bodyBytes, { - status: parseInt(statusCodeText, 10) || 500, - statusText: statusTextParts.join(' ').trim(), - headers: responseHeaders, - }); -} - -export async function __testOnlyFetchLotteMartSocketResponse( - url: string, - init: RequestInit, - sessionCookie: string, - connectFn: LotteMartSocketConnect, - timeout: number, -): Promise { - const requestUrl = new URL(url); - const headers = withLotteMartSessionCookie( - { - Accept: 'text/html, */*; q=0.01', - Host: LOTTEMART_API.ORIGIN_HOST, - Origin: 'https://company.lottemart.com', - Referer: LOTTEMART_API.ORIGIN_REFERER, - Connection: 'close', - ...init.headers, - }, - sessionCookie, - ); - const bodyText = toBodyText(init.body) || ''; - if (bodyText.length > 0 && !headers.has('Content-Length')) { - headers.set('Content-Length', String(new TextEncoder().encode(bodyText).length)); - } - - const headerLines = Array.from(headers.entries()).map(([name, value]) => `${name}: ${value}`); - const path = `${requestUrl.pathname}${requestUrl.search}`; - const requestText = [`${init.method || 'GET'} ${path} HTTP/1.1`, ...headerLines, '', bodyText].join('\r\n'); - const socket = connectFn({ - hostname: LOTTEMART_API.SOCKET_HOST, - port: 80, - }, { allowHalfOpen: true }); - const writer = socket.writable.getWriter(); - const encodedRequest = new TextEncoder().encode(requestText); - if ((await withSocketTimeout(writer.write(encodedRequest), timeout)) === null) { - void writer.abort().catch(Boolean); - return null; - } - if ((await withSocketTimeout(writer.close(), timeout)) === null) { - void writer.abort().catch(Boolean); - return null; - } - - const reader = socket.readable.getReader(); - const chunks: Uint8Array[] = []; - try { - while (true) { - const readResult = await withSocketTimeout(reader.read(), timeout); - if (readResult === null) { - await reader.cancel().catch(() => undefined); - return null; - } - const { value, done } = readResult; - if (done) { - break; - } - chunks.push(value as Uint8Array); - } - } finally { - reader.releaseLock(); - } - - const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const raw = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - raw.set(chunk, offset); - offset += chunk.length; - } - - return __testOnlyCreateLotteMartSocketResponse(raw); -} - -/* c8 ignore start */ -async function fetchLotteMartSocketResponse( - url: string, - init: RequestInit, - sessionCookie: string, - timeout: number, -): Promise { - let connectFn: LotteMartSocketConnect | null = null; - try { - const socketsModule = await import('cloudflare:sockets'); - connectFn = socketsModule.connect as LotteMartSocketConnect; - } catch { - return null; - } - - if (!connectFn) { - return null; - } - - return __testOnlyFetchLotteMartSocketResponse(url, init, sessionCookie, connectFn, timeout); -} -/* c8 ignore end */ - function cacheSessionCookie(cookie: string): string { const normalized = cookie.trim(); if (normalized.length === 0) { @@ -237,14 +81,6 @@ export async function getFreshLotteMartSessionCookie(timeout: number): Promise 0) { - result.set('Cookie', sessionCookie); - } - return result; -} - async function fetchLotteMartResponse( url: string, init: RequestInit, @@ -273,18 +109,6 @@ function toZyteHeaders(headers: Headers): Array<{ name: string; value: string }> return Array.from(headers.entries()).map(([name, value]) => ({ name, value })); } -function toBodyText(body: RequestInit['body']): string | undefined { - if (typeof body === 'string') { - return body; - } - - if (body instanceof URLSearchParams) { - return body.toString(); - } - - return undefined; -} - async function fetchLotteMartHtmlByZyte( url: string, init: RequestInit, @@ -305,7 +129,7 @@ async function fetchLotteMartHtmlByZyte( timeout, method: (init.method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | undefined) || 'GET', headers: toZyteHeaders(headers), - bodyText: toBodyText(init.body), + bodyText: toLotteMartBodyText(init.body), tags: { service: 'lottemart' }, }); diff --git a/src/services/lottemart/socketTransport.ts b/src/services/lottemart/socketTransport.ts new file mode 100644 index 0000000..a166bf5 --- /dev/null +++ b/src/services/lottemart/socketTransport.ts @@ -0,0 +1,195 @@ +/** + * 롯데마트 HTTP 소켓 전송 + */ + +import { LOTTEMART_API } from './api.js'; + +export type LotteMartSocketConnect = ( + address: { hostname: string; port: number }, + options?: { allowHalfOpen: boolean }, +) => { + readable: ReadableStream; + writable: WritableStream; +}; + +function withSocketTimeout(operation: Promise, timeout: number): Promise { + const timeoutMs = Math.max(1, Math.trunc(timeout)); + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => resolve(null), timeoutMs); + operation.then( + (value) => { + clearTimeout(timeoutId); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timeoutId); + reject(error); + }, + ); + }); +} + +export function withLotteMartSessionCookie(headers: HeadersInit | undefined, sessionCookie: string): Headers { + const result = new Headers(headers); + if (sessionCookie.trim().length > 0) { + result.set('Cookie', sessionCookie); + } + return result; +} + +export function toLotteMartBodyText(body: RequestInit['body']): string | undefined { + if (typeof body === 'string') { + return body; + } + + if (body instanceof URLSearchParams) { + return body.toString(); + } + + return undefined; +} + +export function __testOnlyCreateLotteMartSocketResponse(raw: Uint8Array): Response | null { + const delimiter = new TextEncoder().encode('\r\n\r\n'); + let boundary = -1; + for (let index = 0; index <= raw.length - delimiter.length; index += 1) { + let matched = true; + for (let inner = 0; inner < delimiter.length; inner += 1) { + if (raw[index + inner] !== delimiter[inner]) { + matched = false; + break; + } + } + if (matched) { + boundary = index; + break; + } + } + + if (boundary < 0) { + return null; + } + + const headerText = new TextDecoder().decode(raw.slice(0, boundary)); + if (!headerText.startsWith('HTTP/')) { + return null; + } + + const bodyBytes = raw.slice(boundary + delimiter.length); + const headerLinesRaw = headerText.split('\r\n'); + const statusLine = headerLinesRaw.shift() as string; + const [, statusCodeText = '500', ...statusTextParts] = statusLine.split(' '); + const responseHeaders = new Headers(); + for (const line of headerLinesRaw) { + const separatorIndex = line.indexOf(':'); + if (separatorIndex < 0) { + continue; + } + responseHeaders.append(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim()); + } + + return new Response(bodyBytes, { + status: parseInt(statusCodeText, 10) || 500, + statusText: statusTextParts.join(' ').trim(), + headers: responseHeaders, + }); +} + +export async function __testOnlyFetchLotteMartSocketResponse( + url: string, + init: RequestInit, + sessionCookie: string, + connectFn: LotteMartSocketConnect, + timeout: number, +): Promise { + const requestUrl = new URL(url); + const headers = withLotteMartSessionCookie( + { + Accept: 'text/html, */*; q=0.01', + Host: LOTTEMART_API.ORIGIN_HOST, + Origin: 'https://company.lottemart.com', + Referer: LOTTEMART_API.ORIGIN_REFERER, + Connection: 'close', + ...init.headers, + }, + sessionCookie, + ); + const bodyText = toLotteMartBodyText(init.body) || ''; + if (bodyText.length > 0 && !headers.has('Content-Length')) { + headers.set('Content-Length', String(new TextEncoder().encode(bodyText).length)); + } + + const headerLines = Array.from(headers.entries()).map(([name, value]) => `${name}: ${value}`); + const path = `${requestUrl.pathname}${requestUrl.search}`; + const requestText = [`${init.method || 'GET'} ${path} HTTP/1.1`, ...headerLines, '', bodyText].join('\r\n'); + const socket = connectFn( + { + hostname: LOTTEMART_API.SOCKET_HOST, + port: 80, + }, + { allowHalfOpen: true }, + ); + const writer = socket.writable.getWriter(); + const encodedRequest = new TextEncoder().encode(requestText); + if ((await withSocketTimeout(writer.write(encodedRequest), timeout)) === null) { + void writer.abort().catch(Boolean); + return null; + } + if ((await withSocketTimeout(writer.close(), timeout)) === null) { + void writer.abort().catch(Boolean); + return null; + } + + const reader = socket.readable.getReader(); + const chunks: Uint8Array[] = []; + try { + while (true) { + const readResult = await withSocketTimeout(reader.read(), timeout); + if (readResult === null) { + await reader.cancel().catch(() => undefined); + return null; + } + const { value, done } = readResult; + if (done) { + break; + } + chunks.push(value as Uint8Array); + } + } finally { + reader.releaseLock(); + } + + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const raw = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + raw.set(chunk, offset); + offset += chunk.length; + } + + return __testOnlyCreateLotteMartSocketResponse(raw); +} + +/* c8 ignore start */ +export async function fetchLotteMartSocketResponse( + url: string, + init: RequestInit, + sessionCookie: string, + timeout: number, +): Promise { + let connectFn: LotteMartSocketConnect | null = null; + try { + const socketsModule = await import('cloudflare:sockets'); + connectFn = socketsModule.connect as LotteMartSocketConnect; + } catch { + return null; + } + + if (!connectFn) { + return null; + } + + return __testOnlyFetchLotteMartSocketResponse(url, init, sessionCookie, connectFn, timeout); +} +/* c8 ignore end */ diff --git a/tests/services/lottemart/session.test.ts b/tests/services/lottemart/session.test.ts index b0ab12f..c2a7683 100644 --- a/tests/services/lottemart/session.test.ts +++ b/tests/services/lottemart/session.test.ts @@ -1,14 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { __testOnlyClearLotteMartSessionCache, - __testOnlyCreateLotteMartSocketResponse, - __testOnlyFetchLotteMartSocketResponse, fetchLotteMartHtml, fetchLotteMartPageWithSession, getCachedLotteMartSessionCookie, getFreshLotteMartSessionCookie, probeLotteMartRequest, } from '../../../src/services/lottemart/session.js'; +import { + __testOnlyCreateLotteMartSocketResponse, + __testOnlyFetchLotteMartSocketResponse, +} from '../../../src/services/lottemart/socketTransport.js'; const mockFetch = vi.fn(); From eaaf7a97d41c0fb6e322865d106e6cef6c619fc6 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 17:07:48 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B7=A8=EC=95=BD=EC=A0=90=EA=B3=BC=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=20=EB=B2=84=EC=A0=84=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 140 ++++++++++++------------ package.json | 10 +- tests/scripts/repository-config.test.ts | 11 ++ 3 files changed, 86 insertions(+), 75 deletions(-) diff --git a/package-lock.json b/package-lock.json index 405186a..64a4b4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "hono": "^4.12.31", + "hono": "^4.12.32", "proj4": "^2.20.9", "workers-mcp": "^0.0.13" }, @@ -19,22 +19,22 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.5", - "@cloudflare/workers-types": "^5.20260722.1", + "@cloudflare/workers-types": "^5.20260724.1", "@eslint/js": "^10.0.1", "@types/node": "^26.1.0", "@vitest/coverage-v8": "^4.1.7", - "eslint": "^10.6.0", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "globals": "^17.7.0", "jiti": "^2.7.0", - "playwright": "^1.61.1", + "playwright": "^1.62.0", "prettier": "^3.9.6", "sharp": "^0.35.3", "tsx": "^4.23.1", "typescript": "6.0.3", "typescript-eslint": "^8.65.0", "vitest": "^4.1.7", - "wrangler": "^4.113.0" + "wrangler": "^4.114.0" }, "engines": { "node": ">=22" @@ -319,9 +319,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260721.1.tgz", - "integrity": "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz", + "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==", "cpu": [ "x64" ], @@ -336,9 +336,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260721.1.tgz", - "integrity": "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz", + "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==", "cpu": [ "arm64" ], @@ -353,9 +353,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260721.1.tgz", - "integrity": "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz", + "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==", "cpu": [ "x64" ], @@ -370,9 +370,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260721.1.tgz", - "integrity": "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz", + "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==", "cpu": [ "arm64" ], @@ -387,9 +387,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260721.1.tgz", - "integrity": "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz", + "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==", "cpu": [ "x64" ], @@ -404,9 +404,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "5.20260722.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260722.1.tgz", - "integrity": "sha512-8+kivCgFGzwrAfNOWgSpzy/VDvmT/i5KWBgQhnygv3d1kajNn6mCYTbLKpouG0aY8mXjhv+IQm1a8r2K/H4pqQ==", + "version": "5.20260724.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260724.1.tgz", + "integrity": "sha512-gl0brZ60JhkZU3INgr1jsxaFEiWf3AB9RsCjLTMwT5RKspWRUpsFd0wxwbys7gb8Ywkfq9tORhw0y9G+7bDUYw==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -931,9 +931,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2738,16 +2738,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -3139,9 +3139,9 @@ } }, "node_modules/eslint": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", - "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -3151,7 +3151,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -3175,7 +3175,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3793,9 +3793,9 @@ } }, "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4652,16 +4652,16 @@ } }, "node_modules/miniflare": { - "version": "4.20260721.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260721.0.tgz", - "integrity": "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==", + "version": "4.20260722.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260722.0.tgz", + "integrity": "sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.34.5", + "sharp": "0.35.2", "undici": "7.28.0", - "workerd": "1.20260721.1", + "workerd": "1.20260722.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -4673,13 +4673,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -4982,35 +4982,35 @@ } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.62.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/playwright/node_modules/fsevents": { @@ -6171,9 +6171,9 @@ } }, "node_modules/workerd": { - "version": "1.20260721.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260721.1.tgz", - "integrity": "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==", + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz", + "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -6184,11 +6184,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260721.1", - "@cloudflare/workerd-darwin-arm64": "1.20260721.1", - "@cloudflare/workerd-linux-64": "1.20260721.1", - "@cloudflare/workerd-linux-arm64": "1.20260721.1", - "@cloudflare/workerd-windows-64": "1.20260721.1" + "@cloudflare/workerd-darwin-64": "1.20260722.1", + "@cloudflare/workerd-darwin-arm64": "1.20260722.1", + "@cloudflare/workerd-linux-64": "1.20260722.1", + "@cloudflare/workerd-linux-arm64": "1.20260722.1", + "@cloudflare/workerd-windows-64": "1.20260722.1" } }, "node_modules/workers-mcp": { @@ -6218,9 +6218,9 @@ } }, "node_modules/wrangler": { - "version": "4.113.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.113.0.tgz", - "integrity": "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==", + "version": "4.114.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.114.0.tgz", + "integrity": "sha512-M65P25t5UHA1TIJfgZXDcj+YzVobgKdRguM2QPz0xnxLFuOcuE3ErgllDht0iaho7MS4o0g/Bb4YK2+GT+bibg==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -6228,10 +6228,10 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "4.20260721.0", + "miniflare": "4.20260722.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260721.1" + "workerd": "1.20260722.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -6245,7 +6245,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260721.1" + "@cloudflare/workers-types": "^5.20260722.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index 8a6397d..a96a9c9 100644 --- a/package.json +++ b/package.json @@ -81,22 +81,22 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.5", - "@cloudflare/workers-types": "^5.20260722.1", + "@cloudflare/workers-types": "^5.20260724.1", "@eslint/js": "^10.0.1", "@types/node": "^26.1.0", "@vitest/coverage-v8": "^4.1.7", - "eslint": "^10.6.0", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "globals": "^17.7.0", "jiti": "^2.7.0", - "playwright": "^1.61.1", + "playwright": "^1.62.0", "prettier": "^3.9.6", "sharp": "^0.35.3", "tsx": "^4.23.1", "typescript": "6.0.3", "typescript-eslint": "^8.65.0", "vitest": "^4.1.7", - "wrangler": "^4.113.0" + "wrangler": "^4.114.0" }, "overrides": { "@modelcontextprotocol/sdk": { @@ -108,7 +108,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "hono": "^4.12.31", + "hono": "^4.12.32", "proj4": "^2.20.9", "workers-mcp": "^0.0.13" } diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index d011818..7b6df99 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -65,6 +65,17 @@ describe('repository maintenance configuration', () => { expect(pkg.overrides?.miniflare).toEqual({ sharp: '0.35.3' }); }); + it('brace-expansion은 취약점이 수정된 5.0.8 이상을 사용한다', () => { + const lock = JSON.parse(readText('package-lock.json')) as { + packages?: Record; + }; + const version = lock.packages?.['node_modules/brace-expansion']?.version; + const [major = 0, minor = 0, patch = 0] = version?.split('.').map(Number) ?? []; + + expect(version).toBeDefined(); + expect(major * 1_000_000 + minor * 1_000 + patch).toBeGreaterThanOrEqual(5_000_008); + }); + it('모든 GitHub Actions workflow에서 setup-node v6을 사용하지 않는다', () => { const workflows = readdirSync('.github/workflows') .filter((file) => file.endsWith('.yml')) From 0b191452a266d339a9fdf7b5fb058880ba1b2893 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 17:10:46 +0900 Subject: [PATCH 7/9] =?UTF-8?q?ci:=20=EC=99=B8=EB=B6=80=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=20=EC=88=9C=EC=B0=A8=20=EC=8B=A4=ED=96=89=EA=B3=BC=20?= =?UTF-8?q?=EC=9D=BC=EC=9D=BC=20=EB=8B=A8=EC=9D=BC=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/external-smoke.yml | 164 +++++++++++------------- .github/workflows/health-checks.yml | 19 ++- tests/scripts/repository-config.test.ts | 39 +++--- 3 files changed, 111 insertions(+), 111 deletions(-) diff --git a/.github/workflows/external-smoke.yml b/.github/workflows/external-smoke.yml index 9bdfadf..52d5a5b 100644 --- a/.github/workflows/external-smoke.yml +++ b/.github/workflows/external-smoke.yml @@ -9,48 +9,15 @@ on: permissions: contents: read +concurrency: + group: external-smoke-${{ github.ref }} + cancel-in-progress: false + jobs: external-smoke: runs-on: ubuntu-latest - name: ${{ matrix.suite }} smoke (${{ matrix.service }}) - concurrency: - group: external-smoke-${{ github.ref }}-${{ matrix.suite }}-${{ matrix.service }} - cancel-in-progress: false - strategy: - fail-fast: false - max-parallel: 4 - matrix: - include: - - suite: cli - service: daiso - - suite: cli - service: gs25 - - suite: cli - service: seveneleven - - suite: cli - service: emart24 - - suite: cli - service: lottemart - - suite: cli - service: oliveyoung - - suite: cli - service: megabox - - suite: cli - service: lottecinema - - suite: cli - service: cgv - - suite: cli - service: opinet - - suite: mcp - service: daiso - - suite: mcp - service: gs25 - - suite: mcp - service: seveneleven - - suite: mcp - service: emart24 - - suite: mcp - service: opinet + timeout-minutes: 60 + name: Sequential external smoke steps: - name: Checkout uses: actions/checkout@v7 @@ -64,72 +31,74 @@ jobs: - name: Install dependencies run: npm ci - - name: Run external smoke + - name: Build once + run: npm run build + + - name: Run external smoke sequentially env: - SMOKE_SUITE: ${{ matrix.suite }} - SMOKE_SERVICE: ${{ matrix.service }} + CLI_SMOKE_SERVICES: daiso gs25 seveneleven emart24 lottemart oliveyoung megabox lottecinema cgv opinet + MCP_SMOKE_SERVICES: daiso gs25 seveneleven emart24 opinet run: | - write_summary() { - SMOKE_STATUS="$1" node <<'NODE' - const fs = require('node:fs'); - const output = fs.existsSync('external-smoke-output.txt') - ? fs.readFileSync('external-smoke-output.txt', 'utf8') - : ''; - const excerpt = output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .slice(-8) - .join(' | ') - .slice(0, 1000); - const failure = - output - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => - /(?:CLI smoke 실패|CLI smoke 검증 실패|MCP smoke 실패|HTTP \d{3}|error code: \d+)/.test( - line, - ), - ) || 'unknown'; - const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; - const summary = [ - `status=${process.env.SMOKE_STATUS || 'failed'}`, - `suite=${process.env.SMOKE_SUITE}`, - `service=${process.env.SMOKE_SERVICE}`, - `failure=${failure.slice(0, 300)}`, - `run=${runUrl}`, - excerpt, - ].filter(Boolean).join(' '); - fs.writeFileSync('external-smoke-summary.txt', summary); - console.log(summary); - NODE - } + set +e + FAILURES=() + + run_smoke_once() { + local SMOKE_SUITE="$1" + local SMOKE_SERVICE="$2" + local OUTPUT_FILE="$3" - run_smoke() { if [ "${SMOKE_SUITE}" = "cli" ]; then - npm run cli:smoke -- --service "${SMOKE_SERVICE}" 2>&1 | tee external-smoke-output.txt + npx tsx scripts/ops/cli-smoke.ts --service "${SMOKE_SERVICE}" 2>&1 | tee "${OUTPUT_FILE}" return "${PIPESTATUS[0]}" fi - npm run mcp:smoke -- --service "${SMOKE_SERVICE}" 2>&1 | tee external-smoke-output.txt + npx tsx scripts/ops/mcp-smoke.ts --service "${SMOKE_SERVICE}" 2>&1 | tee "${OUTPUT_FILE}" return "${PIPESTATUS[0]}" } - if ! run_smoke; then - if [ "${SMOKE_SUITE}" = "cli" ]; then - echo "CLI smoke failed; retrying after 15 seconds" - else - echo "MCP smoke failed; retrying after 15 seconds" + run_smoke_with_retry() { + local SMOKE_SUITE="$1" + local SMOKE_SERVICE="$2" + local OUTPUT_FILE="external-smoke-${SMOKE_SUITE}-${SMOKE_SERVICE}.txt" + + if run_smoke_once "${SMOKE_SUITE}" "${SMOKE_SERVICE}" "${OUTPUT_FILE}"; then + return 0 fi + + echo "${SMOKE_SUITE}.${SMOKE_SERVICE} failed; retrying after 15 seconds" sleep 15 - if ! run_smoke; then - write_summary "failed" - exit 1 + if run_smoke_once "${SMOKE_SUITE}" "${SMOKE_SERVICE}" "${OUTPUT_FILE}"; then + return 0 fi + + local EXCERPT + EXCERPT="$(tail -n 8 "${OUTPUT_FILE}" | tr '\n' ' ' | cut -c1-1000)" + FAILURES+=("${SMOKE_SUITE}.${SMOKE_SERVICE}: ${EXCERPT:-unknown}") + return 1 + } + + for SMOKE_SERVICE in ${CLI_SMOKE_SERVICES}; do + run_smoke_with_retry "cli" "${SMOKE_SERVICE}" || true + done + + for SMOKE_SERVICE in ${MCP_SMOKE_SERVICES}; do + run_smoke_with_retry "mcp" "${SMOKE_SERVICE}" || true + done + + RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + if [ "${#FAILURES[@]}" -gt 0 ]; then + { + printf 'status=failed failures=%s run=%s ' "${#FAILURES[@]}" "${RUN_URL}" + printf '%s | ' "${FAILURES[@]}" + printf '\n' + } | tee external-smoke-summary.txt + exit 1 fi + echo "status=ok failures=0 run=${RUN_URL}" | tee external-smoke-summary.txt + - name: Notify smoke failure - if: failure() + if: failure() && github.event_name == 'schedule' env: MOSHI_WEBHOOK_TOKEN: ${{ secrets.MOSHI_WEBHOOK_TOKEN }} run: | @@ -138,7 +107,18 @@ jobs: exit 0 fi - SUMMARY="$(cat external-smoke-summary.txt 2>/dev/null || echo 'external smoke command failed')" - curl -X POST https://api.getmoshi.app/api/webhook \ + SUMMARY="$(cat external-smoke-summary.txt 2>/dev/null || echo 'external smoke command failed')" node <<'NODE' + const fs = require('node:fs'); + fs.writeFileSync( + 'moshi-payload.json', + JSON.stringify({ + token: process.env.MOSHI_WEBHOOK_TOKEN, + title: 'External Smoke Failed', + message: `${process.env.GITHUB_REPOSITORY}: ${process.env.SUMMARY}`, + }), + ); + NODE + curl --fail-with-body --silent --show-error \ + -X POST https://api.getmoshi.app/api/webhook \ -H "Content-Type: application/json" \ - -d "{\"token\":\"${MOSHI_WEBHOOK_TOKEN}\",\"title\":\"External Smoke Failed\",\"message\":\"${GITHUB_REPOSITORY}: ${SUMMARY}\"}" + --data-binary @moshi-payload.json diff --git a/.github/workflows/health-checks.yml b/.github/workflows/health-checks.yml index bba2ea9..5f27f55 100644 --- a/.github/workflows/health-checks.yml +++ b/.github/workflows/health-checks.yml @@ -78,7 +78,7 @@ jobs: fi - name: Notify health failure - if: failure() + if: failure() && github.event_name == 'schedule' && github.event.schedule == '10 15 * * *' env: MOSHI_WEBHOOK_TOKEN: ${{ secrets.MOSHI_WEBHOOK_TOKEN }} run: | @@ -87,7 +87,18 @@ jobs: exit 0 fi - SUMMARY="$(cat health-check-summary.txt 2>/dev/null || echo 'health check command failed')" - curl -X POST https://api.getmoshi.app/api/webhook \ + SUMMARY="$(cat health-check-summary.txt 2>/dev/null || echo 'health check command failed')" node <<'NODE' + const fs = require('node:fs'); + fs.writeFileSync( + 'moshi-payload.json', + JSON.stringify({ + token: process.env.MOSHI_WEBHOOK_TOKEN, + title: 'Health Checks Failed', + message: `${process.env.GITHUB_REPOSITORY}: ${process.env.SUMMARY}`, + }), + ); + NODE + curl --fail-with-body --silent --show-error \ + -X POST https://api.getmoshi.app/api/webhook \ -H "Content-Type: application/json" \ - -d "{\"token\":\"${MOSHI_WEBHOOK_TOKEN}\",\"title\":\"Health Checks Failed\",\"message\":\"${GITHUB_REPOSITORY}: ${SUMMARY}\"}" + --data-binary @moshi-payload.json diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index 7b6df99..ff5d826 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -128,31 +128,37 @@ describe('repository maintenance configuration', () => { expect(contributing).toContain('450줄 제한은 CI에서 자동으로 강제'); }); - it('external smoke workflow는 수동 및 야간 실행으로 CLI smoke를 수행한다', () => { + it('external smoke workflow는 한 runner에서 서비스를 순차 점검하고 야간 실패만 한 번 알린다', () => { const workflow = readText('.github/workflows/external-smoke.yml'); expect(workflow).toContain('workflow_dispatch:'); expect(workflow).toContain("cron: '40 15 * * *'"); + expect(workflow).toContain('group: external-smoke-${{ github.ref }}'); + expect(workflow).not.toContain('matrix:'); + expect(workflow).not.toContain('matrix.'); + expect(workflow).not.toContain('max-parallel:'); + expect(workflow.match(/runs-on:/g)).toHaveLength(1); + expect(workflow).toContain('timeout-minutes: 60'); + expect(workflow).toContain("node-version: '24'"); + expect(workflow.match(/npm run build/g)).toHaveLength(1); expect(workflow).toContain( - 'group: external-smoke-${{ github.ref }}-${{ matrix.suite }}-${{ matrix.service }}', + 'CLI_SMOKE_SERVICES: daiso gs25 seveneleven emart24 lottemart oliveyoung megabox lottecinema cgv opinet', ); - expect(workflow).toContain('fail-fast: false'); - expect(workflow).toContain('max-parallel: 4'); - expect(workflow).toContain('service: daiso'); - expect(workflow).toContain('service: oliveyoung'); - expect(workflow).toContain('service: opinet'); - expect(workflow).toContain('suite: mcp'); - expect(workflow).toContain("node-version: '24'"); - expect(workflow).toContain('npm run cli:smoke -- --service "${SMOKE_SERVICE}"'); - expect(workflow).toContain('npm run mcp:smoke -- --service "${SMOKE_SERVICE}"'); - expect(workflow).toContain('CLI smoke failed; retrying after 15 seconds'); - expect(workflow).toContain('MCP smoke failed; retrying after 15 seconds'); + expect(workflow).toContain( + 'MCP_SMOKE_SERVICES: daiso gs25 seveneleven emart24 opinet', + ); + expect(workflow).toContain('for SMOKE_SERVICE in ${CLI_SMOKE_SERVICES}; do'); + expect(workflow).toContain('for SMOKE_SERVICE in ${MCP_SMOKE_SERVICES}; do'); + expect(workflow).toContain('npx tsx scripts/ops/cli-smoke.ts --service "${SMOKE_SERVICE}"'); + expect(workflow).toContain('npx tsx scripts/ops/mcp-smoke.ts --service "${SMOKE_SERVICE}"'); + expect(workflow).toContain('run_smoke_with_retry'); + expect(workflow).toContain('retrying after 15 seconds'); expect(workflow).toContain('external-smoke-summary.txt'); - expect(workflow).toContain('failure='); + expect(workflow).toContain('failures='); expect(workflow).toContain('SUMMARY="$(cat external-smoke-summary.txt'); expect(workflow).toContain('Notify smoke failure'); expect(workflow).toContain('MOSHI_WEBHOOK_TOKEN'); - expect(workflow).toContain('if: failure()'); + expect(workflow).toContain("if: failure() && github.event_name == 'schedule'"); }); it('deploy workflow는 배포 때마다 Worker secret을 다시 쓰지 않는다', () => { @@ -227,6 +233,9 @@ describe('repository maintenance configuration', () => { expect(workflow).toContain('console.log(summary)'); expect(workflow).toContain("payload.status === 'fail'"); expect(workflow).toContain('Health Checks Failed'); + expect(workflow).toContain( + "if: failure() && github.event_name == 'schedule' && github.event.schedule == '10 15 * * *'", + ); }); it('차트 자동 커밋은 불필요한 push workflow를 다시 실행하지 않는다', () => { From 001715ceb7d721d1e919f6c71a2b09ea55a0e216 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 17:16:21 +0900 Subject: [PATCH 8/9] =?UTF-8?q?test:=20=EB=A1=AF=EB=8D=B0=EB=A7=88?= =?UTF-8?q?=ED=8A=B8=20=EC=84=B8=EC=85=98=20=EB=B6=84=EA=B8=B0=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=EB=B2=94=EC=9C=84=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/services/lottemart/session.test.ts | 112 +++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/tests/services/lottemart/session.test.ts b/tests/services/lottemart/session.test.ts index c2a7683..512c124 100644 --- a/tests/services/lottemart/session.test.ts +++ b/tests/services/lottemart/session.test.ts @@ -12,6 +12,20 @@ import { __testOnlyFetchLotteMartSocketResponse, } from '../../../src/services/lottemart/socketTransport.js'; +const socketMocks = vi.hoisted(() => ({ + fetchResponse: vi.fn(), +})); + +vi.mock('../../../src/services/lottemart/socketTransport.js', async () => { + const actual = await vi.importActual< + typeof import('../../../src/services/lottemart/socketTransport.js') + >('../../../src/services/lottemart/socketTransport.js'); + return { + ...actual, + fetchLotteMartSocketResponse: socketMocks.fetchResponse, + }; +}); + const mockFetch = vi.fn(); function createZyteResponse(bodyText: string, status = 200) { @@ -31,6 +45,7 @@ function createZyteResponse(bodyText: string, status = 200) { beforeEach(() => { mockFetch.mockReset(); + socketMocks.fetchResponse.mockReset().mockResolvedValue(null); __testOnlyClearLotteMartSessionCache(); vi.stubGlobal('fetch', mockFetch); }); @@ -183,6 +198,16 @@ describe('lottemart session helpers', () => { await expect(response?.text()).resolves.toBe('socket ok'); }); + it('소켓 전송이 응답하면 일반 fetch 없이 해당 응답을 사용한다', async () => { + socketMocks.fetchResponse.mockResolvedValueOnce(new Response('socket direct')); + + await expect( + fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), + ).resolves.toBe('socket direct'); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('소켓 쓰기나 닫기가 멈추면 null을 반환한다', async () => { const never = new Promise(() => {}); const writableWithStalledWrite = new WritableStream({ @@ -545,6 +570,93 @@ describe('lottemart session helpers', () => { ]); }); + it('probeLotteMartRequest는 실패 응답과 빈 본문을 요약한다', async () => { + mockFetch.mockResolvedValue(new Response('', { status: 500, statusText: 'Server Error' })); + + await expect( + probeLotteMartRequest('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), + ).resolves.toEqual([ + expect.objectContaining({ + used: 'direct', + success: false, + error: expect.stringContaining('API 요청 실패: 500 Server Error'), + bodyPreview: null, + }), + ]); + }); + + it('probeLotteMartRequest는 Error가 아닌 direct 실패도 기본 메시지로 요약한다', async () => { + mockFetch.mockRejectedValue('network unavailable'); + + await expect( + probeLotteMartRequest('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), + ).resolves.toEqual([ + expect.objectContaining({ + used: 'direct', + success: false, + error: '알 수 없는 오류가 발생했습니다.', + }), + ]); + }); + + it('probeLotteMartRequest는 Zyte 성공 시 세션 쿠키 유무를 보존한다', async () => { + mockFetch + .mockResolvedValueOnce(new Response('direct ok')) + .mockResolvedValueOnce(createZyteResponse('zyte ok')) + .mockResolvedValueOnce(new Response('direct ok')) + .mockResolvedValueOnce(createZyteResponse('zyte ok')); + + const withCookie = await probeLotteMartRequest( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + 'ASPSESSIONID=PROBE', + 'test-key', + ); + const withoutCookie = await probeLotteMartRequest( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + 'test-key', + ); + + expect(withCookie[1]).toEqual( + expect.objectContaining({ + used: 'zyte', + success: true, + sessionCookie: 'ASPSESSIONID=PROBE', + }), + ); + expect(withoutCookie[1]).toEqual( + expect.objectContaining({ + used: 'zyte', + success: true, + sessionCookie: null, + }), + ); + }); + + it('probeLotteMartRequest는 Error가 아닌 Zyte 실패도 기본 메시지로 요약한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('direct ok')).mockRejectedValueOnce('zyte unavailable'); + + const result = await probeLotteMartRequest( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + 'test-key', + ); + + expect(result[1]).toEqual( + expect.objectContaining({ + used: 'zyte', + success: false, + error: '알 수 없는 오류가 발생했습니다.', + }), + ); + }); + it('probeLotteMartRequest는 direct 실패와 zyte 실패를 함께 기록한다', async () => { mockFetch .mockRejectedValueOnce(new Error('The operation was aborted')) From ac0db8828867ca2bdcf18b5e9f2c6f334b2fcdfe Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sat, 25 Jul 2026 17:19:07 +0900 Subject: [PATCH 9/9] =?UTF-8?q?ci:=20=EC=99=B8=EB=B6=80=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=EB=B3=84=20=EC=8B=A4=ED=96=89=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/external-smoke.yml | 7 ++++--- .github/workflows/health-checks.yml | 2 +- tests/scripts/repository-config.test.ts | 10 ++++++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/external-smoke.yml b/.github/workflows/external-smoke.yml index 52d5a5b..8e54cbd 100644 --- a/.github/workflows/external-smoke.yml +++ b/.github/workflows/external-smoke.yml @@ -38,6 +38,7 @@ jobs: env: CLI_SMOKE_SERVICES: daiso gs25 seveneleven emart24 lottemart oliveyoung megabox lottecinema cgv opinet MCP_SMOKE_SERVICES: daiso gs25 seveneleven emart24 opinet + SMOKE_ATTEMPT_TIMEOUT_SECONDS: 90 run: | set +e FAILURES=() @@ -48,11 +49,11 @@ jobs: local OUTPUT_FILE="$3" if [ "${SMOKE_SUITE}" = "cli" ]; then - npx tsx scripts/ops/cli-smoke.ts --service "${SMOKE_SERVICE}" 2>&1 | tee "${OUTPUT_FILE}" + timeout --signal=TERM --kill-after=10s "${SMOKE_ATTEMPT_TIMEOUT_SECONDS}s" npx tsx scripts/ops/cli-smoke.ts --service "${SMOKE_SERVICE}" 2>&1 | tee "${OUTPUT_FILE}" return "${PIPESTATUS[0]}" fi - npx tsx scripts/ops/mcp-smoke.ts --service "${SMOKE_SERVICE}" 2>&1 | tee "${OUTPUT_FILE}" + timeout --signal=TERM --kill-after=10s "${SMOKE_ATTEMPT_TIMEOUT_SECONDS}s" npx tsx scripts/ops/mcp-smoke.ts --service "${SMOKE_SERVICE}" 2>&1 | tee "${OUTPUT_FILE}" return "${PIPESTATUS[0]}" } @@ -114,7 +115,7 @@ jobs: JSON.stringify({ token: process.env.MOSHI_WEBHOOK_TOKEN, title: 'External Smoke Failed', - message: `${process.env.GITHUB_REPOSITORY}: ${process.env.SUMMARY}`, + message: `${process.env.GITHUB_REPOSITORY}: ${process.env.SUMMARY || ''}`.slice(0, 3500), }), ); NODE diff --git a/.github/workflows/health-checks.yml b/.github/workflows/health-checks.yml index 5f27f55..8c366f1 100644 --- a/.github/workflows/health-checks.yml +++ b/.github/workflows/health-checks.yml @@ -94,7 +94,7 @@ jobs: JSON.stringify({ token: process.env.MOSHI_WEBHOOK_TOKEN, title: 'Health Checks Failed', - message: `${process.env.GITHUB_REPOSITORY}: ${process.env.SUMMARY}`, + message: `${process.env.GITHUB_REPOSITORY}: ${process.env.SUMMARY || ''}`.slice(0, 3500), }), ); NODE diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index ff5d826..82571bd 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -147,10 +147,15 @@ describe('repository maintenance configuration', () => { expect(workflow).toContain( 'MCP_SMOKE_SERVICES: daiso gs25 seveneleven emart24 opinet', ); + expect(workflow).toContain('SMOKE_ATTEMPT_TIMEOUT_SECONDS: 90'); expect(workflow).toContain('for SMOKE_SERVICE in ${CLI_SMOKE_SERVICES}; do'); expect(workflow).toContain('for SMOKE_SERVICE in ${MCP_SMOKE_SERVICES}; do'); - expect(workflow).toContain('npx tsx scripts/ops/cli-smoke.ts --service "${SMOKE_SERVICE}"'); - expect(workflow).toContain('npx tsx scripts/ops/mcp-smoke.ts --service "${SMOKE_SERVICE}"'); + expect(workflow).toContain( + 'timeout --signal=TERM --kill-after=10s "${SMOKE_ATTEMPT_TIMEOUT_SECONDS}s" npx tsx scripts/ops/cli-smoke.ts', + ); + expect(workflow).toContain( + 'timeout --signal=TERM --kill-after=10s "${SMOKE_ATTEMPT_TIMEOUT_SECONDS}s" npx tsx scripts/ops/mcp-smoke.ts', + ); expect(workflow).toContain('run_smoke_with_retry'); expect(workflow).toContain('retrying after 15 seconds'); expect(workflow).toContain('external-smoke-summary.txt'); @@ -158,6 +163,7 @@ describe('repository maintenance configuration', () => { expect(workflow).toContain('SUMMARY="$(cat external-smoke-summary.txt'); expect(workflow).toContain('Notify smoke failure'); expect(workflow).toContain('MOSHI_WEBHOOK_TOKEN'); + expect(workflow).toContain('.slice(0, 3500)'); expect(workflow).toContain("if: failure() && github.event_name == 'schedule'"); });