mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
* docs(flight-ticket-search): register skill in README table and add feature guide PR #224 머지 시 README "어떤 걸 할 수 있나" 표와 "포함된 기능" 리스트, 그리고 docs/features/flight-ticket-search.md 가이드가 등록되지 않아 main에 있는 다른 모든 스킬과 달리 사용자/에이전트가 README만 봐서는 이 스킬을 발견할 수 없는 상태였다. 누락분을 hotfix로 보강한다. - README 표에 `flight-ticket-search` 행 추가 (마이리얼트립 옆 항공 클러스터) - README "포함된 기능" 리스트에 가이드 링크 추가 - docs/features/flight-ticket-search.md 신규 작성: · 사용 시나리오, 구현 표면(fast-flights==2.2, 사용자 venv 격리) · search / compare-month / compare-range / compare-years CLI 예시 · 응답 필드, IATA 입력 가이드, 예약 링크 정책 · 검증된 노선 목록, 실패 모드, 비범위, 출처 검증: - node --test scripts/skill-docs.test.js → 138/138 pass - ./scripts/validate-skills.sh → skill layout looks valid 코드 변경 없음 → changeset 불필요. * feat(daiso-product-search): replace blocked-API fallback with Bearer token auth selStrPkupStck는 더 이상 차단 상태가 아니며, /api/auth/request로 비로그인 JWT를 발급받아 AES-128-CBC(키: PRE_AUTH_ENC_KEY)로 암호화한 Bearer 토큰으로 접근한다. 403 응답 시 토큰을 재발급해 1회 재시도한다. pickupEligibility(selPkupStr) 폴백 로직은 제거했다. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Preserve Daiso pickup answers when Bearer auth degrades Keep exact stock lookup on the official Bearer-token path while restoring the public selPkupStr fallback for repeated auth blocks. Constraint: PR #250 review required Bearer auth to remain primary without removing the resilient pickup eligibility API. Rejected: Throwing after the retry | it collapses callers back to a brittle single upstream-auth dependency. Confidence: high Scope-risk: narrow Directive: Keep pickupStock quantity semantics separate from pickupEligibility yes/no fallback. Tested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100. Not-tested: Live forced 403 from Daiso upstream; covered with injected fetch regression tests. * Prove Daiso stock retry sends auth headers Strengthen the retry regression so the Bearer-token contract cannot regress while still returning success from mocked stock responses.\n\nConstraint: PR #250 review requested explicit Authorization, X-DM-UID, and request body assertions on the retry path.\nRejected: Counting requests only | it allowed header/body regressions to pass.\nConfidence: high\nScope-risk: narrow\nDirective: Keep auth-header assertions on both initial and retry stock requests when editing this flow.\nTested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100; repeated-403 fixture probe.\nNot-tested: Live repeated upstream 403 because forcing Daiso production auth failure is not available without changing upstream state. * Preserve Daiso caller headers through Bearer stock lookup Keep advanced caller headers on the authenticated stock endpoint while generated Bearer and X-DM-UID values remain authoritative. Document the degraded selPkupStr fallback order in skill and source docs so the public workflow matches the restored API surface.\n\nConstraint: PR #250 review required resilient Bearer-primary stock lookup plus selPkupStr fallback and header/body contract coverage.\nRejected: Replacing caller headers with only auth headers | It regressed tracing/test-control header pass-through.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Authorization and X-DM-UID generated by the auth flow even when callers provide same-named headers.\nTested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; node --test scripts/skill-docs.test.js; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100.\nNot-tested: Forced live upstream repeated 403; covered by injected fixture tests. * fix(danawa-price-search): capture .ico.* payment-condition badges and surface as row labels PR #226 row 파서에 결제조건 배지(`.ico.cash`/`.ico.point`/`.ico.coupon`/`.ico.card`) selector가 누락돼, 카드 결제 불가능한 현금/쿠폰/포인트 전용가가 일반 최저가로 노출되는 결함을 고친다. - `offers()` row 파싱부에 결제조건 배지 화이트리스트 캡처 블록 추가 (클래스 `cash`/`point`/`coupon`/`discount`/`card`/`membership` 또는 텍스트 `현금`/`포인트`/`쿠폰`/`할인`만 인정 — 빠른배송/안내/상품리뷰 노이즈 차단) - row dict 신규 필드 6개: `payment_badges`, `cash_only`, `point_only`, `coupon_only`, `card_only_badge`, `is_conditional_price` - 반환 dict에 `normal_count`, `conditional_count` 추가 - `SKILL.md` / `docs/features/danawa-price-search.md` 갱신 (Output shape · Response style · Workflow · Failure modes에 결제조건 정책과 표 예시 명시) 정렬 정책은 그대로 `total_price` 단일 기준이며, 결제조건은 row 단위 플래그/라벨로만 노출해 호출자가 결제수단에 맞춰 직접 판단하도록 한다. 회귀 (pcode=75001853, 갤럭시 S25 256GB 자급제 `offers --limit 5`): - 1위 킴스클럽 979,000원 / `cash_only=True` / `payment_badges=["현금"]` - 2위 롯데ON 1,072,080원 / `cash_only=False` / `payment_badges=[]` - 3~5위 일반가 row 모두 `payment_badges` 빈 리스트 (노이즈 0건) Closes #252 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Ensure captured Danawa payment badges stay conditional Classify every whitelisted payment badge into normalized condition types so callers cannot count captured discount, membership, or text-only card rows as normal prices. Constraint: PR #253 review required TDD follow-up on feature/#252 without changing total_price sorting.\nRejected: Removing discount and membership from the whitelist | would lose Danawa condition labels already captured by the parser.\nConfidence: high\nScope-risk: narrow\nDirective: Keep payment_badge whitelist and payment_condition_types in sync whenever adding new badge classes or text keywords.\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_danawa_price_search; live offers 75001853 --limit 5; npm run lint; npm run typecheck; npm run test; architect verification CLEAR.\nNot-tested: Danawa markup variants not represented by current live page or synthetic badge fixtures. * Keep icon-only Danawa payment badges visible Class-only Danawa payment icons can carry eligibility information without visible text, so synthesize display labels from the same normalized condition map used for types and booleans. This keeps raw row labels, condition fields, and returned-window counts aligned for downstream table renderers.\n\nConstraint: PR #253 review follow-up requires TDD coverage before parser changes.\nRejected: Leaving payment_badges text-only | icon-only conditional rows would still render without visible payment labels.\nConfidence: high\nScope-risk: narrow\nDirective: Derive future payment badge labels, types, and booleans from one canonical mapping.\nTested: python3 -m py_compile danawa-price-search/scripts/danawa_search.py scripts/test_danawa_price_search.py; PYTHONPATH=.:scripts python3 -m unittest scripts.test_danawa_price_search; python3 danawa-price-search/scripts/danawa_search.py offers 75001853 --limit 5; npm run lint; npm run typecheck; npm run test\nNot-tested: Danawa icon-only markup was verified with synthetic fixtures rather than a live page snapshot. * Merge pull request #249 from NomaDamas/feature/#248 Feature/#248 * Restore SH notice lookup without proxy policy drift Reintroduce SH notice search as a direct public HTML client so the skill complies with the free-API proxy boundary while preserving verifiable keyword, pagination, and attachment behavior. Constraint: i-sh.co.kr board is public unauthenticated HTML, so k-skill-proxy must not host the scraper.\nRejected: Re-adding /v1/sh-notice proxy routes | public HTML scraping in proxy violates repository policy.\nConfidence: high\nScope-risk: moderate\nDirective: Keep SH public HTML access local/direct unless a key-required official free API is discovered and documented.\nTested: npm run ci; npm run lint --workspace sh-notice-search; npm test --workspace sh-notice-search; live SH smoke for 행복주택, 매입임대, 신혼희망타운, page 1/page 5, 1/6/9/11/0 attachment details.\nNot-tested: authenticated SH flows, 청약 application/submission, direct attachment downloads. * Preserve public SH helper semantics Route exported URL builders through the same normalization as the CLI/API so natural category aliases cannot bypass srchTp title narrowing or category mapping.\n\nConstraint: PR #254 review found exported helper callers could pass Korean/English public category inputs and get broken or broadened SH URLs.\nRejected: Keep normalized-only fast paths | exported helpers are public API and must protect natural inputs.\nConfidence: high\nScope-risk: narrow\nDirective: Keep exported helper behavior aligned with normalizeSearchOptions and normalizeDetailOptions when adding new public aliases.\nTested: npm test --workspace sh-notice-search; npm run lint --workspace sh-notice-search; npm run typecheck; npm run ci; node helper smoke for 임대 search/detail URLs.\nNot-tested: Live SH network smoke was not rerun for this helper-only change. * Preserve SH parser helper aliases Route exported parser helpers through the same public normalizers used by the SH fetch and URL-builder APIs so natural category aliases stay consistent across the package surface. Constraint: PR #254 Round 2 review found parser helpers still treated raw category aliases as pre-normalized inputs. Rejected: Keep parser helpers normalized-only | inconsistent with exported URL builders and public helper ergonomics. Confidence: high Scope-risk: narrow Directive: Keep exported SH helper entry points on canonical normalizeSearchOptions/normalizeDetailOptions unless a separate internal-only API is introduced. Tested: npm test --workspace sh-notice-search; npm run lint --workspace sh-notice-search; npm run typecheck; npm pack --workspace sh-notice-search --dry-run; npm run ci; parser smoke for Korean 임대 list/detail helpers; Ralph architect verification CLEAR; post-deslop regression npm run ci Not-tested: Live SH network smoke for this follow-up; fixture and injected-fetch coverage exercised the helper contract. * Make SH parser failures explicit Warn when SH returns block or maintenance HTML without the expected public board markup, and constrain exposed preview links to the SH converter origin/path.\n\nConstraint: Round 3 review required TDD coverage for block/maintenance HTML and untrusted preview URLs.\nRejected: Throwing on unexpected HTML | Existing parser helpers return partial fixture-friendly results, so warnings preserve compatibility while exposing failure evidence.\nConfidence: high\nScope-risk: narrow\nDirective: Keep SH public HTML lookup direct; do not add proxy routing unless a key-required official free API is adopted.\nTested: npm run lint --workspace sh-notice-search; npm test --workspace sh-notice-search; npm run typecheck; npm pack --workspace sh-notice-search --dry-run; npm run ci; Node smoke for blocked HTML warnings and external preview filtering.\nNot-tested: Live blocked/NetFunnel SH response, because no live blocked page was available during implementation. * ci: install beautifulsoup4 so danawa price search tests can import bs4 The new scripts/test_danawa_price_search.py imports danawa_search.py, which requires beautifulsoup4. CI only runs npm ci, so the bs4 import fails with 'beautifulsoup4 is required: python -m pip install beautifulsoup4' and the validate job exits with code 1. Install beautifulsoup4 via pip before running npm run ci so the Python test suite can import danawa_search and run the new payment badge regression tests. * Revert "ci: install beautifulsoup4 so danawa price search tests can import bs4" This reverts commit8330e5adf7. * test: install beautifulsoup4 inside npm test before Python tests The new scripts/test_danawa_price_search.py imports danawa_search.py, which requires beautifulsoup4. CI runs npm ci + npm run ci and does not install Python packages, so the bs4 import fails at module load. Install beautifulsoup4 via 'pip install --user' as the first step of the test script so it is available when Python unittests import the danawa helper. Local dev environments are unaffected because pip install is idempotent and quiet. * feat(qa-bot): add k-skill-qa-bot under tools/ External macOS daemon that clones NomaDamas/k-skill main every 3 days, runs each skill through codex exec, has an LLM judge grade pass/fail/skip via codex exec --output-schema, and files dedup'd GitHub issues for true failures. Layout: - install.sh copies tools/k-skill-qa-bot/ to ~/.local/share/k-skill-qa-bot/ and registers a LaunchAgent at ~/Library/LaunchAgents/. - update-clone.sh has a hard guard: refuses any K_SKILL_CLONE outside K_QA_HOME/k-skill-clone unless ALLOW_EXTERNAL_CLONE_TARGET=1. - Force-skip 10 destructive/login-required skills (ktx-booking, srt-booking, catchtable-sniper, kakaotalk-mac, hipass-receipt, toss-securities, etc.) so the bot never triggers reservation abuse. - Deprecated skills (strike-through + 지원 중단 in README) auto-detected and skipped, never failed. - First-run safety: CREATE_ISSUES=false by default. - mkdir-based concurrency lock with atomic stale reclaim. - Issue dedup: sha1(skill_name + symptom_class)[:12] body marker. - Deterministic gates override LLM judge to FAIL on exit_code != 0, missing VERDICT line, or near-timeout duration. * Support nearby ER status checks Add an E-Gen based emergency-room skill that resolves a user location, queries the public nearby emergency-room list, and reports operation flags while documenting that exact remaining bed counts are not exposed by this surface. Constraint: Issue #255 requested NEMC emergency bed status using public monitoring/E-Gen surfaces. Rejected: Scraping private monitoring dashboards or claiming exact bed utilization | public endpoints expose operation flags, not per-hospital remaining bed counts. Confidence: high Scope-risk: narrow Directive: Preserve the public-data limitation text unless a verified official bed-count endpoint is added. Tested: npm run lint --workspace emergency-room-beds; npm test --workspace emergency-room-beds; node --test scripts/skill-docs.test.js; npm run typecheck; npm pack --workspace emergency-room-beds --dry-run; ./scripts/validate-skills.sh; live E-Gen coordinate smoke. Not-tested: npm run ci end-to-end due local Python 3.14 pip/pyexpat import error before tests. * Prevent ER status ambiguity from reaching users Constraint: Health-adjacent public E-Gen/Kakao data can be absent, delayed, schema-drifted, or partially unknown. Rejected: Mapping all non-Y operation flags to false | It misrepresents missing upstream data as a negative operating status. Rejected: Treating unknown E-Gen payloads as empty results | It hides upstream failure behind a false no-results response. Confidence: high Scope-risk: narrow Directive: Keep unknown health availability data explicit and preserve upstream failure evidence. Tested: npm run lint --workspace emergency-room-beds; npm test --workspace emergency-room-beds; node --test scripts/skill-docs.test.js; npm run typecheck; npm pack --workspace emergency-room-beds --dry-run; ./scripts/validate-skills.sh; direct Node smoke for tri-state/schema/coordinate guards. Not-tested: npm run ci due pre-existing local Python 3.14 pyexpat/libexpat bootstrap failure noted on PR. Co-authored-by: OmX <omx@oh-my-codex.dev> * fix(ci): exclude tools/ from skill validator The tools/ directory hosts repo tooling (e.g. k-skill-qa-bot), not skills, so validate-skills.sh should skip it like other non-skill top-level directories. * 영화관 검색 스킬 추가 (#260) * Add korean cinema search skill * Document playDate for cinema skill * feat(kstartup-search): 창업진흥원 K-Startup 조회 스킬 + 프록시 라우트 4종 (#259) * feat(kstartup-search): 창업진흥원 K-Startup 조회 스킬과 프록시 라우트 추가 공공데이터포털 dataset 15125364 (창업진흥원_K-Startup(사업소개,사업공고,콘텐츠 등)_조회서비스) 의 4개 endpoint 를 k-skill-proxy 경유로 조회하는 스킬을 추가한다. - 신규 라우트: GET /v1/kstartup/{business-info,announcements,contents,statistics} - 각각 getBusinessInformation01/getAnnouncementInformation01/getContentInformation01/ getStatisticalInformation01 으로 중계 - ServiceKey 는 서버 측 DATA_GO_KR_API_KEY 로 주입, returnType=json 강제 - 정상 응답만 캐시, data.go.kr 에러 envelope (resultCode != "00", errMsg 등) 은 캐시 우회 - helper: kstartup-search/scripts/run_kstartup.py (stdlib only) - 일반 조회는 hosted proxy 사용 → 사용자 키 불필요 - --direct 옵션은 사용자가 본인 KSKILL_KSTARTUP_API_KEY (혹은 DATA_GO_KR_API_KEY) 로 upstream 직접 호출 + --dry-run 시 키 redact - 입력 검증: page/perPage 정수·범위, YYYYMMDD 날짜 + 시작일 ≤ 종료일, Y/N 대문자화, 텍스트 필드 길이 상한, biz_yr 4자리 - 테스트: k-skill-proxy 서버 테스트 10건 신규 (normalizer, 라우트, 캐시 분리, returnType=json 강제, 503/400/502, 키 누수 회귀), Python unittest 13건 - 문서: SKILL.md, docs/features/kstartup-search.md, README 표/리스트, docs/sources.md, .changeset/kstartup-search.md (k-skill-proxy minor) * docs(kstartup-search): docs/setup·security·k-skill-setup·proxy README 에 K-Startup 항목 추가 seoul-density · KOSIS · NTS 선례와 동일한 위치·문구로 다음을 보강한다. - docs/setup.md: dotenv 예시에 KSKILL_KSTARTUP_API_KEY 추가, credential 표에 K-Startup 행 추가, "다음에 볼 문서" 리스트 추가 - docs/security-and-secrets.md: standard variable names 에 KSKILL_KSTARTUP_API_KEY 추가, hosted proxy 사용 스킬 목록·proxy 운영 prose 에 K-Startup 추가, dotenv 예시 추가 - k-skill-setup/SKILL.md: credential resolution prose 와 시크릿 요약 표에 K-Startup 안내 추가 - packages/k-skill-proxy/README.md: 라우트 목록에 /v1/kstartup/{business-info,announcements,contents,statistics} 추가 - docs/features/k-skill-proxy.md: 라우트 목록에 같은 4개 추가 * fix(kstartup-search): strict calendar-date validation in Python helper validate_yyyymmdd() previously only checked month in [1,12] and day in [1,31], which accepted impossible dates like 20240230 or 20240431 in --direct mode. The proxy-side normalizer in packages/k-skill-proxy/src/kstartup.js already uses Date.UTC() to reject such inputs, so this aligns the --direct path with the proxy path and eliminates validator drift. Uses datetime.date(year, month, day) and raises HelperError on ValueError. Adds regression test covering impossible calendar dates (Feb 30, Apr 31, month 13, day 0) and the leap-year boundary (2024-02-29 valid, 2023-02-29 not). --------- Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com> * fix(qa-bot): upgrade judge to gpt-5.5 and run codex with sandbox bypass PR #257 follow-up. Two changes: 1. JUDGE_MODEL default: gpt-5.4-mini -> gpt-5.5 The cheaper judge was misclassifying every wrong-output verdict because the offline matcher fell through to the dumb 'VERDICT: FAIL in transcript' check. Re-running the same 10 historical fail cases with gpt-5.5 + real LLM judge correctly reclassified 7 of them as pass (the codex agent actually accomplished the skill goal) and the remaining 3 as network-error / partial-success / skip with accurate reasons. 2. Drop -s read-only, add --dangerously-bypass-approvals-and-sandbox The read-only codex sandbox was triggering spurious DNS resolution failures inside the test runs (host blocked at the syscall level even for legitimate proxy / public-API calls). Live re-test with the bypass flag and provider pin produced clean transcripts: cheap-gas-nearby, daangn-realty-search, han-river-water-level, naver-news-search, naver-shopping-search, seoul-density, seoul-subway-arrival all PASS. The QA bot is sandboxed externally by launchd anyway. 3. New CODEX_PROVIDER env (default: openai) Lets users pin the codex model_provider explicitly so the bot does not accidentally route through a private OpenAI-compatible proxy that may not have keys registered for all model names. * Add Ohou today deal skill * fix spacing in package.json * fix(qa-bot): per-skill test_prompt overrides and smarter judge 11 skills that need specific inputs (not just a 'demonstrate' query) now ship with a hardcoded test_prompt in config/skill-overrides.yml: flight-ticket-search ICN -> NRT, 2026-08-20 one-way nts-business-registration 124-81-00998 (Samsung Electronics) korean-stock-search 005930 Samsung 5-day quote joseon-sillok-search 키워드 훈민정음 korean-law-search 산업안전보건법 제5조 library-book-search 코스모스 칼 세이건 lotto-results latest round k-schoollunch-menu 서울특별시교육청 초등학교 오늘 식단 delivery-tracking CJ dummy invoice (negative case ok) ticket-availability YES24 / 인터파크 sample zipcode-search 서울특별시 강남구 테헤란로 152 These were previously synthesized from the SKILL.md first When-to-use bullet, which is a one-line teaser without concrete inputs. The agent would then either ask the user for the missing input (partial-success) or fall back to a generic demo (often producing a VERDICT: FAIL response). Both got mis-classified as fail by the judge. qa_utils.synthesize_test_prompt now honors default_inputs.test_prompt as a verbatim override (only appending the VERDICT line if the override does not already include it). Two additional fixes for negative-case correctness: 1. judge-prompt.md: explicitly tells the judge that the agent's literal VERDICT: PASS / VERDICT: FAIL is just a hint, not binding. A skill that correctly returns 'no such business number' or 'invoice not found' for a deliberately invalid input is PASS, not fail. 2. judge-skill.py: drop the deterministic gate that flipped pass to fail when 'VERDICT: PASS' literal was missing from the transcript. That gate was producing false fails for negative-case tests where the agent correctly responded with VERDICT: FAIL because the skill rejected an invalid input. The judge LLM (gpt-5.5) is now trusted to evaluate the transcript against the SKILL.md 'Done when' criteria. Verified live: - nts-business-registration with valid number -> pass/success (0.99) - nts-business-registration with fake number -> pass/success (0.99) - flight-ticket-search ICN->NRT 2026-08-20 -> pass/success (0.99) * fix(ohou-today-deal): address PR #264 review (live UA, explicit feed selection, argv validators) - HIGH: switch fetch_html() to well-formed bot UA with contact URL (k-skill-ohou-today-deal/1.0 (+https://github.com/NomaDamas/k-skill)). ohou.se Akamai bot manager 403s anonymous UAs but allows identified bot UAs that include a contact URL. Live default workflow now returns 74 deals end-to-end instead of failing with HTTP 403. - MEDIUM: extract_deals() now explicitly selects React Query entries with queryKey == ['today-deal-feed'] or ['special-today-deal-feed'] and reads only state.data.todayDealFeed.slots[type=='DEAL']. Unrelated DEAL-shaped nodes from navigation/banner modules are excluded. Legacy fixture/JSON-payload fallback path preserved for tests that construct simplified payloads. - LOW: --limit now requires a positive integer; --min-discount is constrained to 0..100. Both validated via argparse.ArgumentTypeError so users get a clear CLI error instead of silent slicing or nonsensical thresholds. - Tests: add 9 new unit tests covering explicit feed selection, navigation/GOODS exclusion, fallback compatibility, and argv validators. Strengthen skill-docs.test.js to lock the special-today-deal-feed surface and well-formed UA signature. - Docs: update SKILL.md and feature doc to document the explicit today-deal-feed + special-today-deal-feed extraction boundary and the Akamai UA policy. * Merge pull request #263 from NomaDamas/feature/#257 Feature/#257 * Feature/#256 (#266) * Enable public local-election candidate lookups Add an NEC integrated-search skill and helper package so agents can answer 지방선거 후보자 lookup requests without credentials or proxy routes. Constraint: Issue #256 requested TDD, Ralph completion, branch feature/#256, and PR targeting dev. Rejected: k-skill-proxy route | NEC integrated candidate search is public and requires no API key. Confidence: high Scope-risk: moderate Directive: Keep the helper read-only and do not automate NEC login, CAPTCHA, filing, or privileged election workflows. Tested: git diff --check; node --test packages/local-election-candidate-search/test/index.test.js; npm run lint --workspace local-election-candidate-search; npm run test --workspace local-election-candidate-search; npm pack --workspace local-election-candidate-search --dry-run; node packages/local-election-candidate-search/src/cli.js 오세훈 --election 시도지사 --region 서울 --limit 1; PATH=/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg0a6JueA:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/path:/Users/jeffrey/.cmuxterm/omo-bin:/opt/homebrew/share/android-commandlinetools/platform-tools:/opt/homebrew/share/android-commandlinetools/emulator:/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin:/Users/jeffrey/.local/bin:/Users/jeffrey/.bun/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/opt/openjdk@21/bin:/opt/homebrew/opt/postgresql@18/bin:/Users/jeffrey/.jenv/shims:/Users/jeffrey/.jenv/bin:/opt/homebrew/opt/imagemagick/bin:/opt/homebrew/Cellar/pyenv-virtualenv/1.4.0/shims:/Users/jeffrey/.pyenv/shims:/opt/homebrew/opt/openssl@3/bin:/Users/jeffrey/.rbenv/shims:/Users/jeffrey/.rbenv/bin:/Users/jeffrey/google-cloud-sdk/bin:/Applications/cmux.app/Contents/Resources/bin:/Users/jeffrey/Library/pnpm:/Users/jeffrey/.nvm/versions/node/v24.13.0/bin:/Users/jeffrey/.cops/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Library/TeX/texbin:/Users/jeffrey/.cargo/bin:/Users/jeffrey/Library/Application Support/JetBrains/Toolbox/scripts:/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin:/Users/jeffrey/xcode-projects/marshroom/cli npm run ci Not-tested: Exhaustive NEC markup variants for every historical election type. Co-authored-by: OmX <omx@oh-my-codex.dev> * Enforce fail-closed candidate identity parsing Constraint: PR #266 review required exact candidate-name matching and CLI help regression coverage.\nRejected: fallback-to-query-name on missing upstream markup | it can mislabel unrelated candidates as exact matches.\nConfidence: high\nScope-risk: narrow\nDirective: Keep NEC parser changes fail-closed when candidate identity cannot be parsed.\nTested: git diff --check; node --test packages/local-election-candidate-search/test/index.test.js; npm run lint --workspace local-election-candidate-search; npm run test --workspace local-election-candidate-search; npm pack --workspace local-election-candidate-search --dry-run; live CLI smoke for 오세훈; CLI --help smoke.\nNot-tested: repo-wide npm run ci remains blocked by pre-existing missing SKILL.md: ohou-today-deal. * Preserve unique candidate lookup results Deduplicate parsed NEC candidate/election rows before applying user limits, and make expected CLI validation failures concise by default while keeping an explicit debug stack escape hatch. Constraint: PR #266 round-2 follow-up requested TDD fixes for duplicate NEC rows and CLI validation UX.\nRejected: Deduplicating after limit | would still allow duplicates to crowd out unique rows.\nRejected: Always printing stack traces | exposes local paths for normal user-input failures.\nConfidence: high\nScope-risk: narrow\nDirective: Keep dedupe keys stable enough to avoid collapsing legitimately distinct historical election rows.\nTested: git diff --check; node --test packages/local-election-candidate-search/test/index.test.js; npm run lint --workspace local-election-candidate-search; npm run test --workspace local-election-candidate-search; npm pack --workspace local-election-candidate-search --dry-run; live 오세훈 smoke; live 김동연 duplicate repro; CLI no-args/help.\nNot-tested: Full npm run ci remains blocked by pre-existing missing SKILL.md: ohou-today-deal. * Prevent filtered NEC lookup false negatives Fix the candidate parser so documented education-superintendent and filtered local-election lookups return bounded, evidence-backed results instead of silently dropping valid rows. Constraint: PR #266 round-3 review required TDD, Ralph verification, and branch update for issue #256. Rejected: Full NEC pagination in this follow-up | broader than the approved change; bounded 100-row fetch now avoids user-limit false negatives and warns when capped. Confidence: high Scope-risk: narrow Directive: Preserve exact-name fail-closed parsing and count raw parsed upstream rows before cap-warning decisions. Tested: git diff --check; node --test packages/local-election-candidate-search/test/index.test.js; npm run lint --workspace local-election-candidate-search; npm run test --workspace local-election-candidate-search; npm pack --workspace local-election-candidate-search --dry-run; live CLI smokes for 오세훈, 조희연, 김동연; CLI help/no-args checks; architect verification CLEAR. Not-tested: Full npm run ci remains blocked by pre-existing repo-wide missing SKILL.md: ohou-today-deal. --------- Co-authored-by: OmX <omx@oh-my-codex.dev> * chore(changesets): rename daiso bearer-auth changeset to avoid name collision with consumed main release PR #245 already consumed .changeset/issue-207-daiso-pickup-eligibility.md into daiso-product-search v0.3.0 on main. The dev branch later modified that same changeset file ind7263a5to describe the newer Bearer-auth fix, which collides with main's deletion on the next dev→main sync. Renaming the still-unreleased Bearer-auth note to issue-207-daiso-bearer-auth.md preserves the release entry for the next version-packages run and clears the modify/delete conflict on PR #271 without losing the changelog content. * fix(kstartup-search): implement promised client-side filter to deliver on SKILL.md L121 Live data revealed two unmet contracts in the kstartup-search helper: 1. SKILL.md L121 promised the helper re-applies supt_regin / aply_trgt / biz_enyy filters on the client side because K-Startup upstream ignores them server-side. The helper had no such logic — calling `--supt-regin 서울특별시 --rcrt-prgs-yn Y` returned 경북/충북/충남 announcements as-is, silently misleading callers. 2. The upstream `supt_regin` field is stored as the short form (`서울`, `경기`, `충북`, ...) but every CLI example in the skill used the standard 광역지자체 long form (`서울특별시`), which would never substring-match even after a client filter was added. Add `apply_client_filters()` that runs after `urlopen` returns. It honors the SKILL.md contract literally: substring match per token, AND-joined across comma-separated user values, with a 17-region (+`전국`) shortname normalisation table so both `--supt-regin 서울특별시` and `--supt-regin 서울` resolve to upstream's `서울`. Filtered responses expose a new `client_filter: {fields, upstream_returned, after_filter}` metadata block so callers can detect "first page was depleted by filter" and page through. Tests: 9 new ClientFilterTests + 2 normalisation tests on top of the existing 14 (25 total, all passing). Live smoke (against a dev proxy with DATA_GO_KR_API_KEY activated for dataset 15125364): `--supt-regin 서울특별시 --rcrt-prgs-yn Y --per-page 10` now returns 4 actual 서울 announcements (upstream returned 10 mixed-region rows; client filter narrowed to 4), with detl_pg_url to k-startup.go.kr. Confidence: high. Scope-risk: narrow — purely additive on the response path; other endpoints (business-info / contents / statistics) pass through unchanged. * ci(k-skill-proxy): replace local pm2+cloudflared with Cloud Run auto-deploy via GitHub Actions main에 머지되면 GitHub Actions가 자동으로 Workload Identity Federation으로 GCP 인증 후 Artifact Registry에 컨테이너 이미지를 빌드/푸시하고 Cloud Run(asia-northeast1) 서비스 k-skill-proxy를 재배포한다. 시크릿은 GCP Secret Manager에서 런타임에 주입된다. - add .github/workflows/deploy-k-skill-proxy.yml (WIF, on push to main) - add packages/k-skill-proxy/Dockerfile (multi-stage node:20-alpine, port bridge) - add docs/deploy-k-skill-proxy.md (1회성 GCP 셋업 + 운영 점검 절차) - remove ecosystem.config.cjs (PM2 root config) - remove scripts/run-k-skill-proxy.sh (local secrets.env source + node launcher) - remove wrangler devDependency (unused Cloudflare Workers CLI) - update AGENTS.md, CLAUDE.md, CONTRIBUTING.md, docs/features/k-skill-proxy.md, packages/k-skill-proxy/README.md to describe the new Cloud Run + GHA flow - clean dead k-skill-proxy-cloudrun entries from .gitignore * docs(AGENTS): proxy 운영 전반(회전·롤백·비상 수동 배포 포함) docs/deploy-k-skill-proxy.md 참고 명시 * test(skill-docs): update stale CONTRIBUTING.md assertion for Cloud Run migration 80e7805(ci(k-skill-proxy): replace local pm2+cloudflared with Cloud Run auto-deploy) 가 CONTRIBUTING.md의 '프록시 서버 개발과 배포' 섹션을 Cloud Run + GCP Secret Manager 흐름으로 다시 썼는데, 같은 섹션을 검증하는 skill-docs.test.js의 어서션은 구버전(`~/.local/share/k-skill-proxy`) 그대로였다. PR #276 CI에서 이 stale 어서션이 fail하여 머지를 막고 있었다. 기존 한 줄 regex(localhost 시크릿 경로)를 새 사실에 맞춰 두 개의 어서션으로 교체: 1. 프로덕션이 Google Cloud Run(asia-northeast1) + k-skill-proxy.nomadamas.org에서 운영된다는 문구를 강제한다. 2. 시크릿이 GCP Secret Manager에 있고 운영 점검 절차가 docs/deploy-k-skill-proxy.md에 있다는 문구를 강제한다. 이렇게 하면 문서가 다시 옛 로컬 흐름으로 돌아가거나 운영 가이드 링크가 빠지는 회귀가 발생할 때 CI가 잡아준다. --------- Co-authored-by: arnold714 <arnold714@naver.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: chanmin <cmju@cowave.kr> Co-authored-by: OmX <omx@oh-my-codex.dev> Co-authored-by: hmmhmmhm/ <hmmhmmhm@naver.com> Co-authored-by: 배기민 <53887180+BAEM1N@users.noreply.github.com> Co-authored-by: lee-ji-hong <zhffktkdlekghksxk@naver.com>
4171 lines
203 KiB
JavaScript
4171 lines
203 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const childProcess = require("node:child_process");
|
|
|
|
const repoRoot = path.join(__dirname, "..");
|
|
|
|
function read(relativePath) {
|
|
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
|
}
|
|
|
|
function readJson(relativePath) {
|
|
return JSON.parse(read(relativePath));
|
|
}
|
|
|
|
function escapeRegex(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function findSection(doc, heading) {
|
|
const escaped = escapeRegex(heading);
|
|
const match = doc.match(new RegExp(`${escaped}[\\s\\S]*?(?=\\n## |\\n### |$)`));
|
|
|
|
assert.ok(match, `expected section headed by "${heading}"`);
|
|
return match[0];
|
|
}
|
|
|
|
function assertOliveYoungCloneFallbackCommands(doc, label) {
|
|
assert.match(doc, /node dist\/bin\.js health/, `${label} should document the runnable local health command`);
|
|
assert.match(
|
|
doc,
|
|
/node dist\/bin\.js get \/api\/oliveyoung\/stores --keyword 명동 --limit 5 --json/,
|
|
`${label} should document the runnable local store lookup command`,
|
|
);
|
|
assert.match(
|
|
doc,
|
|
/node dist\/bin\.js get \/api\/oliveyoung\/products --keyword 선크림 --size 5 --json/,
|
|
`${label} should document the runnable local product lookup command`,
|
|
);
|
|
assert.match(
|
|
doc,
|
|
/node dist\/bin\.js get \/api\/oliveyoung\/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json/,
|
|
`${label} should document the runnable local inventory lookup command`,
|
|
);
|
|
assert.doesNotMatch(doc, /^\s*npx daiso\b/m, `${label} should not publish broken clone-local npx commands`);
|
|
}
|
|
|
|
function assertOliveYoungCloneFallbackShorthand(doc, label) {
|
|
assert.match(
|
|
doc,
|
|
/git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git && cd daiso-mcp && npm install && npm run build/,
|
|
`${label} should include a runnable shorthand that changes into the clone before install/build`,
|
|
);
|
|
assert.doesNotMatch(
|
|
doc,
|
|
/git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git && npm install && npm run build/,
|
|
`${label} should not publish the broken shorthand that skips cd daiso-mcp`,
|
|
);
|
|
}
|
|
|
|
function extractQuotedEntries(block, indent) {
|
|
return block
|
|
.split("\n")
|
|
.map((line) => line.match(new RegExp(`^ {${indent}}"([^"]+)":\\s*(.+?)(?:,)?$`)))
|
|
.filter(Boolean)
|
|
.map(([, key, value]) => [key, value.trim()]);
|
|
}
|
|
|
|
function findPrintedObjectBlock(doc, carrier) {
|
|
const block = [...doc.matchAll(/print\(json\.dumps\(\{\n([\s\S]*?)\n\}, ensure_ascii=False, indent=2\)\)/g)]
|
|
.map((match) => match[1])
|
|
.find((candidate) => candidate.includes(`"carrier": "${carrier}"`));
|
|
|
|
assert.ok(block, `expected ${carrier} normalized JSON example`);
|
|
return block;
|
|
}
|
|
|
|
function findRecentEventsBlock(doc, carrier) {
|
|
const block = [...doc.matchAll(/normalized_events = \[\n\s*\{\n([\s\S]*?)\n\s*\}\n\s*for [^\n]+ in events\n\]/g)]
|
|
.map((match) => match[1])
|
|
.find((candidate) => candidate.includes('"status_code":') === (carrier === "cj"));
|
|
|
|
assert.ok(block, `expected ${carrier} recent_events example`);
|
|
return block;
|
|
}
|
|
|
|
function findJsonFenceAfterLabel(doc, label) {
|
|
return JSON.parse(findJsonFenceTextAfterLabel(doc, label));
|
|
}
|
|
|
|
function findJsonFenceTextAfterLabel(doc, label) {
|
|
const escaped = escapeRegex(label);
|
|
const match = doc.match(new RegExp(`${escaped}[\\s\\S]*?\\\`\\\`\\\`json\\n([\\s\\S]*?)\\n\\\`\\\`\\\``));
|
|
|
|
assert.ok(match, `expected JSON example after "${label}"`);
|
|
return match[1];
|
|
}
|
|
|
|
function assertSampleProvenance(doc, sectionLabel, expected, docLabel) {
|
|
const escapedSectionLabel = escapeRegex(sectionLabel);
|
|
const escapedVerifiedAt = escapeRegex(expected.verified_at);
|
|
const escapedInvoice = escapeRegex(expected.invoice);
|
|
|
|
assert.match(
|
|
doc,
|
|
new RegExp(
|
|
`${escapedSectionLabel}[\\s\\S]*?아래 값은 ${escapedVerifiedAt} 기준 live smoke test\\(\\x60${escapedInvoice}\\x60\\)에서 확인한 정규화 결과다\\.\\n\\n\\\`\\\`\\\`json`,
|
|
),
|
|
`${docLabel} ${sectionLabel} provenance line must stay pinned to the verified smoke-test date and invoice`,
|
|
);
|
|
}
|
|
|
|
function assertSanitizedPublicOutput(output, label) {
|
|
const serialized = JSON.stringify(output);
|
|
|
|
assert.doesNotMatch(serialized, /\bTEL\b/i, `${label} must not leak TEL fragments`);
|
|
assert.doesNotMatch(
|
|
serialized,
|
|
/\d{2,4}[.\-]\d{3,4}[.\-]\d{4}/,
|
|
`${label} must not leak phone-number-like strings anywhere in the published sample`,
|
|
);
|
|
assert.doesNotMatch(serialized, /crgNm/, `${label} must not leak CJ assignee/source fields`);
|
|
assert.doesNotMatch(serialized, /sender/i, `${label} must not leak sender fields`);
|
|
assert.doesNotMatch(serialized, /receiver/i, `${label} must not leak receiver fields`);
|
|
assert.doesNotMatch(serialized, /delivered_to/i, `${label} must not leak delivered_to fields`);
|
|
}
|
|
|
|
function assertKakaoBarNearbySadangSmokeSnapshot(smoke, label) {
|
|
assert.equal(smoke.anchor.name, "사당1동먹자골목상점가", `${label} anchor should stay on the verified area landmark`);
|
|
assert.equal(smoke.meta.openNowCount, 4, `${label} should publish the verified open-now count`);
|
|
assert.deepEqual(
|
|
smoke.items.map((item) => item.name),
|
|
["우미노식탁", "방배을지로골뱅이술집포차 사당역점", "커먼테이블"],
|
|
`${label} should keep the verified top-3 ordering`,
|
|
);
|
|
}
|
|
|
|
test("root npm test script includes the skill docs regression suite", () => {
|
|
const packageJson = JSON.parse(read("package.json"));
|
|
|
|
assert.match(packageJson.scripts.test, /node --test scripts\/skill-docs\.test\.js/);
|
|
});
|
|
|
|
test("README advertises OpenClaw among the supported coding agents", () => {
|
|
const readme = read("README.md");
|
|
|
|
assert.match(
|
|
readme,
|
|
/Claude Code, Codex, OpenCode, OpenClaw\/ClawHub 등 각종 코딩 에이전트 지원합니다\./,
|
|
);
|
|
});
|
|
|
|
test("repository publishes Korean contribution guidance for external contributors", () => {
|
|
const contributingPath = path.join(repoRoot, "CONTRIBUTING.md");
|
|
|
|
assert.ok(fs.existsSync(contributingPath), "expected CONTRIBUTING.md to exist");
|
|
|
|
const contributing = read("CONTRIBUTING.md");
|
|
|
|
assert.match(contributing, /^# 기여 가이드$/m);
|
|
assert.match(contributing, /PR 코멘트, 이슈, 리뷰 등 모든 소통은 한국어로 진행/);
|
|
assert.match(contributing, /PR의 대상 브랜치는 반드시 `dev`/);
|
|
assert.match(contributing, /`main` 브랜치로 PR을 만들 수 있는 사람은 `@vkehfdl1`뿐/);
|
|
assert.match(contributing, /스킬을 추가하거나 변경할 때는 관련 기능 문서와 `README\.md`의 표/);
|
|
assert.match(contributing, /npm 패키지를 수정할 때는 Changesets/);
|
|
assert.match(contributing, /Changeset 파일의 존재 여부를 테스트로 검증하지 않는다/);
|
|
assert.match(contributing, /`package\.json`과 `package-lock\.json`의 `version` 필드를 테스트에서 고정하지 않는다/);
|
|
assert.match(contributing, /`name`, `license`, `engines\.node`, workspace link metadata/);
|
|
assert.match(contributing, /현재 구현이 registry token 기반인 경우에도 신규 또는 재설계 흐름은 trusted publishing\/OIDC를 우선/);
|
|
assert.match(contributing, /신규 proxy route는 upstream이 API key를 요구하는 무료 API인 경우에만 `k-skill-proxy` 경유를 검토/);
|
|
assert.match(contributing, /인증 없이 동작하는 공개 read-only endpoint는 기본적으로 사용자 머신에서 직접 호출/);
|
|
assert.doesNotMatch(contributing, /무료 API이고 1일 리미트가 충분한 경우/);
|
|
assert.match(contributing, /유료 API/);
|
|
assert.match(contributing, /`k-skill-proxy`를 타지 않도록 설계/);
|
|
assert.match(contributing, /릴리스나 패키징 관련 변경은 `npm run ci`/);
|
|
assert.match(contributing, /`~\/\.claude\/skills\/<skill-name>`/);
|
|
assert.match(contributing, /`~\/\.agents\/skills\/<skill-name>`/);
|
|
assert.match(
|
|
contributing,
|
|
/프로덕션 프록시는 \*\*Google Cloud Run\*\* \(project `k-skill-proxy`, region `asia-northeast1`\)에서 운영하며 `k-skill-proxy\.nomadamas\.org` 도메인에 매핑/,
|
|
);
|
|
assert.match(
|
|
contributing,
|
|
/프로덕션 시크릿은 GCP Secret Manager에 보관되고 Cloud Run 런타임에 주입됩니다\. 프록시 운영자\(maintainer\)가 한 번 수행해야 하는 WIF\/Secret Manager 셋업과 운영 점검 절차는 \[`docs\/deploy-k-skill-proxy\.md`\]\(docs\/deploy-k-skill-proxy\.md\)에 정리/,
|
|
);
|
|
});
|
|
|
|
test("README links to the contribution guide", () => {
|
|
const readme = read("README.md");
|
|
|
|
assert.match(readme, /\[기여 가이드\]\(CONTRIBUTING\.md\)/);
|
|
});
|
|
|
|
|
|
test("repository docs advertise Daangn read-only search skills", () => {
|
|
const readme = read("README.md");
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const skills = [
|
|
["daangn-used-goods-search", "당근 중고거래 검색"],
|
|
["daangn-realty-search", "당근부동산 검색"],
|
|
["daangn-jobs-search", "당근알바 검색"],
|
|
["daangn-cars-search", "당근중고차 검색"],
|
|
];
|
|
|
|
assert.match(sources, /www\.daangn\.com\/kr\/api\/v1\/regions\/keyword/);
|
|
assert.match(sources, /realty\.daangn\.com\/articles/);
|
|
|
|
for (const [skillName, label] of skills) {
|
|
const skill = read(path.join(skillName, "SKILL.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", `${skillName}.md`);
|
|
const featureDoc = read(path.join("docs", "features", `${skillName}.md`));
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), `expected docs/features/${skillName}.md to exist`);
|
|
assert.ok(
|
|
readme.includes(`| ${label} | \`${skillName}\``),
|
|
`README should advertise ${skillName}`,
|
|
);
|
|
assert.ok(
|
|
readme.includes(`](docs/features/${skillName}.md)`),
|
|
`README should link docs/features/${skillName}.md`,
|
|
);
|
|
assert.match(skill, /kr\/api\/v1\/regions\/keyword/);
|
|
assert.match(featureDoc, /kr\/api\/v1\/regions\/keyword/);
|
|
assert.match(featureDoc, /(로그인|채팅|구매|문의|지원).*자동화/);
|
|
}
|
|
});
|
|
|
|
test("hwp skill documents kordoc-based parsing and supported operations", () => {
|
|
const skillPath = path.join(repoRoot, "hwp", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected hwp/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("hwp", "SKILL.md"));
|
|
|
|
assert.match(skill, /^name: hwp$/m);
|
|
assert.match(skill, /\bkordoc\b/);
|
|
assert.doesNotMatch(skill, /@ohah\/hwpjs/);
|
|
assert.doesNotMatch(skill, /\bhwp-mcp\b/);
|
|
assert.match(skill, /JSON/i);
|
|
assert.match(skill, /Markdown/i);
|
|
assert.match(skill, /image/i);
|
|
assert.match(skill, /(batch|배치)/i);
|
|
assert.match(skill, /HWPX/i);
|
|
assert.match(skill, /(역변환|되돌려)/);
|
|
assert.match(skill, /(비교|compare)/i);
|
|
assert.match(skill, /pdfjs-dist/);
|
|
assert.match(skill, /(extractFormFields|양식 필드)/);
|
|
assert.doesNotMatch(skill, /fillForm/);
|
|
assert.doesNotMatch(skill, /kordoc fill/);
|
|
assert.doesNotMatch(skill, /kordoc mcp/);
|
|
});
|
|
|
|
test("hwp docs match the published kordoc install and runtime contract", () => {
|
|
const skill = read(path.join("hwp", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "hwp.md"));
|
|
const install = read(path.join("docs", "install.md"));
|
|
const readme = read("README.md");
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
|
|
assert.match(skill, /npx --yes --package kordoc --package pdfjs-dist kordoc .* -o .*\.md/);
|
|
assert.match(skill, /markdownToHwpx/);
|
|
assert.match(skill, /extractFormFields/);
|
|
assert.match(skill, /npm init -y/);
|
|
assert.match(skill, /npm install kordoc pdfjs-dist/);
|
|
assert.doesNotMatch(skill, /^\s*npx kordoc\b/m);
|
|
assert.doesNotMatch(skill, /export NODE_PATH/);
|
|
assert.match(featureDoc, /npx --yes --package kordoc --package pdfjs-dist kordoc .* --format json/);
|
|
assert.match(featureDoc, /markdownToHwpx/);
|
|
assert.match(featureDoc, /(extractFormFields|양식 필드)/);
|
|
assert.match(featureDoc, /npx --yes --package kordoc --package pdfjs-dist kordoc watch/);
|
|
assert.match(featureDoc, /npm init -y/);
|
|
assert.match(featureDoc, /npm install kordoc pdfjs-dist/);
|
|
assert.doesNotMatch(featureDoc, /^\s*npx kordoc\b/m);
|
|
assert.doesNotMatch(featureDoc, /export NODE_PATH/);
|
|
assert.match(featureDoc, /npm install -g kordoc pdfjs-dist/);
|
|
assert.doesNotMatch(featureDoc, /선택적으로 `pdfjs-dist`/);
|
|
assert.doesNotMatch(featureDoc, /kordoc fill/);
|
|
assert.doesNotMatch(featureDoc, /kordoc mcp/);
|
|
assert.doesNotMatch(featureDoc, /fillForm/);
|
|
assert.match(install, /npm install -g kordoc pdfjs-dist /);
|
|
assert.match(install, /HWP Node API 예시는 전역 `NODE_PATH` 대신 로컬 프로젝트에 `npm install kordoc pdfjs-dist` 후 실행/);
|
|
assert.match(install, /`kordoc` CLI를 일회성으로만 쓸 때는 `npx --yes --package kordoc --package pdfjs-dist kordoc \.\.\.` 형태를 사용한다\./);
|
|
assert.match(readme, /\| HWP 문서 조회\/변환 \| .*양식 필드 추출.*Markdown→HWPX 역변환/);
|
|
assert.doesNotMatch(readme, /\| HWP 문서 조회\/변환 \| .*양식 채우기/);
|
|
assert.match(sources, /kordoc/);
|
|
assert.match(sources, /pdfjs-dist/);
|
|
});
|
|
|
|
test("repository docs advertise the hwp skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "hwp.md");
|
|
const featureDoc = read(path.join("docs", "features", "hwp.md"));
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/hwp.md to exist");
|
|
assert.match(readme, /\| HWP 문서 조회\/변환 \|/);
|
|
assert.match(readme, /\[HWP 문서 처리 가이드\]\(docs\/features\/hwp\.md\)/);
|
|
assert.match(install, /--skill hwp/);
|
|
assert.match(featureDoc, /\bkordoc\b/);
|
|
assert.doesNotMatch(featureDoc, /@ohah\/hwpjs/);
|
|
assert.doesNotMatch(featureDoc, /\bhwp-mcp\b/);
|
|
assert.match(install, /npm install -g kordoc /);
|
|
assert.doesNotMatch(install, /@ohah\/hwpjs/);
|
|
});
|
|
|
|
test("repository docs advertise the kakaotalk-mac skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "kakaotalk-mac.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakaotalk-mac.md to exist");
|
|
assert.match(readme, /\| 카카오톡 Mac CLI \|/);
|
|
assert.match(readme, /\[카카오톡 Mac CLI\]\(docs\/features\/kakaotalk-mac\.md\)/);
|
|
assert.match(install, /--skill kakaotalk-mac/);
|
|
});
|
|
|
|
test("repository docs advertise the used-car-price-search skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "used-car-price-search.md");
|
|
const skillPath = path.join(repoRoot, "used-car-price-search", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/used-car-price-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected used-car-price-search/SKILL.md to exist");
|
|
assert.match(readme, /\| 중고차 가격 조회 \|/);
|
|
assert.match(readme, /\[중고차 가격 조회 가이드\]\(docs\/features\/used-car-price-search\.md\)/);
|
|
assert.match(install, /--skill used-car-price-search/);
|
|
assert.match(
|
|
install,
|
|
/npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp/,
|
|
);
|
|
});
|
|
|
|
test("repository docs advertise the public-restroom-nearby skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "public-restroom-nearby.md");
|
|
const skillPath = path.join(repoRoot, "public-restroom-nearby", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/public-restroom-nearby.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected public-restroom-nearby/SKILL.md to exist");
|
|
assert.match(readme, /\| 근처 공중화장실 찾기 \|/);
|
|
assert.match(readme, /\[근처 공중화장실 찾기 가이드\]\(docs\/features\/public-restroom-nearby\.md\)/);
|
|
assert.match(install, /--skill public-restroom-nearby/);
|
|
assert.match(install, /npm install -g .*public-restroom-nearby/);
|
|
});
|
|
|
|
test("public-restroom-nearby docs describe the maxDistanceMeters distance cap", () => {
|
|
const featureDoc = read(path.join("docs", "features", "public-restroom-nearby.md"));
|
|
const packageReadme = read(path.join("packages", "public-restroom-nearby", "README.md"));
|
|
|
|
assert.match(featureDoc, /maxDistanceMeters/);
|
|
assert.match(featureDoc, /100m/);
|
|
assert.match(packageReadme, /maxDistanceMeters/);
|
|
assert.match(packageReadme, /100m/);
|
|
});
|
|
|
|
test("repository docs advertise the lck-analytics skill and package", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "lck-analytics.md");
|
|
const skillPath = path.join(repoRoot, "lck-analytics", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/lck-analytics.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected lck-analytics/SKILL.md to exist");
|
|
assert.match(readme, /\| LCK 경기 분석 \|/);
|
|
assert.match(readme, /\[LCK 경기 분석 가이드\]\(docs\/features\/lck-analytics\.md\)/);
|
|
assert.match(install, /--skill lck-analytics/);
|
|
assert.match(install, /npm install -g .*lck-analytics/);
|
|
});
|
|
|
|
test("lck-analytics docs and skill credit the original author and reference repo", () => {
|
|
const skill = read(path.join("lck-analytics", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "lck-analytics.md"));
|
|
const packageReadme = read(path.join("packages", "lck-analytics", "README.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
|
|
for (const doc of [skill, featureDoc, packageReadme]) {
|
|
assert.match(doc, /jerjangmin/);
|
|
assert.match(doc, /https:\/\/github\.com\/jerjangmin\/share\/tree\/main\/SKILL\/lck-analytics/);
|
|
assert.match(doc, /Riot|LoL Esports|Oracle(?:'s)? Elixir/i);
|
|
}
|
|
|
|
assert.match(sources, /https:\/\/github\.com\/jerjangmin\/share\/tree\/main\/SKILL\/lck-analytics/);
|
|
});
|
|
|
|
test("repository docs advertise the korean-spell-check skill and usage constraints", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-spell-check.md");
|
|
const skillPath = path.join(repoRoot, "korean-spell-check", "SKILL.md");
|
|
const featureDoc = read(path.join("docs", "features", "korean-spell-check.md"));
|
|
const skill = read(path.join("korean-spell-check", "SKILL.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-spell-check.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-spell-check/SKILL.md to exist");
|
|
assert.match(readme, /\| 한국어 맞춤법 검사 \|/);
|
|
assert.match(readme, /\[한국어 맞춤법 검사 가이드\]\(docs\/features\/korean-spell-check\.md\)/);
|
|
assert.match(install, /--skill korean-spell-check/);
|
|
assert.match(skill, /비상업적 용도|개인이나 학생만 무료/);
|
|
assert.match(skill, /robots\.txt/i);
|
|
assert.match(skill, /청크|chunk/i);
|
|
assert.match(skill, /원문.*교정안.*이유/s);
|
|
assert.match(featureDoc, /old_speller\/results/);
|
|
assert.match(featureDoc, /Cloudflare|403/);
|
|
assert.match(featureDoc, /python3 scripts\/korean_spell_check\.py/);
|
|
assert.match(sources, /https:\/\/nara-speller\.co\.kr\/speller\//);
|
|
assert.match(sources, /https:\/\/nara-speller\.co\.kr\/old_speller\//);
|
|
assert.match(sources, /https:\/\/nara-speller\.co\.kr\/robots\.txt/);
|
|
assert.match(roadmap, /한국어 맞춤법 검사 스킬 출시/);
|
|
});
|
|
|
|
test("repository docs advertise the MFDS public-health skills and mandatory symptom interview", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const security = read(path.join("docs", "security-and-secrets.md"));
|
|
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
|
const drugSkillPath = path.join(repoRoot, "mfds-drug-safety", "SKILL.md");
|
|
const foodSkillPath = path.join(repoRoot, "mfds-food-safety", "SKILL.md");
|
|
const drugFeaturePath = path.join(repoRoot, "docs", "features", "mfds-drug-safety.md");
|
|
const foodFeaturePath = path.join(repoRoot, "docs", "features", "mfds-food-safety.md");
|
|
|
|
assert.ok(fs.existsSync(drugSkillPath), "expected mfds-drug-safety/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(foodSkillPath), "expected mfds-food-safety/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(drugFeaturePath), "expected docs/features/mfds-drug-safety.md to exist");
|
|
assert.ok(fs.existsSync(foodFeaturePath), "expected docs/features/mfds-food-safety.md to exist");
|
|
assert.match(readme, /\| 의약품 안전 체크 \|/);
|
|
assert.match(readme, /\| 식품 안전 체크 \|/);
|
|
assert.match(readme, /\| 의약품 안전 체크 \| .* \| 불필요 \|/);
|
|
assert.match(readme, /\| 식품 안전 체크 \| .* \| 불필요 \|/);
|
|
assert.match(install, /--skill mfds-drug-safety/);
|
|
assert.match(install, /--skill mfds-food-safety/);
|
|
assert.match(sources, /15075057\/openapi\.do/);
|
|
assert.match(sources, /15097208\/openapi\.do/);
|
|
assert.match(sources, /15056516\/openapi\.do/);
|
|
assert.match(sources, /15074318\/openapi\.do/);
|
|
assert.match(sources, /foodsafetykorea\.go\.kr\/api\/openApiInfo\.do.*svc_no=I0490/);
|
|
for (const doc of [setup, security, setupSkill]) {
|
|
assert.match(doc, /의약품 안전 체크|식품 안전 체크/);
|
|
assert.match(doc, /FOODSAFETYKOREA_API_KEY|DATA_GO_KR_API_KEY/);
|
|
assert.match(doc, /사용자.*불필요|proxy 서버/u);
|
|
}
|
|
|
|
for (const relativePath of [
|
|
path.join("mfds-drug-safety", "SKILL.md"),
|
|
path.join("mfds-food-safety", "SKILL.md"),
|
|
path.join("docs", "features", "mfds-drug-safety.md"),
|
|
path.join("docs", "features", "mfds-food-safety.md")
|
|
]) {
|
|
const doc = read(relativePath);
|
|
|
|
assert.match(doc, /인터뷰|되묻/);
|
|
assert.match(doc, /호흡곤란/);
|
|
assert.match(doc, /직접 진단|진단\/처방|진단\)이나/);
|
|
assert.match(doc, /119|응급실/);
|
|
}
|
|
});
|
|
test("used-car-price-search docs document the provider survey and SK direct surface", () => {
|
|
const skill = read(path.join("used-car-price-search", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "used-car-price-search.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /SK렌터카|SK렌터카 다이렉트|타고BUY/);
|
|
assert.match(doc, /롯데렌탈|롯데오토옥션/);
|
|
assert.match(doc, /레드캡렌터카/);
|
|
assert.match(doc, /MCP/i);
|
|
assert.match(doc, /Skill/i);
|
|
assert.match(doc, /https:\/\/www\.skdirect\.co\.kr\/tb/);
|
|
assert.match(doc, /__NEXT_DATA__/);
|
|
assert.match(doc, /인수가/);
|
|
assert.match(doc, /월\s*렌트료|월\s*요금|월\s*가격/);
|
|
assert.match(doc, /10회 이상|최소 10회/);
|
|
}
|
|
|
|
assert.match(featureDoc, /2026-04-02/);
|
|
assert.match(featureDoc, /inventory 규모는 시점에 따라 변동될 수/);
|
|
assert.doesNotMatch(featureDoc, /총 `\d+대`/);
|
|
assert.match(sources, /https:\/\/www\.skdirect\.co\.kr\/tb/);
|
|
assert.match(sources, /https:\/\/www\.lotteautoauction\.net\/hp\/pub\/cmm\/viewMain\.do/);
|
|
assert.match(sources, /https:\/\/biz\.redcap\.co\.kr\/rent\//);
|
|
assert.match(roadmap, /중고차 가격 조회 스킬 출시/);
|
|
});
|
|
|
|
test("seoul subway docs default to the hosted proxy when KSKILL_PROXY_BASE_URL is unset", () => {
|
|
const readme = read("README.md");
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const install = read(path.join("docs", "install.md"));
|
|
const security = read(path.join("docs", "security-and-secrets.md"));
|
|
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
|
const skill = read(path.join("seoul-subway-arrival", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "seoul-subway-arrival.md"));
|
|
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
|
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
|
const secretsExample = read(path.join("examples", "secrets.env.example"));
|
|
|
|
assert.match(readme, /\| 서울 지하철 도착정보 조회 \| .* \| 불필요 \|/);
|
|
assert.match(setup, /\| 서울 지하철 도착정보 조회 \| 사용자 시크릿 불필요 \(기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`\) \|/);
|
|
assert.match(install, /--skill seoul-subway-arrival/);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /KSKILL_PROXY_BASE_URL/);
|
|
assert.match(doc, /\/v1\/seoul-subway\/arrival/);
|
|
assert.match(doc, /사용자가 .*OpenAPI key.*직접.*필요(가|는)? 없다|개인 API key 없이/i);
|
|
assert.match(doc, /비우면 기본 hosted `https:\/\/k-skill-proxy\.nomadamas\.org`|없으면 기본 hosted proxy/i);
|
|
assert.doesNotMatch(doc, /SEOUL_OPEN_API_KEY/);
|
|
assert.doesNotMatch(doc, /swopenAPI\.seoul\.go\.kr\/api\/subway\/\$\{SEOUL_OPEN_API_KEY\}/);
|
|
assert.doesNotMatch(doc, /필수: self-host|반드시 명시|먼저 확보|public hosted route rollout 전/);
|
|
}
|
|
|
|
assert.match(proxyDoc, /GET \/v1\/seoul-subway\/arrival/);
|
|
assert.match(proxyDoc, /KSKILL_PROXY_BASE_URL/);
|
|
assert.match(proxyDoc, /unset\/empty|비워 두면/);
|
|
assert.match(proxyDoc, /https:\/\/k-skill-proxy\.nomadamas\.org/);
|
|
assert.match(proxyDoc, /KSKILL_PROXY_BASE_URL=https:\/\/your-proxy\.example\.com/);
|
|
assert.match(proxyDoc, /self-host|alternate proxy/i);
|
|
assert.match(proxyDoc, /override/i);
|
|
assert.match(proxyDoc, /SEOUL_OPEN_API_KEY/);
|
|
assert.match(proxyReadme, /GET \/v1\/seoul-subway\/arrival/);
|
|
assert.match(proxyReadme, /SEOUL_OPEN_API_KEY/);
|
|
assert.match(security, /KSKILL_PROXY_BASE_URL/);
|
|
assert.match(security, /서울 지하철.*한국 날씨.*기본 hosted proxy|기본 hosted proxy.*서울 지하철.*한국 날씨/i);
|
|
assert.match(setupSkill, /서울 지하철: 사용자 시크릿 불필요 \(기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`\)/);
|
|
assert.doesNotMatch(secretsExample, /SEOUL_OPEN_API_KEY/);
|
|
assert.doesNotMatch(secretsExample, /^KSKILL_PROXY_BASE_URL=https:\/\/your-proxy\.example\.com$/m);
|
|
assert.match(secretsExample, /^(#\s*)?KSKILL_PROXY_BASE_URL=$|^#\s*KSKILL_PROXY_BASE_URL=https:\/\/your-proxy\.example\.com$/m);
|
|
assert.doesNotMatch(secretsExample, /KSKILL_PROXY_BASE_URL=https:\/\/k-skill-proxy\.nomadamas\.org/);
|
|
});
|
|
|
|
test("repository docs advertise the korea-weather skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korea-weather.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korea-weather.md to exist");
|
|
assert.match(readme, /\| 한국 날씨 조회 \|/);
|
|
assert.match(readme, /\[한국 날씨 조회 가이드\]\(docs\/features\/korea-weather\.md\)/);
|
|
assert.match(install, /--skill korea-weather/);
|
|
assert.match(roadmap, /한국 날씨 조회 스킬 출시/);
|
|
assert.match(sources, /기상청 단기예보 조회서비스: https:\/\/www\.data\.go\.kr\/data\/15084084\/openapi\.do/);
|
|
});
|
|
|
|
test("korea-weather docs route short-term forecast calls through the proxy without requiring a user API key", () => {
|
|
const skillPath = path.join(repoRoot, "korea-weather", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected korea-weather/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("korea-weather", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "korea-weather.md"));
|
|
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
|
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
|
|
|
assert.match(skill, /^name: korea-weather$/m);
|
|
assert.match(skill, /^description: .*날씨.*기상청.*프록시.*$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /\/v1\/korea-weather\/forecast/);
|
|
assert.match(doc, /기상청.*단기예보|단기예보.*기상청/);
|
|
assert.match(doc, /사용자가 .*API key.*직접.*필요(가|는)? 없다|개인 API key 없이/i);
|
|
assert.match(doc, /nx|ny|위도|경도/u);
|
|
assert.match(doc, /TMP|SKY|PTY|POP/);
|
|
assert.match(doc, /KSKILL_PROXY_BASE_URL|k-skill-proxy\.nomadamas\.org/);
|
|
assert.match(doc, /비우면 기본 hosted `https:\/\/k-skill-proxy\.nomadamas\.org`|없으면 기본 hosted proxy/i);
|
|
assert.doesNotMatch(doc, /필수: self-host|반드시 명시|먼저 확보|public hosted route rollout 전/);
|
|
assert.doesNotMatch(doc, /KMA_OPEN_API_KEY=.*사용자/);
|
|
}
|
|
|
|
assert.match(proxyDoc, /GET \/v1\/korea-weather\/forecast/);
|
|
assert.match(proxyDoc, /KMA_OPEN_API_KEY/);
|
|
assert.match(proxyReadme, /GET \/v1\/korea-weather\/forecast/);
|
|
assert.match(proxyReadme, /KMA_OPEN_API_KEY/);
|
|
});
|
|
|
|
test("hosted proxy docs keep self-host overrides inactive and demonstrate resolver fallback", () => {
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const security = read(path.join("docs", "security-and-secrets.md"));
|
|
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
|
const secretsExample = read(path.join("examples", "secrets.env.example"));
|
|
const subwaySkill = read(path.join("seoul-subway-arrival", "SKILL.md"));
|
|
const weatherSkill = read(path.join("korea-weather", "SKILL.md"));
|
|
const subwayFeatureDoc = read(path.join("docs", "features", "seoul-subway-arrival.md"));
|
|
const weatherFeatureDoc = read(path.join("docs", "features", "korea-weather.md"));
|
|
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
|
|
|
for (const doc of [setup, security, setupSkill, secretsExample]) {
|
|
assert.doesNotMatch(doc, /^KSKILL_PROXY_BASE_URL=https:\/\/your-proxy\.example\.com$/m);
|
|
}
|
|
|
|
for (const doc of [setup, security, setupSkill, secretsExample]) {
|
|
assert.match(
|
|
doc,
|
|
/^(#\s*)?KSKILL_PROXY_BASE_URL=$|^#\s*KSKILL_PROXY_BASE_URL=https:\/\/your-proxy\.example\.com$/m,
|
|
);
|
|
}
|
|
|
|
for (const doc of [subwaySkill, weatherSkill, subwayFeatureDoc, weatherFeatureDoc]) {
|
|
assert.match(doc, /BASE="\$\{KSKILL_PROXY_BASE_URL:-https:\/\/k-skill-proxy\.nomadamas\.org\}"/);
|
|
assert.match(doc, /curl -fsS --get "\$\{BASE\}/);
|
|
assert.doesNotMatch(doc, /curl -fsS --get 'https:\/\/k-skill-proxy\.nomadamas\.org/);
|
|
}
|
|
|
|
assert.match(proxyDoc, /BASE="\$\{KSKILL_PROXY_BASE_URL:-https:\/\/k-skill-proxy\.nomadamas\.org\}"/);
|
|
for (const endpoint of ["seoul-subway/arrival", "korea-weather/forecast"]) {
|
|
assert.match(proxyDoc, new RegExp(`curl -fsS --get "\\$\\{BASE\\}/v1/${endpoint}"`));
|
|
assert.doesNotMatch(proxyDoc, new RegExp(`curl -fsS --get 'http://127\\.0\\.0\\.1:4020/v1/${endpoint}'`));
|
|
}
|
|
});
|
|
|
|
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
|
|
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
|
|
const helperPath = path.join(repoRoot, "scripts", "kakaotalk_mac.py");
|
|
const featureDoc = read(path.join("docs", "features", "kakaotalk-mac.md"));
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected kakaotalk-mac/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(helperPath), "expected scripts/kakaotalk_mac.py to exist");
|
|
|
|
const skill = read(path.join("kakaotalk-mac", "SKILL.md"));
|
|
|
|
assert.match(skill, /^name: kakaotalk-mac$/m);
|
|
assert.match(skill, /kakaocli/);
|
|
assert.match(skill, /macOS/i);
|
|
assert.match(skill, /KakaoTalk/i);
|
|
assert.match(skill, /Full Disk Access/i);
|
|
assert.match(skill, /Accessibility/i);
|
|
assert.match(skill, /--me/);
|
|
assert.match(skill, /confirm before sending/i);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py auth/);
|
|
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py chats --limit 10 --json/);
|
|
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py messages --chat/);
|
|
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py search/);
|
|
assert.match(doc, /user_id 자동 감지 실패|SHA-512|DESIGNATEDFRIENDSREVISION/i);
|
|
assert.match(doc, /cache|캐시/);
|
|
assert.match(doc, /read-only|읽기 전용/i);
|
|
assert.doesNotMatch(doc, /`query`/);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the KTX booking skill as supported", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "ktx-booking.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/ktx-booking.md to exist");
|
|
assert.match(readme, /\| KTX 예매 \|/);
|
|
assert.match(readme, /\[KTX 예매 가이드\]\(docs\/features\/ktx-booking\.md\)/);
|
|
assert.doesNotMatch(readme, /KTX 예매는 현재 작동하지 않습니다/);
|
|
assert.doesNotMatch(readme, /KTX 예매 \| 현재 작동하지 않음/);
|
|
assert.match(install, /--skill ktx-booking/);
|
|
});
|
|
|
|
test("ktx-booking docs document the helper-based live Korail workflow", () => {
|
|
const skillPath = path.join(repoRoot, "ktx-booking", "SKILL.md");
|
|
const helperPath = path.join(repoRoot, "scripts", "ktx_booking.py");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected ktx-booking/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(helperPath), "expected scripts/ktx_booking.py to exist");
|
|
|
|
const skill = read(path.join("ktx-booking", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "ktx-booking.md"));
|
|
const helper = read(path.join("scripts", "ktx_booking.py"));
|
|
|
|
assert.match(skill, /^name: ktx-booking$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /python3 scripts\/ktx_booking\.py search/);
|
|
assert.match(doc, /python3 scripts\/ktx_booking\.py reserve/);
|
|
assert.match(doc, /python3 scripts\/ktx_booking\.py reservations/);
|
|
assert.match(doc, /python3 scripts\/ktx_booking\.py cancel/);
|
|
assert.match(doc, /train_id/);
|
|
assert.match(doc, /--train-id/);
|
|
assert.match(doc, /--include-no-seats/);
|
|
assert.match(doc, /--include-waiting-list/);
|
|
assert.match(doc, /--try-waiting/);
|
|
assert.match(doc, /credential resolution order|KSKILL_KTX_ID/);
|
|
assert.match(doc, /anti-bot|Dynapath|x-dynapath-m-token/i);
|
|
assert.match(doc, /결제(까지)?는 자동화하지 않는다|결제는 제외/);
|
|
assert.doesNotMatch(doc, /예약 시 선택할 `--train-index`/);
|
|
}
|
|
|
|
assert.match(helper, /x-dynapath-m-token/);
|
|
assert.match(helper, /250601002/);
|
|
assert.match(helper, /def build_parser/);
|
|
assert.match(helper, /train_id/);
|
|
});
|
|
|
|
test("ktx-booking helper python regression tests pass", () => {
|
|
const result = childProcess.spawnSync(
|
|
"python3",
|
|
["-m", "unittest", "discover", "-s", "scripts", "-p", "test_ktx_booking.py"],
|
|
{
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
env: { ...process.env, PYTHONNOUSERSITE: "1" },
|
|
},
|
|
);
|
|
|
|
assert.equal(
|
|
result.status,
|
|
0,
|
|
`expected python KTX helper regression tests to pass\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
);
|
|
});
|
|
|
|
test("repository docs advertise the geeknews-search skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "geeknews-search.md");
|
|
const skillPath = path.join(repoRoot, "geeknews-search", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/geeknews-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected geeknews-search/SKILL.md to exist");
|
|
assert.match(readme, /\| 긱뉴스 조회 \|/);
|
|
assert.match(readme, /\[긱뉴스 조회 가이드\]\(docs\/features\/geeknews-search\.md\)/);
|
|
assert.match(install, /--skill geeknews-search/);
|
|
});
|
|
|
|
test("geeknews-search docs lock the RSS-first list-search-detail workflow", () => {
|
|
const skill = read(path.join("geeknews-search", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "geeknews-search.md"));
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /feeds\.feedburner\.com\/geeknews-feed/);
|
|
assert.match(doc, /python3 scripts\/geeknews_search\.py list/);
|
|
assert.match(doc, /python3 scripts\/geeknews_search\.py search/);
|
|
assert.match(doc, /python3 scripts\/geeknews_search\.py detail/);
|
|
assert.match(doc, /RSS-first|RSS first|RSS 피드/);
|
|
assert.match(doc, /read-only|읽기 전용/);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the subway-lost-property skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "subway-lost-property.md");
|
|
const skillPath = path.join(repoRoot, "subway-lost-property", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/subway-lost-property.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected subway-lost-property/SKILL.md to exist");
|
|
assert.match(readme, /\| 지하철 분실물 조회 \|/);
|
|
assert.match(readme, /\[지하철 분실물 조회 가이드\]\(docs\/features\/subway-lost-property\.md\)/);
|
|
assert.match(install, /--skill subway-lost-property/);
|
|
});
|
|
|
|
test("subway-lost-property docs lock the official LOST112 guidance flow", () => {
|
|
const skill = read(path.join("subway-lost-property", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "subway-lost-property.md"));
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /LOST112/);
|
|
assert.match(doc, /seoulmetro\.co\.kr\/kr\/page\.do\?menuIdx=541/);
|
|
assert.match(doc, /python3 scripts\/subway_lost_property\.py/);
|
|
assert.match(doc, /SITE=V/);
|
|
assert.match(doc, /안내형|하이브리드/);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the zipcode-search skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "zipcode-search.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/zipcode-search.md to exist");
|
|
assert.match(readme, /\| 우편번호 검색 \|/);
|
|
assert.match(readme, /\[우편번호 검색 가이드\]\(docs\/features\/zipcode-search\.md\)/);
|
|
assert.match(install, /--skill zipcode-search/);
|
|
assert.match(roadmap, /우편번호 검색/);
|
|
assert.match(sources, /우체국 도로명주소 검색: https:\/\/parcel\.epost\.go\.kr\/parcel\/comm\/zipcode\/comm_newzipcd_list\.jsp/);
|
|
});
|
|
|
|
test("zipcode-search docs lock the official postcode plus English-address extraction flow", () => {
|
|
const skillPath = path.join(repoRoot, "zipcode-search", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected zipcode-search/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("zipcode-search", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "zipcode-search.md"));
|
|
const readme = read("README.md");
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
|
|
assert.match(skill, /^name: zipcode-search$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /https:\/\/www\.epost\.kr\/search\.RetrieveIntegrationNewZipCdList\.comm/);
|
|
assert.match(doc, /viewDetail/);
|
|
assert.match(doc, /English\/집배코드/);
|
|
assert.match(doc, /Rep\. of KOREA/);
|
|
assert.match(doc, /curl --http1\.1 --tls-max 1\.2/);
|
|
assert.match(doc, /--max-time/);
|
|
assert.match(doc, /"--retry",\s+"3"/);
|
|
assert.match(doc, /--retry-all-errors/);
|
|
assert.match(doc, /"--retry-delay",\s+"1"/);
|
|
assert.match(doc, /영문 주소|영문주소/);
|
|
assert.match(doc, /python3 scripts\/zipcode_search\.py/);
|
|
assert.match(doc, /\.\/scripts\/zipcode_search\.py/);
|
|
assert.match(doc, /mktemp|임시 파일/);
|
|
assert.doesNotMatch(doc, /urllib\.request/);
|
|
}
|
|
|
|
assert.match(readme, /우편번호 \+ 공식 영문주소 조회/);
|
|
assert.match(sources, /우체국 통합 우편번호\/영문주소 검색: https:\/\/www\.epost\.kr\/search\.RetrieveIntegrationNewZipCdList\.comm/);
|
|
assert.match(skill, /검색 결과가 없으면/i);
|
|
assert.doesNotMatch(skill, /timeout\s*=/);
|
|
assert.doesNotMatch(featureDoc, /timeout\s*=/);
|
|
assert.match(featureDoc, /프로토콜\/클라이언트 제약/i);
|
|
});
|
|
|
|
test("repository docs advertise the delivery-tracking skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "delivery-tracking.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/delivery-tracking.md to exist");
|
|
assert.match(readme, /\| 택배 배송조회 \|/);
|
|
assert.match(readme, /\[택배 배송조회 가이드\]\(docs\/features\/delivery-tracking\.md\)/);
|
|
assert.match(install, /--skill delivery-tracking/);
|
|
assert.match(roadmap, /택배 배송조회 스킬 출시/);
|
|
assert.match(sources, /CJ대한통운 배송조회: https:\/\/www\.cjlogistics\.com\/ko\/tool\/parcel\/tracking/);
|
|
assert.match(sources, /우체국 배송조회: https:\/\/service\.epost\.go\.kr\/trace\.RetrieveRegiPrclDeliv\.postal\?sid1=/);
|
|
});
|
|
|
|
test("delivery-tracking skill documents official CJ and ePost flows with extension guidance", () => {
|
|
const skillPath = path.join(repoRoot, "delivery-tracking", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected delivery-tracking/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("delivery-tracking", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "delivery-tracking.md"));
|
|
|
|
assert.match(skill, /^name: delivery-tracking$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /https:\/\/www\.cjlogistics\.com\/ko\/tool\/parcel\/tracking/);
|
|
assert.match(doc, /tracking-detail/);
|
|
assert.match(doc, /paramInvcNo/);
|
|
assert.match(doc, /_csrf/);
|
|
assert.match(doc, /10자리 또는 12자리/);
|
|
assert.match(doc, /https:\/\/service\.epost\.go\.kr\/trace\.RetrieveRegiPrclDeliv\.postal\?sid1=/);
|
|
assert.match(doc, /trace\.RetrieveDomRigiTraceList\.comm/);
|
|
assert.match(doc, /sid1/);
|
|
assert.match(doc, /13자리/);
|
|
assert.match(doc, /curl --http1\.1 --tls-max 1\.2/);
|
|
assert.match(doc, /carrier adapter/i);
|
|
assert.match(doc, /다른 택배사/);
|
|
}
|
|
|
|
assert.match(skill, /1234567890/);
|
|
assert.match(skill, /1234567890123/);
|
|
assert.match(skill, /python3/);
|
|
assert.match(featureDoc, /JSON/);
|
|
assert.match(featureDoc, /HTML/);
|
|
});
|
|
|
|
test("delivery-tracking published examples lock a shared normalized non-PII schema", () => {
|
|
const skill = read(path.join("delivery-tracking", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "delivery-tracking.md"));
|
|
const expectedTopLevelEntries = {
|
|
cj: [
|
|
["carrier", '"cj"'],
|
|
["invoice", 'payload["parcelDetailResultMap"]["paramInvcNo"]'],
|
|
["status_code", 'latest.get("crgSt")'],
|
|
["status", 'status_map.get(latest.get("crgSt"), latest.get("scanNm") or "알수없음")'],
|
|
["timestamp", 'latest.get("dTime")'],
|
|
["location", 'latest.get("regBranNm")'],
|
|
["event_count", "len(events)"],
|
|
["recent_events", "normalized_events[-min(3, len(normalized_events)):]"],
|
|
],
|
|
epost: [
|
|
["carrier", '"epost"'],
|
|
["invoice", 'clean(summary.group("tracking"))'],
|
|
["status", 'clean(summary.group("result"))'],
|
|
["timestamp", 'latest_event["timestamp"] if latest_event else None'],
|
|
["location", 'latest_event["location"] if latest_event else None'],
|
|
["event_count", "len(normalized_events)"],
|
|
["recent_events", "normalized_events[-min(3, len(normalized_events)):]"],
|
|
],
|
|
};
|
|
const expectedRecentEventEntries = {
|
|
cj: [
|
|
["timestamp", 'event.get("dTime")'],
|
|
["location", 'event.get("regBranNm")'],
|
|
["status_code", 'event.get("crgSt")'],
|
|
["status", 'status_map.get(event.get("crgSt"), event.get("scanNm") or "알수없음")'],
|
|
],
|
|
epost: [
|
|
["timestamp", 'f"{day} {time_}"'],
|
|
["location", "clean_location(location)"],
|
|
["status", "clean(status)"],
|
|
],
|
|
};
|
|
|
|
assert.doesNotMatch(skill, /"message":\s*latest\.get\("crgNm"\)/);
|
|
assert.doesNotMatch(
|
|
featureDoc,
|
|
/print\(json\.dumps\(payload\["parcelDetailResultMap"\]\["resultList"\]\[-1\],\s*ensure_ascii=False,\s*indent=2\)\)/,
|
|
);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /공통 포맷/);
|
|
assert.match(doc, /공통 결과 스키마/);
|
|
assert.match(doc, /최근 이벤트/);
|
|
assert.match(doc, /`carrier`/);
|
|
assert.match(doc, /`invoice`/);
|
|
assert.match(doc, /`status`/);
|
|
assert.match(doc, /`timestamp`/);
|
|
assert.match(doc, /`location`/);
|
|
assert.match(doc, /`event_count`/);
|
|
assert.match(doc, /`recent_events`/);
|
|
assert.match(doc, /최근 최대 3개 이벤트/);
|
|
assert.doesNotMatch(doc, /최근 3~5개 이벤트/);
|
|
assert.match(doc, /"invoice":\s*payload\["parcelDetailResultMap"\]\["paramInvcNo"\]/);
|
|
assert.match(doc, /"status_code":\s*latest\.get\("crgSt"\)/);
|
|
assert.match(doc, /"status":\s*status_map\.get\(latest\.get\("crgSt"\),/);
|
|
assert.match(doc, /"timestamp":\s*latest\.get\("dTime"\)/);
|
|
assert.match(doc, /"location":\s*latest\.get\("regBranNm"\)/);
|
|
assert.match(doc, /"event_count":\s*len\(events\)/);
|
|
assert.match(doc, /"recent_events":/);
|
|
assert.match(doc, /"invoice":\s*clean\(summary\.group/);
|
|
assert.match(doc, /"timestamp":\s*latest_event\["timestamp"\] if latest_event else None/);
|
|
assert.match(doc, /"location":\s*latest_event\["location"\] if latest_event else None/);
|
|
assert.match(doc, /"event_count":\s*len\(normalized_events\)/);
|
|
assert.match(doc, /"recent_events":\s*normalized_events\[-min\(3,\s*len\(normalized_events\)\):\]/);
|
|
assert.match(doc, /def clean_location\(raw: str\) -> str:/);
|
|
assert.match(doc, /TEL/);
|
|
assert.match(doc, /\\d\{2,4\}/);
|
|
assert.match(doc, /"location":\s*clean_location\(location\)/);
|
|
assert.doesNotMatch(doc, /"tracking_no":/);
|
|
assert.doesNotMatch(doc, /"latest_event_date":/);
|
|
assert.doesNotMatch(doc, /"latest_event_time":/);
|
|
assert.doesNotMatch(doc, /"latest_event_location":/);
|
|
assert.doesNotMatch(doc, /"delivered_to":/);
|
|
assert.doesNotMatch(doc, /"delivery_result":/);
|
|
}
|
|
|
|
for (const [label, doc] of [
|
|
["skill doc", skill],
|
|
["feature doc", featureDoc],
|
|
]) {
|
|
assert.deepEqual(
|
|
extractQuotedEntries(findPrintedObjectBlock(doc, "cj"), 4),
|
|
expectedTopLevelEntries.cj,
|
|
`${label} CJ example must keep the exact normalized top-level mapping`,
|
|
);
|
|
assert.deepEqual(
|
|
extractQuotedEntries(findPrintedObjectBlock(doc, "epost"), 4),
|
|
expectedTopLevelEntries.epost,
|
|
`${label} ePost example must keep the exact normalized top-level mapping`,
|
|
);
|
|
assert.deepEqual(
|
|
extractQuotedEntries(
|
|
findRecentEventsBlock(doc, "cj"),
|
|
8,
|
|
),
|
|
expectedRecentEventEntries.cj,
|
|
`${label} CJ recent_events entries must keep the exact normalized mapping`,
|
|
);
|
|
assert.deepEqual(
|
|
extractQuotedEntries(
|
|
findRecentEventsBlock(doc, "epost"),
|
|
8,
|
|
),
|
|
expectedRecentEventEntries.epost,
|
|
`${label} ePost recent_events entries must keep the exact normalized mapping`,
|
|
);
|
|
}
|
|
|
|
assert.doesNotMatch(skill, /"message":\s*latest\.get\("crgNm"\)/);
|
|
assert.doesNotMatch(featureDoc, /print\(\{\s*"tracking_no"/);
|
|
});
|
|
|
|
test("delivery-tracking docs publish aligned sample normalized outputs for both carriers", () => {
|
|
const expectedSamples = readJson(
|
|
path.join("scripts", "fixtures", "delivery-tracking-public-samples.json"),
|
|
);
|
|
const skill = read(path.join("delivery-tracking", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "delivery-tracking.md"));
|
|
const cjSkillOutput = findJsonFenceAfterLabel(skill, "CJ 공개 출력 예시");
|
|
const cjFeatureOutput = findJsonFenceAfterLabel(featureDoc, "CJ 공개 출력 예시");
|
|
const epostSkillOutput = findJsonFenceAfterLabel(skill, "우체국 공개 출력 예시");
|
|
const epostFeatureOutput = findJsonFenceAfterLabel(featureDoc, "우체국 공개 출력 예시");
|
|
|
|
for (const [docLabel, doc] of [
|
|
["skill doc", skill],
|
|
["feature doc", featureDoc],
|
|
]) {
|
|
for (const [carrier, label] of [
|
|
["cj", "CJ 공개 출력 예시"],
|
|
["epost", "우체국 공개 출력 예시"],
|
|
]) {
|
|
assert.equal(
|
|
findJsonFenceTextAfterLabel(doc, label),
|
|
JSON.stringify(expectedSamples[carrier], null, 2),
|
|
`${docLabel} ${carrier} sample JSON block must stay byte-for-byte aligned with the checked-in public fixture`,
|
|
);
|
|
}
|
|
}
|
|
assert.deepEqual(cjSkillOutput, cjFeatureOutput, "CJ sample output must stay aligned across docs");
|
|
assert.deepEqual(epostSkillOutput, epostFeatureOutput, "ePost sample output must stay aligned across docs");
|
|
assert.deepEqual(cjSkillOutput, expectedSamples.cj, "CJ sample output must stay pinned to the verified public fixture");
|
|
assert.deepEqual(epostSkillOutput, expectedSamples.epost, "ePost sample output must stay pinned to the verified public fixture");
|
|
assertSanitizedPublicOutput(cjSkillOutput, "CJ sample output");
|
|
assertSanitizedPublicOutput(epostSkillOutput, "ePost sample output");
|
|
});
|
|
|
|
test("delivery-tracking docs pin sample provenance to the verified smoke-test date and invoice", () => {
|
|
const expectedProvenance = readJson(
|
|
path.join("scripts", "fixtures", "delivery-tracking-public-provenance.json"),
|
|
);
|
|
const skill = read(path.join("delivery-tracking", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "delivery-tracking.md"));
|
|
|
|
for (const [docLabel, doc] of [
|
|
["skill doc", skill],
|
|
["feature doc", featureDoc],
|
|
]) {
|
|
assertSampleProvenance(doc, "CJ 공개 출력 예시", expectedProvenance.cj, docLabel);
|
|
assertSampleProvenance(doc, "우체국 공개 출력 예시", expectedProvenance.epost, docLabel);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the daiso-product-search skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "daiso-product-search.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/daiso-product-search.md to exist");
|
|
assert.match(readme, /\| 다이소 상품 조회 \|/);
|
|
assert.match(readme, /\[다이소 상품 조회 가이드\]\(docs\/features\/daiso-product-search\.md\)/);
|
|
assert.match(install, /--skill daiso-product-search/);
|
|
});
|
|
|
|
test("daiso-product-search skill documents the official Daiso Mall lookup flow", () => {
|
|
const skillPath = path.join(repoRoot, "daiso-product-search", "SKILL.md");
|
|
const featureDoc = read(path.join("docs", "features", "daiso-product-search.md"));
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected daiso-product-search/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("daiso-product-search", "SKILL.md"));
|
|
|
|
assert.match(skill, /^name: daiso-product-search$/m);
|
|
assert.match(skill, /다이소몰/i);
|
|
assert.match(skill, /매장명/);
|
|
assert.match(skill, /상품명|검색어/);
|
|
assert.match(skill, /https:\/\/www\.daisomall\.co\.kr\/api\/ms\/msg\/selStr/);
|
|
assert.match(skill, /https:\/\/www\.daisomall\.co\.kr\/ssn\/search\/SearchGoods/);
|
|
assert.match(skill, /https:\/\/www\.daisomall\.co\.kr\/api\/pd\/pdh\/selStrPkupStck/);
|
|
assert.match(skill, /공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심/);
|
|
assert.match(featureDoc, /SearchGoods/);
|
|
assert.match(featureDoc, /selStrPkupStck/);
|
|
});
|
|
|
|
test("daiso-product-search package exposes reusable store, product, and stock helpers", () => {
|
|
const pkg = require(path.join(repoRoot, "packages", "daiso-product-search", "src", "index.js"));
|
|
|
|
assert.equal(typeof pkg.searchStores, "function");
|
|
assert.equal(typeof pkg.searchProducts, "function");
|
|
assert.equal(typeof pkg.getStorePickupStock, "function");
|
|
assert.equal(typeof pkg.lookupStoreProductAvailability, "function");
|
|
});
|
|
|
|
test("daiso-product-search docs record the shipped feature and official sources", () => {
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
|
|
assert.match(roadmap, /다이소 상품 조회 스킬 출시/);
|
|
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/api\/ms\/msg\/selStr/);
|
|
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/ssn\/search\/SearchGoods/);
|
|
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/api\/pd\/pdh\/selStrPkupStck/);
|
|
});
|
|
|
|
test("repository docs advertise the gangnamunni-clinic-search skill across install surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "gangnamunni-clinic-search.md");
|
|
const skillPath = path.join(repoRoot, "gangnamunni-clinic-search", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/gangnamunni-clinic-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected gangnamunni-clinic-search/SKILL.md to exist");
|
|
assert.match(readme, /\| 강남언니 병원 조회 \| `gangnamunni-clinic-search` \|/);
|
|
assert.match(readme, /\[강남언니 병원 조회 가이드\]\(docs\/features\/gangnamunni-clinic-search\.md\)/);
|
|
assert.match(install, /--skill gangnamunni-clinic-search/);
|
|
assert.match(install, /npm install -g .*gangnamunni-clinic-search/);
|
|
assert.match(sources, /강남언니 공개 검색: https:\/\/www\.gangnamunni\.com\/search\?q=<keyword>/);
|
|
assert.match(sources, /강남언니 공개 병원 페이지: https:\/\/www\.gangnamunni\.com\/hospitals\/<id>/);
|
|
});
|
|
|
|
test("repository docs advertise the market-kurly-search skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "market-kurly-search.md");
|
|
const skillPath = path.join(repoRoot, "market-kurly-search", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/market-kurly-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected market-kurly-search/SKILL.md to exist");
|
|
assert.match(readme, /\| 마켓컬리 상품 조회 \|/);
|
|
assert.match(readme, /\[마켓컬리 상품 조회 가이드\]\(docs\/features\/market-kurly-search\.md\)/);
|
|
assert.match(install, /--skill market-kurly-search/);
|
|
assert.match(install, /npm install -g .* market-kurly-search/);
|
|
assert.match(roadmap, /마켓컬리 상품 조회 스킬 출시/);
|
|
assert.match(sources, /https:\/\/api\.kurly\.com\/search\/v4\/sites\/market\/normal-search/);
|
|
assert.match(sources, /https:\/\/api\.kurly\.com\/search\/v3\/sites\/market\/normal-search\/count/);
|
|
assert.match(sources, /https:\/\/www\.kurly\.com\/goods\/5063110/);
|
|
});
|
|
|
|
test("market-kurly-search skill and docs describe the unauthenticated Kurly search and detail flow", () => {
|
|
const skill = read(path.join("market-kurly-search", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "market-kurly-search.md"));
|
|
|
|
assert.match(skill, /^name: market-kurly-search$/m);
|
|
assert.match(skill, /^description: .*마켓컬리.*상품.*가격.*$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /api\.kurly\.com\/search\/v4\/sites\/market\/normal-search/);
|
|
assert.match(doc, /api\.kurly\.com\/search\/v3\/sites\/market\/normal-search\/count/);
|
|
assert.match(doc, /www\.kurly\.com\/goods\/<productNo>|www\.kurly\.com\/goods\/5063110/);
|
|
assert.match(doc, /로그인 없이|비로그인/);
|
|
assert.match(doc, /현재 가격|할인/);
|
|
assert.match(doc, /품절 여부|판매 상태/);
|
|
assert.match(doc, /가격.*달라질 수|시점에 따라 달라질 수/u);
|
|
assert.match(doc, /주문|장바구니/);
|
|
assert.match(doc, /보수적으로|보수적/);
|
|
}
|
|
});
|
|
|
|
test("market-kurly-search package exposes reusable search/count/detail helpers", () => {
|
|
const pkg = require(path.join(repoRoot, "packages", "market-kurly-search", "src", "index.js"));
|
|
|
|
assert.equal(typeof pkg.searchProducts, "function");
|
|
assert.equal(typeof pkg.countProducts, "function");
|
|
assert.equal(typeof pkg.getProductDetail, "function");
|
|
});
|
|
|
|
test("repository docs advertise the olive-young-search skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "olive-young-search.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/olive-young-search.md to exist");
|
|
assert.match(readme, /\| 올리브영 검색 \|/);
|
|
assert.match(readme, /\[올리브영 검색 가이드\]\(docs\/features\/olive-young-search\.md\)/);
|
|
assert.match(install, /--skill olive-young-search/);
|
|
assert.match(install, /npm install -g .* daiso/);
|
|
assert.match(roadmap, /올리브영 검색 스킬 출시/);
|
|
assert.match(sources, /https:\/\/github\.com\/hmmhmmhm\/daiso-mcp/);
|
|
assert.match(sources, /https:\/\/www\.npmjs\.com\/package\/daiso/);
|
|
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/oliveyoung\/stores/);
|
|
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/oliveyoung\/products/);
|
|
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/oliveyoung\/inventory/);
|
|
});
|
|
|
|
test("olive-young install docs warn about intermittent public endpoint failures and direct users to retry or clone fallback", () => {
|
|
const install = read(path.join("docs", "install.md"));
|
|
const quickstart = findSection(install, "### `olive-young-search` upstream CLI quickstart");
|
|
|
|
assert.match(install, /olive-young-search/);
|
|
assert.match(install, /5xx\/503/);
|
|
assert.match(install, /재시도|retry/i);
|
|
assert.match(install, /clone fallback|git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git/i);
|
|
assertOliveYoungCloneFallbackShorthand(quickstart, "olive-young install quickstart");
|
|
assertOliveYoungCloneFallbackCommands(quickstart, "olive-young install quickstart");
|
|
});
|
|
|
|
test("olive-young-search skill documents the upstream daiso CLI flow for stores, products, and inventory", () => {
|
|
const skillPath = path.join(repoRoot, "olive-young-search", "SKILL.md");
|
|
const featureDoc = read(path.join("docs", "features", "olive-young-search.md"));
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected olive-young-search/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("olive-young-search", "SKILL.md"));
|
|
const featureTop = findSection(featureDoc, "## 가장 중요한 규칙");
|
|
const featureFallback = findSection(featureDoc, "## 원본 저장소 clone fallback");
|
|
const skillFallback = findSection(skill, "## Fallback: clone the original repository and run the same CLI locally");
|
|
|
|
assert.match(skill, /^name: olive-young-search$/m);
|
|
assert.match(skill, /^description: .*올리브영.*매장.*상품.*재고.*$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /hmmhmmhm\/daiso-mcp/);
|
|
assert.match(doc, /https:\/\/github\.com\/hmmhmmhm\/daiso-mcp/);
|
|
assert.match(doc, /npm install -g daiso|npx --yes daiso|npx daiso/);
|
|
assert.match(doc, /git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git/);
|
|
assert.match(doc, /npm install/);
|
|
assert.match(doc, /npm run build/);
|
|
assert.match(doc, /MCP 서버를 .*직접 설치.*않고.*CLI/u);
|
|
assert.match(doc, /매장 검색/);
|
|
assert.match(doc, /상품 검색/);
|
|
assert.match(doc, /재고 확인/);
|
|
assert.match(doc, /\/api\/oliveyoung\/stores/);
|
|
assert.match(doc, /\/api\/oliveyoung\/products/);
|
|
assert.match(doc, /\/api\/oliveyoung\/inventory/);
|
|
assert.match(doc, /vendoring 하지 않/);
|
|
}
|
|
|
|
assertOliveYoungCloneFallbackShorthand(featureTop, "olive-young feature guide shorthand");
|
|
|
|
for (const fallbackDoc of [featureFallback, skillFallback]) {
|
|
assertOliveYoungCloneFallbackCommands(fallbackDoc, "olive-young clone fallback docs");
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the korean-cinema-search skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-cinema-search.md");
|
|
const skillPath = path.join(repoRoot, "korean-cinema-search", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-cinema-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-cinema-search/SKILL.md to exist");
|
|
assert.match(readme, /\| 영화관 검색 \|/);
|
|
assert.match(readme, /\[영화관 검색 가이드\]\(docs\/features\/korean-cinema-search\.md\)/);
|
|
assert.match(install, /--skill korean-cinema-search/);
|
|
assert.match(install, /--playDate <YYYYMMDD>/);
|
|
assert.match(install, /5xx|retry|재시도/);
|
|
assert.match(install, /git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git && cd daiso-mcp && npm install && npm run build/);
|
|
assert.match(install, /node dist\/bin\.js get \/api\/cgv\/timetable --keyword 강남 --playDate <YYYYMMDD> --json/);
|
|
assert.match(roadmap, /영화관 검색 스킬 출시/);
|
|
assert.match(sources, /https:\/\/github\.com\/hmmhmmhm\/daiso-mcp/);
|
|
assert.match(sources, /https:\/\/www\.npmjs\.com\/package\/daiso/);
|
|
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/cgv\/theaters/);
|
|
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/megabox\/theaters/);
|
|
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/lottecinema\/theaters/);
|
|
});
|
|
|
|
test("korean-cinema-search skill documents the upstream daiso CLI flow for Korean cinemas", () => {
|
|
const skillPath = path.join(repoRoot, "korean-cinema-search", "SKILL.md");
|
|
const featureDoc = read(path.join("docs", "features", "korean-cinema-search.md"));
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-cinema-search/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("korean-cinema-search", "SKILL.md"));
|
|
|
|
assert.match(skill, /^name: korean-cinema-search$/m);
|
|
assert.match(skill, /^description: .*CGV.*메가박스.*롯데시네마.*$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /hmmhmmhm\/daiso-mcp/);
|
|
assert.match(doc, /npm install -g daiso|npx --yes daiso|npx daiso/);
|
|
assert.match(doc, /MCP 서버를 .*직접 설치.*않고.*CLI/u);
|
|
assert.match(doc, /Asia\/Seoul|KST/);
|
|
assert.match(doc, /YYYYMMDD/);
|
|
assert.match(doc, /--playDate <YYYYMMDD>/);
|
|
assert.match(doc, /\/api\/cgv\/movies --keyword 강남 --playDate <YYYYMMDD> --json/);
|
|
assert.match(doc, /\/api\/cgv\/timetable --keyword 강남 --playDate <YYYYMMDD> --json/);
|
|
assert.match(doc, /\/api\/megabox\/seats --keyword 코엑스 --playDate <YYYYMMDD> --limit 10 --json/);
|
|
assert.match(doc, /\/api\/lottecinema\/seats --keyword 월드타워 --playDate <YYYYMMDD> --limit 10 --json/);
|
|
assert.match(doc, /CGV/);
|
|
assert.match(doc, /메가박스/);
|
|
assert.match(doc, /롯데시네마/);
|
|
assert.match(doc, /\/api\/cgv\/theaters/);
|
|
assert.match(doc, /\/api\/cgv\/movies/);
|
|
assert.match(doc, /\/api\/cgv\/timetable/);
|
|
assert.match(doc, /\/api\/megabox\/theaters/);
|
|
assert.match(doc, /\/api\/megabox\/movies/);
|
|
assert.match(doc, /\/api\/megabox\/seats/);
|
|
assert.match(doc, /\/api\/lottecinema\/theaters/);
|
|
assert.match(doc, /\/api\/lottecinema\/movies/);
|
|
assert.match(doc, /\/api\/lottecinema\/seats/);
|
|
assert.match(doc, /예매|결제/);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the bunjang-search skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "bunjang-search.md");
|
|
const skillPath = path.join(repoRoot, "bunjang-search", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/bunjang-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected bunjang-search/SKILL.md to exist");
|
|
assert.match(readme, /\| 번개장터 검색 \|/);
|
|
assert.match(readme, /\[번개장터 검색 가이드\]\(docs\/features\/bunjang-search\.md\)/);
|
|
assert.match(install, /--skill bunjang-search/);
|
|
assert.match(install, /npm install -g .* bunjang-cli/);
|
|
assert.match(roadmap, /번개장터 검색 스킬 출시/);
|
|
assert.match(sources, /https:\/\/www\.npmjs\.com\/package\/bunjang-cli/);
|
|
assert.match(sources, /https:\/\/github\.com\/pinion05\/bunjangcli/);
|
|
});
|
|
|
|
test("bunjang-search skill documents bunjang-cli search, detail, favorite, chat, and AI export flows", () => {
|
|
const skill = read(path.join("bunjang-search", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "bunjang-search.md"));
|
|
const install = read(path.join("docs", "install.md"));
|
|
|
|
assert.match(skill, /^name: bunjang-search$/m);
|
|
assert.match(skill, /^description: .*번개장터.*검색.*상세.*찜.*채팅.*$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /bunjang-cli/);
|
|
assert.match(doc, /pinion05\/bunjangcli/);
|
|
assert.match(doc, /npx --yes bunjang-cli --help/);
|
|
assert.match(doc, /npx --yes bunjang-cli search /);
|
|
assert.match(doc, /item get/);
|
|
assert.match(doc, /favorite add/);
|
|
assert.match(doc, /favorite remove/);
|
|
assert.match(doc, /favorite list/);
|
|
assert.match(doc, /chat list/);
|
|
assert.match(doc, /chat start/);
|
|
assert.match(doc, /chat send/);
|
|
assert.match(doc, /--start-page/);
|
|
assert.match(doc, /--pages/);
|
|
assert.match(doc, /--max-items/);
|
|
assert.match(doc, /--with-detail/);
|
|
assert.match(doc, /--output/);
|
|
assert.match(doc, /--ai/);
|
|
assert.match(doc, /TOON|toon/i);
|
|
assert.match(doc, /TTY|interactive/);
|
|
assert.match(doc, /로그인.*선택적|선택적.*로그인/u);
|
|
assert.match(
|
|
doc,
|
|
/검색 결과.*(제목.?가격|가격.?제목).*(1차|우선)|title.?price.*(triage|first)/i,
|
|
);
|
|
assert.match(
|
|
doc,
|
|
/(description|status|location).*(item get|--with-detail).*(전|먼저|이후)|((item get|--with-detail).*(description|status|location).*(전|먼저|이후))/i,
|
|
);
|
|
assert.match(doc, /노이즈|noisy|불안정|rely on/i);
|
|
}
|
|
|
|
assert.match(install, /### `bunjang-search` upstream CLI quickstart/);
|
|
assert.match(install, /npx --yes bunjang-cli --help/);
|
|
assert.match(install, /npx --yes bunjang-cli search "아이폰"/);
|
|
assert.match(install, /npx --yes bunjang-cli --json item get/);
|
|
});
|
|
|
|
test("repository docs advertise the coupang-product-search skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "coupang-product-search.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/coupang-product-search.md to exist");
|
|
assert.match(readme, /\| 쿠팡 상품 검색 \|/);
|
|
assert.match(readme, /\[쿠팡 상품 검색 가이드\]\(docs\/features\/coupang-product-search\.md\)/);
|
|
assert.match(install, /--skill coupang-product-search/);
|
|
});
|
|
|
|
test("coupang-product-search skill and docs use retention-corp coupang_partners MCP layer", () => {
|
|
const skillPath = path.join(repoRoot, "coupang-product-search", "SKILL.md");
|
|
const wrapperPath = path.join(repoRoot, "coupang-product-search", "scripts", "coupang_partners_mcp.py");
|
|
const featureDoc = read(path.join("docs", "features", "coupang-product-search.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected coupang-product-search/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(wrapperPath), "expected retention-corp wrapper script to exist");
|
|
|
|
const skill = read(path.join("coupang-product-search", "SKILL.md"));
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /retention-corp\/coupang_partners/);
|
|
assert.match(doc, /local:\/\/coupang-mcp/);
|
|
assert.match(doc, /coupang_partners_mcp\.py/);
|
|
assert.match(doc, /--repo-dir/);
|
|
assert.match(doc, /--no-clone/);
|
|
assert.match(doc, /--update/);
|
|
assert.match(doc, /coupang_partners_mcp\.py\s+tools/);
|
|
assert.match(doc, /coupang_partners_mcp\.py\s+init/);
|
|
assert.match(doc, /search_coupang_products/);
|
|
assert.match(doc, /로켓배송/);
|
|
assert.match(doc, /a\.retn\.kr\/v1\/public\/assist/);
|
|
assert.match(doc, /OPENCLAW_SHOPPING_/);
|
|
assert.match(doc, /(파트너스|어필리에이트|affiliate)/i);
|
|
assert.match(doc, /(hosted\s*fallback|호스티드\s*폴백|호스티드\s*fallback)/i);
|
|
assert.doesNotMatch(doc, /yuju777-coupang-mcp\.hf\.space\/mcp/);
|
|
assert.doesNotMatch(doc, /github\.com\/uju777\/coupang-mcp/);
|
|
}
|
|
|
|
assert.match(sources, /retention-corp\/coupang_partners/);
|
|
assert.match(sources, /a\.retn\.kr\/v1\/public\/assist/);
|
|
assert.doesNotMatch(sources, /yuju777-coupang-mcp\.hf\.space\/mcp/);
|
|
});
|
|
|
|
test("coupang-product-search docs drop non-allowlisted coupang-mcp-fallback and document openclaw-skill as the allowlisted hosted fallback client-id", () => {
|
|
// Direct probes against https://a.retn.kr/v1/public/assist on 2026-04-21 show that
|
|
// `X-OpenClaw-Client-Id: coupang-mcp-fallback` returns HTTP 403 ("Client is not
|
|
// allowlisted"), while `openclaw-skill` (the upstream default that ships with
|
|
// retention-corp/coupang_partners) returns HTTP 200. Until Retention Corp
|
|
// re-allowlists `coupang-mcp-fallback`, k-skill docs must not recommend it and
|
|
// must document `openclaw-skill` as the value the hosted fallback path uses.
|
|
const skill = read(path.join("coupang-product-search", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "coupang-product-search.md"));
|
|
const wrapper = read(path.join("coupang-product-search", "scripts", "coupang_partners_mcp.py"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
|
|
for (const doc of [skill, featureDoc, wrapper, sources]) {
|
|
assert.doesNotMatch(doc, /coupang-mcp-fallback/);
|
|
}
|
|
|
|
for (const doc of [skill, featureDoc, wrapper]) {
|
|
assert.match(doc, /openclaw-skill/);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the ohou-today-deal skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "ohou-today-deal.md");
|
|
const skillPath = path.join(repoRoot, "ohou-today-deal", "SKILL.md");
|
|
const helperPath = path.join(repoRoot, "ohou-today-deal", "scripts", "ohou_today_deal.py");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/ohou-today-deal.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected ohou-today-deal/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(helperPath), "expected ohou-today-deal helper script to exist");
|
|
assert.match(readme, /\| 오늘의집 오늘의딜 조회 \| `ohou-today-deal` \|/);
|
|
assert.match(readme, /\[오늘의집 오늘의딜 조회 가이드\]\(docs\/features\/ohou-today-deal\.md\)/);
|
|
assert.match(install, /--skill ohou-today-deal/);
|
|
assert.match(roadmap, /오늘의집 오늘의딜 조회 스킬 출시/);
|
|
assert.match(sources, /ohou\.se\/commerces\/today_deals/);
|
|
assert.match(sources, /store\.ohou\.se\/today_deals/);
|
|
});
|
|
|
|
test("ohou-today-deal docs lock the public Next data read-only workflow", () => {
|
|
const skill = read(path.join("ohou-today-deal", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "ohou-today-deal.md"));
|
|
const helper = read(path.join("ohou-today-deal", "scripts", "ohou_today_deal.py"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /https:\/\/ohou\.se\/commerces\/today_deals/);
|
|
assert.match(doc, /https:\/\/store\.ohou\.se\/today_deals/);
|
|
assert.match(doc, /__NEXT_DATA__/);
|
|
assert.match(doc, /today-deal-feed/);
|
|
assert.match(doc, /ohou_today_deal\.py list/);
|
|
assert.match(doc, /(로그인|API key|API 키).*(불필요|없음)|(불필요|없음).*(로그인|API key|API 키)/);
|
|
assert.match(doc, /(구매|장바구니|결제).*자동화.*(하지 않는다|하지 말고|제외)/);
|
|
}
|
|
|
|
assert.match(helper, /DEFAULT_URL = "https:\/\/ohou\.se\/commerces\/today_deals"/);
|
|
assert.match(helper, /__NEXT_DATA__/);
|
|
assert.match(helper, /today-deal-feed/);
|
|
assert.match(helper, /special-today-deal-feed/);
|
|
assert.match(
|
|
helper,
|
|
/k-skill-ohou-today-deal\/1\.0 \(\+https:\/\/github\.com\/NomaDamas\/k-skill\)/,
|
|
);
|
|
assert.match(sources, /ohou\.se\/commerces\/today_deals/);
|
|
assert.match(sources, /store\.ohou\.se\/today_deals/);
|
|
});
|
|
|
|
test("root pack:dry-run script covers all publishable workspaces", () => {
|
|
const packageJson = readJson("package.json");
|
|
const packScript = packageJson.scripts["pack:dry-run"];
|
|
const publishableWorkspaces = fs
|
|
.readdirSync(path.join(repoRoot, "packages"), { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => path.join("packages", entry.name, "package.json"))
|
|
.filter((packagePath) => fs.existsSync(path.join(repoRoot, packagePath)))
|
|
.map((packagePath) => readJson(packagePath))
|
|
.filter((workspacePackage) => workspacePackage.private !== true)
|
|
.map((workspacePackage) => workspacePackage.name);
|
|
|
|
assert.ok(publishableWorkspaces.includes("donation-place-search"));
|
|
for (const workspaceName of publishableWorkspaces) {
|
|
assert.match(packScript, new RegExp(`workspace ${escapeRegex(workspaceName)}(?:\\s|$)`));
|
|
}
|
|
});
|
|
|
|
test("README main capability table advertises the donation-place-search skill", () => {
|
|
const readme = read("README.md");
|
|
const tableSection = findSection(readme, "## 어떤 걸 할 수 있나");
|
|
|
|
assert.match(tableSection, /기부처 조회/);
|
|
assert.match(tableSection, /`donation-place-search`/);
|
|
assert.match(tableSection, /docs\/features\/donation-place-search\.md/);
|
|
});
|
|
|
|
test("donation-place-search install docs include the skill and npm helper", () => {
|
|
const install = read(path.join("docs", "install.md"));
|
|
|
|
assert.match(install, /--skill donation-place-search/);
|
|
assert.match(install, /npm install -g .*donation-place-search/);
|
|
});
|
|
|
|
test("donation-place-search docs describe 1365 links as best-effort verification assists", () => {
|
|
const skill = read(path.join("donation-place-search", "SKILL.md"));
|
|
const packageReadme = read(path.join("packages", "donation-place-search", "README.md"));
|
|
const featureDoc = read(path.join("docs", "features", "donation-place-search.md"));
|
|
const packageJson = readJson(path.join("packages", "donation-place-search", "package.json"));
|
|
|
|
for (const doc of [skill, packageReadme, featureDoc, packageJson.description]) {
|
|
assert.match(doc, /best-effort|보조|assist/i);
|
|
assert.doesNotMatch(doc, /candidate-verified|후보별 검증 완료/);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the kbl-results skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "kbl-results.md");
|
|
const skillPath = path.join(repoRoot, "kbl-results", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kbl-results.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected kbl-results/SKILL.md to exist");
|
|
assert.match(readme, /\| KBL 경기 결과 조회 \|/);
|
|
assert.match(readme, /\[KBL 경기 결과 가이드\]\(docs\/features\/kbl-results\.md\)/);
|
|
assert.match(install, /--skill kbl-results/);
|
|
assert.match(roadmap, /KBL 경기 결과 조회 스킬 출시/);
|
|
assert.match(sources, /KBL 일정\/결과 API: https:\/\/api\.kbl\.or\.kr\/match\/list/);
|
|
assert.match(sources, /KBL 팀 순위 API: https:\/\/api\.kbl\.or\.kr\/league\/rank\/team/);
|
|
});
|
|
|
|
test("kbl-results skill documents the official JSON flow for date, team, and standings lookups", () => {
|
|
const skillPath = path.join(repoRoot, "kbl-results", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected kbl-results/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("kbl-results", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "kbl-results.md"));
|
|
|
|
assert.match(skill, /^name: kbl-results$/m);
|
|
assert.match(skill, /^description: .*KBL.*경기 결과.*순위.*$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /YYYY-MM-DD/);
|
|
assert.match(doc, /서울 SK|부산 KCC|팀 코드/);
|
|
assert.match(doc, /https:\/\/api\.kbl\.or\.kr\/match\/list/);
|
|
assert.match(doc, /https:\/\/api\.kbl\.or\.kr\/league\/rank\/team/);
|
|
assert.match(doc, /공식 JSON|공식 API|공식 표면/u);
|
|
assert.match(doc, /현재 순위|standings/i);
|
|
assert.match(doc, /kbl-results|KBL 경기 결과/u);
|
|
}
|
|
});
|
|
|
|
test("kbl-results package exports reusable results and standings helpers", () => {
|
|
const pkg = require(path.join(repoRoot, "packages", "kbl-results", "src", "index.js"));
|
|
|
|
assert.equal(typeof pkg.getMatchResults, "function");
|
|
assert.equal(typeof pkg.getStandings, "function");
|
|
assert.equal(typeof pkg.getKBLSummary, "function");
|
|
});
|
|
|
|
test("kbl-results package README stays aligned with the official KBL JSON lookup flow", () => {
|
|
const packageReadme = read(path.join("packages", "kbl-results", "README.md"));
|
|
|
|
assert.match(packageReadme, /공식 KBL JSON 엔드포인트/u);
|
|
assert.match(packageReadme, /api\.kbl\.or\.kr\/match\/list/);
|
|
assert.match(packageReadme, /league\/rank\/team/);
|
|
assert.match(packageReadme, /getKBLSummary/);
|
|
assert.match(packageReadme, /서울 SK/);
|
|
});
|
|
|
|
test("repository docs advertise the kleague-results skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "kleague-results.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kleague-results.md to exist");
|
|
assert.match(readme, /\| K리그 경기 결과 조회 \|/);
|
|
assert.match(readme, /\[K리그 결과 가이드\]\(docs\/features\/kleague-results\.md\)/);
|
|
assert.match(install, /--skill kleague-results/);
|
|
assert.match(roadmap, /K리그 경기 결과 조회 스킬 출시/);
|
|
assert.match(sources, /K League 일정\/결과 JSON: https:\/\/www\.kleague\.com\/getScheduleList\.do/);
|
|
assert.match(sources, /K League 팀 순위 JSON: https:\/\/www\.kleague\.com\/record\/teamRank\.do/);
|
|
});
|
|
|
|
test("kleague-results skill documents the official JSON flow for date, team, and standings lookups", () => {
|
|
const skillPath = path.join(repoRoot, "kleague-results", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected kleague-results/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("kleague-results", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "kleague-results.md"));
|
|
|
|
assert.match(skill, /^name: kleague-results$/m);
|
|
assert.match(skill, /^description: .*케이리그.*경기 결과.*순위.*$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /YYYY-MM-DD/);
|
|
assert.match(doc, /K리그1|K리그2/);
|
|
assert.match(doc, /FC서울|서울 이랜드|팀 코드/);
|
|
assert.match(doc, /https:\/\/www\.kleague\.com\/getScheduleList\.do/);
|
|
assert.match(doc, /https:\/\/www\.kleague\.com\/record\/teamRank\.do/);
|
|
assert.match(doc, /공식 JSON|공식 API|공식 표면/u);
|
|
assert.match(doc, /현재 순위|standings/i);
|
|
assert.match(doc, /kleague-results|K리그 결과 조회/u);
|
|
}
|
|
});
|
|
|
|
test("kleague-results package exports reusable results and standings helpers", () => {
|
|
const pkg = require(path.join(repoRoot, "packages", "kleague-results", "src", "index.js"));
|
|
|
|
assert.equal(typeof pkg.getMatchResults, "function");
|
|
assert.equal(typeof pkg.getStandings, "function");
|
|
assert.equal(typeof pkg.getKLeagueSummary, "function");
|
|
});
|
|
|
|
test("kleague-results package README stays aligned with the official K League JSON lookup flow", () => {
|
|
const packageReadme = read(path.join("packages", "kleague-results", "README.md"));
|
|
|
|
assert.match(packageReadme, /공식 K리그 JSON 엔드포인트/u);
|
|
assert.match(packageReadme, /getScheduleList\.do/);
|
|
assert.match(packageReadme, /teamRank\.do/);
|
|
assert.match(packageReadme, /getKLeagueSummary/);
|
|
assert.match(packageReadme, /FC서울/);
|
|
});
|
|
|
|
test("repository docs advertise the blue-ribbon-nearby skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "blue-ribbon-nearby.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/blue-ribbon-nearby.md to exist");
|
|
assert.match(readme, /\| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 \|/);
|
|
assert.match(readme, /\[근처 블루리본 맛집 가이드\]\(docs\/features\/blue-ribbon-nearby\.md\)/);
|
|
assert.match(readme, /블루리본 측이 `www\.bluer\.co\.kr` 에 자동화 접근 전면 차단/);
|
|
assert.match(install, /--skill blue-ribbon-nearby/);
|
|
assert.match(roadmap, /근처 블루리본 맛집 스킬 출시/);
|
|
assert.match(sources, /블루리본 지역 검색: https:\/\/www\.bluer\.co\.kr\/search\/zone/);
|
|
assert.match(sources, /블루리본 주변 맛집 JSON: https:\/\/www\.bluer\.co\.kr\/restaurants\/map/);
|
|
});
|
|
|
|
test("blue-ribbon-nearby skill documents mandatory location prompting and official Blue Ribbon nearby search flow", () => {
|
|
const skillPath = path.join(repoRoot, "blue-ribbon-nearby", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected blue-ribbon-nearby/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("blue-ribbon-nearby", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "blue-ribbon-nearby.md"));
|
|
|
|
assert.match(skill, /^name: blue-ribbon-nearby$/m);
|
|
assert.match(skill, /^description: .*근처 맛집.*블루리본.*$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /반드시.*현재 위치/u);
|
|
assert.match(doc, /맛집.*기본적으로.*blue-ribbon-nearby|맛집.*기본적으로.*블루리본/u);
|
|
assert.match(doc, /https:\/\/www\.bluer\.co\.kr\/search\/zone/);
|
|
assert.match(doc, /https:\/\/www\.bluer\.co\.kr\/restaurants\/map/);
|
|
assert.match(doc, /zone2Lat/);
|
|
assert.match(doc, /zone2Lng/);
|
|
assert.match(doc, /isAround=true/);
|
|
assert.match(doc, /ribbon=true/);
|
|
assert.match(doc, /위도|경도|동네|역명/u);
|
|
assert.match(doc, /blue-ribbon-nearby|근처 블루리본 맛집/u);
|
|
}
|
|
});
|
|
|
|
test("blue-ribbon-nearby package README stays aligned with the location-first and official-surface guidance", () => {
|
|
const packageReadme = read(path.join("packages", "blue-ribbon-nearby", "README.md"));
|
|
|
|
assert.match(packageReadme, /먼저 현재 위치를 묻/u);
|
|
assert.match(packageReadme, /코엑스.*삼성동\/대치동/u);
|
|
assert.match(packageReadme, /https:\/\/www\.bluer\.co\.kr\/search\/zone/);
|
|
assert.match(packageReadme, /https:\/\/www\.bluer\.co\.kr\/restaurants\/map/);
|
|
assert.match(packageReadme, /searchNearbyByLocationQuery/);
|
|
});
|
|
|
|
test("repository docs advertise the kakao-bar-nearby skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "kakao-bar-nearby.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakao-bar-nearby.md to exist");
|
|
assert.match(readme, /\| 근처 술집 조회 \|/);
|
|
assert.match(readme, /\[근처 술집 조회 가이드\]\(docs\/features\/kakao-bar-nearby\.md\)/);
|
|
assert.match(install, /--skill kakao-bar-nearby/);
|
|
assert.match(roadmap, /근처 술집 조회 스킬 출시/);
|
|
assert.match(sources, /카카오맵 모바일 검색: https:\/\/m\.map\.kakao\.com\/actions\/searchView/);
|
|
assert.match(sources, /카카오맵 장소 패널 JSON: https:\/\/place-api\.map\.kakao\.com\/places\/panel3\//);
|
|
});
|
|
|
|
test("kakao-bar-nearby skill documents location-first Kakao Map search with open-now/menu/seating hints", () => {
|
|
const skillPath = path.join(repoRoot, "kakao-bar-nearby", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected kakao-bar-nearby/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("kakao-bar-nearby", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "kakao-bar-nearby.md"));
|
|
|
|
assert.match(skill, /^name: kakao-bar-nearby$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /현재 위치/);
|
|
assert.match(doc, /서울역|강남|사당|논현/);
|
|
assert.match(doc, /https:\/\/m\.map\.kakao\.com\/actions\/searchView/);
|
|
assert.match(doc, /https:\/\/place-api\.map\.kakao\.com\/places\/panel3\//);
|
|
assert.match(doc, /영업 중|영업전|영업 상태/);
|
|
assert.match(doc, /메뉴/);
|
|
assert.match(doc, /단체석|좌석 옵션|인원 수용/);
|
|
assert.match(doc, /전화번호/);
|
|
assert.match(doc, /kakao-bar-nearby|근처 술집 조회/u);
|
|
}
|
|
});
|
|
|
|
test("kakao-bar-nearby package README stays aligned with the Kakao Map live lookup flow", () => {
|
|
const packageReadme = read(path.join("packages", "kakao-bar-nearby", "README.md"));
|
|
|
|
assert.match(packageReadme, /현재 위치를 먼저 물어본다/u);
|
|
assert.match(packageReadme, /서울역 술집/);
|
|
assert.match(packageReadme, /https:\/\/m\.map\.kakao\.com\/actions\/searchView/);
|
|
assert.match(packageReadme, /https:\/\/place-api\.map\.kakao\.com\/places\/panel3\//);
|
|
assert.match(packageReadme, /searchNearbyBarsByLocationQuery/);
|
|
});
|
|
|
|
test("kakao-bar-nearby feature doc keeps the verified 2026-03-29 sadang smoke snapshot current", () => {
|
|
const featureDoc = read(path.join("docs", "features", "kakao-bar-nearby.md"));
|
|
const smoke = findJsonFenceAfterLabel(featureDoc, "## 검증된 live smoke 예시");
|
|
|
|
assertKakaoBarNearbySadangSmokeSnapshot(smoke, "feature doc smoke snapshot");
|
|
});
|
|
|
|
test("kakao-bar-nearby package README live smoke snapshot matches the verified 2026-03-29 sadang output", () => {
|
|
const packageReadme = read(path.join("packages", "kakao-bar-nearby", "README.md"));
|
|
const smoke = findJsonFenceAfterLabel(packageReadme, "## Live smoke snapshot");
|
|
|
|
assertKakaoBarNearbySadangSmokeSnapshot(smoke, "package README smoke snapshot");
|
|
});
|
|
|
|
test("repository docs advertise the fine-dust-location skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const security = read(path.join("docs", "security-and-secrets.md"));
|
|
const secretsExample = read(path.join("examples", "secrets.env.example"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "fine-dust-location.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/fine-dust-location.md to exist");
|
|
assert.match(readme, /\| 사용자 위치 미세먼지 조회 \|/);
|
|
assert.match(readme, /\[사용자 위치 미세먼지 조회 가이드\]\(docs\/features\/fine-dust-location\.md\)/);
|
|
assert.match(install, /--skill fine-dust-location/);
|
|
assert.match(roadmap, /사용자 위치 미세먼지 조회 스킬 출시/);
|
|
assert.match(sources, /에어코리아 대기오염정보: https:\/\/www\.data\.go\.kr\/data\/15073861\/openapi\.do/);
|
|
assert.match(sources, /에어코리아 측정소정보: https:\/\/www\.data\.go\.kr\/data\/15073877\/openapi\.do/);
|
|
assert.match(setup, /AIR_KOREA_OPEN_API_KEY/);
|
|
assert.match(security, /AIR_KOREA_OPEN_API_KEY/);
|
|
assert.match(secretsExample, /^AIR_KOREA_OPEN_API_KEY=replace-me$/m);
|
|
});
|
|
|
|
test("fine-dust-location skill documents the official two-api flow and fallback handling", () => {
|
|
const skillPath = path.join(repoRoot, "fine-dust-location", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected fine-dust-location/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("fine-dust-location", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "fine-dust-location.md"));
|
|
|
|
assert.match(skill, /^name: fine-dust-location$/m);
|
|
assert.match(skill, /^description: .*미세먼지.*초미세먼지.*위치.*$/m);
|
|
assert.match(skill, /k-skill-proxy\.nomadamas\.org\/v1\/fine-dust\/report/);
|
|
assert.match(skill, /행정구역 이름/u);
|
|
assert.match(skill, /강남구/);
|
|
assert.match(skill, /python3 scripts\/fine_dust\.py/);
|
|
assert.match(skill, /docs\/features\/fine-dust-location\.md/);
|
|
assert.match(skill, /docs\/features\/k-skill-proxy\.md/);
|
|
assert.match(skill, /PM10/);
|
|
assert.match(skill, /PM2\.5|PM25/);
|
|
assert.match(skill, /통합대기등급/);
|
|
|
|
for (const doc of [featureDoc]) {
|
|
assert.match(doc, /AIR_KOREA_OPEN_API_KEY/);
|
|
assert.match(doc, /B552584\/MsrstnInfoInqireSvc\/getMsrstnList/);
|
|
assert.match(doc, /B552584\/ArpltnInforInqireSvc\/getMsrstnAcctoRltmMesureDnsty/);
|
|
assert.match(doc, /getCtprvnRltmMesureDnsty/);
|
|
assert.match(doc, /PM10/);
|
|
assert.match(doc, /PM2\.5|PM25/);
|
|
assert.match(doc, /행정구역|지역명/);
|
|
assert.match(doc, /fallback|폴백|대체 흐름/i);
|
|
assert.match(doc, /후보 측정소|candidate_stations/);
|
|
assert.match(doc, /조회 시각|조회 시점/);
|
|
assert.match(doc, /python3 scripts\/fine_dust\.py/);
|
|
}
|
|
});
|
|
|
|
test("fine-dust helper python regression tests pass", () => {
|
|
const result = childProcess.spawnSync(
|
|
"python3",
|
|
["-m", "unittest", "discover", "-s", "scripts", "-p", "test_fine_dust.py"],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
);
|
|
|
|
assert.equal(
|
|
result.status,
|
|
0,
|
|
`expected python fine-dust helper regression tests to pass\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
);
|
|
});
|
|
|
|
test("repository docs advertise the toss-securities skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "toss-securities.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/toss-securities.md to exist");
|
|
assert.match(readme, /\| 토스증권 조회 \|/);
|
|
assert.match(readme, /\[토스증권 조회 가이드\]\(docs\/features\/toss-securities\.md\)/);
|
|
assert.match(install, /--skill toss-securities/);
|
|
assert.match(roadmap, /토스증권 조회 스킬 출시/);
|
|
assert.match(sources, /tossinvest-cli: https:\/\/github\.com\/JungHoonGhae\/tossinvest-cli/);
|
|
});
|
|
|
|
test("repository docs advertise the hipass-receipt skill across the documented surfaces", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "hipass-receipt.md");
|
|
const skillPath = path.join(repoRoot, "hipass-receipt", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/hipass-receipt.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected hipass-receipt/SKILL.md to exist");
|
|
assert.match(readme, /\| 하이패스 영수증 발급 \|/);
|
|
assert.match(readme, /\[하이패스 영수증 발급 가이드\]\(docs\/features\/hipass-receipt\.md\)/);
|
|
assert.match(install, /--skill hipass-receipt/);
|
|
assert.match(setup, /하이패스 영수증 발급 \| 사용자 시크릿 불필요 \(로그인된 브라우저 세션 필요\)/);
|
|
assert.match(roadmap, /하이패스 영수증 발급 스킬 출시/);
|
|
assert.match(sources, /https:\/\/www\.hipass\.co\.kr\/main\.do/);
|
|
assert.match(sources, /https:\/\/www\.hipass\.co\.kr\/html\/guide\/siteguide_6\.jsp/);
|
|
});
|
|
|
|
test("toss-securities skill documents the tossctl install, auth, and read-only workflow", () => {
|
|
const skillPath = path.join(repoRoot, "toss-securities", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected toss-securities/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("toss-securities", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "toss-securities.md"));
|
|
|
|
assert.match(skill, /^name: toss-securities$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /tossctl/);
|
|
assert.match(doc, /JungHoonGhae\/tossinvest-cli/);
|
|
assert.match(doc, /auth login/);
|
|
assert.match(doc, /account summary/);
|
|
assert.match(doc, /portfolio positions/);
|
|
assert.match(doc, /quote get/);
|
|
assert.match(doc, /watchlist list/);
|
|
assert.match(doc, /read-only|조회 전용/u);
|
|
assert.doesNotMatch(doc, /order place/);
|
|
}
|
|
});
|
|
|
|
test("hipass-receipt skill documents the logged-in browser session contract", () => {
|
|
const skillPath = path.join(repoRoot, "hipass-receipt", "SKILL.md");
|
|
const packageReadmePath = path.join(repoRoot, "packages", "hipass-receipt", "README.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected hipass-receipt/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(packageReadmePath), "expected packages/hipass-receipt/README.md to exist");
|
|
|
|
const skill = read(path.join("hipass-receipt", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "hipass-receipt.md"));
|
|
const packageReadme = read(path.join("packages", "hipass-receipt", "README.md"));
|
|
|
|
assert.match(skill, /^name: hipass-receipt$/m);
|
|
assert.match(skill, /로그인은 반드시 사용자가 직접 해야 한다/);
|
|
assert.match(skill, /Playwright persistent context|user-data-dir/);
|
|
assert.match(skill, /세션이 만료되면 즉시 중단하고 다시 로그인/);
|
|
assert.match(featureDoc, /20분/);
|
|
assert.match(featureDoc, /영수증선택출력|영수증전체출력/);
|
|
assert.match(featureDoc, /로그인된 브라우저 세션에서만 동작/);
|
|
assert.match(featureDoc, /playwright-core/);
|
|
assert.match(skill, /--encrypted-card-number/);
|
|
assert.match(packageReadme, /buildUsageHistoryQuery/);
|
|
assert.match(packageReadme, /parseUsageHistoryList/);
|
|
assert.match(packageReadme, /inspectHipassPage/);
|
|
assert.match(packageReadme, /playwright-core/);
|
|
});
|
|
|
|
test("toss-securities package exposes safe read-only tossctl helpers", () => {
|
|
const pkg = require(path.join(repoRoot, "packages", "toss-securities", "src", "index.js"));
|
|
|
|
assert.equal(typeof pkg.buildReadOnlyCommand, "function");
|
|
assert.equal(typeof pkg.runReadOnlyCommand, "function");
|
|
assert.equal(typeof pkg.getAccountSummary, "function");
|
|
assert.equal(typeof pkg.getPortfolioPositions, "function");
|
|
assert.equal(typeof pkg.getQuote, "function");
|
|
assert.equal(typeof pkg.getQuoteBatch, "function");
|
|
assert.equal(typeof pkg.listWatchlist, "function");
|
|
});
|
|
|
|
test("hipass-receipt package exposes fixture-friendly query, parse, and session helpers", () => {
|
|
const pkg = require(path.join(repoRoot, "packages", "hipass-receipt", "src", "index.js"));
|
|
|
|
assert.equal(pkg.HIPASS_ENDPOINTS.loginPage, "https://www.hipass.co.kr/comm/lginpg.do");
|
|
assert.equal(typeof pkg.buildUsageHistoryQuery, "function");
|
|
assert.equal(typeof pkg.parseUsageHistoryList, "function");
|
|
assert.equal(typeof pkg.inspectHipassPage, "function");
|
|
assert.equal(typeof pkg.buildReceiptRequest, "function");
|
|
});
|
|
|
|
test("toss-securities package README stays aligned with the read-only tossctl wrapper contract", () => {
|
|
const packageReadme = read(path.join("packages", "toss-securities", "README.md"));
|
|
|
|
assert.match(packageReadme, /read-only tossctl wrapper/i);
|
|
assert.match(packageReadme, /brew tap JungHoonGhae\/tossinvest-cli/);
|
|
assert.match(packageReadme, /account summary/);
|
|
assert.match(packageReadme, /quote get/);
|
|
assert.match(packageReadme, /order place/);
|
|
assert.match(packageReadme, /지원하지 않음|not supported/u);
|
|
});
|
|
|
|
test("hipass-receipt package README and npm metadata stay aligned with the helper contract", () => {
|
|
const packageReadme = read(path.join("packages", "hipass-receipt", "README.md"));
|
|
const packageJson = readJson(path.join("packages", "hipass-receipt", "package.json"));
|
|
|
|
assert.equal(packageJson.name, "hipass-receipt");
|
|
assert.match(packageJson.description, /Hi-Pass/);
|
|
assert.ok(packageJson.files.includes("test/fixtures"));
|
|
assert.match(packageReadme, /logged-in browser session/i);
|
|
assert.match(packageReadme, /Playwright/);
|
|
assert.equal(typeof packageJson.dependencies?.["playwright-core"], "string");
|
|
assert.match(packageReadme, /playwright-core/);
|
|
assert.match(packageReadme, /buildReceiptRequest/);
|
|
assert.match(packageReadme, /test\/fixtures\/usage-history-list\.html/);
|
|
});
|
|
|
|
test("hipass-receipt pack dry-run ships fixture-demo assets for the published README workflow", () => {
|
|
const packResult = JSON.parse(
|
|
childProcess.execFileSync("npm", ["pack", "--workspace", "hipass-receipt", "--json", "--dry-run"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8"
|
|
}),
|
|
);
|
|
|
|
const files = packResult[0]?.files?.map((entry) => entry.path) || [];
|
|
assert.ok(files.includes("test/fixtures/usage-history-list.html"));
|
|
assert.ok(files.includes("test/fixtures/login-page.html"));
|
|
assert.ok(files.includes("README.md"));
|
|
});
|
|
|
|
test("pack:dry-run includes the toss-securities workspace", () => {
|
|
const packageJson = JSON.parse(read("package.json"));
|
|
|
|
assert.match(packageJson.scripts["pack:dry-run"], /workspace toss-securities/);
|
|
assert.match(packageJson.scripts["pack:dry-run"], /workspace hipass-receipt/);
|
|
assert.match(packageJson.scripts["pack:dry-run"], /workspace used-car-price-search/);
|
|
});
|
|
|
|
test("package-lock captures the toss-securities workspace metadata for npm ci", () => {
|
|
const packageLock = readJson("package-lock.json");
|
|
|
|
assert.deepEqual(packageLock.packages[""].workspaces, ["packages/*"]);
|
|
assert.deepEqual(packageLock.packages["node_modules/toss-securities"], {
|
|
resolved: "packages/toss-securities",
|
|
link: true,
|
|
});
|
|
assert.equal(packageLock.packages["packages/toss-securities"].license, "MIT");
|
|
assert.equal(packageLock.packages["packages/toss-securities"].engines.node, ">=18");
|
|
});
|
|
|
|
test("repository docs advertise the korean-law-search skill with mode-specific korean-law-mcp setup guidance", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const security = read(path.join("docs", "security-and-secrets.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "korean-law-search.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-law-search.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-law-search.md to exist");
|
|
assert.match(readme, /\| 한국 법령 검색 \|/);
|
|
assert.match(readme, /\[한국 법령 검색 가이드\]\(docs\/features\/korean-law-search\.md\)/);
|
|
assert.match(readme, /\| 한국 법령 검색 \| .* \| 불필요 \|/);
|
|
assert.match(install, /--skill korean-law-search/);
|
|
assert.match(install, /로컬 CLI\/MCP 경로는 `LAW_OC`/);
|
|
assert.match(install, /remote endpoint는 `LAW_OC` 없이 `url`만/);
|
|
assert.match(setup, /한국 법령 검색의 로컬 CLI\/MCP 경로용 `LAW_OC`/);
|
|
assert.match(setup, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
|
assert.match(featureDoc, /로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC`/);
|
|
assert.match(featureDoc, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
|
assert.match(setupSkill, /로컬 한국 법령 검색: `LAW_OC` \+ `korean-law-mcp`/);
|
|
assert.match(setupSkill, /remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록/);
|
|
|
|
for (const doc of [setup, security, setupSkill]) {
|
|
assert.match(doc, /LAW_OC/);
|
|
assert.match(doc, /korean-law-mcp/);
|
|
}
|
|
|
|
assert.match(sources, /korean-law-mcp: https:\/\/github\.com\/chrisryugj\/korean-law-mcp/);
|
|
assert.match(sources, /beopmang: https:\/\/api\.beopmang\.org/);
|
|
assert.match(roadmap, /한국 법령 검색 스킬 출시/);
|
|
});
|
|
|
|
test("korean-law-search skill keeps korean-law-mcp-first guidance while documenting the approved Beopmang fallback", () => {
|
|
const skillPath = path.join(repoRoot, "korean-law-search", "SKILL.md");
|
|
const featureDoc = read(path.join("docs", "features", "korean-law-search.md"));
|
|
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
|
|
const packageJson = readJson("package.json");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-law-search/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("korean-law-search", "SKILL.md"));
|
|
const doneSectionMatch = skill.match(/## Done when([\s\S]*?)## Notes/);
|
|
|
|
assert.match(skill, /^name: korean-law-search$/m);
|
|
assert.ok(doneSectionMatch, "expected korean-law-search skill to include a Done when section");
|
|
|
|
const doneSection = doneSectionMatch[1];
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /korean-law-mcp.*먼저|먼저.*korean-law-mcp|항상 `korean-law-mcp`를 먼저 사용/u);
|
|
assert.match(doc, /npm install -g korean-law-mcp/);
|
|
assert.match(doc, /로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC`/);
|
|
assert.match(doc, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
|
assert.match(doc, /open\.law\.go\.kr/);
|
|
assert.match(doc, /search_law/);
|
|
assert.match(doc, /get_law_text/);
|
|
assert.match(doc, /search_precedents/);
|
|
assert.match(doc, /search_interpretations/);
|
|
assert.match(doc, /search_ordinance/);
|
|
assert.match(doc, /https:\/\/korean-law-mcp\.fly\.dev\/mcp/);
|
|
assert.match(doc, /법망|Beopmang/i);
|
|
assert.match(doc, /https:\/\/api\.beopmang\.org/);
|
|
assert.match(doc, /fallback/i);
|
|
assert.match(doc, /MCP/i);
|
|
assert.match(doc, /CLI/i);
|
|
assert.doesNotMatch(doc, /packages\/korean-law-search/);
|
|
assert.doesNotMatch(doc, /python-packages\/korean-law-search/);
|
|
}
|
|
|
|
assert.match(doneSection, /search_interpretations/);
|
|
assert.match(doneSection, /search_ordinance/);
|
|
assert.match(doneSection, /법망|Beopmang/i);
|
|
assert.match(doneSection, /fallback/i);
|
|
|
|
assert.doesNotMatch(
|
|
featureDoc,
|
|
/[ \t]+$/m,
|
|
"expected docs/features/korean-law-search.md to avoid trailing whitespace so git diff --check stays clean",
|
|
);
|
|
|
|
assert.match(examplesSecrets, /^LAW_OC=replace-me$/m);
|
|
assert.ok(
|
|
!packageJson.workspaces.some((workspace) => workspace.includes("korean-law")),
|
|
"expected no repo workspace to be added for korean-law-search",
|
|
);
|
|
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-law-search")), false);
|
|
});
|
|
|
|
test("repository docs advertise the joseon-sillok-search skill and helper", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "joseon-sillok-search.md");
|
|
const featureDoc = read(path.join("docs", "features", "joseon-sillok-search.md"));
|
|
const skillPath = path.join(repoRoot, "joseon-sillok-search", "SKILL.md");
|
|
const skill = read(path.join("joseon-sillok-search", "SKILL.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/joseon-sillok-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected joseon-sillok-search/SKILL.md to exist");
|
|
assert.match(readme, /\| 조선왕조실록 검색 \|/);
|
|
assert.match(readme, /\[조선왕조실록 검색 가이드\]\(docs\/features\/joseon-sillok-search\.md\)/);
|
|
assert.match(install, /--skill joseon-sillok-search/);
|
|
assert.match(install, /python3 scripts\/sillok_search\.py --query "훈민정음" --king 세종 --year 1443/);
|
|
assert.match(skill, /sillok\.history\.go\.kr/);
|
|
assert.match(skill, /--king/);
|
|
assert.match(skill, /--year/);
|
|
assert.match(featureDoc, /python3 scripts\/sillok_search\.py --query "훈민정음"/);
|
|
assert.match(featureDoc, /1443/);
|
|
assert.match(featureDoc, /kda_12512030_002/);
|
|
assert.match(sources, /https:\/\/sillok\.history\.go\.kr/);
|
|
assert.match(sources, /https:\/\/sillok\.history\.go\.kr\/search\/searchResultList\.do/);
|
|
assert.match(roadmap, /조선왕조실록 검색 스킬 출시/);
|
|
});
|
|
|
|
test("joseon-sillok-search install payload includes the documented helper command", () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "joseon-sillok-"));
|
|
const installedSkillPath = path.join(tempRoot, "joseon-sillok-search");
|
|
const bundledHelperPath = path.join(installedSkillPath, "scripts", "sillok_search.py");
|
|
|
|
try {
|
|
fs.cpSync(path.join(repoRoot, "joseon-sillok-search"), installedSkillPath, { recursive: true });
|
|
|
|
assert.ok(fs.existsSync(bundledHelperPath), "expected joseon-sillok-search/scripts/sillok_search.py to exist");
|
|
|
|
const helpText = childProcess.execFileSync("python3", ["scripts/sillok_search.py", "--help"], {
|
|
cwd: installedSkillPath,
|
|
encoding: "utf8",
|
|
});
|
|
|
|
assert.match(helpText, /Search Joseon Sillok records from sillok\.history\.go\.kr/);
|
|
assert.match(helpText, /--query/);
|
|
assert.match(helpText, /--king/);
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the korean-patent-search skill and official KIPRIS Plus API setup", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const security = read(path.join("docs", "security-and-secrets.md"));
|
|
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
|
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-patent-search.md");
|
|
const featureDoc = read(path.join("docs", "features", "korean-patent-search.md"));
|
|
const skillPath = path.join(repoRoot, "korean-patent-search", "SKILL.md");
|
|
const skill = read(path.join("korean-patent-search", "SKILL.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const packageJson = readJson("package.json");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-patent-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-patent-search/SKILL.md to exist");
|
|
|
|
assert.match(readme, /\| 한국 특허 정보 검색 \|/);
|
|
assert.match(readme, /\[한국 특허 정보 검색 가이드\]\(docs\/features\/korean-patent-search\.md\)/);
|
|
assert.match(install, /--skill korean-patent-search/);
|
|
assert.match(install, /KIPRIS_PLUS_API_KEY/);
|
|
assert.match(install, /python3 scripts\/patent_search\.py --query "배터리"/);
|
|
assert.match(setup, /한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`/);
|
|
assert.match(security, /KIPRIS_PLUS_API_KEY/);
|
|
assert.match(setupSkill, /한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`/);
|
|
assert.match(examplesSecrets, /^KIPRIS_PLUS_API_KEY=replace-me$/m);
|
|
assert.match(skill, /^name: korean-patent-search$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /KIPRIS Plus/i);
|
|
assert.match(doc, /getWordSearch/);
|
|
assert.match(doc, /getBibliographyDetailInfoSearch/);
|
|
assert.match(doc, /ServiceKey/);
|
|
assert.match(doc, /python3 scripts\/patent_search\.py/);
|
|
assert.match(doc, /Done when/i);
|
|
assert.doesNotMatch(doc, /packages\/korean-patent-search/);
|
|
assert.doesNotMatch(doc, /python-packages\/korean-patent-search/);
|
|
}
|
|
|
|
assert.match(sources, /https:\/\/plus\.kipris\.or\.kr\/portal\/data\/service\/List\.do\?subTab=SC001&entYn=N&menuNo=200100/);
|
|
assert.match(sources, /https:\/\/www\.data\.go\.kr\/data\/15058788\/openapi\.do/);
|
|
assert.match(roadmap, /한국 특허 정보 검색 스킬 출시/);
|
|
assert.ok(
|
|
!packageJson.workspaces.some((workspace) => workspace.includes("korean-patent-search")),
|
|
"expected no repo workspace to be added for korean-patent-search",
|
|
);
|
|
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-patent-search")), false);
|
|
});
|
|
|
|
test("korean-patent-search install payload includes the documented helper command", () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "korean-patent-search-"));
|
|
const installedSkillPath = path.join(tempRoot, "korean-patent-search");
|
|
const bundledHelperPath = path.join(installedSkillPath, "scripts", "patent_search.py");
|
|
|
|
try {
|
|
fs.cpSync(path.join(repoRoot, "korean-patent-search"), installedSkillPath, { recursive: true });
|
|
|
|
assert.ok(fs.existsSync(bundledHelperPath), "expected korean-patent-search/scripts/patent_search.py to exist");
|
|
|
|
const helpText = childProcess.execFileSync("python3", ["scripts/patent_search.py", "--help"], {
|
|
cwd: installedSkillPath,
|
|
encoding: "utf8",
|
|
});
|
|
|
|
assert.match(helpText, /Search Korean patent information via the official KIPRIS Plus Open API/);
|
|
assert.match(helpText, /--query/);
|
|
assert.match(helpText, /--application-number/);
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the real-estate-search skill and proxy-based approach", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const security = read(path.join("docs", "security-and-secrets.md"));
|
|
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "real-estate-search.md");
|
|
const featureDoc = read(path.join("docs", "features", "real-estate-search.md"));
|
|
const skillPath = path.join(repoRoot, "real-estate-search", "SKILL.md");
|
|
const skill = read(path.join("real-estate-search", "SKILL.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const packageJson = readJson("package.json");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/real-estate-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected real-estate-search/SKILL.md to exist");
|
|
|
|
assert.match(readme, /\| 한국 부동산 실거래가 조회 \|/);
|
|
assert.match(readme, /\[한국 부동산 실거래가 조회 가이드\]\(docs\/features\/real-estate-search\.md\)/);
|
|
assert.match(install, /--skill real-estate-search/);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /https:\/\/github\.com\/tae0y\/real-estate-mcp\/tree\/main/);
|
|
assert.match(doc, /k-skill-proxy\.nomadamas\.org/);
|
|
assert.match(doc, /\/v1\/real-estate\//);
|
|
assert.match(doc, /apartment\/trade|apartment\/rent/);
|
|
assert.match(doc, /region-code/);
|
|
assert.doesNotMatch(doc, /packages\/real-estate-search/);
|
|
assert.doesNotMatch(doc, /python-packages\/real-estate-search/);
|
|
}
|
|
|
|
for (const doc of [install]) {
|
|
assert.match(doc, /https:\/\/github\.com\/tae0y\/real-estate-mcp\/tree\/main/);
|
|
assert.match(doc, /k-skill-proxy\.nomadamas\.org|hosted proxy/);
|
|
}
|
|
|
|
for (const doc of [setup, security, setupSkill]) {
|
|
assert.match(doc, /DATA_GO_KR_API_KEY/);
|
|
}
|
|
|
|
assert.match(sources, /real-estate-mcp: https:\/\/github\.com\/tae0y\/real-estate-mcp\/tree\/main/);
|
|
assert.match(roadmap, /한국 부동산 실거래가 조회 스킬 출시/);
|
|
assert.ok(
|
|
!packageJson.workspaces.some((workspace) => workspace.includes("real-estate-search")),
|
|
"expected no repo workspace to be added for real-estate-search",
|
|
);
|
|
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "real-estate-search")), false);
|
|
});
|
|
|
|
test("repository docs advertise the korean-scholarship-search skill and official-source workflow", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-scholarship-search.md");
|
|
const featureDoc = read(path.join("docs", "features", "korean-scholarship-search.md"));
|
|
const skillPath = path.join(repoRoot, "korean-scholarship-search", "SKILL.md");
|
|
const skill = read(path.join("korean-scholarship-search", "SKILL.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const helperPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "scholarship_filter.py");
|
|
const plannerPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "university_search_plan.py");
|
|
const searchCluesPath = path.join(repoRoot, "korean-scholarship-search", "references", "search-clues.md");
|
|
const reportFormatPath = path.join(repoRoot, "korean-scholarship-search", "references", "report-format.md");
|
|
const packageJson = readJson("package.json");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-scholarship-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-scholarship-search/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(helperPath), "expected korean-scholarship-search/scripts/scholarship_filter.py to exist");
|
|
assert.ok(fs.existsSync(plannerPath), "expected korean-scholarship-search/scripts/university_search_plan.py to exist");
|
|
assert.ok(fs.existsSync(searchCluesPath), "expected korean-scholarship-search/references/search-clues.md to exist");
|
|
assert.ok(fs.existsSync(reportFormatPath), "expected korean-scholarship-search/references/report-format.md to exist");
|
|
|
|
assert.match(readme, /\| 장학금 검색 및 조회 \|/);
|
|
assert.match(readme, /\[장학금 검색 및 조회 가이드\]\(docs\/features\/korean-scholarship-search\.md\)/);
|
|
assert.match(install, /--skill korean-scholarship-search/);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /장학금 검색 및 조회/);
|
|
assert.match(doc, /kosaf\.go\.kr/);
|
|
assert.match(doc, /\*\.ac\.kr/);
|
|
assert.match(doc, /전국 대학교|전국 대학/);
|
|
assert.match(doc, /공식 공고 우선/);
|
|
assert.match(doc, /학자금 지원구간/);
|
|
assert.match(doc, /scholarship_filter\.py/);
|
|
assert.match(doc, /university_search_plan\.py/);
|
|
assert.match(doc, /학과/);
|
|
assert.match(doc, /외부 장학 추천|등록금 감면|생활비 지원/);
|
|
}
|
|
|
|
assert.match(sources, /한국장학재단 학자금 지원구간 산정절차/);
|
|
assert.match(sources, /한국장학재단 푸른등대 기부장학금/);
|
|
assert.match(sources, /삼성꿈장학재단/);
|
|
assert.match(roadmap, /장학금 검색 및 조회 스킬 출시/);
|
|
assert.ok(
|
|
!packageJson.workspaces.some((workspace) => workspace.includes("korean-scholarship-search")),
|
|
"expected no repo workspace to be added for korean-scholarship-search",
|
|
);
|
|
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-scholarship-search")), false);
|
|
});
|
|
|
|
test("korean-scholarship-search helper filters normalized records, renders reports, and returns eligibility verdicts", () => {
|
|
const helperPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "scholarship_filter.py");
|
|
const plannerPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "university_search_plan.py");
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "k-skill-scholarship-"));
|
|
|
|
try {
|
|
const inputPath = path.join(tempRoot, "scholarships.json");
|
|
fs.writeFileSync(
|
|
inputPath,
|
|
JSON.stringify(
|
|
[
|
|
{
|
|
name: "테스트재단 생활비 장학금",
|
|
organization: { name: "테스트재단", type: "foundation" },
|
|
source_url: "https://foundation.example.com/notice/1",
|
|
apply_url: "https://foundation.example.com/apply/1",
|
|
amount: { text: "학기당 250만 원", per_semester_krw: 2500000, category: "living" },
|
|
eligibility: {
|
|
student_levels: ["undergraduate"],
|
|
school_kinds: ["university"],
|
|
school_names: ["서울대학교", "연세대학교"],
|
|
department_names: ["컴퓨터공학부"],
|
|
grade_years: [2, 3, 4],
|
|
gpa_min: 3.2,
|
|
income_band_min: 0,
|
|
income_band_max: 6,
|
|
},
|
|
deadline: { start: "2026-04-01", end: "2026-04-16" },
|
|
},
|
|
{
|
|
name: "교내 성적우수 장학금",
|
|
organization: { name: "샘플대학교", type: "school" },
|
|
source_url: "https://sample.ac.kr/notice/2",
|
|
apply_url: "https://sample.ac.kr/apply/2",
|
|
amount: { text: "등록금 전액", category: "tuition" },
|
|
eligibility: {
|
|
student_levels: ["undergraduate"],
|
|
school_kinds: ["university"],
|
|
school_names: ["샘플대학교"],
|
|
grade_years: [1],
|
|
gpa_min: 4.0,
|
|
income_band_min: 0,
|
|
income_band_max: 10,
|
|
},
|
|
deadline: { start: "2026-05-01", end: "2026-05-20" },
|
|
},
|
|
],
|
|
null,
|
|
2,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
const helpText = childProcess.execFileSync("python3", [helperPath, "--help"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
});
|
|
assert.match(helpText, /Filter normalized Korean scholarship records/);
|
|
assert.match(helpText, /\bfilter\b/);
|
|
assert.match(helpText, /\beligibility\b/);
|
|
assert.match(helpText, /\breport\b/);
|
|
|
|
const plannerHelpText = childProcess.execFileSync("python3", [plannerPath, "--help"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
});
|
|
assert.match(plannerHelpText, /nationwide/i);
|
|
assert.match(plannerHelpText, /school-name/);
|
|
|
|
const filtered = JSON.parse(
|
|
childProcess.execFileSync(
|
|
"python3",
|
|
[
|
|
helperPath,
|
|
"filter",
|
|
"--input",
|
|
inputPath,
|
|
"--org-type",
|
|
"foundation",
|
|
"--student-level",
|
|
"undergraduate",
|
|
"--department-name",
|
|
"컴퓨터공학부",
|
|
"--income-band",
|
|
"4",
|
|
"--min-amount",
|
|
"2000000",
|
|
"--today",
|
|
"2026-04-14",
|
|
"--deadline-within-days",
|
|
"7",
|
|
],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
),
|
|
);
|
|
|
|
assert.equal(filtered.total, 1);
|
|
assert.equal(filtered.items[0].name, "테스트재단 생활비 장학금");
|
|
assert.equal(filtered.items[0]._match.amount_krw, 2500000);
|
|
assert.equal(filtered.items[0]._match.deadline.status, "open");
|
|
assert.equal(filtered.items[0]._match.deadline.days_until_end, 2);
|
|
|
|
const report = childProcess.execFileSync(
|
|
"python3",
|
|
[
|
|
helperPath,
|
|
"report",
|
|
"--input",
|
|
inputPath,
|
|
"--today",
|
|
"2026-04-14",
|
|
"--only-open-now",
|
|
],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
);
|
|
|
|
assert.match(report, /# 장학금 검색 및 조회 리포트/);
|
|
assert.match(report, /## 지금 지원 가능/);
|
|
assert.match(report, /테스트재단 생활비 장학금/);
|
|
assert.match(report, /D-2/);
|
|
|
|
const plannerPayload = JSON.parse(
|
|
childProcess.execFileSync(
|
|
"python3",
|
|
[
|
|
plannerPath,
|
|
"--school-name",
|
|
"부산대학교",
|
|
"--department",
|
|
"컴퓨터공학과",
|
|
"--year",
|
|
"2026",
|
|
],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
),
|
|
);
|
|
assert.equal(plannerPayload.scope, "school");
|
|
assert.equal(plannerPayload.school_name, "부산대학교");
|
|
assert.match(plannerPayload.search_queries.join("\n"), /컴퓨터공학과/);
|
|
|
|
const nationwidePayload = JSON.parse(
|
|
childProcess.execFileSync(
|
|
"python3",
|
|
[plannerPath, "--nationwide", "--year", "2026"],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
),
|
|
);
|
|
assert.equal(nationwidePayload.scope, "nationwide-universities");
|
|
assert.match(nationwidePayload.search_queries.join("\n"), /site:\*\.ac\.kr 2026 장학 공고/);
|
|
|
|
const eligibility = JSON.parse(
|
|
childProcess.execFileSync(
|
|
"python3",
|
|
[
|
|
helperPath,
|
|
"eligibility",
|
|
"--input",
|
|
inputPath,
|
|
"--school-name",
|
|
"서울대학교",
|
|
"--student-level",
|
|
"undergraduate",
|
|
"--grade-year",
|
|
"2",
|
|
"--gpa",
|
|
"3.5",
|
|
"--income-band",
|
|
"4",
|
|
],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
),
|
|
);
|
|
|
|
assert.equal(eligibility.total, 2);
|
|
assert.equal(eligibility.results[0].status, "eligible");
|
|
assert.equal(eligibility.results[1].status, "not_eligible");
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("real-estate-search skill uses proxy endpoints not MCP self-host", () => {
|
|
const featureDoc = read(path.join("docs", "features", "real-estate-search.md"));
|
|
const skill = read(path.join("real-estate-search", "SKILL.md"));
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /k-skill-proxy\.nomadamas\.org\/v1\/real-estate/);
|
|
assert.match(doc, /curl/);
|
|
assert.doesNotMatch(doc, /uv run/);
|
|
assert.doesNotMatch(doc, /codex mcp add/);
|
|
assert.doesNotMatch(doc, /Cloudflare Tunnel/i);
|
|
assert.doesNotMatch(doc, /launchd/i);
|
|
assert.doesNotMatch(doc, /docker compose/i);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the korean-stock-search skill and proxy-backed KRX approach", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const security = read(path.join("docs", "security-and-secrets.md"));
|
|
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-stock-search.md");
|
|
const featureDoc = read(path.join("docs", "features", "korean-stock-search.md"));
|
|
const skillPath = path.join(repoRoot, "korean-stock-search", "SKILL.md");
|
|
const skill = read(path.join("korean-stock-search", "SKILL.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
|
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
|
const packageJson = readJson("package.json");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-stock-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-stock-search/SKILL.md to exist");
|
|
|
|
assert.match(readme, /\| 한국 주식 정보 조회 \|/);
|
|
assert.match(readme, /\[한국 주식 정보 조회 가이드\]\(docs\/features\/korean-stock-search\.md\)/);
|
|
assert.match(install, /--skill korean-stock-search/);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /https:\/\/github\.com\/jjlabsio\/korea-stock-mcp/);
|
|
assert.match(doc, /k-skill-proxy\.nomadamas\.org/);
|
|
assert.match(doc, /\/v1\/korean-stock\/search/);
|
|
assert.match(doc, /\/v1\/korean-stock\/base-info/);
|
|
assert.match(doc, /\/v1\/korean-stock\/trade-info/);
|
|
assert.match(doc, /KRX_API_KEY/);
|
|
assert.match(doc, /사용자.*KRX_API_KEY.*(불필요|준비할 필요가 없)/u);
|
|
assert.doesNotMatch(doc, /packages\/korean-stock-search/);
|
|
assert.doesNotMatch(doc, /python-packages\/korean-stock-search/);
|
|
}
|
|
|
|
for (const doc of [setup, security, setupSkill]) {
|
|
assert.match(doc, /KRX_API_KEY/);
|
|
}
|
|
|
|
for (const doc of [proxyReadme, proxyDoc]) {
|
|
assert.match(doc, /\/v1\/korean-stock\/search/);
|
|
assert.match(doc, /\/v1\/korean-stock\/base-info/);
|
|
assert.match(doc, /\/v1\/korean-stock\/trade-info/);
|
|
}
|
|
|
|
assert.match(sources, /korea-stock-mcp: https:\/\/github\.com\/jjlabsio\/korea-stock-mcp/);
|
|
assert.match(roadmap, /한국 주식 정보 조회 스킬 출시/);
|
|
assert.ok(
|
|
!packageJson.workspaces.some((workspace) => workspace.includes("korean-stock-search")),
|
|
"expected no repo workspace to be added for korean-stock-search",
|
|
);
|
|
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-stock-search")), false);
|
|
});
|
|
|
|
test("korean-stock-search skill stays proxy-first and does not require local MCP install", () => {
|
|
const featureDoc = read(path.join("docs", "features", "korean-stock-search.md"));
|
|
const skill = read(path.join("korean-stock-search", "SKILL.md"));
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /k-skill-proxy\.nomadamas\.org\/v1\/korean-stock/);
|
|
assert.match(doc, /curl/);
|
|
assert.match(doc, /proxy.*서버.*KRX_API_KEY|KRX_API_KEY.*proxy.*서버/u);
|
|
assert.doesNotMatch(doc, /npx\s+(?:-y|--yes)\s+korea-stock-mcp/);
|
|
assert.doesNotMatch(doc, /codex mcp add/);
|
|
assert.doesNotMatch(doc, /claude_desktop_config\.json/);
|
|
assert.doesNotMatch(doc, /DART_API_KEY/);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the shipped korean-spell-check helper assets", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-spell-check.md");
|
|
const helperPath = path.join(repoRoot, "scripts", "korean_spell_check.py");
|
|
|
|
assert.equal(fs.existsSync(featureDocPath), true);
|
|
assert.equal(fs.existsSync(helperPath), true);
|
|
assert.match(readme, /\[한국어 맞춤법 검사 가이드\]\(docs\/features\/korean-spell-check\.md\)/);
|
|
assert.match(install, /python3 scripts\/korean_spell_check\.py/);
|
|
});
|
|
|
|
test("repository docs advertise the korean-character-count skill and deterministic counting contract", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-character-count.md");
|
|
const featureDoc = read(path.join("docs", "features", "korean-character-count.md"));
|
|
const skillPath = path.join(repoRoot, "korean-character-count", "SKILL.md");
|
|
const skill = read(path.join("korean-character-count", "SKILL.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const packageJson = readJson("package.json");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-character-count.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-character-count/SKILL.md to exist");
|
|
|
|
assert.match(readme, /\| 한국어 글자 수 세기 \|/);
|
|
assert.match(readme, /\[한국어 글자 수 세기 가이드\]\(docs\/features\/korean-character-count\.md\)/);
|
|
assert.match(install, /--skill korean-character-count/);
|
|
assert.match(
|
|
install,
|
|
/--skill k-schoollunch-menu \\\n --skill korean-character-count/,
|
|
"docs/install.md selective-install block should keep k-schoollunch-menu and korean-character-count in the same continued shell command",
|
|
);
|
|
assert.match(install, /node scripts\/korean_character_count\.js --text "가나다"/);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /grapheme|extended grapheme/i);
|
|
assert.match(doc, /UTF-8/);
|
|
assert.match(doc, /NEIS/i);
|
|
assert.match(doc, /CRLF|U\+2028|U\+2029/);
|
|
assert.match(doc, /node scripts\/korean_character_count\.js/);
|
|
assert.doesNotMatch(doc, /packages\/korean-character-count/);
|
|
assert.doesNotMatch(doc, /python-packages\/korean-character-count/);
|
|
}
|
|
|
|
assert.match(sources, /https:\/\/www\.unicode\.org\/reports\/tr29\//);
|
|
assert.match(sources, /https:\/\/encoding\.spec\.whatwg\.org\//);
|
|
assert.match(sources, /https:\/\/nodejs\.org\/api\/buffer\.html/);
|
|
assert.match(roadmap, /한국어 글자 수 세기 스킬 출시/);
|
|
assert.ok(
|
|
!packageJson.workspaces.some((workspace) => workspace.includes("korean-character-count")),
|
|
"expected no repo workspace to be added for korean-character-count",
|
|
);
|
|
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-character-count")), false);
|
|
});
|
|
|
|
test("korean-character-count feature doc NEIS example matches live helper output", () => {
|
|
const featureDoc = read(path.join("docs", "features", "korean-character-count.md"));
|
|
const helperOutput = childProcess.execFileSync(
|
|
"node",
|
|
[
|
|
"scripts/korean_character_count.js",
|
|
"--text",
|
|
"첫 줄\n둘째 줄🙂",
|
|
"--profile",
|
|
"neis",
|
|
"--format",
|
|
"text",
|
|
],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
);
|
|
const bytesMatch = helperOutput.match(/^bytes:\s+(\d+)$/m);
|
|
|
|
assert.ok(bytesMatch, `expected helper text output to include a bytes line, got: ${helperOutput}`);
|
|
assert.equal(bytesMatch[1], "23");
|
|
assert.match(featureDoc, new RegExp(String.raw`bytes:\s+${bytesMatch[1]}`));
|
|
assert.match(featureDoc, /bytes=23/);
|
|
});
|
|
|
|
test("korean-character-count install payload includes the documented helper command", () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "korean-character-count-"));
|
|
const installedSkillPath = path.join(tempRoot, "korean-character-count");
|
|
const bundledHelperPath = path.join(installedSkillPath, "scripts", "korean_character_count.js");
|
|
|
|
try {
|
|
fs.cpSync(path.join(repoRoot, "korean-character-count"), installedSkillPath, { recursive: true });
|
|
|
|
assert.ok(
|
|
fs.existsSync(bundledHelperPath),
|
|
"expected korean-character-count/scripts/korean_character_count.js to exist",
|
|
);
|
|
|
|
const helpText = childProcess.execFileSync("node", ["scripts/korean_character_count.js", "--help"], {
|
|
cwd: installedSkillPath,
|
|
encoding: "utf8",
|
|
});
|
|
|
|
assert.match(helpText, /--profile/);
|
|
assert.match(helpText, /default/);
|
|
assert.match(helpText, /neis/i);
|
|
assert.match(helpText, /--stdin/);
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the cheap-gas-nearby skill and Opinet key requirements", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const security = read(path.join("docs", "security-and-secrets.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
|
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "cheap-gas-nearby.md");
|
|
const skillPath = path.join(repoRoot, "cheap-gas-nearby", "SKILL.md");
|
|
|
|
assert.equal(fs.existsSync(featureDocPath), true);
|
|
assert.equal(fs.existsSync(skillPath), true);
|
|
assert.match(readme, /\| 근처 가장 싼 주유소 찾기 \|/);
|
|
assert.match(readme, /\[근처 가장 싼 주유소 찾기 가이드\]\(docs\/features\/cheap-gas-nearby\.md\)/);
|
|
assert.match(install, /--skill cheap-gas-nearby/);
|
|
|
|
for (const doc of [setup, security, setupSkill]) {
|
|
assert.match(doc, /주유소 가격|OPINET_API_KEY/);
|
|
assert.match(doc, /hosted proxy|proxy.*경유/);
|
|
}
|
|
|
|
assert.doesNotMatch(examplesSecrets, /^OPINET_API_KEY=replace-me$/m);
|
|
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/user\/custapi\/openApiInfo\.do/);
|
|
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/api\/aroundAll\.do/);
|
|
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/api\/detailById\.do/);
|
|
assert.match(roadmap, /근처 가장 싼 주유소 찾기 스킬 출시/);
|
|
});
|
|
|
|
test("cheap-gas-nearby skill docs require location-first prompts and official Opinet surfaces", () => {
|
|
const skill = read(path.join("cheap-gas-nearby", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "cheap-gas-nearby.md"));
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /현재 위치를 알려주세요/);
|
|
assert.match(doc, /OPINET_API_KEY/);
|
|
assert.match(doc, /aroundAll\.do/);
|
|
assert.match(doc, /detailById\.do/);
|
|
assert.match(doc, /areaCode\.do/);
|
|
assert.match(doc, /휘발유|경유/);
|
|
assert.match(doc, /KATEC/);
|
|
assert.match(doc, /카카오맵|Kakao Map/);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the han-river-water-level skill and rollout-pending proxy workflow", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const security = read(path.join("docs", "security-and-secrets.md"));
|
|
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
|
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "han-river-water-level.md");
|
|
const featureDoc = read(path.join("docs", "features", "han-river-water-level.md"));
|
|
const skillPath = path.join(repoRoot, "han-river-water-level", "SKILL.md");
|
|
const skill = read(path.join("han-river-water-level", "SKILL.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/han-river-water-level.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected han-river-water-level/SKILL.md to exist");
|
|
|
|
assert.match(readme, /\| 한강 수위 정보 조회 \|/);
|
|
assert.match(readme, /\[한강 수위 정보 가이드\]\(docs\/features\/han-river-water-level\.md\)/);
|
|
assert.match(install, /--skill han-river-water-level/);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /\/v1\/han-river\/water-level/);
|
|
assert.match(doc, /stationName|station_code|stationCode/);
|
|
assert.match(doc, /수위|유량/);
|
|
assert.match(doc, /candidate_stations|ambiguous_station/);
|
|
assert.match(doc, /KSKILL_PROXY_BASE_URL/);
|
|
}
|
|
|
|
assert.match(featureDoc, /HRFCO_OPEN_API_KEY/);
|
|
|
|
assert.match(skill, /기본적으로 `https:\/\/k-skill-proxy\.nomadamas\.org\/v1\/han-river\/water-level`/);
|
|
assert.doesNotMatch(featureDoc, /기본 hosted 조회:/);
|
|
|
|
for (const doc of [proxyDoc, proxyReadme]) {
|
|
assert.match(doc, /\/v1\/han-river\/water-level/);
|
|
assert.match(doc, /HRFCO_OPEN_API_KEY/);
|
|
assert.match(doc, /waterlevel\/info\.json/);
|
|
assert.match(doc, /waterlevel\/list\/10M/);
|
|
}
|
|
|
|
assert.match(setup, /한강 수위 정보 조회 \| 사용자 시크릿 불필요/);
|
|
assert.match(setup, /한강 수위.*기본 hosted p/i);
|
|
assert.match(security, /KSKILL_PROXY_BASE_URL.*서울 지하철.*한국 날씨.*기본 hosted proxy|서울 지하철.*한국 날씨.*KSKILL_PROXY_BASE_URL.*기본 hosted proxy/);
|
|
assert.match(sources, /hrfco\.go\.kr\/web\/openapiPage\/reference\.do/);
|
|
assert.match(sources, /api\.hrfco\.go\.kr/);
|
|
assert.match(roadmap, /한강 수위 정보 조회 스킬 출시/);
|
|
});
|
|
|
|
test("repository docs advertise the MFDS drug and food safety skills", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const drugFeatureDocPath = path.join(repoRoot, "docs", "features", "mfds-drug-safety.md");
|
|
const foodFeatureDocPath = path.join(repoRoot, "docs", "features", "mfds-food-safety.md");
|
|
const drugSkillPath = path.join(repoRoot, "mfds-drug-safety", "SKILL.md");
|
|
const foodSkillPath = path.join(repoRoot, "mfds-food-safety", "SKILL.md");
|
|
|
|
assert.equal(fs.existsSync(drugFeatureDocPath), true);
|
|
assert.equal(fs.existsSync(foodFeatureDocPath), true);
|
|
assert.equal(fs.existsSync(drugSkillPath), true);
|
|
assert.equal(fs.existsSync(foodSkillPath), true);
|
|
assert.match(readme, /\| 의약품 안전 체크 \|/);
|
|
assert.match(readme, /\| 식품 안전 체크 \|/);
|
|
assert.match(readme, /\[의약품 안전 체크 가이드\]\(docs\/features\/mfds-drug-safety\.md\)/);
|
|
assert.match(readme, /\[식품 안전 체크 가이드\]\(docs\/features\/mfds-food-safety\.md\)/);
|
|
assert.match(install, /--skill mfds-drug-safety/);
|
|
assert.match(install, /--skill mfds-food-safety/);
|
|
assert.match(sources, /15075057\/openapi\.do/);
|
|
assert.match(sources, /15097208\/openapi\.do/);
|
|
assert.match(sources, /15056516\/openapi\.do/);
|
|
assert.match(sources, /foodsafetykorea\.go\.kr\/api\/openApiInfo\.do/);
|
|
});
|
|
|
|
test("MFDS public-health skill docs require interview-first safety flow and official endpoints", () => {
|
|
const drugSkill = read(path.join("mfds-drug-safety", "SKILL.md"));
|
|
const foodSkill = read(path.join("mfds-food-safety", "SKILL.md"));
|
|
const drugFeatureDoc = read(path.join("docs", "features", "mfds-drug-safety.md"));
|
|
const foodFeatureDoc = read(path.join("docs", "features", "mfds-food-safety.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
|
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
|
|
|
for (const doc of [drugSkill, drugFeatureDoc]) {
|
|
assert.match(doc, /증상.*바로 단정하지 말고.*먼저 되묻/);
|
|
assert.match(doc, /호흡곤란|의식저하|심한 발진/);
|
|
assert.match(doc, /DrbEasyDrugInfoService\/getDrbEasyDrugList/);
|
|
assert.match(doc, /SafeStadDrugService\/getSafeStadDrugInq/);
|
|
assert.match(doc, /KSKILL_PROXY_BASE_URL|k-skill-proxy\.nomadamas\.org/);
|
|
assert.match(doc, /사용자.*시크릿 없음|사용자 API key 없이/u);
|
|
assert.match(doc, /DATA_GO_KR_API_KEY.*프록시 운영 서버/u);
|
|
assert.match(doc, /\/v1\/mfds\/drug-safety\/lookup/);
|
|
assert.match(doc, /python3 scripts\/mfds_drug_safety\.py/);
|
|
}
|
|
|
|
for (const doc of [foodSkill, foodFeatureDoc]) {
|
|
assert.match(doc, /증상.*바로 단정하지 말고.*먼저 되묻/);
|
|
assert.match(doc, /혈변|탈수|호흡곤란/);
|
|
assert.match(doc, /PrsecImproptFoodInfoService03\/getPrsecImproptFoodList01/);
|
|
assert.match(doc, /I0490/);
|
|
assert.match(doc, /KSKILL_PROXY_BASE_URL|k-skill-proxy\.nomadamas\.org/);
|
|
assert.match(doc, /사용자.*시크릿 없음|사용자 API key 없이/u);
|
|
assert.match(doc, /DATA_GO_KR_API_KEY.*프록시 운영 서버/u);
|
|
assert.match(doc, /FOODSAFETYKOREA_API_KEY/);
|
|
assert.match(doc, /\/v1\/mfds\/food-safety\/search/);
|
|
assert.match(doc, /python3 scripts\/mfds_food_safety\.py/);
|
|
assert.match(doc, /https:\/\/openapi\.foodsafetykorea\.go\.kr\/api\/sample\/I0490\/json\/1\/5/);
|
|
assert.doesNotMatch(doc, /http:\/\/openapi\.foodsafetykorea\.go\.kr/);
|
|
}
|
|
|
|
assert.match(sources, /https:\/\/openapi\.foodsafetykorea\.go\.kr\/api\/sample\/I0490\/json\/1\/5/);
|
|
assert.doesNotMatch(sources, /http:\/\/openapi\.foodsafetykorea\.go\.kr/);
|
|
for (const doc of [proxyReadme, proxyDoc]) {
|
|
assert.match(doc, /\/v1\/mfds\/drug-safety\/lookup/);
|
|
assert.match(doc, /\/v1\/mfds\/food-safety\/search/);
|
|
assert.match(doc, /FOODSAFETYKOREA_API_KEY/);
|
|
}
|
|
});
|
|
|
|
test("docs/setup.md and k-skill-setup document hosted household waste proxy flow", () => {
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
|
|
|
assert.match(
|
|
setup,
|
|
/한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로/,
|
|
"setup.md intro should list household waste, school lunch, and MFDS skills among hosted-proxy features with no user-side key",
|
|
);
|
|
assert.match(setup, /DATA_GO_KR_API_KEY.*서버에 설정/);
|
|
assert.match(
|
|
setup,
|
|
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)/,
|
|
"setup.md should list fine dust, Han River, gas, household waste, school lunch, and MFDS skills when KSKILL_PROXY_BASE_URL is unset",
|
|
);
|
|
assert.match(
|
|
setupSkill,
|
|
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL`/,
|
|
"k-skill-setup SKILL should mirror setup.md hosted-proxy unset-base-URL guidance including MFDS skills",
|
|
);
|
|
|
|
assert.match(setup, /\| 생활쓰레기 배출정보 조회 \|/);
|
|
assert.match(setup, /DATA_GO_KR_API_KEY/);
|
|
assert.match(setup, /pageNo=1.*numOfRows=100|numOfRows=100.*pageNo=1/);
|
|
assert.match(setup, /\[생활쓰레기 배출정보 조회 가이드\]\(features\/household-waste-info\.md\)/);
|
|
|
|
assert.match(setupSkill, /\/v1\/household-waste\/info/);
|
|
assert.match(setupSkill, /DATA_GO_KR_API_KEY/);
|
|
assert.match(setupSkill, /생활쓰레기 배출정보 조회: 사용자 시크릿 불필요/);
|
|
});
|
|
|
|
test("docs/setup.md and k-skill-setup document hosted school lunch proxy flow", () => {
|
|
const setup = read(path.join("docs", "setup.md"));
|
|
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
|
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
|
|
assert.match(setup, /학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy/);
|
|
assert.match(setup, /KEDU_INFO_KEY.*서버에 설정/);
|
|
assert.match(
|
|
setup,
|
|
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)/,
|
|
"setup.md should list fine dust, Han River, gas, household waste, school lunch, and MFDS skills when KSKILL_PROXY_BASE_URL is unset",
|
|
);
|
|
assert.match(
|
|
setupSkill,
|
|
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL`/,
|
|
"k-skill-setup SKILL should mirror setup.md hosted-proxy unset-base-URL guidance including MFDS skills",
|
|
);
|
|
|
|
assert.match(setup, /\| 학교 급식 식단 조회 \|/);
|
|
assert.match(setup, /KEDU_INFO_KEY/);
|
|
assert.match(setup, /\[학교 급식 식단 조회 가이드\]\(features\/k-schoollunch-menu\.md\)/);
|
|
|
|
assert.match(setupSkill, /\/v1\/neis\/school-search/);
|
|
assert.match(setupSkill, /\/v1\/neis\/school-meal/);
|
|
assert.match(setupSkill, /KEDU_INFO_KEY/);
|
|
assert.match(setupSkill, /학교 급식 식단 조회: 사용자 시크릿 불필요/);
|
|
|
|
assert.doesNotMatch(
|
|
examplesSecrets,
|
|
/^KEDU_INFO_KEY=/m,
|
|
"client secrets example must not encourage KEDU_INFO_KEY (proxy server only)",
|
|
);
|
|
});
|
|
|
|
test("repository docs advertise the hola-poke-yeoksam skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "hola-poke-yeoksam.md");
|
|
const skillPath = path.join(repoRoot, "hola-poke-yeoksam", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/hola-poke-yeoksam.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected hola-poke-yeoksam/SKILL.md to exist");
|
|
|
|
const featureDoc = read(path.join("docs", "features", "hola-poke-yeoksam.md"));
|
|
const skill = read(path.join("hola-poke-yeoksam", "SKILL.md"));
|
|
|
|
assert.match(readme, /\| 올라포케 역삼 포케 \|/);
|
|
assert.match(readme, /\[올라포케 역삼 포케 가이드\]\(docs\/features\/hola-poke-yeoksam\.md\)/);
|
|
assert.match(install, /--skill hola-poke-yeoksam/);
|
|
assert.match(sources, /mnspkm\/hola-poke-yeoksam-skill/);
|
|
assert.match(roadmap, /올라포케 역삼 포케 스킬 출시/);
|
|
});
|
|
|
|
test("hola-poke-yeoksam docs pin the verified remote MCP contract snapshot and phone-only event flow", () => {
|
|
const fixture = readJson(path.join("scripts", "fixtures", "hola-poke-yeoksam-contract-smoke.json"));
|
|
const skill = read(path.join("hola-poke-yeoksam", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "hola-poke-yeoksam.md"));
|
|
const snapshotLabels = [
|
|
["initialize 결과", "initialize", "initialize snapshot"],
|
|
["tools/list 결과", "tools_list", "tools/list snapshot"],
|
|
["get_menu 구조 예시", "get_menu", "get_menu snapshot"],
|
|
["get_shop_info 구조 예시", "get_shop_info", "get_shop_info snapshot"],
|
|
["enter_event(phone='010-12') 예시", "enter_event_invalid_phone", "invalid-phone snapshot"],
|
|
["enter_event 성공 응답 필수 필드", "enter_event_success_contract", "success-contract snapshot"],
|
|
];
|
|
|
|
assert.match(skill, /^name: hola-poke-yeoksam$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /올라포케 역삼점/);
|
|
assert.match(doc, /get_menu/);
|
|
assert.match(doc, /get_shop_info/);
|
|
assert.match(doc, /enter_event/);
|
|
assert.match(doc, /이름(?:·|\/)?이메일.*받지 않/);
|
|
assert.match(doc, /already_entered_today/);
|
|
assert.match(doc, /message.*글자 그대로/);
|
|
assert.match(doc, /주문\/결제\/배달앱 자동화는 하지 않/);
|
|
assert.match(doc, /성공 경로는.*(?:fixture|스냅샷|recorded)/i);
|
|
assert.match(doc, /라이브 스모크.*invalid-phone|invalid-phone.*라이브 스모크/i);
|
|
assert.match(doc, /01012345678|010-1234-5678/);
|
|
assert.match(doc, /hola-poke-yeoksam-skill\.onrender\.com\/mcp/);
|
|
|
|
for (const [label, key, message] of snapshotLabels) {
|
|
assert.equal(
|
|
findJsonFenceTextAfterLabel(doc, label),
|
|
JSON.stringify(fixture[key], null, 2),
|
|
`${message} must stay byte-aligned with the checked-in fixture`,
|
|
);
|
|
}
|
|
}
|
|
|
|
assert.deepEqual(
|
|
fixture.tools_list.tools.map((tool) => tool.name),
|
|
["get_menu", "get_shop_info", "enter_event"],
|
|
"tools/list fixture must pin the expected remote tool roster",
|
|
);
|
|
assert.equal(fixture.get_shop_info.group_order_url, "");
|
|
assert.match(fixture.get_shop_info.group_order_note, /단체주문|네이버페이/);
|
|
assert.deepEqual(fixture.enter_event_success_contract.required_fields, ["message", "code", "next_action"]);
|
|
assert.equal(fixture.enter_event_invalid_phone.error, "phone_format");
|
|
assert.match(fixture.enter_event_invalid_phone.message, /01012345678|010-1234-5678/);
|
|
});
|
|
|
|
test("repository docs advertise the library-book-search skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "library-book-search.md");
|
|
const skillPath = path.join(repoRoot, "library-book-search", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/library-book-search.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected library-book-search/SKILL.md to exist");
|
|
|
|
const featureDoc = read(path.join("docs", "features", "library-book-search.md"));
|
|
const skill = read(path.join("library-book-search", "SKILL.md"));
|
|
|
|
assert.match(skill, /^name: library-book-search$/m);
|
|
assert.match(readme, /\| 도서관 도서 조회 \|/);
|
|
assert.match(readme, /\[도서관 도서 조회 가이드\]\(docs\/features\/library-book-search\.md\)/);
|
|
assert.match(install, /--skill library-book-search/);
|
|
assert.match(install, /DATA4LIBRARY_AUTH_KEY/);
|
|
assert.match(sources, /data4library\.kr\/apiUtilization/);
|
|
assert.match(proxyDoc, /\/v1\/data4library\/book-search/);
|
|
assert.match(proxyDoc, /DATA4LIBRARY_AUTH_KEY/);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /도서관 정보나루/);
|
|
assert.match(doc, /\/v1\/data4library\/book-search/);
|
|
assert.match(doc, /\/v1\/data4library\/book-detail/);
|
|
assert.match(doc, /\/v1\/data4library\/book-exists/);
|
|
assert.match(doc, /\/v1\/data4library\/libraries-by-book/);
|
|
assert.match(doc, /DATA4LIBRARY_AUTH_KEY.*프록시 서버/s);
|
|
assert.match(doc, /사용자.*시크릿.*없/);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the korean-privacy-terms thin-wrapper skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-privacy-terms.md");
|
|
const skillPath = path.join(repoRoot, "korean-privacy-terms", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-privacy-terms.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-privacy-terms/SKILL.md to exist");
|
|
assert.match(readme, /\| 한국 개인정보처리방침·이용약관 자동 생성 \|/);
|
|
assert.match(
|
|
readme,
|
|
/\[한국 개인정보처리방침·이용약관 자동 생성 가이드\]\(docs\/features\/korean-privacy-terms\.md\)/,
|
|
);
|
|
assert.match(install, /--skill korean-privacy-terms/);
|
|
assert.match(roadmap, /한국 개인정보처리방침.이용약관 스킬 출시/);
|
|
assert.match(sources, /https:\/\/github\.com\/kimlawtech\/korean-privacy-terms/);
|
|
assert.match(sources, /Apache-2\.0/);
|
|
});
|
|
|
|
test("korean-privacy-terms skill is a thin wrapper that cites upstream and enforces a legal disclaimer", () => {
|
|
const skillPath = path.join(repoRoot, "korean-privacy-terms", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-privacy-terms/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("korean-privacy-terms", "SKILL.md"));
|
|
|
|
assert.match(skill, /^name: korean-privacy-terms$/m);
|
|
assert.match(skill, /^license: Apache-2\.0$/m);
|
|
assert.match(skill, /^description: .*개인정보처리방침.*이용약관.*$/m);
|
|
assert.match(
|
|
skill,
|
|
/\[?kimlawtech\/korean-privacy-terms\]?.*https:\/\/github\.com\/kimlawtech\/korean-privacy-terms/,
|
|
);
|
|
assert.match(skill, /Apache-2\.0/);
|
|
assert.match(skill, /참고용 초안/);
|
|
assert.match(skill, /법률 자문/);
|
|
assert.match(skill, /변호사 검토/);
|
|
assert.match(skill, /2026\.9\.11/);
|
|
assert.match(skill, /되묻/);
|
|
assert.match(skill, /개인정보처리방침/);
|
|
assert.match(skill, /이용약관/);
|
|
assert.match(skill, /쿠키 배너/);
|
|
assert.match(skill, /~\/\.claude\/skills\/korean-privacy-terms/);
|
|
assert.match(skill, /~\/\.agents\/skills\/korean-privacy-terms/);
|
|
assert.match(skill, /scripts\/install\.sh/);
|
|
assert.match(skill, /scripts\/upstream\.pin/);
|
|
assert.match(skill, /DISCLAIMER\.md/);
|
|
assert.match(skill, /## Notes/);
|
|
assert.doesNotMatch(skill, /AskUserQuestion/);
|
|
});
|
|
|
|
test("korean-privacy-terms preserves upstream NOTICE and DISCLAIMER for Apache-2.0 compliance", () => {
|
|
const noticePath = path.join(repoRoot, "korean-privacy-terms", "NOTICE");
|
|
const disclaimerPath = path.join(repoRoot, "korean-privacy-terms", "DISCLAIMER.md");
|
|
|
|
assert.ok(fs.existsSync(noticePath), "expected korean-privacy-terms/NOTICE to exist");
|
|
assert.ok(fs.existsSync(disclaimerPath), "expected korean-privacy-terms/DISCLAIMER.md to exist");
|
|
|
|
const notice = fs.readFileSync(noticePath, "utf8");
|
|
const disclaimer = fs.readFileSync(disclaimerPath, "utf8");
|
|
|
|
assert.match(notice, /korean-privacy-terms/);
|
|
assert.match(notice, /Copyright 2026 kimlawtech/);
|
|
assert.match(notice, /Apache License, Version 2\.0/);
|
|
assert.match(notice, /kimlawtech/i);
|
|
|
|
assert.match(disclaimer, /한국어/);
|
|
assert.match(disclaimer, /English/);
|
|
assert.match(disclaimer, /참고용 초안/);
|
|
assert.match(disclaimer, /reference drafts/i);
|
|
assert.match(disclaimer, /legal advice/i);
|
|
assert.match(disclaimer, /개인정보보호법/);
|
|
});
|
|
|
|
test("korean-privacy-terms ships an install.sh wrapper and a pinned upstream SHA", () => {
|
|
const pinPath = path.join(repoRoot, "korean-privacy-terms", "scripts", "upstream.pin");
|
|
const installPath = path.join(repoRoot, "korean-privacy-terms", "scripts", "install.sh");
|
|
|
|
assert.ok(fs.existsSync(pinPath), "expected korean-privacy-terms/scripts/upstream.pin to exist");
|
|
assert.ok(fs.existsSync(installPath), "expected korean-privacy-terms/scripts/install.sh to exist");
|
|
|
|
const pin = fs.readFileSync(pinPath, "utf8").trim();
|
|
|
|
assert.match(pin, /^[0-9a-f]{40}$/, "upstream.pin must contain a single 40-char git SHA");
|
|
assert.notStrictEqual(
|
|
pin,
|
|
"0".repeat(40),
|
|
"upstream.pin must not be a placeholder all-zero SHA",
|
|
);
|
|
assert.notStrictEqual(
|
|
pin,
|
|
"f".repeat(40),
|
|
"upstream.pin must not be a placeholder all-f SHA",
|
|
);
|
|
|
|
const install = fs.readFileSync(installPath, "utf8");
|
|
|
|
assert.match(install, /^#!\/(?:usr\/bin\/env bash|bin\/bash)/m, "install.sh must start with a bash shebang");
|
|
assert.match(install, /set -euo pipefail/, "install.sh must opt into strict bash mode");
|
|
assert.match(install, /~\/\.claude\/skills\/korean-privacy-terms/);
|
|
assert.match(install, /~\/\.agents\/skills\/korean-privacy-terms/);
|
|
assert.match(
|
|
install,
|
|
/https:\/\/github\.com\/kimlawtech\/korean-privacy-terms\.git/,
|
|
"install.sh must reference the full upstream clone URL",
|
|
);
|
|
assert.match(
|
|
install,
|
|
/git clone --filter=blob:none/,
|
|
"install.sh must perform a blobless git clone of the upstream repo",
|
|
);
|
|
assert.match(install, /upstream\.pin/);
|
|
|
|
const stat = fs.statSync(installPath);
|
|
|
|
assert.ok(
|
|
(stat.mode & 0o111) !== 0,
|
|
"install.sh must have the executable bit set on at least one of user/group/other",
|
|
);
|
|
});
|
|
|
|
test("korean-privacy-terms bundles the Apache-2.0 LICENSE per §4(a) redistribution requirement", () => {
|
|
const licensePath = path.join(repoRoot, "korean-privacy-terms", "LICENSE.upstream");
|
|
|
|
assert.ok(
|
|
fs.existsSync(licensePath),
|
|
"expected korean-privacy-terms/LICENSE.upstream to exist (Apache-2.0 §4(a) requires redistributors to give recipients a copy of this License)",
|
|
);
|
|
|
|
const license = fs.readFileSync(licensePath, "utf8");
|
|
|
|
assert.match(license, /Apache License/);
|
|
assert.match(license, /Version 2\.0, January 2004/);
|
|
assert.match(license, /http:\/\/www\.apache\.org\/licenses\/LICENSE-2\.0/);
|
|
assert.match(license, /TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION/);
|
|
assert.match(license, /Redistribution\. You may reproduce and distribute/);
|
|
assert.match(license, /END OF TERMS AND CONDITIONS/);
|
|
assert.match(license, /APPENDIX: How to apply the Apache License/);
|
|
assert.match(license, /Copyright 2026 kimlawtech/);
|
|
|
|
const skill = read(path.join("korean-privacy-terms", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "korean-privacy-terms.md"));
|
|
|
|
assert.match(
|
|
skill,
|
|
/LICENSE\.upstream/,
|
|
"SKILL.md Notes section must link to LICENSE.upstream so §4(a) is satisfied even before install.sh runs",
|
|
);
|
|
assert.match(
|
|
featureDoc,
|
|
/LICENSE\.upstream/,
|
|
"docs/features/korean-privacy-terms.md must reference LICENSE.upstream",
|
|
);
|
|
});
|
|
|
|
test("korean-privacy-terms feature doc documents the thin-wrapper install flow and legal disclaimer", () => {
|
|
const featureDoc = read(path.join("docs", "features", "korean-privacy-terms.md"));
|
|
|
|
assert.match(featureDoc, /kimlawtech\/korean-privacy-terms/);
|
|
assert.match(featureDoc, /Apache-2\.0/);
|
|
assert.match(featureDoc, /~\/\.claude\/skills\/korean-privacy-terms/);
|
|
assert.match(featureDoc, /~\/\.agents\/skills\/korean-privacy-terms/);
|
|
assert.match(featureDoc, /scripts\/install\.sh/);
|
|
assert.match(featureDoc, /scripts\/upstream\.pin/);
|
|
assert.match(featureDoc, /참고용 초안/);
|
|
assert.match(featureDoc, /법률 자문/);
|
|
assert.match(featureDoc, /변호사 검토/);
|
|
assert.match(featureDoc, /2026\.9\.11/);
|
|
assert.match(featureDoc, /Next\.js/);
|
|
});
|
|
|
|
test("repository docs advertise the korean-jangbu-for thin-wrapper skill", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-jangbu-for.md");
|
|
const skillPath = path.join(repoRoot, "korean-jangbu-for", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-jangbu-for.md to exist");
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-jangbu-for/SKILL.md to exist");
|
|
assert.match(readme, /\| 한국 사업자 장부 자동화 \|/);
|
|
assert.match(readme, /\[한국 사업자 장부 자동화 가이드\]\(docs\/features\/korean-jangbu-for\.md\)/);
|
|
assert.match(install, /--skill korean-jangbu-for/);
|
|
assert.match(sources, /https:\/\/github\.com\/kimlawtech\/korean-jangbu-for/);
|
|
assert.match(sources, /Apache-2\.0/);
|
|
assert.match(sources, /kimlawtech/);
|
|
assert.match(sources, /SpeciAI/);
|
|
});
|
|
|
|
test("korean-jangbu-for skill cites upstream author and enforces accounting/tax disclaimers", () => {
|
|
const skillPath = path.join(repoRoot, "korean-jangbu-for", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected korean-jangbu-for/SKILL.md to exist");
|
|
|
|
const skill = read(path.join("korean-jangbu-for", "SKILL.md"));
|
|
|
|
assert.match(skill, /^name: korean-jangbu-for$/m);
|
|
assert.match(skill, /^license: Apache-2\.0$/m);
|
|
assert.match(skill, /^description: .*장부.*사업자.*$/m);
|
|
assert.match(skill, /https:\/\/github\.com\/kimlawtech\/korean-jangbu-for/);
|
|
assert.match(skill, /@kimlawtech/);
|
|
assert.match(skill, /SpeciAI/);
|
|
assert.match(skill, /Apache-2\.0/);
|
|
assert.match(skill, /참고용 초안/);
|
|
assert.match(skill, /공식 회계감사/);
|
|
assert.match(skill, /세무신고/);
|
|
assert.match(skill, /세무사 검토/);
|
|
assert.match(skill, /공인회계사/);
|
|
assert.match(skill, /CODEF/);
|
|
assert.match(skill, /BYOK/);
|
|
assert.match(skill, /~\/\.claude\/skills\/korean-jangbu-for/);
|
|
assert.match(skill, /~\/\.agents\/skills\/korean-jangbu-for/);
|
|
assert.match(skill, /scripts\/install\.sh/);
|
|
assert.match(skill, /scripts\/upstream\.pin/);
|
|
assert.match(skill, /DISCLAIMER\.md/);
|
|
assert.match(skill, /LICENSE\.upstream/);
|
|
assert.doesNotMatch(skill, /AskUserQuestion/);
|
|
});
|
|
|
|
test("korean-jangbu-for preserves upstream attribution, disclaimer, and Apache license", () => {
|
|
const noticePath = path.join(repoRoot, "korean-jangbu-for", "NOTICE");
|
|
const disclaimerPath = path.join(repoRoot, "korean-jangbu-for", "DISCLAIMER.md");
|
|
const licensePath = path.join(repoRoot, "korean-jangbu-for", "LICENSE.upstream");
|
|
|
|
assert.ok(fs.existsSync(noticePath), "expected korean-jangbu-for/NOTICE to exist");
|
|
assert.ok(fs.existsSync(disclaimerPath), "expected korean-jangbu-for/DISCLAIMER.md to exist");
|
|
assert.ok(fs.existsSync(licensePath), "expected korean-jangbu-for/LICENSE.upstream to exist");
|
|
|
|
const notice = read(path.join("korean-jangbu-for", "NOTICE"));
|
|
const disclaimer = read(path.join("korean-jangbu-for", "DISCLAIMER.md"));
|
|
const license = read(path.join("korean-jangbu-for", "LICENSE.upstream"));
|
|
|
|
assert.match(notice, /korean-jangbu-for/);
|
|
assert.match(notice, /Copyright 2026 kimlawtech/);
|
|
assert.match(notice, /SpeciAI/);
|
|
assert.match(notice, /@kimlawtech/);
|
|
assert.match(notice, /https:\/\/github\.com\/kimlawtech\/korean-jangbu-for/);
|
|
|
|
assert.match(disclaimer, /참고용 초안/);
|
|
assert.match(disclaimer, /공식 회계감사/);
|
|
assert.match(disclaimer, /세무신고/);
|
|
assert.match(disclaimer, /세무사 검토 필수/);
|
|
assert.match(disclaimer, /공인회계사 감사 필수/);
|
|
assert.match(disclaimer, /법적 효력/);
|
|
|
|
assert.match(license, /Apache License/);
|
|
assert.match(license, /Version 2\.0, January 2004/);
|
|
assert.match(license, /END OF TERMS AND CONDITIONS/);
|
|
assert.match(license, /Copyright 2026 kimlawtech \(SpeciAI\)/);
|
|
});
|
|
|
|
test("korean-jangbu-for ships an install.sh wrapper and a pinned upstream SHA", () => {
|
|
const pinPath = path.join(repoRoot, "korean-jangbu-for", "scripts", "upstream.pin");
|
|
const installPath = path.join(repoRoot, "korean-jangbu-for", "scripts", "install.sh");
|
|
|
|
assert.ok(fs.existsSync(pinPath), "expected korean-jangbu-for/scripts/upstream.pin to exist");
|
|
assert.ok(fs.existsSync(installPath), "expected korean-jangbu-for/scripts/install.sh to exist");
|
|
|
|
const pin = read(path.join("korean-jangbu-for", "scripts", "upstream.pin")).trim();
|
|
|
|
assert.match(pin, /^[0-9a-f]{40}$/, "upstream.pin must contain a single 40-char git SHA");
|
|
assert.notStrictEqual(pin, "0".repeat(40), "upstream.pin must not be a placeholder all-zero SHA");
|
|
|
|
const install = read(path.join("korean-jangbu-for", "scripts", "install.sh"));
|
|
|
|
assert.match(install, /^#!\/(?:usr\/bin\/env bash|bin\/bash)/m);
|
|
assert.match(install, /set -euo pipefail/);
|
|
assert.match(install, /~\/\.claude\/skills\/korean-jangbu-for/);
|
|
assert.match(install, /~\/\.agents\/skills\/korean-jangbu-for/);
|
|
assert.match(install, /https:\/\/github\.com\/kimlawtech\/korean-jangbu-for\.git/);
|
|
assert.match(install, /git clone --filter=blob:none/);
|
|
assert.match(install, /upstream\.pin/);
|
|
assert.match(install, /verify\.sh/);
|
|
assert.match(install, /Re-run this wrapper installer after upstream runtime install/);
|
|
|
|
const stat = fs.statSync(installPath);
|
|
assert.ok((stat.mode & 0o111) !== 0, "install.sh must be executable");
|
|
});
|
|
|
|
test("korean-jangbu-for installer registers upstream subskills for Claude and agents", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "korean-jangbu-for-install-"));
|
|
const homeDir = path.join(tmpDir, "home");
|
|
const upstreamDir = path.join(tmpDir, "upstream");
|
|
const installPath = path.join(repoRoot, "korean-jangbu-for", "scripts", "install.sh");
|
|
const upstreamSubskills = [
|
|
"jangbu-connect",
|
|
"jangbu-dash",
|
|
"jangbu-import",
|
|
"jangbu-jongso",
|
|
"jangbu-tag",
|
|
"jangbu-tax",
|
|
];
|
|
|
|
fs.mkdirSync(path.join(upstreamDir, "skills"), { recursive: true });
|
|
fs.mkdirSync(homeDir, { recursive: true });
|
|
|
|
for (const skillName of ["korean-jangbu-for", ...upstreamSubskills]) {
|
|
const skillDir = path.join(upstreamDir, "skills", skillName);
|
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(skillDir, "SKILL.md"),
|
|
`---\nname: ${skillName}\n---\n\n# ${skillName}\n`,
|
|
);
|
|
}
|
|
|
|
fs.mkdirSync(path.join(upstreamDir, "scripts"), { recursive: true });
|
|
fs.writeFileSync(path.join(upstreamDir, "scripts", "verify.sh"), "#!/usr/bin/env bash\nexit 0\n");
|
|
|
|
childProcess.execFileSync("git", ["init"], { cwd: upstreamDir, stdio: "ignore" });
|
|
childProcess.execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: upstreamDir });
|
|
childProcess.execFileSync("git", ["config", "user.name", "Test"], { cwd: upstreamDir });
|
|
childProcess.execFileSync("git", ["add", "."], { cwd: upstreamDir });
|
|
childProcess.execFileSync("git", ["commit", "-m", "seed upstream skills"], { cwd: upstreamDir, stdio: "ignore" });
|
|
const upstreamSha = childProcess.execFileSync("git", ["rev-parse", "HEAD"], { cwd: upstreamDir, encoding: "utf8" }).trim();
|
|
|
|
fs.mkdirSync(path.join(homeDir, ".claude", "skills"), { recursive: true });
|
|
fs.symlinkSync(
|
|
path.join(upstreamDir, "skills", "korean-jangbu-for"),
|
|
path.join(homeDir, ".claude", "skills", "korean-jangbu-for"),
|
|
);
|
|
|
|
childProcess.execFileSync("bash", [installPath], {
|
|
cwd: repoRoot,
|
|
env: {
|
|
...process.env,
|
|
HOME: homeDir,
|
|
KOREAN_JANGBU_FOR_UPSTREAM_REPO: upstreamDir,
|
|
KOREAN_JANGBU_FOR_UPSTREAM_SHA: upstreamSha,
|
|
},
|
|
stdio: "pipe",
|
|
});
|
|
|
|
for (const root of [".claude", ".agents"]) {
|
|
const skillRoot = path.join(homeDir, root, "skills");
|
|
|
|
assert.ok(
|
|
fs.existsSync(path.join(skillRoot, "korean-jangbu-for", "upstream", "skills", "jangbu-connect", "SKILL.md")),
|
|
`${root} should keep the pinned upstream checkout nested under the wrapper`,
|
|
);
|
|
assert.match(
|
|
fs.readFileSync(path.join(skillRoot, "korean-jangbu-for", "SKILL.md"), "utf8"),
|
|
/@kimlawtech/,
|
|
`${root} should keep the korean-jangbu-for wrapper policy at the top level`,
|
|
);
|
|
assert.ok(
|
|
!fs.lstatSync(path.join(skillRoot, "korean-jangbu-for")).isSymbolicLink(),
|
|
`${root} should replace conflicting upstream korean-jangbu-for symlinks with a wrapper directory`,
|
|
);
|
|
|
|
for (const requiredPath of [
|
|
"scripts/install.sh",
|
|
"scripts/upstream.pin",
|
|
"LICENSE.upstream",
|
|
"DISCLAIMER.md",
|
|
"NOTICE",
|
|
]) {
|
|
assert.ok(
|
|
fs.existsSync(path.join(skillRoot, "korean-jangbu-for", requiredPath)),
|
|
`${root} should install wrapper support payload ${requiredPath}`,
|
|
);
|
|
}
|
|
|
|
for (const skillName of upstreamSubskills) {
|
|
const installedSubskillPath = path.join(skillRoot, skillName, "SKILL.md");
|
|
assert.ok(
|
|
fs.existsSync(installedSubskillPath),
|
|
`${root} should register upstream subskill ${skillName} as a top-level discoverable skill`,
|
|
);
|
|
|
|
const installedSubskill = fs.readFileSync(installedSubskillPath, "utf8");
|
|
assert.match(installedSubskill, /https:\/\/github\.com\/kimlawtech\/korean-jangbu-for/);
|
|
assert.match(installedSubskill, /@kimlawtech/);
|
|
assert.match(installedSubskill, /SpeciAI/);
|
|
assert.match(installedSubskill, /Apache-2\.0/);
|
|
assert.match(installedSubskill, /참고용 초안/);
|
|
assert.match(installedSubskill, /공식 회계감사/);
|
|
assert.match(installedSubskill, /세무신고/);
|
|
}
|
|
}
|
|
|
|
for (const installedRoot of [".claude", ".agents"]) {
|
|
childProcess.execFileSync("bash", [path.join(homeDir, installedRoot, "skills", "korean-jangbu-for", "scripts", "install.sh")], {
|
|
cwd: repoRoot,
|
|
env: {
|
|
...process.env,
|
|
HOME: homeDir,
|
|
KOREAN_JANGBU_FOR_UPSTREAM_REPO: upstreamDir,
|
|
KOREAN_JANGBU_FOR_UPSTREAM_SHA: upstreamSha,
|
|
},
|
|
stdio: "pipe",
|
|
});
|
|
}
|
|
});
|
|
|
|
test("korean-jangbu-for installer preflights promoted subskill collisions before home writes", () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "korean-jangbu-for-collision-"));
|
|
const homeDir = path.join(tmpDir, "home");
|
|
const upstreamDir = path.join(tmpDir, "upstream");
|
|
const installPath = path.join(repoRoot, "korean-jangbu-for", "scripts", "install.sh");
|
|
const upstreamSubskills = [
|
|
"jangbu-connect",
|
|
"jangbu-dash",
|
|
"jangbu-import",
|
|
"jangbu-jongso",
|
|
"jangbu-tag",
|
|
"jangbu-tax",
|
|
];
|
|
|
|
for (const skillName of upstreamSubskills) {
|
|
fs.mkdirSync(path.join(upstreamDir, "skills", skillName), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(upstreamDir, "skills", skillName, "SKILL.md"),
|
|
`---\nname: ${skillName}\n---\n\n# ${skillName}\n`,
|
|
);
|
|
}
|
|
|
|
fs.mkdirSync(path.join(upstreamDir, "scripts"), { recursive: true });
|
|
fs.writeFileSync(path.join(upstreamDir, "scripts", "verify.sh"), "#!/usr/bin/env bash\nexit 0\n");
|
|
|
|
childProcess.execFileSync("git", ["init"], { cwd: upstreamDir, stdio: "ignore" });
|
|
childProcess.execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: upstreamDir });
|
|
childProcess.execFileSync("git", ["config", "user.name", "Test"], { cwd: upstreamDir });
|
|
childProcess.execFileSync("git", ["add", "."], { cwd: upstreamDir });
|
|
childProcess.execFileSync("git", ["commit", "-m", "seed upstream skills"], { cwd: upstreamDir, stdio: "ignore" });
|
|
const upstreamSha = childProcess.execFileSync("git", ["rev-parse", "HEAD"], { cwd: upstreamDir, encoding: "utf8" }).trim();
|
|
|
|
const unrelatedSkillDir = path.join(homeDir, ".agents", "skills", "jangbu-tax");
|
|
fs.mkdirSync(unrelatedSkillDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(unrelatedSkillDir, "SKILL.md"),
|
|
"---\nname: jangbu-tax\n---\n\n# user-authored jangbu-tax\n",
|
|
);
|
|
|
|
assert.throws(
|
|
() =>
|
|
childProcess.execFileSync("bash", [installPath], {
|
|
cwd: repoRoot,
|
|
env: {
|
|
...process.env,
|
|
HOME: homeDir,
|
|
KOREAN_JANGBU_FOR_UPSTREAM_REPO: upstreamDir,
|
|
KOREAN_JANGBU_FOR_UPSTREAM_SHA: upstreamSha,
|
|
},
|
|
stdio: "pipe",
|
|
}),
|
|
/refusing to overwrite unrelated skill/,
|
|
);
|
|
|
|
assert.match(
|
|
fs.readFileSync(path.join(unrelatedSkillDir, "SKILL.md"), "utf8"),
|
|
/user-authored jangbu-tax/,
|
|
"unrelated existing subskill should be preserved after installer refusal",
|
|
);
|
|
|
|
for (const root of [".claude", ".agents"]) {
|
|
const skillRoot = path.join(homeDir, root, "skills");
|
|
assert.ok(
|
|
!fs.existsSync(path.join(skillRoot, "korean-jangbu-for")),
|
|
`${root} should not create the wrapper directory after a promoted-subskill preflight failure`,
|
|
);
|
|
|
|
for (const skillName of upstreamSubskills.filter((name) => name !== "jangbu-tax")) {
|
|
assert.ok(
|
|
!fs.existsSync(path.join(skillRoot, skillName)),
|
|
`${root} should not create promoted subskill ${skillName} after a promoted-subskill preflight failure`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("korean-jangbu-for feature doc documents source-first use and mandatory attribution", () => {
|
|
const featureDoc = read(path.join("docs", "features", "korean-jangbu-for.md"));
|
|
|
|
assert.match(featureDoc, /kimlawtech\/korean-jangbu-for/);
|
|
assert.match(featureDoc, /https:\/\/github\.com\/kimlawtech\/korean-jangbu-for/);
|
|
assert.match(featureDoc, /@kimlawtech/);
|
|
assert.match(featureDoc, /SpeciAI/);
|
|
assert.match(featureDoc, /Apache-2\.0/);
|
|
assert.match(featureDoc, /~\/\.claude\/skills\/korean-jangbu-for/);
|
|
assert.match(featureDoc, /~\/\.agents\/skills\/korean-jangbu-for/);
|
|
assert.match(featureDoc, /scripts\/install\.sh/);
|
|
assert.match(featureDoc, /scripts\/upstream\.pin/);
|
|
assert.match(featureDoc, /CODEF/);
|
|
assert.match(featureDoc, /BYOK/);
|
|
assert.match(featureDoc, /세무사 검토/);
|
|
assert.match(featureDoc, /공인회계사/);
|
|
});
|
|
|
|
test("corporate-registration-consulting skill covers court registry workflow, tax pitfalls, and rhwp automation", () => {
|
|
const skillPath = path.join(repoRoot, "corporate-registration-consulting", "SKILL.md");
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "corporate-registration-consulting.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected corporate-registration-consulting/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(featureDocPath), "expected corporate-registration-consulting feature doc to exist");
|
|
const documentPackTemplatePath = path.join(
|
|
"corporate-registration-consulting",
|
|
"templates",
|
|
"incorporation-document-pack.md",
|
|
);
|
|
const officialFormSourcesPath = path.join(
|
|
"corporate-registration-consulting",
|
|
"templates",
|
|
"official-form-sources.md",
|
|
);
|
|
const officialPromoterHwpPath = path.join(
|
|
"corporate-registration-consulting",
|
|
"templates",
|
|
"official",
|
|
"form-65-1-stock-company-incorporation-promoter.hwp",
|
|
);
|
|
const officialSubscriptionHwpPath = path.join(
|
|
"corporate-registration-consulting",
|
|
"templates",
|
|
"official",
|
|
"form-65-2-stock-company-incorporation-subscription.hwp",
|
|
);
|
|
const officialFillMapPath = path.join(
|
|
"corporate-registration-consulting",
|
|
"templates",
|
|
"official",
|
|
"form-65-1-fill-map.json",
|
|
);
|
|
const officialSourceManifestPath = path.join(
|
|
"corporate-registration-consulting",
|
|
"templates",
|
|
"official",
|
|
"source-manifest.json",
|
|
);
|
|
const officialFillScriptPath = path.join(
|
|
"corporate-registration-consulting",
|
|
"scripts",
|
|
"fill_official_hwp.py",
|
|
);
|
|
const attachmentHwpDir = path.join("corporate-registration-consulting", "templates", "attachment-hwp");
|
|
const attachmentHwpArtifacts = [
|
|
"articles-of-incorporation.hwp",
|
|
"standard-articles-startup-moj.hwp",
|
|
"share-issuance-consent.hwp",
|
|
"share-subscription.hwp",
|
|
"founder-meeting-minutes.hwp",
|
|
"founder-meeting-period-shortening-consent.hwp",
|
|
"shareholder-register.hwp",
|
|
"inspection-report.hwp",
|
|
"officer-acceptance-director-ceo.hwp",
|
|
"officer-acceptance-auditor.hwp",
|
|
"board-minutes.hwp",
|
|
"corporate-seal-report.hwp",
|
|
"power-of-attorney.hwp",
|
|
].map((fileName) => path.join(attachmentHwpDir, fileName));
|
|
|
|
assert.ok(
|
|
fs.existsSync(path.join(repoRoot, documentPackTemplatePath)),
|
|
"expected an incorporation document pack template artifact",
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(repoRoot, officialFormSourcesPath)),
|
|
"expected an official form sources artifact",
|
|
);
|
|
for (const artifactPath of [officialPromoterHwpPath, officialSubscriptionHwpPath, officialFillMapPath, officialSourceManifestPath, officialFillScriptPath, ...attachmentHwpArtifacts, path.join(attachmentHwpDir, "source-manifest.json")]) {
|
|
assert.ok(fs.existsSync(path.join(repoRoot, artifactPath)), `expected ${artifactPath} to exist`);
|
|
}
|
|
|
|
const documentPackTemplate = read(documentPackTemplatePath);
|
|
const officialFormSources = read(officialFormSourcesPath);
|
|
const officialFillMap = JSON.parse(read(officialFillMapPath));
|
|
const officialSourceManifest = JSON.parse(read(officialSourceManifestPath));
|
|
const attachmentSourceManifest = JSON.parse(read(path.join(attachmentHwpDir, "source-manifest.json")));
|
|
for (const hwpPath of [officialPromoterHwpPath, officialSubscriptionHwpPath, ...attachmentHwpArtifacts]) {
|
|
const hwpMagic = fs.readFileSync(path.join(repoRoot, hwpPath)).subarray(0, 8).toString("hex");
|
|
assert.equal(hwpMagic, "d0cf11e0a1b11ae1", `${hwpPath} should be an OLE HWP file`);
|
|
}
|
|
const skill = read(path.join("corporate-registration-consulting", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "corporate-registration-consulting.md"));
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
|
|
assert.match(skill, /^---\nname: corporate-registration-consulting\n/);
|
|
assert.match(skill, /참고용/);
|
|
assert.match(skill, /법률 자문/);
|
|
assert.match(skill, /변호사|법무사|세무사/);
|
|
assert.match(skill, /상호/);
|
|
assert.match(skill, /정관/);
|
|
assert.match(skill, /잔고증명|주금납입/);
|
|
assert.match(skill, /취임승낙서/);
|
|
assert.match(skill, /조사보고서/);
|
|
assert.match(skill, /등록면허세/);
|
|
assert.match(skill, /과밀억제권역/);
|
|
assert.match(skill, /지방세법 제28조/);
|
|
assert.match(skill, /소프트웨어/);
|
|
assert.match(skill, /주민등록번호/);
|
|
assert.match(skill, /마스킹/);
|
|
assert.match(skill, /에이전트는[\s\S]*로그인[\s\S]*전자서명[\s\S]*세금 납부[\s\S]*등기 제출[\s\S]*수행하지 않는다/);
|
|
assert.match(skill, /사용자 사칭[\s\S]*수행하지 않는다/);
|
|
assert.match(skill, /최종 법률 판단[\s\S]*수행하지 않는다/);
|
|
assert.match(skill, /최종 세무 판단[\s\S]*수행하지 않는다/);
|
|
assert.match(skill, /모집설립/);
|
|
assert.match(skill, /모집설립은 일반적이지[\s\S]*기본 플로우에서는 제외/);
|
|
assert.match(skill, /저장된 발기설립 양식과 첨부서류 양식/);
|
|
assert.match(skill, /등기신청양식/);
|
|
assert.match(skill, /첨부서면예시/);
|
|
assert.match(skill, /상업등기신청서의 양식에 관한 예규/);
|
|
assert.match(skill, /official-form-sources\.md/);
|
|
assert.match(skill, /form-65-1-stock-company-incorporation-promoter\.hwp/);
|
|
assert.match(skill, /fill_official_hwp\.py/);
|
|
assert.match(skill, /form-65-1-fill-map\.json/);
|
|
assert.match(skill, /기본 산출물은 실제 HWP 파일/);
|
|
assert.match(skill, /Markdown만 반환하지 말고/);
|
|
assert.match(skill, /dummy[\s\S]*지양|dummy[\s\S]*남기지 않는다/);
|
|
assert.match(skill, /반드시 작성할 문서와 저장된 양식 경로/);
|
|
assert.match(skill, /저장해 둔 HWP 양식을 우선 활용|저장된 HWP 양식을 우선 활용/);
|
|
assert.match(skill, /에이전트가 매번 공식 양식을 새로 찾게 하지 말고/);
|
|
assert.match(skill, /정관은 최대한 저장된 표준정관을 그대로 따른다/);
|
|
assert.match(skill, /정관[\s\S]*제2조[\s\S]*업태·종목/);
|
|
assert.match(skill, /정관[\s\S]*맨 마지막[\s\S]*작성일자[\s\S]*서명|정관[\s\S]*맨 마지막[\s\S]*날짜[\s\S]*기명날인/);
|
|
assert.match(skill, /간인/);
|
|
assert.match(skill, /법인인감/);
|
|
assert.match(skill, /templates\/attachment-hwp\//);
|
|
assert.match(skill, /자리표시자/);
|
|
assert.match(skill, /articles-of-incorporation\.hwp/);
|
|
assert.match(skill, /founder-meeting-minutes\.hwp/);
|
|
assert.match(skill, /share-subscription\.hwp/);
|
|
assert.match(skill, /inspection-report\.hwp/);
|
|
assert.match(skill, /officer-acceptance-director-ceo\.hwp/);
|
|
assert.match(skill, /rhwp-edit/);
|
|
assert.match(skill, /k-skill-rhwp/);
|
|
assert.match(skill, /본문[\s\S]*replace-all|replace-all[\s\S]*본문/);
|
|
assert.match(skill, /replace-all[\s\S]*shortcut/);
|
|
assert.match(skill, /한 장 한 장[\s\S]*순차/);
|
|
assert.match(skill, /set-cell-text/);
|
|
assert.match(skill, /info.*list-paragraphs/);
|
|
assert.match(skill, /render|kordoc/);
|
|
assert.match(skill, /표.*셀/);
|
|
assert.match(skill, /레포.*밖|레포.*외부|저장소.*밖|저장소.*외부/);
|
|
assert.doesNotMatch(skill, /\.\/out\/court-form-filled\.hwp/);
|
|
assert.match(skill, /등기신청수수료 영수필확인서/);
|
|
assert.match(skill, /등기이사[\s\S]*인감증명서 또는 본인서명사실확인서/);
|
|
assert.match(skill, /등기이사[\s\S]*주민등록초본\/등본/);
|
|
assert.match(skill, /상법 제291조/);
|
|
assert.match(skill, /명의개서대리인/);
|
|
assert.match(skill, /현물출자[\s\S]*재산인수/);
|
|
assert.match(skill, /인허가/);
|
|
assert.match(skill, /정관 공증|의사록 인증/);
|
|
assert.match(skill, /법인명/);
|
|
assert.match(skill, /이사/);
|
|
assert.match(skill, /주소/);
|
|
assert.match(skill, /쉬운 말/);
|
|
assert.match(skill, /사용자 결정/);
|
|
assert.match(documentPackTemplate, /취임승낙서/);
|
|
assert.match(documentPackTemplate, /인감신고서/);
|
|
assert.match(documentPackTemplate, /등록면허세/);
|
|
assert.match(documentPackTemplate, /등기신청수수료 영수필확인서/);
|
|
assert.match(documentPackTemplate, /등기이사[\s\S]*인감증명서 또는 본인서명사실확인서/);
|
|
assert.match(documentPackTemplate, /등기이사[\s\S]*주민등록초본\/등본/);
|
|
assert.match(documentPackTemplate, /명의개서대리인/);
|
|
assert.match(documentPackTemplate, /현물출자[\s\S]*재산인수/);
|
|
assert.match(documentPackTemplate, /인허가/);
|
|
assert.match(documentPackTemplate, /정관 공증|의사록 인증/);
|
|
assert.match(documentPackTemplate, /개인정보|민감정보/);
|
|
assert.match(documentPackTemplate, /양식별 수정 위치/);
|
|
assert.match(documentPackTemplate, /간인/);
|
|
assert.match(documentPackTemplate, /법인인감/);
|
|
assert.match(documentPackTemplate, /레포.*커밋/);
|
|
assert.match(documentPackTemplate, /\{\{INSPECTION_CONCLUSION_AFTER_USER_OR_EXPERT_REVIEW\}\}/);
|
|
assert.doesNotMatch(documentPackTemplate, /중대한 흠이 없음을 보고합니다/);
|
|
assert.match(officialFormSources, /인터넷등기소/);
|
|
assert.match(officialFormSources, /등기신청양식/);
|
|
assert.match(officialFormSources, /첨부서면예시/);
|
|
assert.match(officialFormSources, /상업등기신청서의 양식에 관한 예규/);
|
|
assert.match(officialFormSources, /양식 제65-1호/);
|
|
assert.match(officialFormSources, /양식 제65-2호/);
|
|
assert.match(officialFormSources, /이미 저장된 HWP 양식 경로/);
|
|
assert.match(officialFormSources, /새 양식을 찾는 것부터 시작하지 말고/);
|
|
assert.match(officialFormSources, /모집설립[\s\S]*기본 플로우에서는 사용하지 않음/);
|
|
assert.match(officialFormSources, /HWP\/HWPX\/PDF/);
|
|
assert.match(officialFormSources, /templates\/official/);
|
|
assert.match(officialFormSources, /form-65-1-stock-company-incorporation-promoter\.hwp/);
|
|
assert.match(officialFormSources, /form-65-2-stock-company-incorporation-subscription\.hwp/);
|
|
assert.match(officialFormSources, /fill_official_hwp\.py/);
|
|
assert.match(officialFormSources, /templates\/attachment-hwp\//);
|
|
assert.match(officialFormSources, /실제 공개 배포 첨부서류 HWP 양식 묶음/);
|
|
assert.match(officialFormSources, /자리표시자/);
|
|
assert.match(officialFormSources, /articles-of-incorporation\.hwp/);
|
|
assert.match(officialFormSources, /corporate-seal-report\.hwp/);
|
|
assert.match(officialFormSources, /최신본을 다시 확인|최신 예규/);
|
|
assert.match(officialFormSources, /replace-all[\s\S]*shortcut/);
|
|
assert.match(officialFormSources, /간인/);
|
|
assert.match(officialFormSources, /법인인감/);
|
|
assert.match(officialFormSources, /등기신청수수료 영수필확인서/);
|
|
assert.match(officialFormSources, /등기이사[\s\S]*인감증명서 또는 본인서명사실확인서/);
|
|
assert.match(officialFormSources, /등기이사[\s\S]*주민등록초본\/등본/);
|
|
assert.match(officialFormSources, /상업등기규칙 제129조/);
|
|
assert.match(officialFormSources, /명의개서대리인/);
|
|
assert.match(officialFormSources, /현물출자[\s\S]*재산인수/);
|
|
assert.match(officialFormSources, /인허가/);
|
|
assert.match(officialFormSources, /정관 공증|의사록 인증/);
|
|
assert.equal(officialFillMap.fields.company_name.cell, 21);
|
|
assert.equal(officialFillMap.fields.head_office_address.cell, 23);
|
|
assert.equal(officialFillMap.fields.capital_krw.cell, 33);
|
|
assert.equal(officialSourceManifest.source.adm_rul_seq, "2200000106061");
|
|
assert.match(officialSourceManifest.files[0].sha256, /^[a-f0-9]{64}$/);
|
|
assert.match(attachmentSourceManifest.note, /sanitized to placeholders|자리표시자/);
|
|
|
|
assert.match(featureDoc, /법인등기 신청 컨설팅/);
|
|
assert.match(featureDoc, /정관/);
|
|
assert.match(featureDoc, /등록면허세/);
|
|
assert.match(featureDoc, /과밀억제권역/);
|
|
assert.match(featureDoc, /조세특례제한법 제6조/);
|
|
assert.match(featureDoc, /지방세법 제28조/);
|
|
assert.match(featureDoc, /에이전트는[\s\S]*로그인[\s\S]*전자서명[\s\S]*세금 납부[\s\S]*등기 제출[\s\S]*지원하지 않는다|에이전트는[\s\S]*로그인[\s\S]*전자서명[\s\S]*세금 납부[\s\S]*등기 제출[\s\S]*수행하지 않는다/);
|
|
assert.match(featureDoc, /사용자 사칭[\s\S]*(지원하지 않는다|수행하지 않는다|사용자가 직접 또는 전문가)/);
|
|
assert.match(featureDoc, /최종 법률 판단[\s\S]*(지원하지 않는다|수행하지 않는다|사용자가 직접 또는 전문가)/);
|
|
assert.match(featureDoc, /최종 세무 판단[\s\S]*(지원하지 않는다|수행하지 않는다|사용자가 직접 또는 전문가)/);
|
|
assert.match(featureDoc, /개인정보|민감정보/);
|
|
assert.match(featureDoc, /자리표시자/);
|
|
assert.match(featureDoc, /최종 산출물은 실제 `?\.hwp`? 사본/);
|
|
assert.match(featureDoc, /저장된 HWP 양식 사본/);
|
|
assert.match(featureDoc, /표 셀|set-cell-text/);
|
|
assert.match(featureDoc, /모집설립/);
|
|
assert.match(featureDoc, /모집설립은 일반적이지[\s\S]*기본 플로우/);
|
|
assert.match(featureDoc, /저장된 양식 경로/);
|
|
assert.match(featureDoc, /필수 문서와 양식/);
|
|
assert.match(featureDoc, /replace-all/);
|
|
assert.match(featureDoc, /replace-all[\s\S]*shortcut/);
|
|
assert.match(featureDoc, /한 장 한 장[\s\S]*순차/);
|
|
assert.match(featureDoc, /표 셀|set-cell-text/);
|
|
assert.match(featureDoc, /간인/);
|
|
assert.match(featureDoc, /법인인감/);
|
|
assert.match(featureDoc, /등기신청수수료 영수필확인서/);
|
|
assert.match(featureDoc, /등기이사[\s\S]*인감증명서\/본인서명사실확인서|등기이사[\s\S]*인감증명서 또는 본인서명사실확인서/);
|
|
assert.match(featureDoc, /등기이사[\s\S]*주민등록초본\/등본/);
|
|
assert.match(featureDoc, /상법 제291조/);
|
|
assert.match(featureDoc, /명의개서대리인/);
|
|
assert.match(featureDoc, /현물출자[\s\S]*재산인수/);
|
|
assert.match(featureDoc, /인허가/);
|
|
assert.match(featureDoc, /정관 공증|의사록 인증/);
|
|
assert.match(featureDoc, /업태·종목/);
|
|
assert.match(featureDoc, /표.*셀/);
|
|
assert.match(featureDoc, /세금 납부/);
|
|
assert.match(featureDoc, /실제 제출은 사용자가 직접/);
|
|
assert.match(featureDoc, /인터넷등기소|온라인법인설립시스템/);
|
|
assert.match(featureDoc, /form-65-1-stock-company-incorporation-promoter\.hwp/);
|
|
assert.doesNotMatch(featureDoc, /양식 제65-2호/);
|
|
assert.match(featureDoc, /templates\/official\/\*\.hwp|templates\/official\//);
|
|
assert.match(featureDoc, /templates\/attachment-hwp\//);
|
|
assert.match(featureDoc, /templates\/attachment-hwp\/\*\.hwp|첨부/);
|
|
assert.match(featureDoc, /참고용/);
|
|
|
|
assert.match(readme, /\| 법인등기 신청 컨설팅 \|/);
|
|
assert.match(readme, /docs\/features\/corporate-registration-consulting\.md/);
|
|
assert.match(install, /--skill corporate-registration-consulting/);
|
|
assert.match(sources, /corporate-registration-consulting/);
|
|
assert.match(sources, /startbiz\.go\.kr/);
|
|
assert.match(sources, /law\.go\.kr/);
|
|
});
|
|
|
|
test("iros-registry-automation skill documents safe IROS registry certificate automation and upstream credit", () => {
|
|
const skillPath = path.join(repoRoot, "iros-registry-automation", "SKILL.md");
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "iros-registry-automation.md");
|
|
const upstreamPinPath = path.join(repoRoot, "iros-registry-automation", "scripts", "upstream.pin");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected iros-registry-automation/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(featureDocPath), "expected iros-registry-automation feature doc to exist");
|
|
assert.ok(fs.existsSync(upstreamPinPath), "expected iros-registry-automation/scripts/upstream.pin to exist");
|
|
|
|
const skill = read(path.join("iros-registry-automation", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "iros-registry-automation.md"));
|
|
const upstreamPin = read(path.join("iros-registry-automation", "scripts", "upstream.pin")).trim();
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
|
|
assert.match(upstreamPin, /^[0-9a-f]{40}$/, "upstream pin should be a reviewed 40-character Git SHA");
|
|
|
|
assert.match(skill, /^---\nname: iros-registry-automation\n/);
|
|
assert.match(skill, /등기부등본|등기사항증명서/);
|
|
assert.match(skill, /법인/);
|
|
assert.match(skill, /부동산/);
|
|
assert.match(skill, /인터넷등기소|IROS/);
|
|
assert.match(skill, /TouchEn nxKey/);
|
|
assert.match(skill, /Playwright|Chromium/);
|
|
assert.match(skill, /로그인[\s\S]*수동|사용자.*직접.*로그인/);
|
|
assert.match(skill, /결제[\s\S]*수동|사용자.*직접.*결제/);
|
|
assert.match(skill, /카드|공동인증서|간편인증/);
|
|
assert.match(skill, /페이지당\s*10건|10건/);
|
|
assert.match(skill, /장바구니/);
|
|
assert.match(skill, /열람/);
|
|
assert.match(skill, /저장|다운로드/);
|
|
assert.match(skill, /법인등록번호/);
|
|
assert.match(skill, /상호명/);
|
|
assert.match(skill, /주소/);
|
|
assert.match(skill, /개인정보|민감정보/);
|
|
assert.match(skill, /법률 자문|참고용/);
|
|
assert.match(skill, /challengekim/);
|
|
assert.match(skill, /https:\/\/github\.com\/challengekim\/iros-registry-automation/);
|
|
assert.match(skill, /MIT/);
|
|
assert.match(skill, /원 저작자|원저작자|upstream|참고 구현/);
|
|
assert.match(skill, new RegExp(`git checkout ${upstreamPin}`), "skill install flow should check out the pinned upstream SHA");
|
|
assert.match(skill, /scripts\/upstream\.pin/, "skill should document where the reviewed upstream pin lives");
|
|
assert.match(skill, /pin update|핀 업데이트|업스트림 핀|review/i, "skill should require review before updating the pin");
|
|
assert.match(skill, /\$workdir\/corp-input\.json/, "skill should put real corporate inputs under the private workdir");
|
|
assert.match(
|
|
skill,
|
|
/\$workdir\/companies-input\.json[\s\S]*(iros_download\.py|결제 후|열람|저장)/,
|
|
"skill should prepare the company-name list required by iros_download.py before the download flow after payment",
|
|
);
|
|
assert.match(
|
|
skill,
|
|
/"excel_path":\s*str\(workdir \/ "customer-list\.xlsx"\)/,
|
|
"skill should redirect upstream customer Excel input to the private workdir",
|
|
);
|
|
assert.match(skill, /\$workdir\/downloads/, "skill should point save_dir/download output at the private workdir");
|
|
assert.match(skill, /config\.json[\s\S]*save_dir[\s\S]*\$workdir/, "skill should wire config.json save_dir to the private workdir");
|
|
assert.match(skill, /upstream repo `data\/`|upstream `data\/`|data\/.*실제/, "skill should warn not to use upstream data/ for real inputs");
|
|
|
|
for (const doc of [featureDoc, sources]) {
|
|
assert.match(doc, /challengekim/);
|
|
assert.match(doc, /https:\/\/github\.com\/challengekim\/iros-registry-automation/);
|
|
assert.match(doc, /인터넷등기소|IROS|iros\.go\.kr/);
|
|
}
|
|
|
|
assert.match(featureDoc, new RegExp(`git checkout ${upstreamPin}`), "feature doc install flow should check out the pinned upstream SHA");
|
|
assert.match(featureDoc, /scripts\/upstream\.pin/, "feature doc should document where the reviewed upstream pin lives");
|
|
assert.match(featureDoc, /pin update|핀 업데이트|업스트림 핀|review/i, "feature doc should require review before updating the pin");
|
|
assert.match(featureDoc, /로그인[\s\S]*수동|사용자.*직접.*로그인/);
|
|
assert.match(featureDoc, /결제[\s\S]*수동|사용자.*직접.*결제/);
|
|
assert.match(featureDoc, /법인[\s\S]*장바구니[\s\S]*열람[\s\S]*저장/);
|
|
assert.match(featureDoc, /부동산[\s\S]*장바구니[\s\S]*수동/);
|
|
assert.match(featureDoc, /TouchEn nxKey/);
|
|
assert.match(featureDoc, /페이지당\s*10건|10건/);
|
|
assert.match(featureDoc, /i?ros_cart_by_corpnum\.py|법인등록번호 기반/);
|
|
assert.match(featureDoc, /i?ros_cart_realty\.py|부동산 장바구니/);
|
|
assert.match(featureDoc, /저장소 밖|레포 밖|커밋하지/);
|
|
assert.match(featureDoc, /\$workdir\/corp-input\.json/, "feature doc should put real corporate inputs under the private workdir");
|
|
assert.match(
|
|
featureDoc,
|
|
/\$workdir\/companies-input\.json[\s\S]*(iros_download\.py|결제 후|열람|저장)/,
|
|
"feature doc should prepare the company-name list required by iros_download.py before the download flow after payment",
|
|
);
|
|
assert.match(
|
|
featureDoc,
|
|
/"excel_path":\s*str\(workdir \/ "customer-list\.xlsx"\)/,
|
|
"feature doc should redirect upstream customer Excel input to the private workdir",
|
|
);
|
|
assert.match(featureDoc, /\$workdir\/downloads/, "feature doc should point save_dir/download output at the private workdir");
|
|
assert.match(featureDoc, /config\.json[\s\S]*save_dir[\s\S]*\$workdir/, "feature doc should wire config.json save_dir to the private workdir");
|
|
assert.match(featureDoc, /upstream repo `data\/`|upstream `data\/`|data\/.*실제/, "feature doc should warn not to use upstream data/ for real inputs");
|
|
assert.doesNotMatch(featureDoc, /결제.*자동화.*지원/);
|
|
|
|
assert.match(readme, /\| 등기부등본 자동화 \| `iros-registry-automation` \|/);
|
|
assert.match(readme, /docs\/features\/iros-registry-automation\.md/);
|
|
assert.match(install, /--skill iros-registry-automation/);
|
|
assert.match(roadmap, /등기부등본 자동화 스킬 출시/);
|
|
});
|
|
|
|
test("rhwp-edit skill pins the k-skill-rhwp CLI as the editing engine and disclaims kordoc/rhwp-advanced routing", () => {
|
|
const skill = read(path.join("rhwp-edit", "SKILL.md"));
|
|
|
|
assert.match(skill, /^---\nname: rhwp-edit\n/);
|
|
assert.match(skill, /k-skill-rhwp/);
|
|
assert.match(skill, /@rhwp\/core/);
|
|
assert.match(skill, /hwp\/SKILL\.md/);
|
|
assert.match(skill, /rhwp-advanced\/SKILL\.md/);
|
|
assert.match(skill, /insert-text/);
|
|
assert.match(skill, /delete-text/);
|
|
assert.match(skill, /replace-all/);
|
|
assert.match(skill, /create-table/);
|
|
assert.match(skill, /set-cell-text/);
|
|
assert.match(skill, /create-blank/);
|
|
assert.match(skill, /#196/);
|
|
assert.match(skill, /본문 문단만/, "SKILL.md must document body-only scope for search/replace-all");
|
|
assert.match(skill, /set-cell-text/, "SKILL.md must reference set-cell-text for cell content workflow");
|
|
assert.match(skill, /non-overlapping|개행|문단 경계/, "SKILL.md must document replace-all edge cases");
|
|
assert.match(
|
|
skill,
|
|
/UTF-?16|U\+0130|İ|case[ -]?fold/i,
|
|
"SKILL.md must disclose the case-insensitive UTF-16 length-drift guard (Unicode follow-up)"
|
|
);
|
|
});
|
|
|
|
test("rhwp-advanced skill pins the upstream rhwp Rust CLI debug/dump/convert surface", () => {
|
|
const skill = read(path.join("rhwp-advanced", "SKILL.md"));
|
|
|
|
assert.match(skill, /^---\nname: rhwp-advanced\n/);
|
|
assert.match(skill, /cargo install rhwp/);
|
|
assert.match(skill, /export-svg/);
|
|
assert.match(skill, /--debug-overlay/);
|
|
assert.match(skill, /\brhwp dump\b/);
|
|
assert.match(skill, /dump-pages/);
|
|
assert.match(skill, /ir-diff/);
|
|
assert.match(skill, /thumbnail/);
|
|
assert.match(skill, /\brhwp convert\b/);
|
|
assert.match(skill, /편집 서브커맨드[가는]? (없다|부재|제공하지 않는다|않는다)/);
|
|
assert.match(skill, /rhwp-edit/);
|
|
});
|
|
|
|
test("rhwp feature docs, README, install, roadmap, and sources are wired for the new skills", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const roadmap = read(path.join("docs", "roadmap.md"));
|
|
const sources = read(path.join("docs", "sources.md"));
|
|
const editDoc = read(path.join("docs", "features", "rhwp-edit.md"));
|
|
const advancedDoc = read(path.join("docs", "features", "rhwp-advanced.md"));
|
|
|
|
assert.match(readme, /\| HWP 문서 편집 \|/);
|
|
assert.match(readme, /\| HWP 레이아웃·IR 디버깅 \|/);
|
|
assert.match(readme, /\[HWP 문서 편집\]\(docs\/features\/rhwp-edit\.md\)/);
|
|
assert.match(readme, /\[HWP 레이아웃·IR 디버깅\]\(docs\/features\/rhwp-advanced\.md\)/);
|
|
|
|
assert.match(install, /--skill rhwp-edit/);
|
|
assert.match(install, /--skill rhwp-advanced/);
|
|
|
|
assert.match(roadmap, /rhwp-edit/);
|
|
assert.match(roadmap, /rhwp-advanced/);
|
|
assert.match(roadmap, /#155/);
|
|
|
|
assert.match(sources, /edwardkim\/rhwp/);
|
|
assert.match(sources, /@rhwp\/core/);
|
|
assert.match(sources, /issues\/196/);
|
|
|
|
assert.match(editDoc, /k-skill-rhwp/);
|
|
assert.match(editDoc, /insert-text/);
|
|
assert.match(editDoc, /create-table/);
|
|
assert.match(editDoc, /#196/);
|
|
assert.match(
|
|
editDoc,
|
|
/본문\S* 문단만|본문 \(body\) 문단만|body paragraphs only/,
|
|
"rhwp-edit feature doc must disclose search/replace-all body-only scope"
|
|
);
|
|
assert.match(
|
|
editDoc,
|
|
/UTF-?16|U\+0130|İ|case[ -]?fold/i,
|
|
"rhwp-edit feature doc must disclose the case-insensitive UTF-16 length-drift guard (Unicode follow-up)"
|
|
);
|
|
|
|
assert.match(advancedDoc, /cargo install rhwp/);
|
|
assert.match(advancedDoc, /export-svg/);
|
|
assert.match(advancedDoc, /ir-diff/);
|
|
assert.match(advancedDoc, /편집/);
|
|
});
|
|
|
|
test("k-skill-rhwp package ships CLI bin, WASM-init shim, and minor semver changeset", () => {
|
|
const packagePath = path.join(repoRoot, "packages", "k-skill-rhwp", "package.json");
|
|
assert.ok(fs.existsSync(packagePath), "expected packages/k-skill-rhwp/package.json");
|
|
const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
|
|
assert.equal(pkg.name, "k-skill-rhwp");
|
|
assert.ok(pkg.bin && pkg.bin["k-skill-rhwp"] === "bin/k-skill-rhwp.js", "expected bin mapping");
|
|
assert.ok(pkg.dependencies && pkg.dependencies["@rhwp/core"], "expected @rhwp/core dependency");
|
|
assert.ok(pkg.engines && /\^|>=\s*1[89]/.test(pkg.engines.node || ""), "expected Node 18+");
|
|
assert.ok(
|
|
fs.existsSync(path.join(repoRoot, "packages", "k-skill-rhwp", "src", "wasm-init.js")),
|
|
"expected src/wasm-init.js"
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(repoRoot, "packages", "k-skill-rhwp", "bin", "k-skill-rhwp.js")),
|
|
"expected bin/k-skill-rhwp.js"
|
|
);
|
|
|
|
});
|
|
|
|
const README_SKILL_NAME_COLUMN_MAPPING = [
|
|
["SRT 예매", "srt-booking"],
|
|
["KTX 예매", "ktx-booking"],
|
|
["카카오톡 Mac CLI", "kakaotalk-mac"],
|
|
["서울 지하철 도착정보 조회", "seoul-subway-arrival"],
|
|
["지하철 분실물 조회", "subway-lost-property"],
|
|
["긱뉴스 조회", "geeknews-search"],
|
|
["한국 날씨 조회", "korea-weather"],
|
|
["사용자 위치 미세먼지 조회", "fine-dust-location"],
|
|
["한강 수위 정보 조회", "han-river-water-level"],
|
|
["한국 법령 검색", "korean-law-search"],
|
|
["법인등기 신청 컨설팅", "corporate-registration-consulting"],
|
|
["한국 개인정보처리방침·이용약관 자동 생성", "korean-privacy-terms"],
|
|
["한국 부동산 실거래가 조회", "real-estate-search"],
|
|
["LH 청약 공고문 조회", "lh-notice-search"],
|
|
["장학금 검색 및 조회", "korean-scholarship-search"],
|
|
["생활쓰레기 배출정보 조회", "household-waste-info"],
|
|
["학교 급식 식단 조회", "k-schoollunch-menu"],
|
|
["도서관 도서 조회", "library-book-search"],
|
|
["의약품 안전 체크", "mfds-drug-safety"],
|
|
["식품 안전 체크", "mfds-food-safety"],
|
|
["한국 주식 정보 조회", "korean-stock-search"],
|
|
["금감원 DART 전자공시 조회", "k-dart"],
|
|
["조선왕조실록 검색", "joseon-sillok-search"],
|
|
["한국 특허 정보 검색", "korean-patent-search"],
|
|
["근처 가장 싼 주유소 찾기", "cheap-gas-nearby"],
|
|
["근처 공중화장실 찾기", "public-restroom-nearby"],
|
|
["근처 공영주차장 찾기", "parking-lot-search"],
|
|
["KBO 경기 결과 조회", "kbo-results"],
|
|
["KBL 경기 결과 조회", "kbl-results"],
|
|
["K리그 경기 결과 조회", "kleague-results"],
|
|
["LCK 경기 분석", "lck-analytics"],
|
|
["토스증권 조회", "toss-securities"],
|
|
["하이패스 영수증 발급", "hipass-receipt"],
|
|
["캐치테이블 예약 스나이핑", "catchtable-sniper"],
|
|
["로또 당첨 확인", "lotto-results"],
|
|
["HWP 문서 조회/변환", "hwp"],
|
|
["HWP 문서 편집", "rhwp-edit"],
|
|
["HWP 레이아웃·IR 디버깅", "rhwp-advanced"],
|
|
["근처 술집 조회", "kakao-bar-nearby"],
|
|
["우편번호 검색", "zipcode-search"],
|
|
["다이소 상품 조회", "daiso-product-search"],
|
|
["마켓컬리 상품 조회", "market-kurly-search"],
|
|
["올리브영 검색", "olive-young-search"],
|
|
["영화관 검색", "korean-cinema-search"],
|
|
["올라포케 역삼 포케", "hola-poke-yeoksam"],
|
|
["택배 배송조회", "delivery-tracking"],
|
|
["쿠팡 상품 검색", "coupang-product-search"],
|
|
["번개장터 검색", "bunjang-search"],
|
|
["중고차 가격 조회", "used-car-price-search"],
|
|
["한국어 맞춤법 검사", "korean-spell-check"],
|
|
["네이버 블로그 리서치", "naver-blog-research"],
|
|
["네이버 쇼핑 가격비교", "naver-shopping-search"],
|
|
["네이버 뉴스 검색", "naver-news-search"],
|
|
["한국어 글자 수 세기", "korean-character-count"],
|
|
["한국어 유행어 글쓰기", "korean-slang-writing"],
|
|
["K-스킬 클리너", "k-skill-cleaner"],
|
|
];
|
|
|
|
test("README skill table header advertises the new 스킬 이름 column (issue #165)", () => {
|
|
const readme = read("README.md");
|
|
|
|
assert.match(
|
|
readme,
|
|
/\| 할 수 있는 일 \| 스킬 이름 \| 설명 \| 사용자 로그인 \| 문서 \|\n\| --- \| --- \| --- \| --- \| --- \|/,
|
|
"expected the 어떤 걸 할 수 있나 table header to include 스킬 이름 between 할 수 있는 일 and 설명 with a 5-column separator",
|
|
);
|
|
});
|
|
|
|
test("README skill table includes inline-code skill names for every documented row (issue #165)", () => {
|
|
const readme = read("README.md");
|
|
|
|
assert.ok(
|
|
README_SKILL_NAME_COLUMN_MAPPING.some(([, skillName]) => skillName === "k-skill-cleaner"),
|
|
"expected k-skill-cleaner to be covered by the central README skill-name mapping fixture",
|
|
);
|
|
|
|
for (const [label, skillName] of README_SKILL_NAME_COLUMN_MAPPING) {
|
|
const escapedLabel = escapeRegex(label);
|
|
const escapedName = escapeRegex(skillName);
|
|
|
|
assert.match(
|
|
readme,
|
|
new RegExp(`\\| ${escapedLabel} \\| \`${escapedName}\` \\|`),
|
|
`expected README row "${label}" to surface skill name \`${skillName}\` as the second column`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("README skill table strikes through the deprecated blue-ribbon-nearby skill name (issue #165)", () => {
|
|
const readme = read("README.md");
|
|
|
|
assert.match(
|
|
readme,
|
|
/\| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 \| ~~`blue-ribbon-nearby`~~ \|/,
|
|
"expected the deprecated blue-ribbon-nearby row to keep the strikethrough on its skill-name cell as well",
|
|
);
|
|
});
|
|
|
|
test("README skill table skill-name column entries match real on-disk skill directories (issue #165)", () => {
|
|
const allEntries = [
|
|
...README_SKILL_NAME_COLUMN_MAPPING.map(([, skillName]) => skillName),
|
|
"blue-ribbon-nearby",
|
|
];
|
|
|
|
for (const skillName of allEntries) {
|
|
const skillFile = path.join(repoRoot, skillName, "SKILL.md");
|
|
|
|
assert.ok(
|
|
fs.existsSync(skillFile),
|
|
`expected ${skillName}/SKILL.md to exist on disk so the README table never advertises a non-existent skill identifier`,
|
|
);
|
|
|
|
const frontmatterMatch = read(path.join(skillName, "SKILL.md")).match(/^name:\s*(\S+)\s*$/m);
|
|
|
|
assert.ok(frontmatterMatch, `expected ${skillName}/SKILL.md to declare a name in frontmatter`);
|
|
assert.equal(
|
|
frontmatterMatch[1].replace(/^"|"$/g, ""),
|
|
skillName,
|
|
`expected ${skillName}/SKILL.md frontmatter name to match the directory name (validate-skills.sh invariant)`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("repository docs advertise the k-skill-cleaner skill and agent usage sources", () => {
|
|
const readme = read("README.md");
|
|
const install = read(path.join("docs", "install.md"));
|
|
const featureDocPath = path.join(repoRoot, "docs", "features", "k-skill-cleaner.md");
|
|
const skillPath = path.join(repoRoot, "k-skill-cleaner", "SKILL.md");
|
|
const skillLocalHelperPath = path.join(repoRoot, "k-skill-cleaner", "scripts", "k_skill_cleaner.py");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected k-skill-cleaner/SKILL.md to exist");
|
|
assert.ok(fs.existsSync(skillLocalHelperPath), "expected k-skill-cleaner/scripts/k_skill_cleaner.py to be included in standalone skill installs");
|
|
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/k-skill-cleaner.md to exist");
|
|
|
|
const skill = read(path.join("k-skill-cleaner", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "k-skill-cleaner.md"));
|
|
|
|
assert.match(skill, /^name: k-skill-cleaner$/m);
|
|
assert.match(skill, /Claude Code/);
|
|
assert.match(skill, /Codex/);
|
|
assert.match(skill, /OpenCode/);
|
|
assert.match(skill, /OpenClaw\/ClawHub/);
|
|
assert.match(skill, /Hermes Agent/);
|
|
assert.match(skill, /python3 scripts\/k_skill_cleaner\.py/);
|
|
assert.match(skill, /--days 90/);
|
|
assert.match(featureDoc, /k-skill-cleaner\/scripts\/k_skill_cleaner\.py/);
|
|
assert.match(featureDoc, /--days 90/);
|
|
assert.match(featureDoc, /인터뷰/);
|
|
assert.match(featureDoc, /트리거 횟수/);
|
|
assert.match(readme, /\| K-스킬 클리너 \| `k-skill-cleaner` \|/);
|
|
assert.match(readme, /\[K-스킬 클리너 가이드\]\(docs\/features\/k-skill-cleaner\.md\)/);
|
|
assert.match(install, /--skill k-skill-cleaner/);
|
|
});
|
|
|
|
test("court auction Workflow C docs preserve the PGJ151 safety contract", () => {
|
|
const featureDoc = read(path.join("docs", "features", "court-auction-notice-search.md"));
|
|
|
|
assert.match(
|
|
featureDoc,
|
|
/물건 자유 조건검색[\s\S]*PGJ151F00/,
|
|
"Workflow C docs should name the PGJ151F00 warmup path for property search",
|
|
);
|
|
assert.match(
|
|
featureDoc,
|
|
/pageSize[\s\S]*`10`\/`20`\/`50`\/`100`/,
|
|
"Workflow C docs should keep pageSize aligned with the observed PGJ151 dropdown values",
|
|
);
|
|
assert.doesNotMatch(
|
|
featureDoc,
|
|
/세션 cookie\([^\n]+\)는 `GET \/pgj\/index\.on\?w2xPath=\/pgj\/ui\/pgj100\/PGJ143M01\.xml&pgjId=143M01` 으로 사전에 한 번 받아둡니다\./,
|
|
"Workflow C docs should not imply every endpoint warms up through PGJ143M01 only",
|
|
);
|
|
});
|
|
|
|
test("court auction pending changeset does not publish stale fallback or pageSize guidance", () => {
|
|
const changesetDir = path.join(repoRoot, ".changeset");
|
|
if (!fs.existsSync(changesetDir)) return;
|
|
|
|
const pendingCourtAuctionChangesets = fs
|
|
.readdirSync(changesetDir)
|
|
.filter((entry) => entry.endsWith(".md"))
|
|
.map((entry) => read(path.join(".changeset", entry)))
|
|
.filter((contents) => contents.includes('"court-auction-notice-search"') && contents.includes("searchProperties()"));
|
|
|
|
for (const contents of pendingCourtAuctionChangesets) {
|
|
assert.doesNotMatch(
|
|
contents,
|
|
/direct HTTP call returns `BLOCKED` or `UPSTREAM_ERROR` 400/,
|
|
"Changeset must not tell users that confirmed BLOCKED/ipcheck=false auto-falls back by default",
|
|
);
|
|
assert.doesNotMatch(
|
|
contents,
|
|
/`pageSize` is capped at 100/,
|
|
"Changeset must not describe pageSize as an arbitrary 1..100 cap",
|
|
);
|
|
assert.match(
|
|
contents,
|
|
/`UPSTREAM_ERROR` 400/,
|
|
"Changeset should still document the raw-HTTP WAF-style 400 fallback path",
|
|
);
|
|
assert.match(
|
|
contents,
|
|
/`BLOCKED`[\s\S]*`fallbackOnBlocked:true`/,
|
|
"Changeset should document that confirmed BLOCKED retry is explicit opt-in",
|
|
);
|
|
assert.match(
|
|
contents,
|
|
/`10`\/`20`\/`50`\/`100`/,
|
|
"Changeset should document the exact PGJ151 pageSize allowlist",
|
|
);
|
|
}
|
|
});
|