From d9900ecda90260a0aeadd7dbc34c72572eec0e9b Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 12 Oct 2026 09:25:15 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=98=AC=EB=A6=AC=EB=B8=8C=EC=98=81?= =?UTF-8?q?=20=EA=B5=90=EC=B2=B4=20=EA=B3=B5=EB=B0=B1=EA=B3=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=A7=88=ED=8A=B824=20=EC=A0=91=EA=B7=BC=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/convenience-relay.md | 42 +- docs/oliveyoung-free-relay.md | 4 +- .../plans/2026-10-12-oliveyoung-prewarm.md | 16 + .../2026-10-12-oliveyoung-prewarm-design.md | 17 + scripts/relay/convenience.ts | 19 +- scripts/relay/lifecycle.ts | 332 ++++++++------ scripts/relay/transport-lifecycle.ts | 51 ++- src/api/compareHandlers.ts | 17 +- src/api/emart24Handlers.ts | 43 +- src/api/routes/compareRoutes.ts | 12 +- src/api/routes/emart24Routes.ts | 4 + src/index.ts | 2 +- src/services/compare/client.ts | 2 +- src/services/emart24/client.ts | 76 ++-- src/services/emart24/index.ts | 12 +- src/services/emart24/inventoryLookup.ts | 51 ++- src/services/emart24/relaySpecs.ts | 155 +++++++ src/services/emart24/tools/checkInventory.ts | 19 +- .../emart24/tools/findNearbyStores.ts | 22 +- src/services/emart24/tools/searchProducts.ts | 19 +- src/services/emart24/types.ts | 2 +- src/utils/convenienceTransport.ts | 6 +- src/utils/directRouteSpecs.ts | 3 +- src/utils/routeHealth.ts | 4 + tests/api/emart24-relay-integration.test.ts | 115 +++++ tests/relay/emart24.test.ts | 242 +++++++++++ tests/relay/guard-runtime.test.ts | 15 + tests/relay/lifecycle-prewarm.test.ts | 410 ++++++++++++++++++ tests/relay/lifecycle.test.ts | 24 +- tests/relay/transport-lifecycle.test.ts | 3 +- tests/relay/transport-slots.test.ts | 72 +++ tests/utils/directRoutes.test.ts | 3 +- tests/utils/emart24-routing.test.ts | 71 +++ tests/utils/routeProbes.test.ts | 4 +- 34 files changed, 1656 insertions(+), 233 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-12-oliveyoung-prewarm.md create mode 100644 docs/superpowers/specs/2026-10-12-oliveyoung-prewarm-design.md create mode 100644 src/services/emart24/relaySpecs.ts create mode 100644 tests/api/emart24-relay-integration.test.ts create mode 100644 tests/relay/emart24.test.ts create mode 100644 tests/relay/lifecycle-prewarm.test.ts create mode 100644 tests/relay/transport-slots.test.ts create mode 100644 tests/utils/emart24-routing.test.ts diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md index 41ed9e48..d20db4cc 100644 --- a/docs/convenience-relay.md +++ b/docs/convenience-relay.md @@ -4,7 +4,7 @@ GS25 세션 만료·인증 장애를 복구할 때는 [정상 로그인 복구 운영 절차](gs25-auth-recovery-runbook.md)부터 읽으세요. 티켓·네이버 로그인·GS 토큰 저장·재기동·가상기기 종료 후 검증과 재현용 참조 코드를 포함합니다. -CU PocketCU, 세븐일레븐 공식 앱 API와 GS25 상품·재고 API를 현재 Mac의 브라우저 없는 HTTP 중계로 호출합니다. 정상 API 응답 계약은 유지합니다. Worker에 설정이 없으면 기존 직접 호출을 사용하며, URL·토큰 또는 Access 인증의 부분 설정은 오류를 반환합니다. +CU PocketCU, 세븐일레븐 공식 앱 API와 GS25 상품·재고 API와 이마트24 공식 웹 API를 현재 Mac의 브라우저 없는 HTTP 중계로 호출합니다. 정상 API 응답 계약은 유지합니다. Worker에 설정이 없으면 기존 직접 호출을 사용하며, URL·토큰 또는 Access 인증의 부분 설정은 오류를 반환합니다. ## 실행과 설정 @@ -15,7 +15,7 @@ CONVENIENCE_RELAY_TOKEN=your_convenience_token CONVENIENCE_RELAY_STATE_DIR=/absolute/path/to/convenience-state ``` -상태 경로는 Dtryx 상태 경로와 다른 디렉터리여야 합니다. 이 디렉터리의 `quota.json`에는 재시작해도 유지되는 호출 원장이, `logs/`에는 입력·토큰·키를 제외한 제한된 관측 로그가 저장됩니다. CU·GS25·세븐일레븐이 공유하는 편의점 예산은 분당 690회, 하루 최대 10,000,000회이며 Dtryx·올리브영 예산과 공유하지 않습니다. +상태 경로는 Dtryx 상태 경로와 다른 디렉터리여야 합니다. 이 디렉터리의 `quota.json`에는 재시작해도 유지되는 호출 원장이, `logs/`에는 입력·토큰·키를 제외한 제한된 관측 로그가 저장됩니다. CU·GS25·세븐일레븐·이마트24가 공유하는 편의점 예산은 분당 690회, 하루 최대 10,000,000회이며 Dtryx·올리브영 예산과 공유하지 않습니다. Worker에는 아래 바인딩을 설정합니다. URL은 중계 호스트의 기본 URL이며 `/v1/convenience`를 붙이지 않습니다. HTTPS 또는 로컬 루프백 HTTP만 허용합니다. REST와 MCP가 동일한 설정을 사용합니다. @@ -38,19 +38,23 @@ Access 토큰 만료1분 전 또는 재고401/403 응답 시 정상 `tokenReissu 모든 조회는 `Authorization: Bearer `를 요구합니다. 아래 작업에 JSON POST만 허용하며 임의 URL·헤더·외부 경로 전달은 거절합니다. -| 작업 | 원본 조회 | 입력 | -| --- | --- | --- | -| `cu-prime` | 재고 검색 초기화 | `{}` | -| `cu-stock` | 상품 재고 검색 | 기존 PocketCU 검색 본문 | -| `cu-store` | 매장별 수량 | 기존 PocketCU 매장 본문 | -| `seven-goods` | 상품 검색 | `query`, `pageNo`, `pageSize` | -| `seven-store` | 매장 검색 | `collection: "store"`, `query`, `sort: "Date/desc"`, `listCount` | -| `seven-popwords` | 인기 검색어 | `label` | -| `seven-stock-meta` | 재고 상품 메타정보 | `itemCd` | -| `seven-stock` | 매장별 실재고 | `smCd`, `stokMngCd`, `stokMngQty`, `stockApplicationRate`, `storeList` | -| `seven-pages`, `seven-issues`, `seven-exhibitions` | 카탈로그 | `{}` | -| `gs25-products` | 상품 검색 | `query` | -| `gs25-stock` | 매장·재고 | 기존 BFF 조회 파라미터를 문자열 JSON으로 전달, 선택 `apiKey` | +| 작업 | 원본 조회 | 입력 | +| -------------------------------------------------- | ------------------ | ---------------------------------------------------------------------- | +| `cu-prime` | 재고 검색 초기화 | `{}` | +| `cu-stock` | 상품 재고 검색 | 기존 PocketCU 검색 본문 | +| `cu-store` | 매장별 수량 | 기존 PocketCU 매장 본문 | +| `seven-goods` | 상품 검색 | `query`, `pageNo`, `pageSize` | +| `seven-store` | 매장 검색 | `collection: "store"`, `query`, `sort: "Date/desc"`, `listCount` | +| `seven-popwords` | 인기 검색어 | `label` | +| `seven-stock-meta` | 재고 상품 메타정보 | `itemCd` | +| `seven-stock` | 매장별 실재고 | `smCd`, `stokMngCd`, `stokMngQty`, `stockApplicationRate`, `storeList` | +| `seven-pages`, `seven-issues`, `seven-exhibitions` | 카탈로그 | `{}` | +| `gs25-products` | 상품 검색 | `query` | +| `gs25-stock` | 매장·재고 | 기존 BFF 조회 파라미터를 문자열 JSON으로 전달, 선택 `apiKey` | +| `emart24-products` | 상품 검색 | `currentPage`, `pageCnt`, `sortType`, `saleProductYn`, `searchWord` | +| `emart24-stores` | 매장 검색 | `page`, 선택 `search`, `AREA1`, `AREA2`, `SVR_24: "1"` | +| `emart24-stock` | 매장별 수량 | `searchPluCode`, 쉼표로 연결한 `bizNoArr` | +| `emart24-store-detail` | 매장 상세 | `bizNo` | 인증된 GET `/v1/convenience/health`는 실행·대기 수와 원장·로그 상태를 반환합니다. 공개 Worker `/health`의 `config.convenienceRelay`는 설정 여부만 반환합니다. @@ -75,3 +79,11 @@ CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포 이 응답은 공개 API에서 HTTP 502를 유지하고, 오류 문구를 `정상적인 점포 조회 요청이 아닙니다. 일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.`로 표시합니다. API와 MCP의 공통 `diagnostics`에는 원본 `upstreamStatus: 400`, `upstreamCode: 501`, `upstreamMessage`가 남습니다. 확인된 문구만 전달하며 응답 본문 전체나 알 수 없는 오류 문구는 공개하지 않습니다. 이 요청 거부는 `retryable: false`로 표시해 자동 재시도를 하지 않습니다. 안내 문구는 정책이 일시적이라는 확정 진단이 아닙니다. 중계 내부에서는 이 확인된 거부를 HTTP 200의 `success: false` 실패 envelope로 전달해 중간 연결 구간의 5xx 오류 페이지 처리와 분리합니다. Worker는 이를 성공으로 반환하지 않고 HTTP 502로 변환합니다. 실패는 캐시하지 않으며 원본 호출 로그에도 오류로 남습니다. 구버전 중계의 HTTP 502 진단도 계속 읽을 수 있습니다. + +## 이마트24 조회 경로 + +이마트24는 기존 공식 도메인과 고정 경로를 그대로 사용합니다. 상품 검색은 `application/x-www-form-urlencoded` 폼, 나머지는 GET이며 기존 `Accept`·`X-Requested-With` 헤더를 유지합니다. 직접 조회와 Mac HTTP 중계는 같은 입력·응답 검증기를 사용하고 임의 URL·헤더·경로를 받지 않습니다. Worker의 사전 점검이 직접 경로를 정상으로 확인했으면 최대 3초의 직접 조회를 먼저 시도합니다. 실패하면 남은 요청 기한 안에서 기존 인증된 편의점 중계를 사용하고 해당 작업의 직접 실패는 기존 5분 재검사 정책을 따릅니다. 설정이 없는 로컬 CLI는 종전 직접 조회를 유지합니다. + +Mac 중계의 이마트24 재고 응답은 30초, 상품·매장 검색·매장 상세는 5분 동안 캐시합니다. 공개 REST의 기존 엣지 캐시는 상품 10분·매장 30분·재고 5분을 유지합니다. 정상 운영 인증과 `x-health-check-force-fresh: true`를 함께 보낸 새 조회는 엣지 캐시와 모든 재고 단계의 중계 캐시를 우회합니다. 비교 조회의 이마트24 검색에도 같은 새 조회 설정을 전달합니다. 인증되지 않은 요청은 새 조회 헤더만으로 캐시를 우회할 수 없습니다. + +공식 재고의 `storeGoodsQty: null`은 빈 목록으로 처리합니다. 수량 0은 확인된 0이며 누락·null·빈 문자열·-1은 미확인으로 유지합니다. 실패 JSON·403 HTML·잘못된 수량을 성공이나 품절로 바꾸지 않습니다. 매장 검색의 정상 `error: 0`과 결과 없음 `error: 1`은 기존 키워드 대체 조회 동작을 유지합니다. diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md index fc41eed6..2e8bfbee 100644 --- a/docs/oliveyoung-free-relay.md +++ b/docs/oliveyoung-free-relay.md @@ -86,11 +86,11 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며 ## 상시 운영 보강 -중계는 한 브라우저·한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 추가 페이지는 즉시 회수하며 회수 실패·페이지 crash·Node 측 watchdog timeout에는 소유한 브라우저 세션을 폐기합니다. 200회 조회 또는 30분 사용 후 유휴 경계에서 정리하고 교체 세션을 한 번 미리 준비합니다. RSS 한도 회수도 같은 준비 경로를 사용합니다. 이전 소유 프로세스의 회수가 완료된 다음 새 브라우저를 띄우므로 브라우저 두 개를 겹쳐 유지하지 않습니다. 준비 중 health 상태는 `starting`입니다. 준비 실패 시 무한 재시도하지 않고 다음 명시적 요청을 기다립니다. 준비 중 도착한 요청은 여전히 준비 시간을 기다릴 수 있으므로 항상 15초 이내 응답을 보장하는 것은 아닙니다. +중계는 조회용 브라우저의 한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 실행 시작부터 27분 또는 API 시도 160회에 후보 브라우저 하나를 준비하고, 후보가 준비된 뒤 진행 중 API가 끝난 경계에서 교환합니다. 소유 슬롯은 최대 두 개이며 이전 소유자의 종료 확인 전에는 세 번째 브라우저를 띄우지 않습니다. 현재 관측 RSS가 512MiB를 넘으면 겹친 준비를 건너뛰고 기존 콜드 교체를 사용합니다. 실행 시작부터 30분이 지났거나 API 시도 200회에 도달한 세션에는 새 조회를 보내지 않습니다. 이미 시작한 API는 기존 18초 watchdog 안에서 종료한 뒤 회수하며, 유휴 수명은 30초 주기로 점검합니다. 후보 실패 시 정상 현재 세션을 보존하지만 수명 상한을 연장하지 않습니다. 후보 준비나 메모리 부족으로 콜드 시작이 필요할 때 응답 공백은 여전히 발생할 수 있습니다. 추가 페이지는 회수하며 회수 실패·페이지 crash·Node watchdog timeout은 해당 세션을 폐기합니다. 준비 중에도 정상 현재 세션이 있으면 health는 `ready`, 현재가 없으면 `starting`입니다. 실행 또는 회수의 소유권 불확실성은 `failed`로 고정하며 추가 실행을 차단합니다. 재시작 전 `browser-owner.json`과 `browser-owner-replacement.json`의 이전 소유권을 모두 확인해야 합니다. 브라우저는 별도 guard 프로세스가 소유합니다. 중계 부모가 SIGKILL로 종료되어도 IPC 단절을 감지해 회수합니다. PID·생성 시각·프로세스 그룹을 확인한 소유 프로세스만 종료합니다. macOS에서 별도 그룹으로 분리되는 crashpad 보조 프로세스도 실행별 고유 경로로 식별해 메모리 집계와 회수에 포함합니다. 브라우저에는 GUI 실행에 필요한 환경만 전달하고 운영 토큰은 제외합니다. 정상 종료가 지연되면 확인된 그룹을 강제 종료하고 실제 소멸을 확인합니다. 개인 Chrome을 프로세스 이름으로 일괄 종료하지 않습니다. guard까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다. -소유 브라우저 프로세스 그룹의 RSS를 30초마다 점검하며 1GiB를 넘으면 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다. `state`는 현재 수명 상태이며 `reason`은 마지막 회수 사유입니다. 준비가 성공해 `state: ready`로 돌아와도 이전 `request-failed` 등이 남을 수 있으므로 `reason`만으로 현재 준비 실패를 판단하지 않습니다. +소유 브라우저 그룹의 RSS 합계를 시작 전·후, 준비 완료 시 및 30초마다 점검하며 종료 확인 전 회수 중인 그룹도 합산합니다. 1GiB를 넘으면 후보를 먼저 회수하고 현재 그룹 자체가 상한을 넘으면 현재도 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다. `state`는 현재 수명 상태이며 `reason`은 마지막 회수 사유입니다. 준비가 성공해 `state: ready`로 돌아와도 이전 `request-failed` 등이 남을 수 있으므로 `reason`만으로 현재 준비 실패를 판단하지 않습니다. ### 전체 호출량과 인증 diff --git a/docs/superpowers/plans/2026-10-12-oliveyoung-prewarm.md b/docs/superpowers/plans/2026-10-12-oliveyoung-prewarm.md new file mode 100644 index 00000000..5b78ce9e --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-oliveyoung-prewarm.md @@ -0,0 +1,16 @@ +# 올리브영 교체 준비 구현 계획 + +**Goal:** 정상 브라우저를 준비 중인 후속 브라우저 때문에 먼저 회수하지 않는다. + +**Architecture:** 고정 소유 슬롯 두 개와 후보 준비 Promise 하나로 수명 관리한다. 기존 guard의 소유권 및 회수 규약을 재사용한다. + +**Tech Stack:** TypeScript, Playwright, Node.js, Vitest. + +- [x] `tests/relay/lifecycle-prewarm.test.ts`에 준비 중 정상 요청과 후보 실패 보존 테스트를 추가하고 기존 코드에서 실패를 확인한다. +- [x] `scripts/relay/lifecycle.ts`에 현재/후보 분리, API 실행 사이 교환, 준비 및 수명 상한을 구현한다. +- [x] `tests/relay/transport-slots.test.ts`에 두 마커·슬롯 회수·불확실성 테스트를 추가하고 기존 코드에서 실패를 확인한다. +- [x] `scripts/relay/transport-lifecycle.ts`에 두 고정 슬롯과 초기 마커 검사를 구현한다. +- [x] 실행 전 수명 시작과 512 MiB 준비 여유 테스트의 실패를 확인하고 최소 수정을 적용한다. +- [x] 후보/현재 충돌, 종료 중 늦은 실행, 200회 상한, 합계 RSS, 회수 실패 회귀 테스트를 추가한다. +- [ ] `npm run check`, `npm run test:coverage`, `npm run build`를 실행하고 실패 원인을 수정한다. +- [ ] 부모 에이전트에 diff와 검증 결과를 전달하여 리뷰한다. 이 작업에서는 커밋과 운영 배포를 수행하지 않는다. diff --git a/docs/superpowers/specs/2026-10-12-oliveyoung-prewarm-design.md b/docs/superpowers/specs/2026-10-12-oliveyoung-prewarm-design.md new file mode 100644 index 00000000..cc05cf88 --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-oliveyoung-prewarm-design.md @@ -0,0 +1,17 @@ +# 올리브영 소유 브라우저 교체 준비 + +2026-10-11 07:29:50 UTC 이후 33회 교체의 준비 공백은 25.8–195.7초, 중앙값 44.6초였다. 원본 운영 검사의 20초 예산보다 길다. 제한 시간을 늘리거나 실패를 성공으로 분류하지 않고 정상 세션을 유지하며 후속 세션을 준비한다. + +## 구조와 제한 + +현재 세션과 준비 후보 하나만 유지한다. 프로세스 시작 시 두 고정 마커 `browser-owner.json`, `browser-owner-replacement.json` 모두 부재를 확인한다. 감시 프로세스의 배타적 마커 생성 및 부모 IPC 단절 회수는 그대로 사용한다. 슬롯은 종료 확인 이후만 재사용하며 실행 또는 회수의 불확실성은 전체 수명을 실패 상태로 고정한다. + +실행 시작 시각부터 27분 또는 API 시도 160회에 후보를 준비한다. 준비된 후보는 API 실행 사이에 교환하고 이전 소유자를 회수한다. 실행 시작부터 30분 이후 또는 API 시도 200회 이후에는 해당 세션에 새 조회를 보내지 않는다. 진행 중 API는 기존 18초 watchdog 안에서 종료한 뒤 회수하며 유휴 수명은 기존 30초 주기로 점검한다. 실패한 후보는 확인된 회수 뒤 정상 현재 세션을 보존하며 자동 준비를 무한 반복하지 않는다. 상한에 도달했거나 현재 세션이 손상된 경우 기존의 명시적 요청 기반 콜드 시작으로 돌아간다. + +겹친 실행은 현재 관측 RSS가 512 MiB 이하인 경우만 허용한다. 시작 전, 후보 소유자 획득 후, 페이지 준비 완료 후 및 30초 주기로 소유자 RSS 합계를 관측한다. 1 GiB 초과 시 후보를 우선 회수하며 현재 자체가 1 GiB를 넘으면 현재도 회수한다. 이는 관측 기반 상한이며 운영체제의 순간 메모리 강제 제한이 아니다. 메모리 여유가 부족하면 콜드 교체 공백이 남는다. 후보 준비 실패나 원본 사이트 차단까지 항상 해결한다는 보장은 하지 않는다. + +페이지 충돌·닫힘·팝업 회수 실패와 RSS 결과는 해당 세션에 귀속된다. 이전 세션의 늦은 결과가 새 세션을 회수하지 않는다. 종료는 준비 중인 실행 결과까지 기다리고 두 소유자 모두에 같은 회수 기한을 적용한다. 종료 시작 이후 승격 및 추가 실행을 금지한다. + +## 검증 + +지연된 후보 준비 중 정상 요청, API 실행 중 승격 금지, 후보 실패 시 현재 보존, 두 마커의 잔존 차단, 늦은 준비 결과와 두 소유자 종료, 충돌 중 같은 후보 대기, RSS 여유 및 합계, 30분/200회 상한, 회수 실패 후 실행 차단을 단위 테스트한다. 기존 감시 프로세스의 부모 단절과 마커 해제 검증도 유지한다. 전체 검사·커버리지 100%·빌드를 수행하며 실제 운영 환경과 인증 및 quota 파일은 구현 단계에서 변경하지 않는다. diff --git a/scripts/relay/convenience.ts b/scripts/relay/convenience.ts index 4a6e5622..fb3f2737 100644 --- a/scripts/relay/convenience.ts +++ b/scripts/relay/convenience.ts @@ -1,3 +1,4 @@ +import { emart24DirectRouteSpecs, isEmart24Operation } from '../../src/services/emart24/relaySpecs.js'; import { parseSevenStockFailure } from '../../src/utils/sevenStockFailure.js'; import { CU_DEFAULT_HEADERS } from '../../src/services/cu/client.js'; import { GS25_DEFAULT_HEADERS } from '../../src/services/gs25/client.js'; @@ -266,15 +267,27 @@ export function createConvenienceRelay( return createHttpRelay(token, { ...options, cacheTtl: (operation) => - ['cu-store', 'seven-stock', 'gs25-stock'].includes(operation) ? 30000 : 300000, + ['cu-store', 'seven-stock', 'gs25-stock', 'emart24-stock'].includes(operation) ? 30000 : 300000, prefix: '/v1/convenience/', - operations: Object.keys(paths), + operations: [...Object.keys(paths), ...Object.keys(emart24DirectRouteSpecs)], validate(value, operation) { - const parsed = schemas[operation as Operation].safeParse(value); + const parsed = (isEmart24Operation(operation) ? emart24DirectRouteSpecs[operation].input : schemas[operation as Operation]).safeParse(value); if (!parsed.success) throw new RelayError(400); return parsed.data; }, async upstream(operation, params, signal, fetcher) { + if (isEmart24Operation(operation)) { + const spec = emart24DirectRouteSpecs[operation]; + const { url, init } = spec.build(params); + const response = await abortable(fetcher(url, { ...init, redirect: 'manual', signal }), signal); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + throw new RelayError(502); + } + const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); + if (!spec.response.safeParse(data).success) throw new RelayError(502); + return data; + } const [base, path, method, schema] = paths[operation as Operation]; const url = new URL(path, base); const body = { ...(params as Record) }; diff --git a/scripts/relay/lifecycle.ts b/scripts/relay/lifecycle.ts index 5f4e85b3..292e4d63 100644 --- a/scripts/relay/lifecycle.ts +++ b/scripts/relay/lifecycle.ts @@ -1,4 +1,4 @@ -/** 한 브라우저 세션의 재사용·교체·회수를 직렬화합니다. */ +/** 두 소유 슬롯 안에서 준비·교환·회수를 제한합니다. */ import type { Browser, BrowserContext, Page } from 'playwright'; import { createBrowserRunner } from './browser.js'; import type { BrowserRunner } from './oliveyoung.js'; @@ -21,159 +21,233 @@ export async function bounded(work: Promise, ms: number): Promise { clearTimeout(timer!); } } +interface Session { + owner: BrowserOwner; + born: number; + calls: number; + page?: Page; + context?: BrowserContext; + ready: boolean; + attempted: boolean; + retired: boolean; + closing?: Promise; +} +const MAX_AGE = 30 * 60 * 1000; +const PREPARE_AGE = 27 * 60 * 1000; +const MAX_RSS = 2 ** 30; export function createBrowserLifecycle( launch: () => Promise, onEvent?: (reason: string) => void | Promise, ) { - const emit = (reason: string) => { + const emit = (why: string) => { try { - void Promise.resolve(onEvent?.(reason)).catch(() => undefined); + void Promise.resolve(onEvent?.(why)).catch(() => undefined); } catch { - /* 관측 실패는 브라우저 회수에 영향을 주지 않습니다. */ + /* 관측 실패는 회수를 방해하지 않습니다. */ } }; - let owner: BrowserOwner | undefined; - let page: Page | undefined; - let context: BrowserContext | undefined; + const sessions = new Set(); + let current: Session | undefined; + let candidate: Session | undefined; + let preparing: Promise | undefined; + let maintenance: Promise | undefined; let active = false; + let executing = false; let closed = false; let failed: unknown; - let cleanup: Promise = Promise.resolve(); - let born = 0; - let calls = 0; let totalCalls = 0; let rotations = 0; let rssBytes = 0; let reason = 'startup'; + let shutdownDeadline = 0; let timer: ReturnType | undefined; - let initializing: Promise | undefined; - let replacementPending = false; - const retire = (why: string) => { + const check = () => { + if (failed) throw failed; + if (closed) throw new Error('Browser lifecycle unavailable'); + }; + const retire = (session: Session, why: string, deadline = Date.now() + 10000) => { + if (session.closing) return session.closing; + deadline = shutdownDeadline || deadline; + session.retired = true; + session.ready = false; + if (current === session) current = undefined; + if (candidate === session) candidate = undefined; + session.page?.removeAllListeners('crash'); + session.page?.removeAllListeners('close'); + session.context?.removeAllListeners('page'); reason = why; emit(why); - if (timer) clearInterval(timer); - timer = undefined; - const previous = owner; - if (!previous) return cleanup; - owner = undefined; - page?.removeAllListeners('crash'); - page?.removeAllListeners('close'); - context?.removeAllListeners('page'); - page = undefined; - context = undefined; - const deadline = Date.now() + 10000; - cleanup = bounded(previous.close(deadline), Math.max(0, deadline - Date.now())).catch( - (error) => { + session.closing = bounded(session.owner.close(deadline), Math.max(0, deadline - Date.now())) + .then(() => { + sessions.delete(session); + }) + .catch((error) => { failed = error; throw error; - }, - ); - // 이벤트 핸들러에서 발생한 실패도 보존하고 다음 요청/종료에 전달합니다. - void cleanup.catch(() => undefined); - return cleanup; + }); + void session.closing.catch(() => undefined); + return session.closing; }; - const retireQuietly = (why: string) => { - void retire(why).catch(() => undefined); + const quietly = (session: Session, why: string) => { + void retire(session, why).catch(() => undefined); }; - const ensureSession = async () => { - await cleanup; - if (closed) throw new Error('Browser lifecycle unavailable'); - if (owner && Date.now() - born >= 30 * 60 * 1000) { - await retire('rotation'); - rotations++; - } - if (owner) return; - try { - owner = await launch(); - } catch (error) { - emit('launch-failed'); - throw error; + const promote = () => { + if (executing || closed || failed || !candidate?.ready) return; + const next = candidate; + if (Date.now() - next.born >= MAX_AGE) { + quietly(next, 'age-limit'); + return; } - born = Date.now(); - calls = 0; - if (closed) { - await retire('shutdown'); - throw new Error('Browser lifecycle unavailable'); + const previous = current; + candidate = undefined; + current = next; + emit('ready'); + if (previous) { + rotations++; + quietly(previous, 'rotation'); } - try { - context = await bounded(owner.browser.newContext({ locale: 'ko-KR' }), 10000); - // 초기 페이지를 먼저 지정하여 이후 추가 페이지는 모두 닫습니다. - page = await bounded(context.newPage(), 10000); - const sessionOwner = owner; - context.on('page', (popup) => { - void bounded(popup.close(), 3000).catch(() => { - if (owner === sessionOwner) retireQuietly('popup-close-failed'); - }); - }); - page.on('crash', () => retireQuietly('page-crash')); - page.on('close', () => retireQuietly('page-closed')); - await bounded( - page.goto('https://www.oliveyoung.co.kr/', { - waitUntil: 'domcontentloaded', - timeout: 30000, - }), - 32000, - ); - await bounded(page.waitForURL('**/store/**', { timeout: 30000 }), 32000); - if (closed || owner !== sessionOwner) throw new Error('Browser lifecycle unavailable'); - emit('ready'); - timer = setInterval(() => { - if (!active && Date.now() - born >= 30 * 60 * 1000) { - rotations++; - rotate('age-limit'); - return; + }; + const sample = async () => { + const owned = [...sessions]; + const values = await Promise.all( + owned.map(async (s) => { + try { + return await bounded(s.owner.rss(), 5000); + } catch { + if (!s.retired) await retire(s, 'rss-failed'); + return 0; } - void bounded(sessionOwner.rss(), 5000) - .then((value) => { - if (owner !== sessionOwner) return; - rssBytes = value; - if (value > 2 ** 30) rotate('rss-limit'); - }) - .catch(() => { - if (owner === sessionOwner) retireQuietly('rss-failed'); - }); - }, 30000); - } catch (error) { - await retire('startup-failed'); - throw error; + }), + ); + const live = owned.filter((s) => sessions.has(s)); + rssBytes = owned.reduce((sum, s, i) => sum + (sessions.has(s) ? values[i] : 0), 0); + if (rssBytes > MAX_RSS) { + const preparingSession = live.find((s) => s !== current); + if (preparingSession) await retire(preparingSession, 'rss-limit'); + const index = owned.findIndex((s) => s === current); + if (index >= 0 && values[index] > MAX_RSS) await retire(current!, 'rss-limit'); } }; - const ensure = () => { - initializing ??= ensureSession().finally(() => { - initializing = undefined; + const prepare = () => { + if (preparing) return preparing; + if (closed || failed || candidate || sessions.size >= 2) return Promise.resolve(); + const previous = current; + if (previous) previous.attempted = true; + preparing = (async () => { + let session: Session | undefined; + try { + await Promise.all([...sessions].filter((s) => s.retired).map((s) => s.closing)); + await sample(); + check(); + if (current && rssBytes > MAX_RSS / 2) { + emit('prewarm-skipped-rss'); + return; + } + const born = Date.now(); + let owner: BrowserOwner; + try { + owner = await launch(); + } catch (error) { + failed = error; + emit('launch-failed'); + throw error; + } + session = { owner, born, calls: 0, ready: false, attempted: false, retired: false }; + sessions.add(session); + candidate = session; + check(); + await sample(); + if (session.retired) throw new Error('Browser lifecycle unavailable'); + session.context = await bounded(owner.browser.newContext({ locale: 'ko-KR' }), 10000); + if (session.retired) throw new Error('Browser lifecycle unavailable'); + session.page = await bounded(session.context.newPage(), 10000); + if (session.retired) throw new Error('Browser lifecycle unavailable'); + const owned = session; + const invalidate = (why: string) => { + if (owned.retired) return; + quietly(owned, why); + if (!active && !preparing) void prepare().catch(() => undefined); + }; + session.context.on('page', (popup) => { + void bounded(popup.close(), 3000).catch(() => invalidate('popup-close-failed')); + }); + session.page.on('crash', () => invalidate('page-crash')); + session.page.on('close', () => invalidate('page-closed')); + await bounded( + session.page.goto('https://www.oliveyoung.co.kr/', { + waitUntil: 'domcontentloaded', + timeout: 30000, + }), + 32000, + ); + if (session.retired) throw new Error('Browser lifecycle unavailable'); + await bounded(session.page.waitForURL('**/store/**', { timeout: 30000 }), 32000); + await sample(); + check(); + if (session.retired) throw new Error('Browser lifecycle unavailable'); + session.ready = true; + promote(); + timer ??= setInterval(() => { + maintenance ??= (async () => { + await sample(); + if (closed || failed) return; + if (current && Date.now() - current.born >= MAX_AGE && !executing) + await retire(current, 'age-limit'); + promote(); + if (current && !current.attempted && Date.now() - current.born >= PREPARE_AGE) + await prepare(); + })() + .catch(() => undefined) + .finally(() => { + maintenance = undefined; + }); + }, 30000); + } catch (error) { + if (session) await retire(session, closed ? 'shutdown' : 'startup-failed'); + throw error; + } + })().finally(() => { + preparing = undefined; }); - return initializing; + void preparing.catch(() => undefined); + return preparing; }; - // 교체당 한 번만 미리 준비하고 실패 시 다음 명시적 요청을 기다립니다. - const prepareReplacement = () => { - replacementPending = false; - void ensure().catch(() => undefined); - }; - const rotate = (why: string) => { - replacementPending = true; - retireQuietly(why); - if (!active) prepareReplacement(); + const ensure = async () => { + check(); + if (current && (current.calls >= 200 || Date.now() - current.born >= MAX_AGE)) + await retire(current, 'age-limit'); + promote(); + if (!current) { + // 실패한 교체를 무한 반복하지 않고 명시적 요청에서만 재시도합니다. + await Promise.all([...sessions].filter((s) => s.retired).map((s) => s.closing)); + await prepare(); + promote(); + } + check(); + if (!current?.ready) throw new Error('Browser lifecycle unavailable'); }; const run: BrowserRunner = async (path, body) => { if (active) throw new Error('Browser already active'); active = true; + let used: Session | undefined; try { await ensure(); - const result = await bounded(createBrowserRunner(page!)(path, body), 18000); - calls++; + used = current!; + used.calls++; totalCalls++; - if (calls >= 200 || Date.now() - born >= 30 * 60 * 1000) { - rotations++; - replacementPending = true; - await retire('rotation'); - } - return result; + executing = true; + if (used.calls >= 160 && !used.attempted) void prepare().catch(() => undefined); + return await bounded(createBrowserRunner(used.page!)(path, body), 18000); } catch (error) { - await retire('request-failed'); + if (used) await retire(used, 'request-failed'); throw error; } finally { + executing = false; active = false; - if (replacementPending) prepareReplacement(); + if (used && !used.retired && (used.calls >= 200 || Date.now() - used.born >= MAX_AGE)) + quietly(used, 'rotation'); + promote(); + if (!current && !closed && !failed && !preparing) void prepare().catch(() => undefined); } }; return { @@ -181,27 +255,39 @@ export function createBrowserLifecycle( start: ensure, async close() { closed = true; - await initializing?.catch(() => undefined); - await retire('shutdown'); - await cleanup; + shutdownDeadline ||= Date.now() + 10000; + for (const session of sessions) quietly(session, 'shutdown'); + if (timer) clearInterval(timer); + timer = undefined; + await preparing?.catch(() => undefined); + await maintenance; + const deadline = shutdownDeadline; + const results = await Promise.allSettled( + [...sessions].map((s) => retire(s, 'shutdown', deadline)), + ); + const rejected = results.find((r) => r.status === 'rejected'); + if (rejected) throw rejected.reason; + if (failed) throw failed; }, status: () => ({ state: failed ? 'failed' : closed ? 'closed' - : initializing - ? 'starting' - : owner - ? 'ready' + : current?.ready + ? 'ready' + : preparing + ? 'starting' : 'idle', active, - calls, + calls: current?.calls || 0, totalCalls, rotations, rssBytes, - pages: context?.pages().length || 0, + pages: current?.context?.pages().length || 0, reason, + preparing: Boolean(preparing), + owned: sessions.size, }), }; } diff --git a/scripts/relay/transport-lifecycle.ts b/scripts/relay/transport-lifecycle.ts index a6cb7746..b8636771 100644 --- a/scripts/relay/transport-lifecycle.ts +++ b/scripts/relay/transport-lifecycle.ts @@ -1,5 +1,6 @@ /** HTTP 경로에서는 Playwright 모듈 자체를 불러오지 않습니다. */ import { join } from 'node:path'; +import { lstat } from 'node:fs/promises'; export async function createTransportLifecycle( mode: string | undefined, stateDir: string, @@ -13,8 +14,52 @@ export async function createTransportLifecycle( throw new Error('OY_RELAY_TRANSPORT 값이 잘못되었습니다.'); const { createBrowserLifecycle } = await import('./lifecycle.js'); const { launchGuardedBrowser } = await import('./supervisor.js'); - return createBrowserLifecycle( - () => launchGuardedBrowser(join(stateDir, 'browser-owner.json')), - onEvent, + const paths = ['browser-owner.json', 'browser-owner-replacement.json'].map((name) => + join(stateDir, name), ); + const occupied = [false, false]; + let failed: unknown; + const absent = async (path: string) => { + try { + await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + throw new Error('Browser ownership marker already exists'); + }; + let initial: Promise | undefined; + return createBrowserLifecycle(async () => { + if (failed) throw failed; + const slot = occupied.indexOf(false); + if (slot < 0) throw new Error('Browser slots occupied'); + initial ??= Promise.all(paths.map(absent)).then(() => undefined); + occupied[slot] = true; + try { + await initial; + await absent(paths[slot]); + if (failed) throw failed; + const owner = await launchGuardedBrowser(paths[slot]); + let closing: Promise | undefined; + return { + ...owner, + close(deadline?: number) { + closing ??= owner + .close(deadline) + .then(() => { + occupied[slot] = false; + }) + .catch((error) => { + failed = error; + throw error; + }); + return closing; + }, + }; + } catch (error) { + // 실행 실패도 소유권 불확실성으로 취급하며 어느 슬롯도 우회하지 않습니다. + failed = error; + throw error; + } + }, onEvent); } diff --git a/src/api/compareHandlers.ts b/src/api/compareHandlers.ts index d9843bdc..26ee8019 100644 --- a/src/api/compareHandlers.ts +++ b/src/api/compareHandlers.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from './operationalFreshness.js'; import { compareProducts, parseCompareServices } from '../services/compare/client.js'; import { type ApiContext, errorResponse, successResponse } from './response.js'; import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; @@ -12,11 +13,17 @@ export async function handleCompareProducts(c: ApiContext) { } try { - const result = await compareProducts({ - keyword, - limit, - services, - }, convenienceTransportFromBindings(c.env)); + const result = await compareProducts( + { + keyword, + limit, + services, + }, + { + ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), + }, + ); return successResponse(c, result, { total: result.resultCount, diff --git a/src/api/emart24Handlers.ts b/src/api/emart24Handlers.ts index bd148f9d..6aad671e 100644 --- a/src/api/emart24Handlers.ts +++ b/src/api/emart24Handlers.ts @@ -1,9 +1,15 @@ +import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; +import { isFreshOperationalRequest } from './operationalFreshness.js'; /** * 이마트24 GET API 핸들러 */ import { type ApiContext, errorResponse, successResponse } from './response.js'; -import { calculateDistanceM, fetchEmart24Stores, searchEmart24Products } from '../services/emart24/client.js'; +import { + calculateDistanceM, + fetchEmart24Stores, + searchEmart24Products, +} from '../services/emart24/client.js'; import { lookupEmart24Inventory } from '../services/emart24/inventoryLookup.js'; /** @@ -18,7 +24,8 @@ export async function handleEmart24FindStores(c: ApiContext) { const page = parseInt(c.req.query('page') || '1', 10); const limit = parseInt(c.req.query('limit') || '20', 10); const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10); - const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000; + const timeoutMs = + Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000; const rawLat = c.req.query('lat'); const rawLng = c.req.query('lng'); const parsedLat = rawLat ? parseFloat(rawLat) : undefined; @@ -36,13 +43,20 @@ export async function handleEmart24FindStores(c: ApiContext) { service24h, }, { + ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), timeout: timeoutMs, }, ); const stores = result.stores .map((store) => { - if (typeof lat !== 'number' || typeof lng !== 'number' || store.latitude === 0 || store.longitude === 0) { + if ( + typeof lat !== 'number' || + typeof lng !== 'number' || + store.latitude === 0 || + store.longitude === 0 + ) { return store; } @@ -51,13 +65,19 @@ export async function handleEmart24FindStores(c: ApiContext) { distanceM: calculateDistanceM(lat, lng, store.latitude, store.longitude), }; }) - .sort((a, b) => (a.distanceM ?? Number.MAX_SAFE_INTEGER) - (b.distanceM ?? Number.MAX_SAFE_INTEGER)) + .sort( + (a, b) => + (a.distanceM ?? Number.MAX_SAFE_INTEGER) - (b.distanceM ?? Number.MAX_SAFE_INTEGER), + ) .slice(0, limit); return successResponse( c, { - location: typeof lat === 'number' && typeof lng === 'number' ? { latitude: lat, longitude: lng } : null, + location: + typeof lat === 'number' && typeof lng === 'number' + ? { latitude: lat, longitude: lng } + : null, keyword, appliedKeyword: result.appliedKeyword, area1, @@ -89,7 +109,8 @@ export async function handleEmart24SearchProducts(c: ApiContext) { (c.req.query('sortType') as 'SALE' | 'LATEST' | 'PRICE_ASC' | 'PRICE_DESC' | null) || 'SALE'; const saleProductYn = (c.req.query('saleProductYn') as 'Y' | 'N' | null) || 'N'; const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10); - const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000; + const timeoutMs = + Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000; if (keyword.trim().length === 0) { return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.'); @@ -105,6 +126,8 @@ export async function handleEmart24SearchProducts(c: ApiContext) { saleProductYn, }, { + ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), timeout: timeoutMs, }, ); @@ -154,7 +177,11 @@ export async function handleEmart24CheckInventory(c: ApiContext) { const lng = typeof parsedLng === 'number' && Number.isFinite(parsedLng) ? parsedLng : undefined; if (pluCd.trim().length === 0 && keyword.trim().length === 0) { - return errorResponse(c, 'MISSING_PLU_CD', '상품 PLU 코드(pluCd) 또는 검색어(keyword)를 입력해주세요.'); + return errorResponse( + c, + 'MISSING_PLU_CD', + '상품 PLU 코드(pluCd) 또는 검색어(keyword)를 입력해주세요.', + ); } const bizNos = bizNoArr @@ -191,6 +218,8 @@ export async function handleEmart24CheckInventory(c: ApiContext) { productPageSize, storeLimit, timeoutMs, + ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), bizNos, includeRequestedStoresWithoutQty: bizNos.length > 0, }); diff --git a/src/api/routes/compareRoutes.ts b/src/api/routes/compareRoutes.ts index 7d94d0a8..67d3c27c 100644 --- a/src/api/routes/compareRoutes.ts +++ b/src/api/routes/compareRoutes.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from '../operationalFreshness.js'; import type { Hono } from 'hono'; import { withEdgeCache } from '../../utils/cache.js'; import { handleCompareProducts } from '../compareHandlers.js'; @@ -8,18 +9,23 @@ export function registerCompareRoutes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, keyPrefix: 'compare-products-v3', shouldCache: async (response) => { try { - const payload = await response.json() as { + const payload = (await response.json()) as { success?: unknown; data?: { results?: unknown; errors?: unknown }; } | null; - return response.ok && payload?.success === true && + return ( + response.ok && + payload?.success === true && Array.isArray(payload.data?.results) && - Array.isArray(payload.data?.errors) && payload.data.errors.length === 0; + Array.isArray(payload.data?.errors) && + payload.data.errors.length === 0 + ); } catch { return false; } diff --git a/src/api/routes/emart24Routes.ts b/src/api/routes/emart24Routes.ts index af0ae45f..f5d559d1 100644 --- a/src/api/routes/emart24Routes.ts +++ b/src/api/routes/emart24Routes.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from '../operationalFreshness.js'; /** * 이마트24 GET API 라우트 등록 */ @@ -16,6 +17,7 @@ export function registerEmart24Routes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 30, staleWhileRevalidateSeconds: 60 * 3, keyPrefix: 'emart24-stores-v2', @@ -28,6 +30,7 @@ export function registerEmart24Routes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, keyPrefix: 'emart24-products-v2', @@ -40,6 +43,7 @@ export function registerEmart24Routes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 5, staleWhileRevalidateSeconds: 60, keyPrefix: 'emart24-inventory-v2', diff --git a/src/index.ts b/src/index.ts index ea409849..9cc6fa08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,7 +106,7 @@ const createRegistry = (bindings?: AppBindings) => { zyteApiKey: bindings?.ZYTE_API_KEY, ...geocodeBindings(bindings), }), - createEmart24Service, + () => createEmart24Service(convenienceTransportFromBindings(bindings)), () => createMegaboxService(geocodeBindings(bindings)), () => createDtryxService(dtryxTransportFromBindings(bindings)), () => diff --git a/src/services/compare/client.ts b/src/services/compare/client.ts index a62dc522..049869a8 100644 --- a/src/services/compare/client.ts +++ b/src/services/compare/client.ts @@ -112,7 +112,7 @@ async function searchService( pageSize: limit, sortType: 'PRICE_ASC', saleProductYn: 'N', - }); + }, options); return result.products.slice(0, limit).map((product) => ({ service, serviceName: SERVICE_NAMES[service], diff --git a/src/services/emart24/client.ts b/src/services/emart24/client.ts index 0f792394..e312bb8b 100644 --- a/src/services/emart24/client.ts +++ b/src/services/emart24/client.ts @@ -4,6 +4,12 @@ import { fetchJson } from '../../utils/http.js'; import { EMART24_API } from './api.js'; +import { + hasConvenienceRelay, + requestConvenienceRelay, + type ConvenienceTransportOptions, +} from '../../utils/convenienceTransport.js'; +import { EMART24_JSON_HEADERS, EMART24_FORM_HEADERS } from './relaySpecs.js'; import type { Emart24Product, Emart24ProductSearchResponse, @@ -13,7 +19,7 @@ import type { Emart24WebStoreResponse, } from './types.js'; -interface RequestOptions { +export interface RequestOptions extends ConvenienceTransportOptions { timeout?: number; } @@ -38,16 +44,6 @@ interface SearchEmart24StockByStoresParams { bizNos: string[]; } -const EMART24_JSON_HEADERS = { - Accept: 'application/json, text/javascript, */*; q=0.01', - 'X-Requested-With': 'XMLHttpRequest', -} as const; - -const EMART24_FORM_HEADERS = { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - Accept: 'application/json, text/javascript, */*; q=0.01', - 'X-Requested-With': 'XMLHttpRequest', -} as const; function emart24FetchOptions(totalTimeout: number) { return { timeout: Math.max(1, Math.floor((totalTimeout - 250) / 2)), @@ -168,11 +164,18 @@ export async function fetchEmart24Stores( endpoint.searchParams.set('SVR_24', '1'); } - const body = await fetchJson(endpoint.toString(), { - ...emart24FetchOptions(deadline - Date.now()), - method: 'GET', - headers: EMART24_JSON_HEADERS, - }); + const body = hasConvenienceRelay(options) + ? await requestConvenienceRelay( + 'emart24-stores', + Object.fromEntries(endpoint.searchParams), + options, + deadline - Date.now(), + ) + : await fetchJson(endpoint.toString(), { + ...emart24FetchOptions(deadline - Date.now()), + method: 'GET', + headers: EMART24_JSON_HEADERS, + }); if (body.error === 0 || body.error === undefined) { const stores = (body.data || []).map(toStore).filter((store) => store.storeCode.length > 0); @@ -207,16 +210,23 @@ export async function searchEmart24Products( form.set('saleProductYn', saleProductYn); form.set('searchWord', keyword.trim()); - const body = await fetchJson( - `${EMART24_API.EVERSE_BASE_URL}${EMART24_API.PRODUCT_SEARCH_PATH}`, - { - ...emart24FetchOptions(timeout), - method: 'POST', - retryUnsafeMethods: true, - headers: EMART24_FORM_HEADERS, - body: form.toString(), - }, - ); + const body = hasConvenienceRelay(options) + ? await requestConvenienceRelay( + 'emart24-products', + Object.fromEntries(form), + options, + timeout, + ) + : await fetchJson( + `${EMART24_API.EVERSE_BASE_URL}${EMART24_API.PRODUCT_SEARCH_PATH}`, + { + ...emart24FetchOptions(timeout), + method: 'POST', + retryUnsafeMethods: true, + headers: EMART24_FORM_HEADERS, + body: form.toString(), + }, + ); const products = (body.productList || []) .map(toProduct) @@ -250,6 +260,13 @@ export async function searchEmart24StockByStores( endpoint.searchParams.set('searchPluCode', pluCd); endpoint.searchParams.set('bizNoArr', filteredBizNos.join(',')); + if (hasConvenienceRelay(options)) + return requestConvenienceRelay( + 'emart24-stock', + Object.fromEntries(endpoint.searchParams), + options, + timeout, + ); return fetchJson(endpoint.toString(), { ...emart24FetchOptions(timeout), method: 'GET', @@ -263,6 +280,13 @@ export async function fetchEmart24StoreDetail( ): Promise { const { timeout = 15000 } = options; + if (hasConvenienceRelay(options)) + return requestConvenienceRelay( + 'emart24-store-detail', + { bizNo }, + options, + timeout, + ); return fetchJson( `${EMART24_API.EVERSE_BASE_URL}${EMART24_API.STORE_DETAIL_PATH_PREFIX}${encodeURIComponent(bizNo)}`, { diff --git a/src/services/emart24/index.ts b/src/services/emart24/index.ts index 36c04480..01199579 100644 --- a/src/services/emart24/index.ts +++ b/src/services/emart24/index.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; /** * 이마트24 서비스 프로바이더 */ @@ -17,14 +18,19 @@ const EMART24_METADATA: ServiceMetadata = { class Emart24Service implements ServiceProvider { readonly metadata = EMART24_METADATA; + constructor(private readonly options: ConvenienceTransportOptions = {}) {} getTools(): ToolRegistration[] { - return [createFindNearbyStoresTool(), createSearchProductsTool(), createCheckInventoryTool()]; + return [ + createFindNearbyStoresTool(this.options), + createSearchProductsTool(this.options), + createCheckInventoryTool(this.options), + ]; } } -export function createEmart24Service(): ServiceProvider { - return new Emart24Service(); +export function createEmart24Service(options: ConvenienceTransportOptions = {}): ServiceProvider { + return new Emart24Service(options); } export * from './types.js'; diff --git a/src/services/emart24/inventoryLookup.ts b/src/services/emart24/inventoryLookup.ts index 820a56ff..01e0dce1 100644 --- a/src/services/emart24/inventoryLookup.ts +++ b/src/services/emart24/inventoryLookup.ts @@ -9,10 +9,11 @@ import { fetchEmart24Stores, searchEmart24Products, searchEmart24StockByStores, + type RequestOptions, } from './client.js'; import type { Emart24Product, Emart24Store, Emart24StoreInventory } from './types.js'; -export interface Emart24InventoryLookupArgs { +export interface Emart24InventoryLookupArgs extends RequestOptions { pluCd?: string; keyword?: string; latitude?: number; @@ -61,7 +62,12 @@ async function resolvePluCd( productPage: number, productPageSize: number, timeoutMs: number, -): Promise<{ pluCd: string; products: Emart24Product[]; selectionReason: Emart24InventoryLookupResult['selectionReason'] }> { + options: RequestOptions, +): Promise<{ + pluCd: string; + products: Emart24Product[]; + selectionReason: Emart24InventoryLookupResult['selectionReason']; +}> { const normalizedPluCd = (pluCd || '').trim(); if (normalizedPluCd.length > 0) { return { @@ -82,12 +88,18 @@ async function resolvePluCd( pageSize: productPageSize, }, { + ...options, timeout: timeoutMs, }, ); - const firstProduct = productResult.products.find((item) => item.pluCd.trim().length > 0 && item.goodsName.replace(/\s+/g, '').toLowerCase() === keyword.replace(/\s+/g, '').toLowerCase()) - ?? productResult.products.find((item) => item.pluCd.trim().length > 0); + const firstProduct = + productResult.products.find( + (item) => + item.pluCd.trim().length > 0 && + item.goodsName.replace(/\s+/g, '').toLowerCase() === + keyword.replace(/\s+/g, '').toLowerCase(), + ) ?? productResult.products.find((item) => item.pluCd.trim().length > 0); if (!firstProduct) { throw new Error('상품 검색 결과에서 PLU 코드를 찾을 수 없습니다.'); } @@ -95,7 +107,11 @@ async function resolvePluCd( return { pluCd: firstProduct.pluCd, products: productResult.products, - selectionReason: firstProduct.goodsName.replace(/\s+/g, '').toLowerCase() === keyword.replace(/\s+/g, '').toLowerCase() ? 'exact_match' : 'first_candidate', + selectionReason: + firstProduct.goodsName.replace(/\s+/g, '').toLowerCase() === + keyword.replace(/\s+/g, '').toLowerCase() + ? 'exact_match' + : 'first_candidate', }; } @@ -152,8 +168,17 @@ export async function lookupEmart24Inventory( includeRequestedStoresWithoutQty = false, } = args; - const resolved = await resolvePluCd(pluCd, keyword, productPage, productPageSize, timeoutMs); - const normalizedBizNos = [...new Set(bizNos.map((value) => value.trim()).filter((value) => value.length > 0))]; + const resolved = await resolvePluCd( + pluCd, + keyword, + productPage, + productPageSize, + timeoutMs, + args, + ); + const normalizedBizNos = [ + ...new Set(bizNos.map((value) => value.trim()).filter((value) => value.length > 0)), + ]; let nearbyStores: Emart24Store[] = []; let nearbyTotalCount = normalizedBizNos.length; @@ -169,22 +194,28 @@ export async function lookupEmart24Inventory( page: 1, }, { + ...args, timeout: timeoutMs, }, ); appliedStoreKeyword = nearbyStoreResult.appliedKeyword; nearbyTotalCount = nearbyStoreResult.totalCount; - nearbyStores = sortStoresByDistance(nearbyStoreResult.stores, latitude, longitude).slice(0, storeLimit); + nearbyStores = sortStoresByDistance(nearbyStoreResult.stores, latitude, longitude).slice( + 0, + storeLimit, + ); } - const requestedBizNos = normalizedBizNos.length > 0 ? normalizedBizNos : nearbyStores.map((store) => store.storeCode); + const requestedBizNos = + normalizedBizNos.length > 0 ? normalizedBizNos : nearbyStores.map((store) => store.storeCode); const stockResult = await searchEmart24StockByStores( { pluCd: resolved.pluCd, bizNos: requestedBizNos, }, { + ...args, timeout: timeoutMs, }, ); @@ -204,7 +235,7 @@ export async function lookupEmart24Inventory( .filter((value) => value.length > 0); const detailResults = await Promise.allSettled( - inventoryBizNos.map((bizNo) => fetchEmart24StoreDetail(bizNo, { timeout: timeoutMs })), + inventoryBizNos.map((bizNo) => fetchEmart24StoreDetail(bizNo, { ...args, timeout: timeoutMs })), ); const nearbyStoreByCode = new Map(nearbyStores.map((store) => [store.storeCode, store])); diff --git a/src/services/emart24/relaySpecs.ts b/src/services/emart24/relaySpecs.ts new file mode 100644 index 00000000..f869ab76 --- /dev/null +++ b/src/services/emart24/relaySpecs.ts @@ -0,0 +1,155 @@ +/** 기존 공식 경로·헤더·폼 인코딩을 직접 조회와 HTTP 중계에서 공유합니다. */ +import * as z from 'zod'; +import { EMART24_API as API } from './api.js'; +import type { DirectRouteSpec } from '../../utils/directRouteSpecs.js'; +const text = z + .string() + .max(200) + .regex(/^[^\p{Cc}]*$/u); +const code = z.string().min(1).max(64).regex(/^\d+$/); +const page = z.string().regex(/^[1-9]\d{0,3}$/); +const quantity = z.union([ + z.number().int().min(-1).max(Number.MAX_SAFE_INTEGER), + z.string().regex(/^(?:-1|\d{1,9}|)$/), + z.null(), +]); +const scalar = z.union([z.number().finite(), z.string()]); +const productRow = z + .object({ + pluCd: code, + goodsNm: z.string().min(1), + originPrice: scalar.optional(), + viewPrice: scalar.optional(), + }) + .passthrough(); +const storeRow = z.object({ CODE: code, TITLE: z.string().min(1) }).passthrough(); +const noError = z + .object({ error: z.never().optional(), success: z.literal(true).optional() }) + .passthrough(); +const storesInput = z + .object({ + page, + search: text.optional(), + AREA1: text.optional(), + AREA2: text.optional(), + SVR_24: z.literal('1').optional(), + }) + .strict(); +const productsInput = z + .object({ + currentPage: page, + pageCnt: page, + sortType: z.enum(['SALE', 'LATEST', 'PRICE_ASC', 'PRICE_DESC']), + saleProductYn: z.enum(['Y', 'N']), + searchWord: text.min(1), + }) + .strict(); +const stockInput = z + .object({ + searchPluCode: code, + bizNoArr: z + .string() + .max(1800) + .regex(/^\d+(,\d+){0,199}$/), + }) + .strict(); +const detailInput = z.object({ bizNo: code }).strict(); +export const EMART24_JSON_HEADERS = { + Accept: 'application/json, text/javascript, */*; q=0.01', + 'X-Requested-With': 'XMLHttpRequest', +} as const; +export const EMART24_FORM_HEADERS = { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + ...EMART24_JSON_HEADERS, +} as const; +const storesResponse = z.union([ + z + .object({ + error: z.literal(0).optional(), + count: scalar, + data: z.array(storeRow), + success: z.literal(true).optional(), + }) + .passthrough(), + z + .object({ + error: z.literal(1), + data: z.array(z.never()).optional(), + success: z.literal(true).optional(), + }) + .passthrough(), +]); +function get(path: string, base: string, input: z.ZodType, body: unknown) { + const parsed = input.parse(body) as Record; + const url = new URL(path, base); + url.search = new URLSearchParams(parsed).toString(); + return { url, init: { method: 'GET', headers: EMART24_JSON_HEADERS } }; +} +export const emart24DirectRouteSpecs = { + 'emart24-stores': { + input: storesInput, + probe: () => ({ page: '1', search: '강남' }), + response: storesResponse, + build: (body: unknown) => get(API.STORE_PATH, API.WEB_BASE_URL, storesInput, body), + }, + 'emart24-products': { + input: productsInput, + probe: () => ({ + currentPage: '1', + pageCnt: '1', + sortType: 'SALE', + saleProductYn: 'N', + searchWord: '커피', + }), + response: noError.and( + z.object({ totalCnt: scalar, productList: z.array(productRow) }).passthrough(), + ), + build(body: unknown) { + return { + url: new URL(API.PRODUCT_SEARCH_PATH, API.EVERSE_BASE_URL), + init: { + method: 'POST', + headers: EMART24_FORM_HEADERS, + body: new URLSearchParams(productsInput.parse(body)).toString(), + }, + }; + }, + }, + 'emart24-stock': { + input: stockInput, + probe: () => ({ searchPluCode: '8801115139836', bizNoArr: '00940' }), + response: noError.and( + z + .object({ + storeGoodsInfo: z.object({ pluCd: code }).passthrough(), + storeGoodsQty: z + .array(z.object({ BIZNO: code, BIZQTY: quantity.optional() }).passthrough()) + .nullable(), + }) + .passthrough(), + ), + build: (body: unknown) => + get(API.STOCK_SEARCH_STORE_PATH, API.EVERSE_BASE_URL, stockInput, body), + }, + 'emart24-store-detail': { + input: detailInput, + probe: () => ({ bizNo: '00940' }), + response: noError.and( + z.object({ storeInfo: z.object({ storeNm: z.string().min(1) }).passthrough() }).passthrough(), + ), + build(body: unknown) { + const { bizNo } = detailInput.parse(body); + return { + url: new URL( + `${API.STORE_DETAIL_PATH_PREFIX}${encodeURIComponent(bizNo)}`, + API.EVERSE_BASE_URL, + ), + init: { method: 'GET', headers: EMART24_JSON_HEADERS }, + }; + }, + }, +} satisfies Record; +export type Emart24Operation = keyof typeof emart24DirectRouteSpecs; +export function isEmart24Operation(operation: string): operation is Emart24Operation { + return Object.hasOwn(emart24DirectRouteSpecs, operation); +} diff --git a/src/services/emart24/tools/checkInventory.ts b/src/services/emart24/tools/checkInventory.ts index 3eeec240..bb545d74 100644 --- a/src/services/emart24/tools/checkInventory.ts +++ b/src/services/emart24/tools/checkInventory.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 이마트24 재고 확인 도구 */ @@ -21,7 +22,10 @@ interface CheckInventoryArgs { timeoutMs?: number; } -async function checkInventory(args: CheckInventoryArgs): Promise { +async function checkInventory( + args: CheckInventoryArgs, + options: ConvenienceTransportOptions, +): Promise { const { pluCd, keyword = '', @@ -49,6 +53,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise Promise, + handler: (args) => checkInventory(args as CheckInventoryArgs, options), }; } diff --git a/src/services/emart24/tools/findNearbyStores.ts b/src/services/emart24/tools/findNearbyStores.ts index 143f99cb..dcd95daf 100644 --- a/src/services/emart24/tools/findNearbyStores.ts +++ b/src/services/emart24/tools/findNearbyStores.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 이마트24 주변 매장 탐색 도구 */ @@ -18,7 +19,10 @@ interface FindNearbyStoresArgs { timeoutMs?: number; } -async function findNearbyStores(args: FindNearbyStoresArgs): Promise { +async function findNearbyStores( + args: FindNearbyStoresArgs, + options: ConvenienceTransportOptions, +): Promise { const { latitude, longitude, @@ -40,6 +44,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise Promise, + handler: (args) => findNearbyStores(args as FindNearbyStoresArgs, options), }; } diff --git a/src/services/emart24/tools/searchProducts.ts b/src/services/emart24/tools/searchProducts.ts index 422c3241..024b647d 100644 --- a/src/services/emart24/tools/searchProducts.ts +++ b/src/services/emart24/tools/searchProducts.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 이마트24 상품 검색 도구 */ @@ -22,7 +23,10 @@ function buildTextResponse(payload: Record): McpToolResponse { }; } -async function searchProducts(args: SearchProductsArgs): Promise { +async function searchProducts( + args: SearchProductsArgs, + options: ConvenienceTransportOptions, +): Promise { const { keyword, page = 1, @@ -46,6 +50,7 @@ async function searchProducts(args: SearchProductsArgs): Promise Promise, + handler: (args) => searchProducts(args as SearchProductsArgs, options), }; } diff --git a/src/services/emart24/types.ts b/src/services/emart24/types.ts index d811132b..cf116ecc 100644 --- a/src/services/emart24/types.ts +++ b/src/services/emart24/types.ts @@ -84,7 +84,7 @@ interface Emart24GoodsInfoRecord { export interface Emart24StockByStoreResponse { storeGoodsInfo?: Emart24GoodsInfoRecord; - storeGoodsQty?: Emart24StoreQtyRecord[]; + storeGoodsQty?: Emart24StoreQtyRecord[] | null; } interface Emart24StoreDetailInfo { diff --git a/src/utils/convenienceTransport.ts b/src/utils/convenienceTransport.ts index d6c2462b..00cd5e86 100644 --- a/src/utils/convenienceTransport.ts +++ b/src/utils/convenienceTransport.ts @@ -1,3 +1,4 @@ +import { emart24DirectRouteSpecs, isEmart24Operation } from '../services/emart24/relaySpecs.js'; import { parseSevenStockFailure, EXTERNAL_SERVICE_RETRY_HINT } from './sevenStockFailure.js'; /** 편의점 공통 릴레이 설정과 인증 전송입니다. */ import { requestDirectRoute } from './directRoutes.js'; @@ -42,6 +43,7 @@ export async function requestConvenienceRelay( timeout = 15000, ): Promise { validateRelay(options); + if (isEmart24Operation(operation)) emart24DirectRouteSpecs[operation].input.parse(body); if (!isRouteKey(operation)) return requestConvenienceRelayRaw(operation, body, options, timeout); return routeOperation( operation, @@ -197,7 +199,9 @@ async function requestConvenienceRelayRaw( typeof data !== 'object' || Array.isArray(data) || ('success' in data && data.success === false) || - 'error' in data || + (isEmart24Operation(operation) + ? !emart24DirectRouteSpecs[operation].response.safeParse(data).success + : 'error' in data) || ('resp_cd' in data && data.resp_cd !== '0000') ) throw Error('Invalid response'); diff --git a/src/utils/directRouteSpecs.ts b/src/utils/directRouteSpecs.ts index 5593342f..dcc15bb4 100644 --- a/src/utils/directRouteSpecs.ts +++ b/src/utils/directRouteSpecs.ts @@ -1,3 +1,4 @@ +import { emart24DirectRouteSpecs } from '../services/emart24/relaySpecs.js'; /** 직접 조회와 주기 검사에서 같은 고정 경로·검증기를 사용합니다. */ import * as z from 'zod'; import { cgvDirectRouteSpecs } from '../services/cgv/directRoute.js'; @@ -241,7 +242,7 @@ Object.entries(dtOperations).forEach(([key, operation]) => { }, }; }); -export const directRouteSpecs = { ...specs, ...cgvDirectRouteSpecs } as Record< +export const directRouteSpecs = { ...specs, ...cgvDirectRouteSpecs, ...emart24DirectRouteSpecs } as Record< RouteKey, DirectRouteSpec >; diff --git a/src/utils/routeHealth.ts b/src/utils/routeHealth.ts index 3178ab57..abd0a12c 100644 --- a/src/utils/routeHealth.ts +++ b/src/utils/routeHealth.ts @@ -11,6 +11,10 @@ export const ROUTE_KEYS = [ 'cu-prime', 'cu-stock', 'cu-store', + 'emart24-stores', + 'emart24-products', + 'emart24-stock', + 'emart24-store-detail', 'gs25-products', 'gs25-stock', 'seven-goods', diff --git a/tests/api/emart24-relay-integration.test.ts b/tests/api/emart24-relay-integration.test.ts new file mode 100644 index 00000000..5e3daa49 --- /dev/null +++ b/tests/api/emart24-relay-integration.test.ts @@ -0,0 +1,115 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { afterEach, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +const env = { + CONVENIENCE_RELAY_URL: 'https://relay.example', + CONVENIENCE_RELAY_TOKEN: 'token', + HEALTH_CHECK_SECRET: 'health', +}; +const fixture = (url: string) => { + const op = url.split('/').at(-1); + if (op === 'emart24-products') + return { totalCnt: 1, productList: [{ pluCd: '123', goodsNm: '커피', viewPrice: 2800 }] }; + if (op === 'emart24-stores') + return { + error: 0, + count: 2, + data: [ + { CODE: '00940', TITLE: '청담하나점' }, + { CODE: '02130', TITLE: '강남역점' }, + ], + }; + if (op === 'emart24-stock') + return { + storeGoodsInfo: { pluCd: '123' }, + storeGoodsQty: [{ BIZNO: '00940', BIZQTY: '0' }, { BIZNO: '02130' }], + }; + if (op === 'emart24-store-detail') return { storeInfo: { storeNm: '강남매장' } }; + throw Error(`unexpected direct request ${url}`); +}; +afterEach(() => vi.unstubAllGlobals()); +it('REST·MCP·비교의 이마트24 조회는 같은 중계 바인딩을 사용한다', async () => { + const fetcher = vi.fn(async (url: string) => Response.json(fixture(url))); + vi.stubGlobal('fetch', fetcher); + for (const path of [ + '/api/emart24/products?keyword=커피', + '/api/emart24/stores?keyword=강남', + '/api/emart24/inventory?keyword=커피&storeKeyword=강남', + '/api/compare/products?keyword=커피&services=emart24', + ]) { + const response = await app.request(path, undefined, env); + expect(response.status).toBe(200); + expect((await response.json()).success).toBe(true); + } + const client = new Client({ name: 'test', version: '1' }); + await client.connect( + new StreamableHTTPClientTransport(new URL('https://local.test/mcp'), { + fetch: async (url, init) => app.request(new Request(url, init), undefined, env), + }), + ); + try { + for (const [name, args] of [ + ['emart24_search_products', { keyword: '커피' }], + ['emart24_find_nearby_stores', { keyword: '강남' }], + ['emart24_check_inventory', { keyword: '커피', storeKeyword: '강남' }], + ] as const) { + const result = await client.callTool({ name, arguments: args }); + expect(result.isError).not.toBe(true); + expect(JSON.stringify(result)).not.toContain('degraded'); + } + expect( + fetcher.mock.calls.every(([url]) => + url.startsWith('https://relay.example/v1/convenience/emart24-'), + ), + ).toBe(true); + } finally { + await client.close(); + } +}); +it('인증된 새 조회만 엣지와 모든 재고 단계의 중계 캐시를 우회한다', async () => { + const fetcher = vi.fn(async (url: string) => Response.json(fixture(url))); + const cache = { + match: vi.fn(async () => Response.json({ success: true, data: { cached: true } })), + put: vi.fn(), + }; + vi.stubGlobal('fetch', fetcher); + vi.stubGlobal('caches', { default: cache }); + const response = await app.request( + '/api/emart24/inventory?keyword=커피&storeKeyword=강남', + { headers: { 'x-health-check-force-fresh': 'true', Authorization: 'Bearer health' } }, + env, + ); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + const data = await response.json(); + expect(data.data.stores.map((store: { bizQty: unknown }) => store.bizQty)).toEqual([0, null]); + expect(cache.match).not.toHaveBeenCalled(); + expect(cache.put).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledTimes(5); + for (const [, init] of fetcher.mock.calls as unknown as [string, RequestInit][]) + expect(init.headers).toEqual(expect.objectContaining({ 'x-relay-cache-bypass': 'true' })); + const compared = await app.request( + '/api/compare/products?keyword=커피&services=emart24', + { headers: { 'x-health-check-force-fresh': 'true', Authorization: 'Bearer health' } }, + env, + ); + expect(compared.headers.get('cache-control')).toBe('no-store'); + expect(cache.match).not.toHaveBeenCalled(); + expect((fetcher.mock.calls.at(-1) as unknown as [string, RequestInit])[1].headers).toEqual( + expect.objectContaining({ 'x-relay-cache-bypass': 'true' }), + ); + await app.request( + '/api/emart24/products?keyword=커피', + { headers: { 'x-health-check-force-fresh': 'true', Authorization: 'Bearer wrong' } }, + env, + ); + expect(cache.match).toHaveBeenCalledTimes(1); + await app.request( + '/api/compare/products?keyword=커피&services=emart24', + { headers: { 'x-health-check-force-fresh': 'true', Authorization: 'Bearer wrong' } }, + env, + ); + expect(cache.match).toHaveBeenCalledTimes(2); + expect(fetcher).toHaveBeenCalledTimes(6); +}); diff --git a/tests/relay/emart24.test.ts b/tests/relay/emart24.test.ts new file mode 100644 index 00000000..83a7ff22 --- /dev/null +++ b/tests/relay/emart24.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createConvenienceRelay } from '../../scripts/relay/convenience.js'; +import { + searchEmart24Products, + fetchEmart24Stores, + searchEmart24StockByStores, + fetchEmart24StoreDetail, +} from '../../src/services/emart24/client.js'; +const request = (operation: string, body: unknown, fresh = false) => + new Request(`http://localhost/v1/convenience/${operation}`, { + method: 'POST', + headers: { + Authorization: 'Bearer convenience', + ...(fresh ? { 'x-relay-cache-bypass': 'true' } : {}), + }, + body: JSON.stringify(body), + }); +const product = { + currentPage: '1', + pageCnt: '1', + sortType: 'SALE', + saleProductYn: 'N', + searchWord: '커피', +}; +const products = { + totalCnt: 1, + productList: [{ pluCd: '8801115139836', goodsNm: '커피', viewPrice: 2800 }], +}; +const options = { + convenienceRelayUrl: 'http://localhost:3000', + convenienceRelayToken: 'convenience', +}; +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); +describe('이마트24 고정 HTTP 중계', () => { + it('상품 검색을 기존 공식 폼 헤더로 보내고 인증·할당량·새 조회를 유지한다', async () => { + const fetcher = vi.fn(async () => Response.json(products)); + const takeQuota = vi.fn(async () => true); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota }); + expect((await relay(request('emart24-products', product))).status).toBe(200); + expect((await relay(request('emart24-products', product))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(1); + expect((await relay(request('emart24-products', product, true))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(takeQuota).toHaveBeenCalledTimes(2); + const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]; + expect(url.href).toBe('https://everse.emart24.co.kr/stock/stock/search'); + expect(init.headers).toEqual({ + Accept: 'application/json, text/javascript, */*; q=0.01', + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }); + expect(new URLSearchParams(init.body as string).get('searchWord')).toBe('커피'); + }); + it.each([ + [ + 'emart24-stores', + { page: '1', search: '강남', AREA1: '서울특별시', SVR_24: '1' }, + { error: 0, count: 1, data: [{ CODE: '00940', TITLE: '청담하나점' }] }, + 'https://emart24.co.kr/api1/store', + ], + [ + 'emart24-stock', + { searchPluCode: '8801115139836', bizNoArr: '00940' }, + { + storeGoodsInfo: { pluCd: '8801115139836' }, + storeGoodsQty: [{ BIZNO: '00940', BIZQTY: '0' }], + }, + 'https://everse.emart24.co.kr/api/stock/v2/stock-search/store', + ], + [ + 'emart24-store-detail', + { bizNo: '00940' }, + { storeInfo: { storeNm: '청담하나점' } }, + 'https://everse.emart24.co.kr/api/stock/stock/store/00940', + ], + ])('%s는 고정 GET 경로와 정상 오류 코드 0을 허용한다', async (operation, body, data, path) => { + const fetcher = vi.fn(async () => Response.json(data)); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + const response = await relay(request(operation, body)); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(data); + const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]; + expect(url.href.split('?')[0]).toBe(path); + expect(init.method).toBe('GET'); + expect(init.body).toBeUndefined(); + }); + it('임의 URL·경로·필드와 인증 없는 호출은 원본을 호출하지 않는다', async () => { + const fetcher = vi.fn(); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + expect( + (await relay(request('emart24-products', { ...product, url: 'https://example.com' }))).status, + ).toBe(400); + expect((await relay(request('emart24-store-detail', { bizNo: '../path' }))).status).toBe(400); + const unauthenticated = new Request('http://localhost/v1/convenience/emart24-products', { + method: 'POST', + body: JSON.stringify(product), + }); + expect((await relay(unauthenticated)).status).toBe(401); + expect(fetcher).not.toHaveBeenCalled(); + }); + it.each([ + new Response('403 Forbidden', { status: 403 }), + Response.json({ totalCnt: 1, productList: [{}] }), + Response.json({ error: 1, data: [] }), + ])('원본 차단과 잘못된 응답은 캐시하거나 성공 처리하지 않는다', async (upstream) => { + const relay = createConvenienceRelay('convenience', { + fetcher: async () => upstream.clone(), + takeQuota: async () => true, + }); + expect((await relay(request('emart24-products', product))).status).toBe(502); + }); + it('원본 차단 응답의 스트림 취소가 실패해도 원래 조회 오류를 반환한다', async () => { + const upstream = new Response('blocked', { status: 403 }); + const cancel = vi.spyOn(upstream.body!, 'cancel').mockRejectedValue(new Error('stream closed')); + const relay = createConvenienceRelay('convenience', { + fetcher: async () => upstream, + takeQuota: async () => true, + }); + expect((await relay(request('emart24-products', product))).status).toBe(502); + expect(cancel).toHaveBeenCalledOnce(); + }); + it('재고 숫자 0과 누락·음수 미확인을 구분하고 객체 수량을 거절한다', async () => { + const data = { + storeGoodsInfo: { pluCd: '123' }, + storeGoodsQty: [{ BIZNO: '00940', BIZQTY: { value: 0 } }], + }; + const fetcher = vi.fn(async () => Response.json(data)); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + expect( + (await relay(request('emart24-stock', { searchPluCode: '123', bizNoArr: '00940' }))).status, + ).toBe(502); + }); + it('공식 원본의 재고 행 null은 실제 빈 목록으로 전달한다', async () => { + const data = { storeGoodsInfo: { pluCd: '123' }, storeGoodsQty: null }; + const relay = createConvenienceRelay('convenience', { + fetcher: async () => Response.json(data), + takeQuota: async () => true, + }); + const response = await relay( + request('emart24-stock', { searchPluCode: '123', bizNoArr: '00940' }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(data); + }); + it('재고만 30초에 만료하고 상품·매장 상세 캐시는 5분 유지한다', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(100000); + try { + const fetcher = vi.fn(async (url: RequestInfo | URL) => + Response.json( + String(url).includes('stock-search') + ? { storeGoodsInfo: { pluCd: '123' }, storeGoodsQty: [] } + : String(url).includes('/store/') + ? { storeInfo: { storeNm: '강남점' } } + : products, + ), + ); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + const calls = [ + ['emart24-stock', { searchPluCode: '123', bizNoArr: '00940' }], + ['emart24-products', product], + ['emart24-store-detail', { bizNo: '00940' }], + ] as const; + for (const [op, body] of calls) expect((await relay(request(op, body))).status).toBe(200); + clock.mockReturnValue(130001); + for (const [op, body] of calls) expect((await relay(request(op, body))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(4); + clock.mockReturnValue(400001); + for (const [op, body] of calls) expect((await relay(request(op, body))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(7); + } finally { + clock.mockRestore(); + } + }); + it('호출 취소는 중계 원본 신호를 중단하며 성공을 만들지 않는다', async () => { + const controller = new AbortController(); + let upstreamSignal: AbortSignal | undefined; + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + const relay = createConvenienceRelay('convenience', { + fetcher: async (_url, init) => { + upstreamSignal = init?.signal ?? undefined; + started(); + return new Promise(() => {}); + }, + takeQuota: async () => true, + }); + const req = new Request(request('emart24-products', product), { signal: controller.signal }); + const pending = relay(req); + await ready; + controller.abort(); + expect((await pending).status).toBe(499); + expect(upstreamSignal?.aborted).toBe(true); + }); + it('설정된 클라이언트의 잘못된 입력은 직접 조회나 중계 전에 거절한다', async () => { + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + await expect(searchEmart24Products({ keyword: '잘못된\n검색' }, options)).rejects.toThrow(); + expect(fetcher).not.toHaveBeenCalled(); + }); + it('설정된 클라이언트는 같은 중계와 새 조회 헤더를 사용한다', async () => { + const fetcher = vi.fn(async () => Response.json(products)); + vi.stubGlobal('fetch', fetcher); + const result = await searchEmart24Products( + { keyword: '커피', pageSize: 1 }, + { ...options, bypassCache: true }, + ); + expect(result.totalCount).toBe(1); + expect(fetcher.mock.calls[0][0]).toBe('http://localhost:3000/v1/convenience/emart24-products'); + const init = fetcher.mock.calls[0][1] as RequestInit; + expect(init.headers).toEqual( + expect.objectContaining({ + Authorization: 'Bearer convenience', + 'x-relay-cache-bypass': 'true', + }), + ); + }); + it('모든 클라이언트 작업은 같은 중계 설정을 전달한다', async () => { + const fetcher = vi.fn(async (url: string) => + Response.json( + url.includes('store-detail') + ? { storeInfo: { storeNm: '매장' } } + : url.includes('emart24-stock') + ? { storeGoodsInfo: { pluCd: '123' }, storeGoodsQty: [] } + : { error: 0, count: 0, data: [] }, + ), + ); + vi.stubGlobal('fetch', fetcher); + await fetchEmart24Stores({ keyword: '강남' }, options); + await searchEmart24StockByStores({ pluCd: '123', bizNos: ['00940'] }, options); + await fetchEmart24StoreDetail('00940', options); + expect(fetcher.mock.calls.map(([url]) => url)).toEqual([ + 'http://localhost:3000/v1/convenience/emart24-stores', + 'http://localhost:3000/v1/convenience/emart24-stock', + 'http://localhost:3000/v1/convenience/emart24-store-detail', + ]); + }); +}); diff --git a/tests/relay/guard-runtime.test.ts b/tests/relay/guard-runtime.test.ts index 6c37748f..25d71735 100644 --- a/tests/relay/guard-runtime.test.ts +++ b/tests/relay/guard-runtime.test.ts @@ -117,3 +117,18 @@ it('최악 경로에서 프로필 종료가 공유 기한을 넘으면 확인 vi.useRealTimers(); await access(f.marker); }); +it('두 슬롯의 감시자는 같은 부모 단절에서 각각 소유 회수와 마커 해제를 완료한다', async () => { + const slots = await Promise.all([fixture(), fixture()]); + await Promise.all( + slots.map((f) => runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot)), + ); + for (const f of slots) { + f.channel.connected = false; + f.channel.emit('disconnect'); + } + for (const f of slots) { + await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(0)); + expect(f.server.close).toHaveBeenCalledTimes(1); + await expect(access(f.marker)).rejects.toThrow(); + } +}); diff --git a/tests/relay/lifecycle-prewarm.test.ts b/tests/relay/lifecycle-prewarm.test.ts new file mode 100644 index 00000000..d2515792 --- /dev/null +++ b/tests/relay/lifecycle-prewarm.test.ts @@ -0,0 +1,410 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createBrowserLifecycle } from '../../scripts/relay/lifecycle.js'; +afterEach(() => vi.useRealTimers()); +function session() { + const page = Object.assign(new EventEmitter(), { + goto: vi.fn().mockResolvedValue(undefined), + waitForURL: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue({ status: 200, body: { status: 'SUCCESS' } }), + }); + const context = Object.assign(new EventEmitter(), { + newPage: vi.fn().mockResolvedValue(page), + pages: () => [page], + }); + const owner = { + browser: { newContext: vi.fn().mockResolvedValue(context) }, + close: vi.fn().mockResolvedValue(undefined), + rss: vi.fn().mockResolvedValue(100), + pid: 1, + }; + return { page, context, owner }; +} +it('교체 준비 중 정상 요청은 기존 페이지에서 즉시 처리하고 준비 완료 뒤 교환한다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + let ready!: () => void; + b.page.waitForURL.mockImplementation( + () => + new Promise((resolve) => { + ready = resolve; + }), + ); + const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); + const life = createBrowserLifecycle(launch); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + expect(launch).toHaveBeenCalledTimes(2); + await life.run('/p', {}); + expect(a.page.evaluate).toHaveBeenCalledTimes(1); + expect(a.owner.close).not.toHaveBeenCalled(); + ready(); + await vi.advanceTimersByTimeAsync(0); + await life.run('/p', {}); + expect(b.page.evaluate).toHaveBeenCalledTimes(1); + expect(a.owner.close).toHaveBeenCalledTimes(1); + await life.close(); + expect(b.owner.close).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); +}); +it('후보 준비 실패는 정상 기존 세션을 보존하고 무한 재시도하지 않는다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + b.page.goto.mockRejectedValue(new Error('navigation')); + const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); + const life = createBrowserLifecycle(launch); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + expect(b.owner.close).toHaveBeenCalledTimes(1); + expect(a.owner.close).not.toHaveBeenCalled(); + await life.run('/p', {}); + await vi.advanceTimersByTimeAsync(60000); + expect(launch).toHaveBeenCalledTimes(2); + expect(life.status().state).toBe('ready'); + await life.close(); +}); +it('기존 RSS가 교체용 절반 예산을 넘으면 겹친 실행을 건너뛴다', async () => { + vi.useFakeTimers(); + const a = session(); + a.owner.rss.mockResolvedValue(2 ** 29 + 1); + const launch = vi.fn().mockResolvedValue(a.owner); + const life = createBrowserLifecycle(launch); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + expect(launch).toHaveBeenCalledTimes(1); + await life.run('/p', {}); + expect(a.page.evaluate).toHaveBeenCalledTimes(1); + await life.close(); +}); +it('실행 시작 시간부터 30분 수명을 계산한다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + let finish!: (owner: typeof a.owner) => void; + const launch = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(b.owner); + const life = createBrowserLifecycle(launch); + const starting = life.start(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + finish(a.owner); + await starting; + await vi.advanceTimersByTimeAsync(22 * 60 * 1000); + expect(launch).toHaveBeenCalledTimes(2); + await life.close(); +}); +it('후보가 준비되어도 실행 중 API가 끝나기 전에는 교환하지 않는다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + let ready!: () => void, done!: (value: unknown) => void; + b.page.waitForURL.mockImplementation( + () => + new Promise((resolve) => { + ready = resolve; + }), + ); + const life = createBrowserLifecycle( + vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner), + ); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + a.page.evaluate.mockImplementationOnce( + () => + new Promise((resolve) => { + done = resolve; + }), + ); + const request = life.run('/p', {}); + await vi.advanceTimersByTimeAsync(0); + ready(); + await vi.advanceTimersByTimeAsync(0); + expect(a.owner.close).not.toHaveBeenCalled(); + expect(life.status()).toMatchObject({ active: true, owned: 2 }); + done({ status: 200, body: { status: 'SUCCESS' } }); + await request; + expect(a.owner.close).toHaveBeenCalledTimes(1); + await life.close(); +}); +it('후보 준비 중 기존 페이지 충돌은 같은 준비를 기다리고 세 번째 실행을 만들지 않는다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + let ready!: () => void; + b.page.waitForURL.mockImplementation( + () => + new Promise((resolve) => { + ready = resolve; + }), + ); + const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); + const life = createBrowserLifecycle(launch); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + a.page.emit('crash'); + const request = life.run('/p', {}); + await vi.advanceTimersByTimeAsync(0); + expect(launch).toHaveBeenCalledTimes(2); + ready(); + await request; + expect(b.page.evaluate).toHaveBeenCalledTimes(1); + await life.close(); +}); +it('후보와 기존 세션 합계 RSS가 예산을 넘으면 후보부터 회수한다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + a.owner.rss.mockResolvedValue(400 * 2 ** 20); + b.owner.rss.mockResolvedValue(700 * 2 ** 20); + const life = createBrowserLifecycle( + vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner), + ); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + expect(b.owner.close).toHaveBeenCalledTimes(1); + expect(a.owner.close).not.toHaveBeenCalled(); + expect(b.context.newPage).not.toHaveBeenCalled(); + await life.run('/p', {}); + await life.close(); +}); +it('종료는 늦게 준비되는 후보와 정상 기존 소유자를 모두 회수한다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + let finish!: (owner: typeof b.owner) => void; + const launch = vi + .fn() + .mockResolvedValueOnce(a.owner) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const life = createBrowserLifecycle(launch); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + const closing = life.close(); + finish(b.owner); + await closing; + expect(a.owner.close).toHaveBeenCalledTimes(1); + expect(b.owner.close).toHaveBeenCalledTimes(1); + expect(b.context.newPage).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); +it('이전 회수 실패는 준비된 새 세션이 있어도 추가 요청과 실행을 차단한다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + a.owner.close.mockRejectedValue(new Error('uncertain')); + const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); + const life = createBrowserLifecycle(launch); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + expect(life.status().state).toBe('failed'); + await expect(life.run('/p', {})).rejects.toThrow('uncertain'); + expect(launch).toHaveBeenCalledTimes(2); + await expect(life.close()).rejects.toThrow('uncertain'); + expect(b.owner.close).toHaveBeenCalledTimes(1); +}); +it('후보가 지연되어도 기존 페이지에 200회를 넘는 요청을 보내지 않는다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + let ready!: () => void; + b.page.waitForURL.mockImplementation( + () => + new Promise((resolve) => { + ready = resolve; + }), + ); + const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); + const life = createBrowserLifecycle(launch); + await life.start(); + for (let i = 0; i < 200; i++) await life.run('/p', {}); + expect(a.page.evaluate).toHaveBeenCalledTimes(200); + expect(a.owner.close).toHaveBeenCalledTimes(1); + const next = life.run('/p', {}); + await vi.advanceTimersByTimeAsync(0); + ready(); + await next; + expect(a.page.evaluate).toHaveBeenCalledTimes(200); + expect(b.page.evaluate).toHaveBeenCalledTimes(1); + await life.close(); +}); +it('종료는 후보 탐색 완료를 기다리기 전에 알려진 두 소유자를 회수한다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + let finish!: () => void; + b.page.goto.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const life = createBrowserLifecycle( + vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner), + ); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + const closing = life.close(); + await vi.advanceTimersByTimeAsync(0); + expect(a.owner.close).toHaveBeenCalledTimes(1); + expect(b.owner.close).toHaveBeenCalledTimes(1); + finish(); + await closing; + expect(b.page.waitForURL).not.toHaveBeenCalled(); +}); +it('교환 후 회수 중인 이전 소유자 RSS도 종료 확인까지 합산한다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + let gone!: () => void, ready!: () => void; + a.owner.rss.mockResolvedValue(400 * 2 ** 20); + b.owner.rss.mockResolvedValue(400 * 2 ** 20); + b.page.waitForURL.mockImplementationOnce( + () => + new Promise((resolve) => { + ready = resolve; + }), + ); + a.owner.close.mockImplementationOnce( + () => + new Promise((resolve) => { + gone = resolve; + }), + ); + const life = createBrowserLifecycle( + vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner), + ); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000 + 29000); + ready(); + await vi.advanceTimersByTimeAsync(1000); + expect(life.status().rssBytes).toBe(800 * 2 ** 20); + gone(); + await life.close(); +}); +it('회수 중 슬롯이 남으면 빠른 다음 160회에도 세 번째 소유자를 실행하지 않는다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + let gone!: () => void; + a.owner.close.mockImplementationOnce( + () => + new Promise((resolve) => { + gone = resolve; + }), + ); + const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); + const life = createBrowserLifecycle(launch); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + for (let i = 0; i < 160; i++) await life.run('/p', {}); + expect(launch).toHaveBeenCalledTimes(2); + gone(); + await life.close(); +}); +it('준비 자체가 30분을 넘은 세션은 준비 완료라도 요청에 사용하지 않는다', async () => { + vi.useFakeTimers(); + const a = session(); + let finish!: (owner: typeof a.owner) => void; + const life = createBrowserLifecycle( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const starting = life.start(); + const rejected = expect(starting).rejects.toThrow('unavailable'); + await vi.advanceTimersByTimeAsync(0); + expect(life.status().state).toBe('starting'); + vi.setSystemTime(Date.now() + 31 * 60 * 1000); + finish(a.owner); + await rejected; + expect(a.page.evaluate).not.toHaveBeenCalled(); + await life.close(); +}); +it.each(['context', 'page'] as const)( + '종료 중 늦게 생성된 %s 이후 탐색을 계속하지 않는다', + async (stage) => { + vi.useFakeTimers(); + const a = session(); + let finish!: () => void; + if (stage === 'context') + a.owner.browser.newContext.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve(a.context); + }), + ); + else + a.context.newPage.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve(a.page); + }), + ); + const life = createBrowserLifecycle(vi.fn().mockResolvedValue(a.owner)); + const starting = life.start(); + const rejected = expect(starting).rejects.toThrow('unavailable'); + await vi.advanceTimersByTimeAsync(0); + const closing = life.close(); + finish(); + await rejected; + await closing; + expect(a.page.goto).not.toHaveBeenCalled(); + expect(a.owner.close).toHaveBeenCalledTimes(1); + }, +); +it('후보 준비 완료 시 RSS가 새로 초과하면 현재를 보존하고 후보를 거절한다', async () => { + vi.useFakeTimers(); + const a = session(), + b = session(); + b.owner.rss.mockResolvedValueOnce(100).mockResolvedValue(2 ** 30 + 1); + const life = createBrowserLifecycle( + vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner), + ); + await life.start(); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + expect(b.page.waitForURL).toHaveBeenCalledTimes(1); + expect(b.owner.close).toHaveBeenCalledTimes(1); + expect(a.owner.close).not.toHaveBeenCalled(); + await life.close(); +}); +it('160번째 준비 실행 실패는 배경 거절을 처리하고 추가 실행을 차단한다', async () => { + const a = session(); + const launch = vi + .fn() + .mockResolvedValueOnce(a.owner) + .mockRejectedValue(new Error('guard uncertain')); + const life = createBrowserLifecycle(launch); + await life.start(); + for (let i = 0; i < 160; i++) await life.run('/p', {}); + await vi.waitFor(() => expect(life.status().state).toBe('failed')); + await expect(life.close()).rejects.toThrow('guard uncertain'); +}); +it('요청 실패 뒤 자동 실행 실패도 처리하며 무한 실행을 만들지 않는다', async () => { + const a = session(); + const launch = vi + .fn() + .mockResolvedValueOnce(a.owner) + .mockRejectedValue(new Error('guard uncertain')); + const life = createBrowserLifecycle(launch); + await life.start(); + a.page.evaluate.mockRejectedValue(new Error('upstream')); + await expect(life.run('/p', {})).rejects.toThrow('upstream'); + await vi.waitFor(() => expect(life.status().state).toBe('failed')); + expect(launch).toHaveBeenCalledTimes(2); + await expect(life.close()).rejects.toThrow('guard uncertain'); +}); diff --git a/tests/relay/lifecycle.test.ts b/tests/relay/lifecycle.test.ts index 4b006d05..83deb93b 100644 --- a/tests/relay/lifecycle.test.ts +++ b/tests/relay/lifecycle.test.ts @@ -22,13 +22,13 @@ function fixture() { const launch = vi.fn().mockResolvedValue(owner); return { page, context, owner, launch }; } -it('반복 요청은 한 페이지를 재사용하고200회 뒤 이전 세션 종료를 확인한 후 교체한다', async () => { +it('반복 요청은 페이지를 재사용하고 준비된 교체 뒤 이전 세션을 회수한다', async () => { const f = fixture(); const life = createBrowserLifecycle(f.launch); for (let i = 0; i < 201; i++) await life.run('/p', {}); expect(f.context.newPage).toHaveBeenCalledTimes(2); expect(f.owner.close).toHaveBeenCalledTimes(1); - expect(f.owner.close.mock.invocationCallOrder[0]).toBeLessThan( + expect(f.owner.close.mock.invocationCallOrder[0]).toBeGreaterThan( f.launch.mock.invocationCallOrder[1], ); await life.close(); @@ -125,7 +125,7 @@ it('시작 중 종료는 늦게 도착한 소유 브라우저도 회수한다', const life = createBrowserLifecycle(f.launch); const pending = life.start(); const rejected = expect(pending).rejects.toThrow('unavailable'); - await Promise.resolve(); + await vi.waitFor(() => expect(f.launch).toHaveBeenCalledTimes(1)); const closing = life.close(); finish(f.owner); await rejected; @@ -198,9 +198,10 @@ it('이전 세션 팝업 및 RSS 작업은 새 세션을 회수하지 않는다' fail = reject; }), }); - await life.close(); + const closing = life.close(); rss(2 ** 30 + 1); fail(new Error('old popup')); + await closing; await vi.advanceTimersByTimeAsync(0); expect(f.owner.close).toHaveBeenCalledTimes(1); }); @@ -217,8 +218,9 @@ it('이전 세션 RSS 실패는 종료 후 추가 작업을 만들지 않는다' }), ); await vi.advanceTimersByTimeAsync(30000); - await life.close(); + const closing = life.close(); fail(new Error('old')); + await closing; await vi.advanceTimersByTimeAsync(0); expect(vi.getTimerCount()).toBe(0); }); @@ -235,9 +237,9 @@ it('자동 교체 중 종료는 준비 중인 브라우저를 회수하고 타 finish = resolve; }), ); - await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + await vi.advanceTimersByTimeAsync(27 * 60 * 1000); expect(f.launch).toHaveBeenCalledTimes(2); - expect(life.status().state).toBe('starting'); + expect(life.status()).toMatchObject({ state: 'ready', preparing: true }); const closing = life.close(); finish(); await closing; @@ -253,7 +255,7 @@ it('자동 교체 준비 실패는 무한 재시작 없이 유휴 상태에서 f.page.goto.mockRejectedValueOnce(new Error('navigation')); await vi.advanceTimersByTimeAsync(30 * 60 * 1000); expect(f.launch).toHaveBeenCalledTimes(2); - expect(life.status()).toMatchObject({ state: 'idle', reason: 'startup-failed' }); + expect(life.status()).toMatchObject({ state: 'idle', reason: 'age-limit' }); await vi.advanceTimersByTimeAsync(60 * 60 * 1000); expect(f.launch).toHaveBeenCalledTimes(2); await life.close(); @@ -290,7 +292,7 @@ it('자동 교체의 종료 확인 실패는 새 브라우저 실행을 차단 await life.start(); f.owner.close.mockRejectedValue(new Error('uncertain')); await vi.advanceTimersByTimeAsync(30 * 60 * 1000); - expect(f.launch).toHaveBeenCalledTimes(1); + expect(f.launch).toHaveBeenCalledTimes(2); expect(life.status().state).toBe('failed'); await expect(life.close()).rejects.toThrow('uncertain'); }); @@ -325,9 +327,9 @@ it('자동 준비 중 팝업 회수 실패는 준비 완료로 보고하지 않 finish(); await vi.advanceTimersByTimeAsync(0); expect(life.status().state).toBe('idle'); - expect(vi.getTimerCount()).toBe(0); expect(f.launch).toHaveBeenCalledTimes(2); await life.close(); + expect(vi.getTimerCount()).toBe(0); }); it('브라우저 시작/회수 사유를 기록하고 관측기 실패를 격리한다', async () => { const f = fixture(); @@ -357,7 +359,7 @@ it('브라우저 프로세스 실행 실패도 별도 사건으로 기록한다' ); await expect(life.start()).rejects.toThrow('launch'); expect(events).toContain('launch-failed'); - await life.close(); + await expect(life.close()).rejects.toThrow('launch'); }); it('수명 계층이 종료 시작 때 하나의 기한을 생성한다', async () => { vi.useFakeTimers(); diff --git a/tests/relay/transport-lifecycle.test.ts b/tests/relay/transport-lifecycle.test.ts index 91ae3129..3a994410 100644 --- a/tests/relay/transport-lifecycle.test.ts +++ b/tests/relay/transport-lifecycle.test.ts @@ -12,7 +12,8 @@ it('defaults to the existing browser lifecycle and forwards observer/ownership p const observer = vi.fn(); expect(await createTransportLifecycle(undefined, '/state', observer)).toBe('browser'); const [launch, observed] = mocks.browser.mock.calls[0] as unknown as [() => unknown, unknown]; - launch(); + mocks.launch.mockResolvedValue({ close: vi.fn().mockResolvedValue(undefined) }); + await launch(); expect(mocks.launch).toHaveBeenCalledWith('/state/browser-owner.json'); expect(observed).toBe(observer); }); diff --git a/tests/relay/transport-slots.test.ts b/tests/relay/transport-slots.test.ts new file mode 100644 index 00000000..bace3072 --- /dev/null +++ b/tests/relay/transport-slots.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ launch: vi.fn(), create: vi.fn() })); +vi.mock('../../scripts/relay/lifecycle.js', () => ({ createBrowserLifecycle: mocks.create })); +vi.mock('../../scripts/relay/supervisor.js', () => ({ launchGuardedBrowser: mocks.launch })); +import { createTransportLifecycle } from '../../scripts/relay/transport-lifecycle.js'; +const dirs: string[] = []; +afterEach(async () => { + vi.clearAllMocks(); + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); +async function fixture() { + const dir = await mkdtemp(join(tmpdir(), 'oy-slots-')); + dirs.push(dir); + await createTransportLifecycle('browser', dir); + const launch = mocks.create.mock.calls.at(-1)![0] as () => Promise<{ close(): Promise }>; + const close = vi.fn().mockResolvedValue(undefined); + mocks.launch.mockImplementation(async () => ({ close })); + return { dir, launch, close }; +} +it('두 고정 마커를 번갈아 사용하고 회수 확인 전 세 번째 실행을 거절한다', async () => { + const f = await fixture(); + const a = await f.launch(), + b = await f.launch(); + expect(mocks.launch.mock.calls.map((x) => x[0])).toEqual([ + join(f.dir, 'browser-owner.json'), + join(f.dir, 'browser-owner-replacement.json'), + ]); + await expect(f.launch()).rejects.toThrow('slots'); + await a.close(); + await f.launch(); + expect(mocks.launch.mock.calls[2][0]).toBe(join(f.dir, 'browser-owner.json')); + await b.close(); +}); +it('어느 마커에든 이전 소유권이 남으면 빈 슬롯을 선택하지 않는다', async () => { + const f = await fixture(); + await writeFile(join(f.dir, 'browser-owner-replacement.json'), '{}'); + await expect(f.launch()).rejects.toThrow('ownership'); + expect(mocks.launch).not.toHaveBeenCalled(); +}); +it('회수 불확실성과 실행 실패는 이후 모든 실행을 차단한다', async () => { + const f = await fixture(); + const a = await f.launch(); + f.close.mockRejectedValue(new Error('uncertain')); + await expect(a.close()).rejects.toThrow('uncertain'); + await expect(f.launch()).rejects.toThrow('uncertain'); + const g = await fixture(); + mocks.launch.mockRejectedValue(new Error('launch uncertain')); + await expect(g.launch()).rejects.toThrow('launch uncertain'); + await expect(g.launch()).rejects.toThrow('launch uncertain'); +}); +it('마커 디렉터리 검사 오류는 부재로 해석하지 않는다', async () => { + const f = await fixture(); + const file = join(f.dir, 'file'); + await writeFile(file, 'x'); + await createTransportLifecycle('browser', file); + const launch = mocks.create.mock.calls.at(-1)![0] as () => Promise; + await expect(launch()).rejects.toThrow(); + expect(mocks.launch).not.toHaveBeenCalled(); +}); +it('다른 슬롯 검사 대기 중 회수 실패가 확인되면 실행하지 않는다', async () => { + const f = await fixture(); + const a = await f.launch(); + f.close.mockRejectedValue(new Error('uncertain')); + const closing = a.close(); + const denied = f.launch(); + await expect(closing).rejects.toThrow('uncertain'); + await expect(denied).rejects.toThrow('uncertain'); + expect(mocks.launch).toHaveBeenCalledTimes(1); +}); diff --git a/tests/utils/directRoutes.test.ts b/tests/utils/directRoutes.test.ts index bf426f42..f9cc7330 100644 --- a/tests/utils/directRoutes.test.ts +++ b/tests/utils/directRoutes.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { requestDirectRoute, checkDirectRoute } from '../../src/utils/directRoutes.js'; +import { ROUTE_KEYS } from '../../src/utils/routeHealth.js'; import { directRouteSpecs } from '../../src/utils/directRouteSpecs.js'; afterEach(() => { @@ -8,7 +9,7 @@ afterEach(() => { }); describe('direct routes', () => { it('defines only fixed official routes and pins GS inventory without network access', async () => { - expect(Object.keys(directRouteSpecs)).toHaveLength(24); + expect(Object.keys(directRouteSpecs)).toHaveLength(ROUTE_KEYS.length); const fetcher = vi.fn(); vi.stubGlobal('fetch', fetcher); await expect(requestDirectRoute('gs25-stock', {}, 3000)).rejects.toThrow('pinned-relay'); diff --git a/tests/utils/emart24-routing.test.ts b/tests/utils/emart24-routing.test.ts new file mode 100644 index 00000000..8ac509ee --- /dev/null +++ b/tests/utils/emart24-routing.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { checkDirectRoute } from '../../src/utils/directRoutes.js'; +import { emart24DirectRouteSpecs } from '../../src/services/emart24/relaySpecs.js'; +import { requestConvenienceRelay } from '../../src/utils/convenienceTransport.js'; +import { withRouteRouting, type RouteSnapshot } from '../../src/utils/routeHealth.js'; +const options = { convenienceRelayUrl: 'https://relay.example', convenienceRelayToken: 'token' }; +const products = { totalCnt: 1, productList: [{ pluCd: '123', goodsNm: '커피' }] }; +const fixtures = { + 'emart24-products': products, + 'emart24-stores': { error: 0, count: 0, data: [] }, + 'emart24-stock': { storeGoodsInfo: { pluCd: '123' }, storeGoodsQty: null }, + 'emart24-store-detail': { storeInfo: { storeNm: '강남매장' } }, +}; +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); +describe('이마트24 기존 직접 경로 선택', () => { + it.each(Object.keys(fixtures) as (keyof typeof fixtures)[])( + '%s의 읽기 전용 사전 점검은 실제 요청과 같은 검증기를 쓴다', + async (key) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(fixtures[key])), + ); + expect(await checkDirectRoute(key)).toMatchObject({ key, direct: true }); + }, + ); + it('403 직접 실패는 남은 예산으로 중계하고 해당 작업만 5분 대기한다', async () => { + const pending: Promise[] = []; + const snapshot: RouteSnapshot = { + routes: { + 'emart24-products': { key: 'emart24-products', direct: true, checkedAt: Date.now() }, + }, + relays: { convenience: { group: 'convenience', healthy: true, checkedAt: Date.now() } }, + }; + const ns = { + idFromName: () => ({ toString: () => 'emart-test' }), + get: () => ({ fetch: async () => Response.json(snapshot) }), + } as unknown as DurableObjectNamespace; + const fetcher = vi.fn(async (url: RequestInfo | URL) => + String(url).startsWith('https://relay.example') + ? Response.json(products) + : new Response('403 Forbidden', { status: 403 }), + ); + vi.stubGlobal('fetch', fetcher); + const run = () => + withRouteRouting( + ns, + (p) => pending.push(p), + () => + requestConvenienceRelay( + 'emart24-products', + emart24DirectRouteSpecs['emart24-products'].probe(), + options, + 5000, + ), + ); + await run(); + await Promise.all(pending); + fetcher.mockClear(); + expect(await run()).toEqual(products); + expect(await run()).toEqual(products); + expect( + fetcher.mock.calls.filter(([url]) => String(url).includes('everse.emart24.co.kr')), + ).toHaveLength(1); + expect( + fetcher.mock.calls.filter(([url]) => String(url).includes('relay.example')), + ).toHaveLength(2); + }); +}); diff --git a/tests/utils/routeProbes.test.ts b/tests/utils/routeProbes.test.ts index 30ba8664..a53225d4 100644 --- a/tests/utils/routeProbes.test.ts +++ b/tests/utils/routeProbes.test.ts @@ -16,7 +16,7 @@ it('skips scheduling work when namespace is absent', async () => { await refreshRouteChecks({}); expect(checkDirectRoute).not.toHaveBeenCalled(); }); -it('writes all twenty-four checks and authenticated health probes with fixed paths', async () => { +it('모든 경로와 고정된 인증 중계 헬스 경로를 점검한다', async () => { await refreshRouteChecks({ UPSTREAM_ROUTE_HEALTH: namespace, OY_RELAY_URL: 'https://oy.example', @@ -28,7 +28,7 @@ it('writes all twenty-four checks and authenticated health probes with fixed pat DTRYX_RELAY_URL: 'https://dt.example', DTRYX_RELAY_TOKEN: 'dt', }); - expect(checkDirectRoute).toHaveBeenCalledTimes(24); + expect(checkDirectRoute).toHaveBeenCalledTimes(ROUTE_KEYS.length); expect(vi.mocked(boundedRouteJson).mock.calls.map(([url]) => String(url))).toEqual([ 'https://dt.example/v1/cgv/health', 'https://oy.example/health', From b3e97f2921882dc4e1312086c000d246890c5eaf Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 12 Oct 2026 09:35:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80?= =?UTF-8?q?=20=EC=A4=80=EB=B9=84=20=EC=8B=A4=ED=8C=A8=20=EC=A7=84=EB=8B=A8?= =?UTF-8?q?=EA=B3=BC=20URL=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/relay/lifecycle.ts | 17 ++++++++++ tests/relay/lifecycle-prewarm.test.ts | 49 +++++++++++++++++++++++++++ tests/relay/lifecycle.test.ts | 9 ++++- tests/utils/emart24-routing.test.ts | 8 +++-- 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/scripts/relay/lifecycle.ts b/scripts/relay/lifecycle.ts index 292e4d63..83ffd337 100644 --- a/scripts/relay/lifecycle.ts +++ b/scripts/relay/lifecycle.ts @@ -135,6 +135,8 @@ export function createBrowserLifecycle( if (previous) previous.attempted = true; preparing = (async () => { let session: Session | undefined; + let phase: 'ownership' | 'context' | 'page' | 'navigation' | 'store-wait' | 'readiness' = + 'ownership'; try { await Promise.all([...sessions].filter((s) => s.retired).map((s) => s.closing)); await sample(); @@ -158,8 +160,12 @@ export function createBrowserLifecycle( check(); await sample(); if (session.retired) throw new Error('Browser lifecycle unavailable'); + phase = 'context'; + emit('prepare-context'); session.context = await bounded(owner.browser.newContext({ locale: 'ko-KR' }), 10000); if (session.retired) throw new Error('Browser lifecycle unavailable'); + phase = 'page'; + emit('prepare-page'); session.page = await bounded(session.context.newPage(), 10000); if (session.retired) throw new Error('Browser lifecycle unavailable'); const owned = session; @@ -173,6 +179,8 @@ export function createBrowserLifecycle( }); session.page.on('crash', () => invalidate('page-crash')); session.page.on('close', () => invalidate('page-closed')); + phase = 'navigation'; + emit('prepare-navigation'); await bounded( session.page.goto('https://www.oliveyoung.co.kr/', { waitUntil: 'domcontentloaded', @@ -181,7 +189,10 @@ export function createBrowserLifecycle( 32000, ); if (session.retired) throw new Error('Browser lifecycle unavailable'); + phase = 'store-wait'; + emit('prepare-store-wait'); await bounded(session.page.waitForURL('**/store/**', { timeout: 30000 }), 32000); + phase = 'readiness'; await sample(); check(); if (session.retired) throw new Error('Browser lifecycle unavailable'); @@ -203,6 +214,12 @@ export function createBrowserLifecycle( }); }, 30000); } catch (error) { + if (session && !closed) { + const timeout = + error instanceof Error && + (error.name === 'TimeoutError' || error.message === 'Browser watchdog timeout'); + emit(`startup-${phase}-${timeout ? 'timeout' : 'error'}`); + } if (session) await retire(session, closed ? 'shutdown' : 'startup-failed'); throw error; } diff --git a/tests/relay/lifecycle-prewarm.test.ts b/tests/relay/lifecycle-prewarm.test.ts index d2515792..44180be5 100644 --- a/tests/relay/lifecycle-prewarm.test.ts +++ b/tests/relay/lifecycle-prewarm.test.ts @@ -408,3 +408,52 @@ it('요청 실패 뒤 자동 실행 실패도 처리하며 무한 실행을 만 expect(launch).toHaveBeenCalledTimes(2); await expect(life.close()).rejects.toThrow('guard uncertain'); }); +it.each(['context', 'page', 'navigation', 'store-wait'] as const)( + '시작 실패는 고정된 %s 단계와 오류 분류만 기록한다', + async (phase) => { + const a = session(); + const events: string[] = []; + const error = new Error('sentinel-content-not-for-logs'); + error.name = 'TimeoutError'; + if (phase === 'context') a.owner.browser.newContext.mockRejectedValueOnce(error); + if (phase === 'page') a.context.newPage.mockRejectedValueOnce(error); + if (phase === 'navigation') a.page.goto.mockRejectedValueOnce(error); + if (phase === 'store-wait') a.page.waitForURL.mockRejectedValueOnce(error); + const life = createBrowserLifecycle(vi.fn().mockResolvedValue(a.owner), (e) => { + events.push(e); + }); + await expect(life.start()).rejects.toThrow(error); + expect(events).toContain(`prepare-${phase}`); + expect(events).toContain(`startup-${phase}-timeout`); + expect(events).toContain('startup-failed'); + expect(events.join(' ')).not.toContain('sentinel-content'); + await life.close(); + }, +); +it('시작 오류의 임의 문자열은 기록하지 않고 error로 분류한다', async () => { + const a = session(); + const events: string[] = []; + a.page.goto.mockRejectedValueOnce('arbitrary-input'); + const life = createBrowserLifecycle(vi.fn().mockResolvedValue(a.owner), (e) => { + events.push(e); + }); + await expect(life.start()).rejects.toBe('arbitrary-input'); + expect(events).toContain('startup-navigation-error'); + expect(events.join(' ')).not.toContain('arbitrary-input'); + await life.close(); +}); +it('Node 시작 watchdog은 navigation timeout으로 기록한다', async () => { + vi.useFakeTimers(); + const a = session(); + const events: string[] = []; + a.page.goto.mockImplementationOnce(() => new Promise(() => {})); + const life = createBrowserLifecycle(vi.fn().mockResolvedValue(a.owner), (e) => { + events.push(e); + }); + const starting = life.start(); + const rejected = expect(starting).rejects.toThrow('watchdog'); + await vi.advanceTimersByTimeAsync(32000); + await rejected; + expect(events).toContain('startup-navigation-timeout'); + await life.close(); +}); diff --git a/tests/relay/lifecycle.test.ts b/tests/relay/lifecycle.test.ts index 83deb93b..6df31009 100644 --- a/tests/relay/lifecycle.test.ts +++ b/tests/relay/lifecycle.test.ts @@ -340,7 +340,14 @@ it('브라우저 시작/회수 사유를 기록하고 관측기 실패를 격리 }); await life.start(); await life.close(); - expect(events).toEqual(['ready', 'shutdown']); + expect(events).toEqual([ + 'prepare-context', + 'prepare-page', + 'prepare-navigation', + 'prepare-store-wait', + 'ready', + 'shutdown', + ]); const second = createBrowserLifecycle(f.launch, async () => { throw Error('async log'); }); diff --git a/tests/utils/emart24-routing.test.ts b/tests/utils/emart24-routing.test.ts index 8ac509ee..19a68dca 100644 --- a/tests/utils/emart24-routing.test.ts +++ b/tests/utils/emart24-routing.test.ts @@ -39,7 +39,7 @@ describe('이마트24 기존 직접 경로 선택', () => { get: () => ({ fetch: async () => Response.json(snapshot) }), } as unknown as DurableObjectNamespace; const fetcher = vi.fn(async (url: RequestInfo | URL) => - String(url).startsWith('https://relay.example') + new URL(String(url)).origin === 'https://relay.example' ? Response.json(products) : new Response('403 Forbidden', { status: 403 }), ); @@ -62,10 +62,12 @@ describe('이마트24 기존 직접 경로 선택', () => { expect(await run()).toEqual(products); expect(await run()).toEqual(products); expect( - fetcher.mock.calls.filter(([url]) => String(url).includes('everse.emart24.co.kr')), + fetcher.mock.calls.filter( + ([url]) => new URL(String(url)).origin === 'https://everse.emart24.co.kr', + ), ).toHaveLength(1); expect( - fetcher.mock.calls.filter(([url]) => String(url).includes('relay.example')), + fetcher.mock.calls.filter(([url]) => new URL(String(url)).origin === 'https://relay.example'), ).toHaveLength(2); }); }); From 55f4da817d96deb2f439dcaea7c7f87a5e5a9333 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 12 Oct 2026 09:37:47 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=EC=9D=B8=EC=A6=9D=EB=90=9C=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EC=A0=90=EA=B2=80=EC=9D=98=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=9A=B0=ED=9A=8C=20=EC=84=A4=EB=AA=85=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/relay-observability.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/relay-observability.md b/docs/relay-observability.md index 0fa0f427..e85e9ff6 100644 --- a/docs/relay-observability.md +++ b/docs/relay-observability.md @@ -47,7 +47,7 @@ rg --fixed-strings '확인할-request-id' "$OY_RELAY_STATE_DIR/logs" "$DTRYX_REL 인증과 payload 검증 뒤 공통 릴레이 캐시를 확인한다. REST와 MCP가 같은 조건을 요청하면 공유된다. 상품 검색/매장은 5분, 상품 ID는 10분, 재고는 1분 TTL이다. 최대 256항목 및 키·값 직렬화 16MiB, 오래된 항목부터 퇴출한다. 성공만 저장하며 동일 진행 요청은 합친다. 탭이나 브라우저를 추가하지 않는다. -캐시 적중은 quota를 소비하지 않는다. `x-relay-cache: hit/miss/coalesced` 및 health.cache로 확인한다. 서로 다른 검색이 대부분이면 절감 효과는 작다. 현재 OY quota는 분당 300회·UTC 일일 300,000회다. 소비자별 신규 원본 작업은 분당 240회이며, 사용한 원장은 유지한다. REST 캐시도 별도로 존재하며 products5분/stores24시간/inventory10분이므로 외부 REST 응답의 최신성은 두 계층의 영향을 받는다. `_healthCheck`는 REST 캐시를 새로 조회하지만 공통 릴레이 캐시는 계속 적용된다. +캐시 적중은 quota를 소비하지 않는다. `x-relay-cache: hit/miss/coalesced` 및 health.cache로 확인한다. 서로 다른 검색이 대부분이면 절감 효과는 작다. 현재 OY quota는 분당 300회·UTC 일일 300,000회다. 소비자별 신규 원본 작업은 분당 240회이며, 사용한 원장은 유지한다. REST 캐시도 별도로 존재하며 products5분/stores24시간/inventory10분이므로 외부 REST 응답의 최신성은 두 계층의 영향을 받는다. 공개 REST 요청의 `_healthCheck`는 REST 캐시 키만 바꾸며 공통 릴레이 캐시는 계속 적용된다. 운영 인증이 유효한 `x-health-check-force-fresh: true` 요청은 REST 캐시와 OY 내부 캐시를 우회하고, 릴레이에도 `x-relay-cache-bypass: true`를 전달하여 저장 응답과 진행 중인 동일 요청을 재사용하지 않는다. 인증 없는 헤더나 `_healthCheck`만으로 이 우회를 활성화할 수 없다. ## 과거 OY 요청 집중 보호 (1.2.7) @@ -64,7 +64,7 @@ health.cache의 `hits`, `misses`, `expired`, `evicted`는 프로세스 시작 ## GitHub Health Checks 판정과 보존 -quick/full 모두 새 검사 요약을 요청한다. 실패 후 한 번 재시도할 때는 REST 캐시도 우회한다. 공통 OY 릴레이 캐시는 유지한다. HTTP 실패 후 이전 성공 파일을 재사용하지 않으며, 비정상 JSON·빈 검사 목록·모순된 요약은 실패로 처리한다. 각 요청은 최대 240초, 전체 job은 최대 10분이다. 시도별 원본 JSON과 오류 요약은 Actions artifact로 7일 보관한다. +quick/full 모두 운영 인증과 `fresh=true`, `x-health-check-force-fresh: true`를 함께 보내 새 검사 요약을 요청한다. 허용된 원점의 검사 대상에는 운영 인증과 fresh 헤더가 전달되며 REST·OY 내부·공통 릴레이 캐시를 우회한다. 실패 직후 전체 검사를 반복하지 않고, 검증된 분당 중계 한도 429만 만료 후 해당 검사 ID를 한 번 fresh로 재확인한다. HTTP 실패 후 이전 성공 파일을 재사용하지 않으며, 비정상 JSON·빈 검사 목록·모순된 요약은 실패로 처리한다. 각 요청은 최대 240초, 전체 job은 최대 10분이다. 시도별 원본 JSON과 오류 요약은 Actions artifact로 7일 보관한다. CGV는 `CGV_UPSTREAM_UNAVAILABLE`, API HTTP 503, 원본 HTTP 403이 모두 확인될 때만 `degraded`로 분류한다. 이는 해당 기능이 제한된 상태이며 복구를 뜻하지 않는다. 인증 오류, 타임아웃, JSON 파싱 실패 등은 계속 `fail`이다. 선택 데이터가 없는 `skipped` 검사는 정상 요약에 포함될 수 있다. Actions 성공 여부와 별도로 요약의 저하 서비스 목록을 확인해야 한다. 유료 Zyte 재시도는 활성화하지 않는다. From c8cf3bf3c31b51b3c9b67eb0d6c082c5223d86d5 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 12 Oct 2026 09:40:56 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=9A=B4=EC=98=81=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20=EB=A7=88=EC=B9=9C=20=EC=9D=B4=EB=A7=88?= =?UTF-8?q?=ED=8A=B824=20=EB=B3=B5=EA=B5=AC=EB=A7=8C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/oliveyoung-free-relay.md | 4 +- .../plans/2026-10-12-oliveyoung-prewarm.md | 16 - .../2026-10-12-oliveyoung-prewarm-design.md | 17 - scripts/relay/lifecycle.ts | 349 +++++-------- scripts/relay/transport-lifecycle.ts | 51 +- tests/relay/guard-runtime.test.ts | 15 - tests/relay/lifecycle-prewarm.test.ts | 459 ------------------ tests/relay/lifecycle.test.ts | 33 +- tests/relay/transport-lifecycle.test.ts | 3 +- tests/relay/transport-slots.test.ts | 72 --- 10 files changed, 141 insertions(+), 878 deletions(-) delete mode 100644 docs/superpowers/plans/2026-10-12-oliveyoung-prewarm.md delete mode 100644 docs/superpowers/specs/2026-10-12-oliveyoung-prewarm-design.md delete mode 100644 tests/relay/lifecycle-prewarm.test.ts delete mode 100644 tests/relay/transport-slots.test.ts diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md index 2e8bfbee..fc41eed6 100644 --- a/docs/oliveyoung-free-relay.md +++ b/docs/oliveyoung-free-relay.md @@ -86,11 +86,11 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며 ## 상시 운영 보강 -중계는 조회용 브라우저의 한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 실행 시작부터 27분 또는 API 시도 160회에 후보 브라우저 하나를 준비하고, 후보가 준비된 뒤 진행 중 API가 끝난 경계에서 교환합니다. 소유 슬롯은 최대 두 개이며 이전 소유자의 종료 확인 전에는 세 번째 브라우저를 띄우지 않습니다. 현재 관측 RSS가 512MiB를 넘으면 겹친 준비를 건너뛰고 기존 콜드 교체를 사용합니다. 실행 시작부터 30분이 지났거나 API 시도 200회에 도달한 세션에는 새 조회를 보내지 않습니다. 이미 시작한 API는 기존 18초 watchdog 안에서 종료한 뒤 회수하며, 유휴 수명은 30초 주기로 점검합니다. 후보 실패 시 정상 현재 세션을 보존하지만 수명 상한을 연장하지 않습니다. 후보 준비나 메모리 부족으로 콜드 시작이 필요할 때 응답 공백은 여전히 발생할 수 있습니다. 추가 페이지는 회수하며 회수 실패·페이지 crash·Node watchdog timeout은 해당 세션을 폐기합니다. 준비 중에도 정상 현재 세션이 있으면 health는 `ready`, 현재가 없으면 `starting`입니다. 실행 또는 회수의 소유권 불확실성은 `failed`로 고정하며 추가 실행을 차단합니다. 재시작 전 `browser-owner.json`과 `browser-owner-replacement.json`의 이전 소유권을 모두 확인해야 합니다. +중계는 한 브라우저·한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 추가 페이지는 즉시 회수하며 회수 실패·페이지 crash·Node 측 watchdog timeout에는 소유한 브라우저 세션을 폐기합니다. 200회 조회 또는 30분 사용 후 유휴 경계에서 정리하고 교체 세션을 한 번 미리 준비합니다. RSS 한도 회수도 같은 준비 경로를 사용합니다. 이전 소유 프로세스의 회수가 완료된 다음 새 브라우저를 띄우므로 브라우저 두 개를 겹쳐 유지하지 않습니다. 준비 중 health 상태는 `starting`입니다. 준비 실패 시 무한 재시도하지 않고 다음 명시적 요청을 기다립니다. 준비 중 도착한 요청은 여전히 준비 시간을 기다릴 수 있으므로 항상 15초 이내 응답을 보장하는 것은 아닙니다. 브라우저는 별도 guard 프로세스가 소유합니다. 중계 부모가 SIGKILL로 종료되어도 IPC 단절을 감지해 회수합니다. PID·생성 시각·프로세스 그룹을 확인한 소유 프로세스만 종료합니다. macOS에서 별도 그룹으로 분리되는 crashpad 보조 프로세스도 실행별 고유 경로로 식별해 메모리 집계와 회수에 포함합니다. 브라우저에는 GUI 실행에 필요한 환경만 전달하고 운영 토큰은 제외합니다. 정상 종료가 지연되면 확인된 그룹을 강제 종료하고 실제 소멸을 확인합니다. 개인 Chrome을 프로세스 이름으로 일괄 종료하지 않습니다. guard까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다. -소유 브라우저 그룹의 RSS 합계를 시작 전·후, 준비 완료 시 및 30초마다 점검하며 종료 확인 전 회수 중인 그룹도 합산합니다. 1GiB를 넘으면 후보를 먼저 회수하고 현재 그룹 자체가 상한을 넘으면 현재도 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다. `state`는 현재 수명 상태이며 `reason`은 마지막 회수 사유입니다. 준비가 성공해 `state: ready`로 돌아와도 이전 `request-failed` 등이 남을 수 있으므로 `reason`만으로 현재 준비 실패를 판단하지 않습니다. +소유 브라우저 프로세스 그룹의 RSS를 30초마다 점검하며 1GiB를 넘으면 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다. `state`는 현재 수명 상태이며 `reason`은 마지막 회수 사유입니다. 준비가 성공해 `state: ready`로 돌아와도 이전 `request-failed` 등이 남을 수 있으므로 `reason`만으로 현재 준비 실패를 판단하지 않습니다. ### 전체 호출량과 인증 diff --git a/docs/superpowers/plans/2026-10-12-oliveyoung-prewarm.md b/docs/superpowers/plans/2026-10-12-oliveyoung-prewarm.md deleted file mode 100644 index 5b78ce9e..00000000 --- a/docs/superpowers/plans/2026-10-12-oliveyoung-prewarm.md +++ /dev/null @@ -1,16 +0,0 @@ -# 올리브영 교체 준비 구현 계획 - -**Goal:** 정상 브라우저를 준비 중인 후속 브라우저 때문에 먼저 회수하지 않는다. - -**Architecture:** 고정 소유 슬롯 두 개와 후보 준비 Promise 하나로 수명 관리한다. 기존 guard의 소유권 및 회수 규약을 재사용한다. - -**Tech Stack:** TypeScript, Playwright, Node.js, Vitest. - -- [x] `tests/relay/lifecycle-prewarm.test.ts`에 준비 중 정상 요청과 후보 실패 보존 테스트를 추가하고 기존 코드에서 실패를 확인한다. -- [x] `scripts/relay/lifecycle.ts`에 현재/후보 분리, API 실행 사이 교환, 준비 및 수명 상한을 구현한다. -- [x] `tests/relay/transport-slots.test.ts`에 두 마커·슬롯 회수·불확실성 테스트를 추가하고 기존 코드에서 실패를 확인한다. -- [x] `scripts/relay/transport-lifecycle.ts`에 두 고정 슬롯과 초기 마커 검사를 구현한다. -- [x] 실행 전 수명 시작과 512 MiB 준비 여유 테스트의 실패를 확인하고 최소 수정을 적용한다. -- [x] 후보/현재 충돌, 종료 중 늦은 실행, 200회 상한, 합계 RSS, 회수 실패 회귀 테스트를 추가한다. -- [ ] `npm run check`, `npm run test:coverage`, `npm run build`를 실행하고 실패 원인을 수정한다. -- [ ] 부모 에이전트에 diff와 검증 결과를 전달하여 리뷰한다. 이 작업에서는 커밋과 운영 배포를 수행하지 않는다. diff --git a/docs/superpowers/specs/2026-10-12-oliveyoung-prewarm-design.md b/docs/superpowers/specs/2026-10-12-oliveyoung-prewarm-design.md deleted file mode 100644 index cc05cf88..00000000 --- a/docs/superpowers/specs/2026-10-12-oliveyoung-prewarm-design.md +++ /dev/null @@ -1,17 +0,0 @@ -# 올리브영 소유 브라우저 교체 준비 - -2026-10-11 07:29:50 UTC 이후 33회 교체의 준비 공백은 25.8–195.7초, 중앙값 44.6초였다. 원본 운영 검사의 20초 예산보다 길다. 제한 시간을 늘리거나 실패를 성공으로 분류하지 않고 정상 세션을 유지하며 후속 세션을 준비한다. - -## 구조와 제한 - -현재 세션과 준비 후보 하나만 유지한다. 프로세스 시작 시 두 고정 마커 `browser-owner.json`, `browser-owner-replacement.json` 모두 부재를 확인한다. 감시 프로세스의 배타적 마커 생성 및 부모 IPC 단절 회수는 그대로 사용한다. 슬롯은 종료 확인 이후만 재사용하며 실행 또는 회수의 불확실성은 전체 수명을 실패 상태로 고정한다. - -실행 시작 시각부터 27분 또는 API 시도 160회에 후보를 준비한다. 준비된 후보는 API 실행 사이에 교환하고 이전 소유자를 회수한다. 실행 시작부터 30분 이후 또는 API 시도 200회 이후에는 해당 세션에 새 조회를 보내지 않는다. 진행 중 API는 기존 18초 watchdog 안에서 종료한 뒤 회수하며 유휴 수명은 기존 30초 주기로 점검한다. 실패한 후보는 확인된 회수 뒤 정상 현재 세션을 보존하며 자동 준비를 무한 반복하지 않는다. 상한에 도달했거나 현재 세션이 손상된 경우 기존의 명시적 요청 기반 콜드 시작으로 돌아간다. - -겹친 실행은 현재 관측 RSS가 512 MiB 이하인 경우만 허용한다. 시작 전, 후보 소유자 획득 후, 페이지 준비 완료 후 및 30초 주기로 소유자 RSS 합계를 관측한다. 1 GiB 초과 시 후보를 우선 회수하며 현재 자체가 1 GiB를 넘으면 현재도 회수한다. 이는 관측 기반 상한이며 운영체제의 순간 메모리 강제 제한이 아니다. 메모리 여유가 부족하면 콜드 교체 공백이 남는다. 후보 준비 실패나 원본 사이트 차단까지 항상 해결한다는 보장은 하지 않는다. - -페이지 충돌·닫힘·팝업 회수 실패와 RSS 결과는 해당 세션에 귀속된다. 이전 세션의 늦은 결과가 새 세션을 회수하지 않는다. 종료는 준비 중인 실행 결과까지 기다리고 두 소유자 모두에 같은 회수 기한을 적용한다. 종료 시작 이후 승격 및 추가 실행을 금지한다. - -## 검증 - -지연된 후보 준비 중 정상 요청, API 실행 중 승격 금지, 후보 실패 시 현재 보존, 두 마커의 잔존 차단, 늦은 준비 결과와 두 소유자 종료, 충돌 중 같은 후보 대기, RSS 여유 및 합계, 30분/200회 상한, 회수 실패 후 실행 차단을 단위 테스트한다. 기존 감시 프로세스의 부모 단절과 마커 해제 검증도 유지한다. 전체 검사·커버리지 100%·빌드를 수행하며 실제 운영 환경과 인증 및 quota 파일은 구현 단계에서 변경하지 않는다. diff --git a/scripts/relay/lifecycle.ts b/scripts/relay/lifecycle.ts index 83ffd337..5f4e85b3 100644 --- a/scripts/relay/lifecycle.ts +++ b/scripts/relay/lifecycle.ts @@ -1,4 +1,4 @@ -/** 두 소유 슬롯 안에서 준비·교환·회수를 제한합니다. */ +/** 한 브라우저 세션의 재사용·교체·회수를 직렬화합니다. */ import type { Browser, BrowserContext, Page } from 'playwright'; import { createBrowserRunner } from './browser.js'; import type { BrowserRunner } from './oliveyoung.js'; @@ -21,250 +21,159 @@ export async function bounded(work: Promise, ms: number): Promise { clearTimeout(timer!); } } -interface Session { - owner: BrowserOwner; - born: number; - calls: number; - page?: Page; - context?: BrowserContext; - ready: boolean; - attempted: boolean; - retired: boolean; - closing?: Promise; -} -const MAX_AGE = 30 * 60 * 1000; -const PREPARE_AGE = 27 * 60 * 1000; -const MAX_RSS = 2 ** 30; export function createBrowserLifecycle( launch: () => Promise, onEvent?: (reason: string) => void | Promise, ) { - const emit = (why: string) => { + const emit = (reason: string) => { try { - void Promise.resolve(onEvent?.(why)).catch(() => undefined); + void Promise.resolve(onEvent?.(reason)).catch(() => undefined); } catch { - /* 관측 실패는 회수를 방해하지 않습니다. */ + /* 관측 실패는 브라우저 회수에 영향을 주지 않습니다. */ } }; - const sessions = new Set(); - let current: Session | undefined; - let candidate: Session | undefined; - let preparing: Promise | undefined; - let maintenance: Promise | undefined; + let owner: BrowserOwner | undefined; + let page: Page | undefined; + let context: BrowserContext | undefined; let active = false; - let executing = false; let closed = false; let failed: unknown; + let cleanup: Promise = Promise.resolve(); + let born = 0; + let calls = 0; let totalCalls = 0; let rotations = 0; let rssBytes = 0; let reason = 'startup'; - let shutdownDeadline = 0; let timer: ReturnType | undefined; - const check = () => { - if (failed) throw failed; - if (closed) throw new Error('Browser lifecycle unavailable'); - }; - const retire = (session: Session, why: string, deadline = Date.now() + 10000) => { - if (session.closing) return session.closing; - deadline = shutdownDeadline || deadline; - session.retired = true; - session.ready = false; - if (current === session) current = undefined; - if (candidate === session) candidate = undefined; - session.page?.removeAllListeners('crash'); - session.page?.removeAllListeners('close'); - session.context?.removeAllListeners('page'); + let initializing: Promise | undefined; + let replacementPending = false; + const retire = (why: string) => { reason = why; emit(why); - session.closing = bounded(session.owner.close(deadline), Math.max(0, deadline - Date.now())) - .then(() => { - sessions.delete(session); - }) - .catch((error) => { + if (timer) clearInterval(timer); + timer = undefined; + const previous = owner; + if (!previous) return cleanup; + owner = undefined; + page?.removeAllListeners('crash'); + page?.removeAllListeners('close'); + context?.removeAllListeners('page'); + page = undefined; + context = undefined; + const deadline = Date.now() + 10000; + cleanup = bounded(previous.close(deadline), Math.max(0, deadline - Date.now())).catch( + (error) => { failed = error; throw error; - }); - void session.closing.catch(() => undefined); - return session.closing; + }, + ); + // 이벤트 핸들러에서 발생한 실패도 보존하고 다음 요청/종료에 전달합니다. + void cleanup.catch(() => undefined); + return cleanup; }; - const quietly = (session: Session, why: string) => { - void retire(session, why).catch(() => undefined); + const retireQuietly = (why: string) => { + void retire(why).catch(() => undefined); }; - const promote = () => { - if (executing || closed || failed || !candidate?.ready) return; - const next = candidate; - if (Date.now() - next.born >= MAX_AGE) { - quietly(next, 'age-limit'); - return; - } - const previous = current; - candidate = undefined; - current = next; - emit('ready'); - if (previous) { + const ensureSession = async () => { + await cleanup; + if (closed) throw new Error('Browser lifecycle unavailable'); + if (owner && Date.now() - born >= 30 * 60 * 1000) { + await retire('rotation'); rotations++; - quietly(previous, 'rotation'); } - }; - const sample = async () => { - const owned = [...sessions]; - const values = await Promise.all( - owned.map(async (s) => { - try { - return await bounded(s.owner.rss(), 5000); - } catch { - if (!s.retired) await retire(s, 'rss-failed'); - return 0; - } - }), - ); - const live = owned.filter((s) => sessions.has(s)); - rssBytes = owned.reduce((sum, s, i) => sum + (sessions.has(s) ? values[i] : 0), 0); - if (rssBytes > MAX_RSS) { - const preparingSession = live.find((s) => s !== current); - if (preparingSession) await retire(preparingSession, 'rss-limit'); - const index = owned.findIndex((s) => s === current); - if (index >= 0 && values[index] > MAX_RSS) await retire(current!, 'rss-limit'); + if (owner) return; + try { + owner = await launch(); + } catch (error) { + emit('launch-failed'); + throw error; } - }; - const prepare = () => { - if (preparing) return preparing; - if (closed || failed || candidate || sessions.size >= 2) return Promise.resolve(); - const previous = current; - if (previous) previous.attempted = true; - preparing = (async () => { - let session: Session | undefined; - let phase: 'ownership' | 'context' | 'page' | 'navigation' | 'store-wait' | 'readiness' = - 'ownership'; - try { - await Promise.all([...sessions].filter((s) => s.retired).map((s) => s.closing)); - await sample(); - check(); - if (current && rssBytes > MAX_RSS / 2) { - emit('prewarm-skipped-rss'); - return; - } - const born = Date.now(); - let owner: BrowserOwner; - try { - owner = await launch(); - } catch (error) { - failed = error; - emit('launch-failed'); - throw error; - } - session = { owner, born, calls: 0, ready: false, attempted: false, retired: false }; - sessions.add(session); - candidate = session; - check(); - await sample(); - if (session.retired) throw new Error('Browser lifecycle unavailable'); - phase = 'context'; - emit('prepare-context'); - session.context = await bounded(owner.browser.newContext({ locale: 'ko-KR' }), 10000); - if (session.retired) throw new Error('Browser lifecycle unavailable'); - phase = 'page'; - emit('prepare-page'); - session.page = await bounded(session.context.newPage(), 10000); - if (session.retired) throw new Error('Browser lifecycle unavailable'); - const owned = session; - const invalidate = (why: string) => { - if (owned.retired) return; - quietly(owned, why); - if (!active && !preparing) void prepare().catch(() => undefined); - }; - session.context.on('page', (popup) => { - void bounded(popup.close(), 3000).catch(() => invalidate('popup-close-failed')); + born = Date.now(); + calls = 0; + if (closed) { + await retire('shutdown'); + throw new Error('Browser lifecycle unavailable'); + } + try { + context = await bounded(owner.browser.newContext({ locale: 'ko-KR' }), 10000); + // 초기 페이지를 먼저 지정하여 이후 추가 페이지는 모두 닫습니다. + page = await bounded(context.newPage(), 10000); + const sessionOwner = owner; + context.on('page', (popup) => { + void bounded(popup.close(), 3000).catch(() => { + if (owner === sessionOwner) retireQuietly('popup-close-failed'); }); - session.page.on('crash', () => invalidate('page-crash')); - session.page.on('close', () => invalidate('page-closed')); - phase = 'navigation'; - emit('prepare-navigation'); - await bounded( - session.page.goto('https://www.oliveyoung.co.kr/', { - waitUntil: 'domcontentloaded', - timeout: 30000, - }), - 32000, - ); - if (session.retired) throw new Error('Browser lifecycle unavailable'); - phase = 'store-wait'; - emit('prepare-store-wait'); - await bounded(session.page.waitForURL('**/store/**', { timeout: 30000 }), 32000); - phase = 'readiness'; - await sample(); - check(); - if (session.retired) throw new Error('Browser lifecycle unavailable'); - session.ready = true; - promote(); - timer ??= setInterval(() => { - maintenance ??= (async () => { - await sample(); - if (closed || failed) return; - if (current && Date.now() - current.born >= MAX_AGE && !executing) - await retire(current, 'age-limit'); - promote(); - if (current && !current.attempted && Date.now() - current.born >= PREPARE_AGE) - await prepare(); - })() - .catch(() => undefined) - .finally(() => { - maintenance = undefined; - }); - }, 30000); - } catch (error) { - if (session && !closed) { - const timeout = - error instanceof Error && - (error.name === 'TimeoutError' || error.message === 'Browser watchdog timeout'); - emit(`startup-${phase}-${timeout ? 'timeout' : 'error'}`); + }); + page.on('crash', () => retireQuietly('page-crash')); + page.on('close', () => retireQuietly('page-closed')); + await bounded( + page.goto('https://www.oliveyoung.co.kr/', { + waitUntil: 'domcontentloaded', + timeout: 30000, + }), + 32000, + ); + await bounded(page.waitForURL('**/store/**', { timeout: 30000 }), 32000); + if (closed || owner !== sessionOwner) throw new Error('Browser lifecycle unavailable'); + emit('ready'); + timer = setInterval(() => { + if (!active && Date.now() - born >= 30 * 60 * 1000) { + rotations++; + rotate('age-limit'); + return; } - if (session) await retire(session, closed ? 'shutdown' : 'startup-failed'); - throw error; - } - })().finally(() => { - preparing = undefined; + void bounded(sessionOwner.rss(), 5000) + .then((value) => { + if (owner !== sessionOwner) return; + rssBytes = value; + if (value > 2 ** 30) rotate('rss-limit'); + }) + .catch(() => { + if (owner === sessionOwner) retireQuietly('rss-failed'); + }); + }, 30000); + } catch (error) { + await retire('startup-failed'); + throw error; + } + }; + const ensure = () => { + initializing ??= ensureSession().finally(() => { + initializing = undefined; }); - void preparing.catch(() => undefined); - return preparing; + return initializing; }; - const ensure = async () => { - check(); - if (current && (current.calls >= 200 || Date.now() - current.born >= MAX_AGE)) - await retire(current, 'age-limit'); - promote(); - if (!current) { - // 실패한 교체를 무한 반복하지 않고 명시적 요청에서만 재시도합니다. - await Promise.all([...sessions].filter((s) => s.retired).map((s) => s.closing)); - await prepare(); - promote(); - } - check(); - if (!current?.ready) throw new Error('Browser lifecycle unavailable'); + // 교체당 한 번만 미리 준비하고 실패 시 다음 명시적 요청을 기다립니다. + const prepareReplacement = () => { + replacementPending = false; + void ensure().catch(() => undefined); + }; + const rotate = (why: string) => { + replacementPending = true; + retireQuietly(why); + if (!active) prepareReplacement(); }; const run: BrowserRunner = async (path, body) => { if (active) throw new Error('Browser already active'); active = true; - let used: Session | undefined; try { await ensure(); - used = current!; - used.calls++; + const result = await bounded(createBrowserRunner(page!)(path, body), 18000); + calls++; totalCalls++; - executing = true; - if (used.calls >= 160 && !used.attempted) void prepare().catch(() => undefined); - return await bounded(createBrowserRunner(used.page!)(path, body), 18000); + if (calls >= 200 || Date.now() - born >= 30 * 60 * 1000) { + rotations++; + replacementPending = true; + await retire('rotation'); + } + return result; } catch (error) { - if (used) await retire(used, 'request-failed'); + await retire('request-failed'); throw error; } finally { - executing = false; active = false; - if (used && !used.retired && (used.calls >= 200 || Date.now() - used.born >= MAX_AGE)) - quietly(used, 'rotation'); - promote(); - if (!current && !closed && !failed && !preparing) void prepare().catch(() => undefined); + if (replacementPending) prepareReplacement(); } }; return { @@ -272,39 +181,27 @@ export function createBrowserLifecycle( start: ensure, async close() { closed = true; - shutdownDeadline ||= Date.now() + 10000; - for (const session of sessions) quietly(session, 'shutdown'); - if (timer) clearInterval(timer); - timer = undefined; - await preparing?.catch(() => undefined); - await maintenance; - const deadline = shutdownDeadline; - const results = await Promise.allSettled( - [...sessions].map((s) => retire(s, 'shutdown', deadline)), - ); - const rejected = results.find((r) => r.status === 'rejected'); - if (rejected) throw rejected.reason; - if (failed) throw failed; + await initializing?.catch(() => undefined); + await retire('shutdown'); + await cleanup; }, status: () => ({ state: failed ? 'failed' : closed ? 'closed' - : current?.ready - ? 'ready' - : preparing - ? 'starting' + : initializing + ? 'starting' + : owner + ? 'ready' : 'idle', active, - calls: current?.calls || 0, + calls, totalCalls, rotations, rssBytes, - pages: current?.context?.pages().length || 0, + pages: context?.pages().length || 0, reason, - preparing: Boolean(preparing), - owned: sessions.size, }), }; } diff --git a/scripts/relay/transport-lifecycle.ts b/scripts/relay/transport-lifecycle.ts index b8636771..a6cb7746 100644 --- a/scripts/relay/transport-lifecycle.ts +++ b/scripts/relay/transport-lifecycle.ts @@ -1,6 +1,5 @@ /** HTTP 경로에서는 Playwright 모듈 자체를 불러오지 않습니다. */ import { join } from 'node:path'; -import { lstat } from 'node:fs/promises'; export async function createTransportLifecycle( mode: string | undefined, stateDir: string, @@ -14,52 +13,8 @@ export async function createTransportLifecycle( throw new Error('OY_RELAY_TRANSPORT 값이 잘못되었습니다.'); const { createBrowserLifecycle } = await import('./lifecycle.js'); const { launchGuardedBrowser } = await import('./supervisor.js'); - const paths = ['browser-owner.json', 'browser-owner-replacement.json'].map((name) => - join(stateDir, name), + return createBrowserLifecycle( + () => launchGuardedBrowser(join(stateDir, 'browser-owner.json')), + onEvent, ); - const occupied = [false, false]; - let failed: unknown; - const absent = async (path: string) => { - try { - await lstat(path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; - throw error; - } - throw new Error('Browser ownership marker already exists'); - }; - let initial: Promise | undefined; - return createBrowserLifecycle(async () => { - if (failed) throw failed; - const slot = occupied.indexOf(false); - if (slot < 0) throw new Error('Browser slots occupied'); - initial ??= Promise.all(paths.map(absent)).then(() => undefined); - occupied[slot] = true; - try { - await initial; - await absent(paths[slot]); - if (failed) throw failed; - const owner = await launchGuardedBrowser(paths[slot]); - let closing: Promise | undefined; - return { - ...owner, - close(deadline?: number) { - closing ??= owner - .close(deadline) - .then(() => { - occupied[slot] = false; - }) - .catch((error) => { - failed = error; - throw error; - }); - return closing; - }, - }; - } catch (error) { - // 실행 실패도 소유권 불확실성으로 취급하며 어느 슬롯도 우회하지 않습니다. - failed = error; - throw error; - } - }, onEvent); } diff --git a/tests/relay/guard-runtime.test.ts b/tests/relay/guard-runtime.test.ts index 25d71735..6c37748f 100644 --- a/tests/relay/guard-runtime.test.ts +++ b/tests/relay/guard-runtime.test.ts @@ -117,18 +117,3 @@ it('최악 경로에서 프로필 종료가 공유 기한을 넘으면 확인 vi.useRealTimers(); await access(f.marker); }); -it('두 슬롯의 감시자는 같은 부모 단절에서 각각 소유 회수와 마커 해제를 완료한다', async () => { - const slots = await Promise.all([fixture(), fixture()]); - await Promise.all( - slots.map((f) => runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot)), - ); - for (const f of slots) { - f.channel.connected = false; - f.channel.emit('disconnect'); - } - for (const f of slots) { - await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(0)); - expect(f.server.close).toHaveBeenCalledTimes(1); - await expect(access(f.marker)).rejects.toThrow(); - } -}); diff --git a/tests/relay/lifecycle-prewarm.test.ts b/tests/relay/lifecycle-prewarm.test.ts deleted file mode 100644 index 44180be5..00000000 --- a/tests/relay/lifecycle-prewarm.test.ts +++ /dev/null @@ -1,459 +0,0 @@ -import { EventEmitter } from 'node:events'; -import { afterEach, expect, it, vi } from 'vitest'; -import { createBrowserLifecycle } from '../../scripts/relay/lifecycle.js'; -afterEach(() => vi.useRealTimers()); -function session() { - const page = Object.assign(new EventEmitter(), { - goto: vi.fn().mockResolvedValue(undefined), - waitForURL: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ status: 200, body: { status: 'SUCCESS' } }), - }); - const context = Object.assign(new EventEmitter(), { - newPage: vi.fn().mockResolvedValue(page), - pages: () => [page], - }); - const owner = { - browser: { newContext: vi.fn().mockResolvedValue(context) }, - close: vi.fn().mockResolvedValue(undefined), - rss: vi.fn().mockResolvedValue(100), - pid: 1, - }; - return { page, context, owner }; -} -it('교체 준비 중 정상 요청은 기존 페이지에서 즉시 처리하고 준비 완료 뒤 교환한다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - let ready!: () => void; - b.page.waitForURL.mockImplementation( - () => - new Promise((resolve) => { - ready = resolve; - }), - ); - const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); - const life = createBrowserLifecycle(launch); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - expect(launch).toHaveBeenCalledTimes(2); - await life.run('/p', {}); - expect(a.page.evaluate).toHaveBeenCalledTimes(1); - expect(a.owner.close).not.toHaveBeenCalled(); - ready(); - await vi.advanceTimersByTimeAsync(0); - await life.run('/p', {}); - expect(b.page.evaluate).toHaveBeenCalledTimes(1); - expect(a.owner.close).toHaveBeenCalledTimes(1); - await life.close(); - expect(b.owner.close).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); -}); -it('후보 준비 실패는 정상 기존 세션을 보존하고 무한 재시도하지 않는다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - b.page.goto.mockRejectedValue(new Error('navigation')); - const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); - const life = createBrowserLifecycle(launch); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - expect(b.owner.close).toHaveBeenCalledTimes(1); - expect(a.owner.close).not.toHaveBeenCalled(); - await life.run('/p', {}); - await vi.advanceTimersByTimeAsync(60000); - expect(launch).toHaveBeenCalledTimes(2); - expect(life.status().state).toBe('ready'); - await life.close(); -}); -it('기존 RSS가 교체용 절반 예산을 넘으면 겹친 실행을 건너뛴다', async () => { - vi.useFakeTimers(); - const a = session(); - a.owner.rss.mockResolvedValue(2 ** 29 + 1); - const launch = vi.fn().mockResolvedValue(a.owner); - const life = createBrowserLifecycle(launch); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - expect(launch).toHaveBeenCalledTimes(1); - await life.run('/p', {}); - expect(a.page.evaluate).toHaveBeenCalledTimes(1); - await life.close(); -}); -it('실행 시작 시간부터 30분 수명을 계산한다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - let finish!: (owner: typeof a.owner) => void; - const launch = vi - .fn() - .mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve; - }), - ) - .mockResolvedValue(b.owner); - const life = createBrowserLifecycle(launch); - const starting = life.start(); - await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(5 * 60 * 1000); - finish(a.owner); - await starting; - await vi.advanceTimersByTimeAsync(22 * 60 * 1000); - expect(launch).toHaveBeenCalledTimes(2); - await life.close(); -}); -it('후보가 준비되어도 실행 중 API가 끝나기 전에는 교환하지 않는다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - let ready!: () => void, done!: (value: unknown) => void; - b.page.waitForURL.mockImplementation( - () => - new Promise((resolve) => { - ready = resolve; - }), - ); - const life = createBrowserLifecycle( - vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner), - ); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - a.page.evaluate.mockImplementationOnce( - () => - new Promise((resolve) => { - done = resolve; - }), - ); - const request = life.run('/p', {}); - await vi.advanceTimersByTimeAsync(0); - ready(); - await vi.advanceTimersByTimeAsync(0); - expect(a.owner.close).not.toHaveBeenCalled(); - expect(life.status()).toMatchObject({ active: true, owned: 2 }); - done({ status: 200, body: { status: 'SUCCESS' } }); - await request; - expect(a.owner.close).toHaveBeenCalledTimes(1); - await life.close(); -}); -it('후보 준비 중 기존 페이지 충돌은 같은 준비를 기다리고 세 번째 실행을 만들지 않는다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - let ready!: () => void; - b.page.waitForURL.mockImplementation( - () => - new Promise((resolve) => { - ready = resolve; - }), - ); - const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); - const life = createBrowserLifecycle(launch); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - a.page.emit('crash'); - const request = life.run('/p', {}); - await vi.advanceTimersByTimeAsync(0); - expect(launch).toHaveBeenCalledTimes(2); - ready(); - await request; - expect(b.page.evaluate).toHaveBeenCalledTimes(1); - await life.close(); -}); -it('후보와 기존 세션 합계 RSS가 예산을 넘으면 후보부터 회수한다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - a.owner.rss.mockResolvedValue(400 * 2 ** 20); - b.owner.rss.mockResolvedValue(700 * 2 ** 20); - const life = createBrowserLifecycle( - vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner), - ); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - expect(b.owner.close).toHaveBeenCalledTimes(1); - expect(a.owner.close).not.toHaveBeenCalled(); - expect(b.context.newPage).not.toHaveBeenCalled(); - await life.run('/p', {}); - await life.close(); -}); -it('종료는 늦게 준비되는 후보와 정상 기존 소유자를 모두 회수한다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - let finish!: (owner: typeof b.owner) => void; - const launch = vi - .fn() - .mockResolvedValueOnce(a.owner) - .mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - const life = createBrowserLifecycle(launch); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - const closing = life.close(); - finish(b.owner); - await closing; - expect(a.owner.close).toHaveBeenCalledTimes(1); - expect(b.owner.close).toHaveBeenCalledTimes(1); - expect(b.context.newPage).not.toHaveBeenCalled(); - expect(vi.getTimerCount()).toBe(0); -}); -it('이전 회수 실패는 준비된 새 세션이 있어도 추가 요청과 실행을 차단한다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - a.owner.close.mockRejectedValue(new Error('uncertain')); - const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); - const life = createBrowserLifecycle(launch); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - expect(life.status().state).toBe('failed'); - await expect(life.run('/p', {})).rejects.toThrow('uncertain'); - expect(launch).toHaveBeenCalledTimes(2); - await expect(life.close()).rejects.toThrow('uncertain'); - expect(b.owner.close).toHaveBeenCalledTimes(1); -}); -it('후보가 지연되어도 기존 페이지에 200회를 넘는 요청을 보내지 않는다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - let ready!: () => void; - b.page.waitForURL.mockImplementation( - () => - new Promise((resolve) => { - ready = resolve; - }), - ); - const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); - const life = createBrowserLifecycle(launch); - await life.start(); - for (let i = 0; i < 200; i++) await life.run('/p', {}); - expect(a.page.evaluate).toHaveBeenCalledTimes(200); - expect(a.owner.close).toHaveBeenCalledTimes(1); - const next = life.run('/p', {}); - await vi.advanceTimersByTimeAsync(0); - ready(); - await next; - expect(a.page.evaluate).toHaveBeenCalledTimes(200); - expect(b.page.evaluate).toHaveBeenCalledTimes(1); - await life.close(); -}); -it('종료는 후보 탐색 완료를 기다리기 전에 알려진 두 소유자를 회수한다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - let finish!: () => void; - b.page.goto.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - const life = createBrowserLifecycle( - vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner), - ); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - const closing = life.close(); - await vi.advanceTimersByTimeAsync(0); - expect(a.owner.close).toHaveBeenCalledTimes(1); - expect(b.owner.close).toHaveBeenCalledTimes(1); - finish(); - await closing; - expect(b.page.waitForURL).not.toHaveBeenCalled(); -}); -it('교환 후 회수 중인 이전 소유자 RSS도 종료 확인까지 합산한다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - let gone!: () => void, ready!: () => void; - a.owner.rss.mockResolvedValue(400 * 2 ** 20); - b.owner.rss.mockResolvedValue(400 * 2 ** 20); - b.page.waitForURL.mockImplementationOnce( - () => - new Promise((resolve) => { - ready = resolve; - }), - ); - a.owner.close.mockImplementationOnce( - () => - new Promise((resolve) => { - gone = resolve; - }), - ); - const life = createBrowserLifecycle( - vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner), - ); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000 + 29000); - ready(); - await vi.advanceTimersByTimeAsync(1000); - expect(life.status().rssBytes).toBe(800 * 2 ** 20); - gone(); - await life.close(); -}); -it('회수 중 슬롯이 남으면 빠른 다음 160회에도 세 번째 소유자를 실행하지 않는다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - let gone!: () => void; - a.owner.close.mockImplementationOnce( - () => - new Promise((resolve) => { - gone = resolve; - }), - ); - const launch = vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner); - const life = createBrowserLifecycle(launch); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - for (let i = 0; i < 160; i++) await life.run('/p', {}); - expect(launch).toHaveBeenCalledTimes(2); - gone(); - await life.close(); -}); -it('준비 자체가 30분을 넘은 세션은 준비 완료라도 요청에 사용하지 않는다', async () => { - vi.useFakeTimers(); - const a = session(); - let finish!: (owner: typeof a.owner) => void; - const life = createBrowserLifecycle( - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - const starting = life.start(); - const rejected = expect(starting).rejects.toThrow('unavailable'); - await vi.advanceTimersByTimeAsync(0); - expect(life.status().state).toBe('starting'); - vi.setSystemTime(Date.now() + 31 * 60 * 1000); - finish(a.owner); - await rejected; - expect(a.page.evaluate).not.toHaveBeenCalled(); - await life.close(); -}); -it.each(['context', 'page'] as const)( - '종료 중 늦게 생성된 %s 이후 탐색을 계속하지 않는다', - async (stage) => { - vi.useFakeTimers(); - const a = session(); - let finish!: () => void; - if (stage === 'context') - a.owner.browser.newContext.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = () => resolve(a.context); - }), - ); - else - a.context.newPage.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = () => resolve(a.page); - }), - ); - const life = createBrowserLifecycle(vi.fn().mockResolvedValue(a.owner)); - const starting = life.start(); - const rejected = expect(starting).rejects.toThrow('unavailable'); - await vi.advanceTimersByTimeAsync(0); - const closing = life.close(); - finish(); - await rejected; - await closing; - expect(a.page.goto).not.toHaveBeenCalled(); - expect(a.owner.close).toHaveBeenCalledTimes(1); - }, -); -it('후보 준비 완료 시 RSS가 새로 초과하면 현재를 보존하고 후보를 거절한다', async () => { - vi.useFakeTimers(); - const a = session(), - b = session(); - b.owner.rss.mockResolvedValueOnce(100).mockResolvedValue(2 ** 30 + 1); - const life = createBrowserLifecycle( - vi.fn().mockResolvedValueOnce(a.owner).mockResolvedValueOnce(b.owner), - ); - await life.start(); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); - expect(b.page.waitForURL).toHaveBeenCalledTimes(1); - expect(b.owner.close).toHaveBeenCalledTimes(1); - expect(a.owner.close).not.toHaveBeenCalled(); - await life.close(); -}); -it('160번째 준비 실행 실패는 배경 거절을 처리하고 추가 실행을 차단한다', async () => { - const a = session(); - const launch = vi - .fn() - .mockResolvedValueOnce(a.owner) - .mockRejectedValue(new Error('guard uncertain')); - const life = createBrowserLifecycle(launch); - await life.start(); - for (let i = 0; i < 160; i++) await life.run('/p', {}); - await vi.waitFor(() => expect(life.status().state).toBe('failed')); - await expect(life.close()).rejects.toThrow('guard uncertain'); -}); -it('요청 실패 뒤 자동 실행 실패도 처리하며 무한 실행을 만들지 않는다', async () => { - const a = session(); - const launch = vi - .fn() - .mockResolvedValueOnce(a.owner) - .mockRejectedValue(new Error('guard uncertain')); - const life = createBrowserLifecycle(launch); - await life.start(); - a.page.evaluate.mockRejectedValue(new Error('upstream')); - await expect(life.run('/p', {})).rejects.toThrow('upstream'); - await vi.waitFor(() => expect(life.status().state).toBe('failed')); - expect(launch).toHaveBeenCalledTimes(2); - await expect(life.close()).rejects.toThrow('guard uncertain'); -}); -it.each(['context', 'page', 'navigation', 'store-wait'] as const)( - '시작 실패는 고정된 %s 단계와 오류 분류만 기록한다', - async (phase) => { - const a = session(); - const events: string[] = []; - const error = new Error('sentinel-content-not-for-logs'); - error.name = 'TimeoutError'; - if (phase === 'context') a.owner.browser.newContext.mockRejectedValueOnce(error); - if (phase === 'page') a.context.newPage.mockRejectedValueOnce(error); - if (phase === 'navigation') a.page.goto.mockRejectedValueOnce(error); - if (phase === 'store-wait') a.page.waitForURL.mockRejectedValueOnce(error); - const life = createBrowserLifecycle(vi.fn().mockResolvedValue(a.owner), (e) => { - events.push(e); - }); - await expect(life.start()).rejects.toThrow(error); - expect(events).toContain(`prepare-${phase}`); - expect(events).toContain(`startup-${phase}-timeout`); - expect(events).toContain('startup-failed'); - expect(events.join(' ')).not.toContain('sentinel-content'); - await life.close(); - }, -); -it('시작 오류의 임의 문자열은 기록하지 않고 error로 분류한다', async () => { - const a = session(); - const events: string[] = []; - a.page.goto.mockRejectedValueOnce('arbitrary-input'); - const life = createBrowserLifecycle(vi.fn().mockResolvedValue(a.owner), (e) => { - events.push(e); - }); - await expect(life.start()).rejects.toBe('arbitrary-input'); - expect(events).toContain('startup-navigation-error'); - expect(events.join(' ')).not.toContain('arbitrary-input'); - await life.close(); -}); -it('Node 시작 watchdog은 navigation timeout으로 기록한다', async () => { - vi.useFakeTimers(); - const a = session(); - const events: string[] = []; - a.page.goto.mockImplementationOnce(() => new Promise(() => {})); - const life = createBrowserLifecycle(vi.fn().mockResolvedValue(a.owner), (e) => { - events.push(e); - }); - const starting = life.start(); - const rejected = expect(starting).rejects.toThrow('watchdog'); - await vi.advanceTimersByTimeAsync(32000); - await rejected; - expect(events).toContain('startup-navigation-timeout'); - await life.close(); -}); diff --git a/tests/relay/lifecycle.test.ts b/tests/relay/lifecycle.test.ts index 6df31009..4b006d05 100644 --- a/tests/relay/lifecycle.test.ts +++ b/tests/relay/lifecycle.test.ts @@ -22,13 +22,13 @@ function fixture() { const launch = vi.fn().mockResolvedValue(owner); return { page, context, owner, launch }; } -it('반복 요청은 페이지를 재사용하고 준비된 교체 뒤 이전 세션을 회수한다', async () => { +it('반복 요청은 한 페이지를 재사용하고200회 뒤 이전 세션 종료를 확인한 후 교체한다', async () => { const f = fixture(); const life = createBrowserLifecycle(f.launch); for (let i = 0; i < 201; i++) await life.run('/p', {}); expect(f.context.newPage).toHaveBeenCalledTimes(2); expect(f.owner.close).toHaveBeenCalledTimes(1); - expect(f.owner.close.mock.invocationCallOrder[0]).toBeGreaterThan( + expect(f.owner.close.mock.invocationCallOrder[0]).toBeLessThan( f.launch.mock.invocationCallOrder[1], ); await life.close(); @@ -125,7 +125,7 @@ it('시작 중 종료는 늦게 도착한 소유 브라우저도 회수한다', const life = createBrowserLifecycle(f.launch); const pending = life.start(); const rejected = expect(pending).rejects.toThrow('unavailable'); - await vi.waitFor(() => expect(f.launch).toHaveBeenCalledTimes(1)); + await Promise.resolve(); const closing = life.close(); finish(f.owner); await rejected; @@ -198,10 +198,9 @@ it('이전 세션 팝업 및 RSS 작업은 새 세션을 회수하지 않는다' fail = reject; }), }); - const closing = life.close(); + await life.close(); rss(2 ** 30 + 1); fail(new Error('old popup')); - await closing; await vi.advanceTimersByTimeAsync(0); expect(f.owner.close).toHaveBeenCalledTimes(1); }); @@ -218,9 +217,8 @@ it('이전 세션 RSS 실패는 종료 후 추가 작업을 만들지 않는다' }), ); await vi.advanceTimersByTimeAsync(30000); - const closing = life.close(); + await life.close(); fail(new Error('old')); - await closing; await vi.advanceTimersByTimeAsync(0); expect(vi.getTimerCount()).toBe(0); }); @@ -237,9 +235,9 @@ it('자동 교체 중 종료는 준비 중인 브라우저를 회수하고 타 finish = resolve; }), ); - await vi.advanceTimersByTimeAsync(27 * 60 * 1000); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); expect(f.launch).toHaveBeenCalledTimes(2); - expect(life.status()).toMatchObject({ state: 'ready', preparing: true }); + expect(life.status().state).toBe('starting'); const closing = life.close(); finish(); await closing; @@ -255,7 +253,7 @@ it('자동 교체 준비 실패는 무한 재시작 없이 유휴 상태에서 f.page.goto.mockRejectedValueOnce(new Error('navigation')); await vi.advanceTimersByTimeAsync(30 * 60 * 1000); expect(f.launch).toHaveBeenCalledTimes(2); - expect(life.status()).toMatchObject({ state: 'idle', reason: 'age-limit' }); + expect(life.status()).toMatchObject({ state: 'idle', reason: 'startup-failed' }); await vi.advanceTimersByTimeAsync(60 * 60 * 1000); expect(f.launch).toHaveBeenCalledTimes(2); await life.close(); @@ -292,7 +290,7 @@ it('자동 교체의 종료 확인 실패는 새 브라우저 실행을 차단 await life.start(); f.owner.close.mockRejectedValue(new Error('uncertain')); await vi.advanceTimersByTimeAsync(30 * 60 * 1000); - expect(f.launch).toHaveBeenCalledTimes(2); + expect(f.launch).toHaveBeenCalledTimes(1); expect(life.status().state).toBe('failed'); await expect(life.close()).rejects.toThrow('uncertain'); }); @@ -327,9 +325,9 @@ it('자동 준비 중 팝업 회수 실패는 준비 완료로 보고하지 않 finish(); await vi.advanceTimersByTimeAsync(0); expect(life.status().state).toBe('idle'); + expect(vi.getTimerCount()).toBe(0); expect(f.launch).toHaveBeenCalledTimes(2); await life.close(); - expect(vi.getTimerCount()).toBe(0); }); it('브라우저 시작/회수 사유를 기록하고 관측기 실패를 격리한다', async () => { const f = fixture(); @@ -340,14 +338,7 @@ it('브라우저 시작/회수 사유를 기록하고 관측기 실패를 격리 }); await life.start(); await life.close(); - expect(events).toEqual([ - 'prepare-context', - 'prepare-page', - 'prepare-navigation', - 'prepare-store-wait', - 'ready', - 'shutdown', - ]); + expect(events).toEqual(['ready', 'shutdown']); const second = createBrowserLifecycle(f.launch, async () => { throw Error('async log'); }); @@ -366,7 +357,7 @@ it('브라우저 프로세스 실행 실패도 별도 사건으로 기록한다' ); await expect(life.start()).rejects.toThrow('launch'); expect(events).toContain('launch-failed'); - await expect(life.close()).rejects.toThrow('launch'); + await life.close(); }); it('수명 계층이 종료 시작 때 하나의 기한을 생성한다', async () => { vi.useFakeTimers(); diff --git a/tests/relay/transport-lifecycle.test.ts b/tests/relay/transport-lifecycle.test.ts index 3a994410..91ae3129 100644 --- a/tests/relay/transport-lifecycle.test.ts +++ b/tests/relay/transport-lifecycle.test.ts @@ -12,8 +12,7 @@ it('defaults to the existing browser lifecycle and forwards observer/ownership p const observer = vi.fn(); expect(await createTransportLifecycle(undefined, '/state', observer)).toBe('browser'); const [launch, observed] = mocks.browser.mock.calls[0] as unknown as [() => unknown, unknown]; - mocks.launch.mockResolvedValue({ close: vi.fn().mockResolvedValue(undefined) }); - await launch(); + launch(); expect(mocks.launch).toHaveBeenCalledWith('/state/browser-owner.json'); expect(observed).toBe(observer); }); diff --git a/tests/relay/transport-slots.test.ts b/tests/relay/transport-slots.test.ts deleted file mode 100644 index bace3072..00000000 --- a/tests/relay/transport-slots.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ launch: vi.fn(), create: vi.fn() })); -vi.mock('../../scripts/relay/lifecycle.js', () => ({ createBrowserLifecycle: mocks.create })); -vi.mock('../../scripts/relay/supervisor.js', () => ({ launchGuardedBrowser: mocks.launch })); -import { createTransportLifecycle } from '../../scripts/relay/transport-lifecycle.js'; -const dirs: string[] = []; -afterEach(async () => { - vi.clearAllMocks(); - for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); -}); -async function fixture() { - const dir = await mkdtemp(join(tmpdir(), 'oy-slots-')); - dirs.push(dir); - await createTransportLifecycle('browser', dir); - const launch = mocks.create.mock.calls.at(-1)![0] as () => Promise<{ close(): Promise }>; - const close = vi.fn().mockResolvedValue(undefined); - mocks.launch.mockImplementation(async () => ({ close })); - return { dir, launch, close }; -} -it('두 고정 마커를 번갈아 사용하고 회수 확인 전 세 번째 실행을 거절한다', async () => { - const f = await fixture(); - const a = await f.launch(), - b = await f.launch(); - expect(mocks.launch.mock.calls.map((x) => x[0])).toEqual([ - join(f.dir, 'browser-owner.json'), - join(f.dir, 'browser-owner-replacement.json'), - ]); - await expect(f.launch()).rejects.toThrow('slots'); - await a.close(); - await f.launch(); - expect(mocks.launch.mock.calls[2][0]).toBe(join(f.dir, 'browser-owner.json')); - await b.close(); -}); -it('어느 마커에든 이전 소유권이 남으면 빈 슬롯을 선택하지 않는다', async () => { - const f = await fixture(); - await writeFile(join(f.dir, 'browser-owner-replacement.json'), '{}'); - await expect(f.launch()).rejects.toThrow('ownership'); - expect(mocks.launch).not.toHaveBeenCalled(); -}); -it('회수 불확실성과 실행 실패는 이후 모든 실행을 차단한다', async () => { - const f = await fixture(); - const a = await f.launch(); - f.close.mockRejectedValue(new Error('uncertain')); - await expect(a.close()).rejects.toThrow('uncertain'); - await expect(f.launch()).rejects.toThrow('uncertain'); - const g = await fixture(); - mocks.launch.mockRejectedValue(new Error('launch uncertain')); - await expect(g.launch()).rejects.toThrow('launch uncertain'); - await expect(g.launch()).rejects.toThrow('launch uncertain'); -}); -it('마커 디렉터리 검사 오류는 부재로 해석하지 않는다', async () => { - const f = await fixture(); - const file = join(f.dir, 'file'); - await writeFile(file, 'x'); - await createTransportLifecycle('browser', file); - const launch = mocks.create.mock.calls.at(-1)![0] as () => Promise; - await expect(launch()).rejects.toThrow(); - expect(mocks.launch).not.toHaveBeenCalled(); -}); -it('다른 슬롯 검사 대기 중 회수 실패가 확인되면 실행하지 않는다', async () => { - const f = await fixture(); - const a = await f.launch(); - f.close.mockRejectedValue(new Error('uncertain')); - const closing = a.close(); - const denied = f.launch(); - await expect(closing).rejects.toThrow('uncertain'); - await expect(denied).rejects.toThrow('uncertain'); - expect(mocks.launch).toHaveBeenCalledTimes(1); -});