mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Kakao Map mobile search results and place panels provide enough public data to turn station/neighborhood queries into nearby bar summaries with live open status, menu hints, seating hints, and phone numbers. This adds a reusable workspace package plus docs/skill wiring, while keeping the flow keyless and grounded in TDD + live smoke verification.
Constraint: Must avoid paid/authenticated Kakao APIs and new dependencies
Constraint: Nearby bar results must use current live panel/open-hour data
Rejected: Kakao Local REST API | requires app key/setup and breaks the no-auth posture
Rejected: Naver Map scraping | public responses were more rate-limited in testing
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: If Kakao changes panel3/search HTML contracts, verify headers and anchor-selection heuristics before expanding fallback logic
Tested: node --test packages/kakao-bar-nearby/test/index.test.js
Tested: node --test scripts/skill-docs.test.js
Tested: lsp diagnostics on affected files (0 errors)
Tested: live smoke searchNearbyBarsByLocationQuery('사당') on 2026-03-29
Tested: npm run ci
Not-tested: Precise distance calculation when Kakao station panels omit coordinates
729 lines
33 KiB
JavaScript
729 lines
33 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
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 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`);
|
|
}
|
|
|
|
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("hwp skill documents environment-aware routing 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, /@ohah\/hwpjs/);
|
|
assert.match(skill, /\bhwp-mcp\b/);
|
|
assert.match(skill, /Windows/i);
|
|
assert.match(skill, /JSON/i);
|
|
assert.match(skill, /Markdown/i);
|
|
assert.match(skill, /HTML/i);
|
|
assert.match(skill, /image/i);
|
|
assert.match(skill, /batch/i);
|
|
});
|
|
|
|
test("hwp skill documents inline image verification for markdown output", () => {
|
|
const skill = read(path.join("hwp", "SKILL.md"));
|
|
const featureDoc = read(path.join("docs", "features", "hwp.md"));
|
|
|
|
assert.match(skill, /hwpjs to-markdown document\.hwp -o output\.md --include-images/);
|
|
assert.match(skill, /Markdown:.*(data:|base64)/);
|
|
assert.match(skill, /--images-dir/);
|
|
assert.doesNotMatch(skill, /Markdown:.*이미지 경로 생성 여부 확인/);
|
|
assert.match(featureDoc, /--images-dir/);
|
|
});
|
|
|
|
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, /--include-images/);
|
|
assert.match(featureDoc, /(data:|base64)/);
|
|
assert.match(featureDoc, /Markdown 출력.*(data:|base64)/);
|
|
assert.doesNotMatch(featureDoc, /Markdown 출력.*이미지 (파일 )?경로 생성 여부 확인/);
|
|
});
|
|
|
|
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("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
|
|
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
|
|
|
|
assert.ok(fs.existsSync(skillPath), "expected kakaotalk-mac/SKILL.md 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);
|
|
});
|
|
|
|
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, /sops exec-env/);
|
|
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 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 ePost extraction flow and reliable transport example", () => {
|
|
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"));
|
|
|
|
assert.match(skill, /^name: zipcode-search$/m);
|
|
|
|
for (const doc of [skill, featureDoc]) {
|
|
assert.match(doc, /parcel\.epost\.go\.kr\/parcel\/comm\/zipcode\/comm_newzipcd_list\.jsp/);
|
|
assert.match(doc, /sch_zipcode/);
|
|
assert.match(doc, /sch_address1/);
|
|
assert.match(doc, /sch_bdNm/);
|
|
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, /mktemp|임시 파일/);
|
|
assert.match(doc, /curl: \(23\)/);
|
|
assert.match(doc, /짧은 도로명 \+ 건물번호/);
|
|
assert.match(doc, /시\/군\/구 포함 전체 주소/);
|
|
assert.doesNotMatch(doc, /urllib\.request/);
|
|
assert.doesNotMatch(doc, /urlopen/);
|
|
}
|
|
|
|
assert.match(skill, /검색 결과가 없으면/i);
|
|
assert.doesNotMatch(skill, /timeout\s*=/);
|
|
assert.doesNotMatch(featureDoc, /timeout\s*=/);
|
|
assert.match(skill, /`curl` 자체 제한/);
|
|
assert.match(featureDoc, /프로토콜\/클라이언트 제약/i);
|
|
assert.match(featureDoc, /`curl` 자체 제한/);
|
|
});
|
|
|
|
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("root pack:dry-run script covers all publishable workspaces", () => {
|
|
const packageJson = readJson("package.json");
|
|
|
|
assert.match(packageJson.scripts["pack:dry-run"], /workspace k-lotto/);
|
|
assert.match(packageJson.scripts["pack:dry-run"], /workspace daiso-product-search/);
|
|
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
|
|
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
|
|
});
|
|
|
|
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(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("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}`,
|
|
);
|
|
});
|