@mcpeak/cli 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/cli.cjs +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/commands.cjs +1 -1
- package/dist/commands.d.cts +17 -1
- package/dist/commands.d.mts +17 -1
- package/dist/commands.mjs +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-D1NW6ckv.mjs → src-BzVS8KXQ.mjs} +1 -1
- package/dist/{src-Df8147pD.cjs → src-CwhDFSWk.cjs} +1 -1
- package/dist/{test-command-BZ_94UmI.cjs → test-command-BbjJIX7j.cjs} +271 -22
- package/dist/{test-command-CsJfojut.mjs → test-command-CtQbeyhy.mjs} +271 -22
- package/package.json +13 -10
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ mcpeak test packages/cli/tests/fixtures/weather-suite.json \
|
|
|
22
22
|
--arg examples/weather-server/server.mjs
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
문법은 `mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--json] [--junit <path>] [--stderr-lines <N>]`입니다. 위 예시의 command와 arg는 `node examples/weather-server/server.mjs`로 실행됩니다. `--arg`는 반복할 수 있고, 하이픈으로 시작하는 값은 `--arg=-m`, 빈 값은 `--arg=`로 전달합니다.
|
|
25
|
+
문법은 `mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--json] [--junit <path>] [--stderr-lines <N>] [--record-session <path> | --session <path>]`입니다. 위 예시의 command와 arg는 `node examples/weather-server/server.mjs`로 실행됩니다. `--arg`는 반복할 수 있고, 하이픈으로 시작하는 값은 `--arg=-m`, 빈 값은 `--arg=`로 전달합니다.
|
|
26
26
|
|
|
27
27
|
stdout에는 보고서만 나갑니다. 기본은 사람이 읽는 보고서이고, `--json`을 주면 `RunnerReport` JSON이 나갑니다. CLI 오류와 서버 프로세스 진단은 stderr로만 나가므로 `--json > report.json`이 깨지지 않습니다. 모든 테스트가 통과하면 종료 코드 0을, failed 또는 aborted report와 입력, 연결, 종료 오류에는 1을 반환합니다.
|
|
28
28
|
|
|
@@ -40,6 +40,11 @@ XML을 만드는 단계에서 실패하면 `CLI_INTERNAL_ERROR`와 함께 종료
|
|
|
40
40
|
|
|
41
41
|
`RunnerReport`에는 시간 정보가 없어 모든 `time` 속성은 `0`입니다. "0초 걸렸다"가 아니라 "시간 정보를 갖고 있지 않다"는 뜻입니다. 근거는 [ADR-0016](../../docs/adr/0016-junit-time-속성-고정.md), 플래그 설계는 [ADR-0019](../../docs/adr/0019-junit-리포터-cli-노출-방식.md)에 있습니다.
|
|
42
42
|
|
|
43
|
+
`--record-session <path>`는 서버가 `globalThis.fetch`로 호출한 외부 HTTP 응답을 녹화하고,
|
|
44
|
+
`--session <path>`는 그 녹화본을 재생합니다. 서버는 재생 때도 실제로 실행됩니다. `node:http`와
|
|
45
|
+
`node:https` 계층 호출은 범위 밖이라 실제 네트워크로 나갈 수 있으며, CLI는 녹화 0건이나 재생
|
|
46
|
+
0건처럼 범위 밖 호출이 의심되는 실행을 stderr 알림으로 드러냅니다.
|
|
47
|
+
|
|
43
48
|
`--stderr-lines <N>`은 실패했거나 서버가 비정상 종료·중단했을 때, 진단 내용이 있으면 stderr에 붙는 서버 프로세스 진단 블록의 stderr 표시 줄 수입니다. 기본값은 20이고, `0`을 주면 블록을 완전히 끕니다(그때 출력 바이트는 이 기능이 없던 때와 같습니다). 블록에는 종료 코드와 시그널, 서버가 남긴 stderr의 마지막 N줄이 담기며 잘린 사실은 헤더에 적힙니다. 서버가 정상 종료했고 stderr도 비어 있으면 보여줄 근거가 없으므로 블록을 쓰지 않습니다.
|
|
44
49
|
|
|
45
50
|
### 승인 지문 대조
|
|
@@ -60,7 +65,7 @@ XML을 만드는 단계에서 실패하면 `CLI_INTERNAL_ERROR`와 함께 종료
|
|
|
60
65
|
|
|
61
66
|
지문이 없는 명세도 정상입니다. 손으로 쓴 명세와 이 기능 이전에 만든 명세가 여기에 해당하며 실패로 취급하지 않습니다.
|
|
62
67
|
|
|
63
|
-
현재는 UTF-8 JSON 단일 명세와 stdio 서버만 지원합니다. shell 문법, 여러 명세, TypeScript 모듈 명세는 지원하지 않습니다. `record`, `
|
|
68
|
+
현재는 UTF-8 JSON 단일 명세와 stdio 서버만 지원합니다. shell 문법, 여러 명세, TypeScript 모듈 명세는 지원하지 않습니다. `record`, `mock` 명령은 아직 구현되지 않았습니다.
|
|
64
69
|
|
|
65
70
|
## generate
|
|
66
71
|
|
package/dist/cli.cjs
CHANGED
package/dist/cli.mjs
CHANGED
package/dist/commands.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_test_command = require("./test-command-
|
|
2
|
+
const require_test_command = require("./test-command-BbjJIX7j.cjs");
|
|
3
3
|
exports.nodeGenerateDependencies = require_test_command.nodeGenerateDependencies;
|
|
4
4
|
exports.nodeReviewIO = require_test_command.nodeReviewIO;
|
|
5
5
|
exports.parseRepairCommand = require_test_command.parseRepairCommand;
|
package/dist/commands.d.cts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { a as RepairCommandDependencies, c as runRepairCommand, d as ReviewIO, f as nodeGenerateDependencies, i as runReplayCommand, l as GenerateCommandDependencies, m as runGenerateCommand, n as ReplayCommandInput, o as RepairCommandInput, p as nodeReviewIO, r as parseReplayCommand, s as parseRepairCommand, t as ReplayCommandDependencies, u as GenerateCommandInput } from "./replay-command-V0ZSJPiD.cjs";
|
|
2
2
|
import { McpStdioConnection } from "@mcpeak/core";
|
|
3
3
|
import { CheckDeterminismOptions, DeterminismResult, FinalizeRunnerExecutionOptions, InputContractOptions, RunSuiteOptions, RunnerExecution, RunnerReport, SpecFindingsResult, SuiteValidationIssue, SuiteValidationResult, TestSuiteSpec } from "@mcpeak/runner";
|
|
4
|
+
import "@mcpeak/record/external";
|
|
4
5
|
//#region src/test-command.d.ts
|
|
5
6
|
interface TestCommandInput {
|
|
6
7
|
readonly suitePath: string;
|
|
@@ -15,9 +16,13 @@ interface TestCommandInput {
|
|
|
15
16
|
readonly determinism: boolean;
|
|
16
17
|
/** `--reset-cmd` 로 받은 초기화 명령. 각 회차 전에 1번씩 실행한다. */
|
|
17
18
|
readonly resetCmd: string | undefined;
|
|
19
|
+
/** `--session`. External 세션을 재생한다. */
|
|
20
|
+
readonly sessionPath: string | undefined;
|
|
21
|
+
/** `--record-session`. External 세션을 녹화한다. */
|
|
22
|
+
readonly recordSessionPath: string | undefined;
|
|
18
23
|
readonly stderrLines: number;
|
|
19
24
|
}
|
|
20
|
-
type CliErrorCode = "CLI_USAGE" | "COMMAND_NOT_IMPLEMENTED" | "SUITE_FORMAT_UNSUPPORTED" | "SUITE_READ_FAILED" | "SUITE_ENCODING_INVALID" | "SUITE_JSON_INVALID" | "SUITE_VALIDATION_FAILED" | "MCP_CONNECTION_FAILED" | "RUNNER_EXECUTION_FAILED" | "RUNNER_FINALIZATION_FAILED" | "JUNIT_WRITE_FAILED" | "REPAIR_BUNDLE_WRITE_FAILED" | "RESET_COMMAND_FAILED" | "CLI_INTERNAL_ERROR";
|
|
25
|
+
type CliErrorCode = "CLI_USAGE" | "COMMAND_NOT_IMPLEMENTED" | "SUITE_FORMAT_UNSUPPORTED" | "SUITE_READ_FAILED" | "SUITE_ENCODING_INVALID" | "SUITE_JSON_INVALID" | "SUITE_VALIDATION_FAILED" | "MCP_CONNECTION_FAILED" | "RUNNER_EXECUTION_FAILED" | "RUNNER_FINALIZATION_FAILED" | "JUNIT_WRITE_FAILED" | "REPAIR_BUNDLE_WRITE_FAILED" | "RESET_COMMAND_FAILED" | "EXTERNAL_SESSION_FAILED" | "CLI_INTERNAL_ERROR";
|
|
21
26
|
interface CliFailure {
|
|
22
27
|
readonly code: CliErrorCode;
|
|
23
28
|
readonly message: string;
|
|
@@ -31,6 +36,8 @@ interface TestCommandDependencies {
|
|
|
31
36
|
connect(options: {
|
|
32
37
|
command: string;
|
|
33
38
|
args: readonly string[];
|
|
39
|
+
/** External 배선이 만든 자식 환경 변수. Bootstrap 주입이 여기 실린다. */
|
|
40
|
+
env?: Readonly<Record<string, string>>;
|
|
34
41
|
}): Promise<McpStdioConnection>;
|
|
35
42
|
startRunner(options: RunSuiteOptions): RunnerExecution;
|
|
36
43
|
finalize(options: FinalizeRunnerExecutionOptions): Promise<RunnerReport>;
|
|
@@ -62,6 +69,15 @@ interface TestCommandDependencies {
|
|
|
62
69
|
writeStderr(text: string): void;
|
|
63
70
|
}
|
|
64
71
|
declare function parseTestCommand(argv: readonly string[]): TestCommandInput;
|
|
72
|
+
/**
|
|
73
|
+
* `test` 실행에 External Record/Replay 수명주기를 씌운다.
|
|
74
|
+
*
|
|
75
|
+
* Coordinator 를 **먼저 열고 마지막에 닫는다**(ADR-0052). 그 사이에 들어가는 것이 기존 실행
|
|
76
|
+
* 전부라, 성공·실패·예외 어느 경로로 빠져나가도 `finish` 가 불려야 한다. 안 불리면 SQLite
|
|
77
|
+
* 파일 핸들이 남고 Record 세션이 `running` 인 채로 남아 다음 실행이 이어 쓸 수 없다.
|
|
78
|
+
*
|
|
79
|
+
* 세션 옵션이 없으면 배선을 아예 만들지 않는다 — 기존 실행 경로가 한 글자도 달라지지 않는다.
|
|
80
|
+
*/
|
|
65
81
|
declare function runCli(argv: readonly string[], dependencies: TestCommandDependencies): Promise<number>;
|
|
66
82
|
//#endregion
|
|
67
83
|
export { type CliErrorCode, type CliFailure, type GenerateCommandDependencies, type GenerateCommandInput, type RepairCommandDependencies, type RepairCommandInput, type ReplayCommandDependencies, type ReplayCommandInput, type ReviewIO, type TestCommandDependencies, type TestCommandInput, nodeGenerateDependencies, nodeReviewIO, parseRepairCommand, parseReplayCommand, parseTestCommand, runCli, runGenerateCommand, runRepairCommand, runReplayCommand };
|
package/dist/commands.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { a as RepairCommandDependencies, c as runRepairCommand, d as ReviewIO, f as nodeGenerateDependencies, i as runReplayCommand, l as GenerateCommandDependencies, m as runGenerateCommand, n as ReplayCommandInput, o as RepairCommandInput, p as nodeReviewIO, r as parseReplayCommand, s as parseRepairCommand, t as ReplayCommandDependencies, u as GenerateCommandInput } from "./replay-command-BAtSzf8R.mjs";
|
|
2
2
|
import { CheckDeterminismOptions, DeterminismResult, FinalizeRunnerExecutionOptions, InputContractOptions, RunSuiteOptions, RunnerExecution, RunnerReport, SpecFindingsResult, SuiteValidationIssue, SuiteValidationResult, TestSuiteSpec } from "@mcpeak/runner";
|
|
3
3
|
import { McpStdioConnection } from "@mcpeak/core";
|
|
4
|
+
import "@mcpeak/record/external";
|
|
4
5
|
//#region src/test-command.d.ts
|
|
5
6
|
interface TestCommandInput {
|
|
6
7
|
readonly suitePath: string;
|
|
@@ -15,9 +16,13 @@ interface TestCommandInput {
|
|
|
15
16
|
readonly determinism: boolean;
|
|
16
17
|
/** `--reset-cmd` 로 받은 초기화 명령. 각 회차 전에 1번씩 실행한다. */
|
|
17
18
|
readonly resetCmd: string | undefined;
|
|
19
|
+
/** `--session`. External 세션을 재생한다. */
|
|
20
|
+
readonly sessionPath: string | undefined;
|
|
21
|
+
/** `--record-session`. External 세션을 녹화한다. */
|
|
22
|
+
readonly recordSessionPath: string | undefined;
|
|
18
23
|
readonly stderrLines: number;
|
|
19
24
|
}
|
|
20
|
-
type CliErrorCode = "CLI_USAGE" | "COMMAND_NOT_IMPLEMENTED" | "SUITE_FORMAT_UNSUPPORTED" | "SUITE_READ_FAILED" | "SUITE_ENCODING_INVALID" | "SUITE_JSON_INVALID" | "SUITE_VALIDATION_FAILED" | "MCP_CONNECTION_FAILED" | "RUNNER_EXECUTION_FAILED" | "RUNNER_FINALIZATION_FAILED" | "JUNIT_WRITE_FAILED" | "REPAIR_BUNDLE_WRITE_FAILED" | "RESET_COMMAND_FAILED" | "CLI_INTERNAL_ERROR";
|
|
25
|
+
type CliErrorCode = "CLI_USAGE" | "COMMAND_NOT_IMPLEMENTED" | "SUITE_FORMAT_UNSUPPORTED" | "SUITE_READ_FAILED" | "SUITE_ENCODING_INVALID" | "SUITE_JSON_INVALID" | "SUITE_VALIDATION_FAILED" | "MCP_CONNECTION_FAILED" | "RUNNER_EXECUTION_FAILED" | "RUNNER_FINALIZATION_FAILED" | "JUNIT_WRITE_FAILED" | "REPAIR_BUNDLE_WRITE_FAILED" | "RESET_COMMAND_FAILED" | "EXTERNAL_SESSION_FAILED" | "CLI_INTERNAL_ERROR";
|
|
21
26
|
interface CliFailure {
|
|
22
27
|
readonly code: CliErrorCode;
|
|
23
28
|
readonly message: string;
|
|
@@ -31,6 +36,8 @@ interface TestCommandDependencies {
|
|
|
31
36
|
connect(options: {
|
|
32
37
|
command: string;
|
|
33
38
|
args: readonly string[];
|
|
39
|
+
/** External 배선이 만든 자식 환경 변수. Bootstrap 주입이 여기 실린다. */
|
|
40
|
+
env?: Readonly<Record<string, string>>;
|
|
34
41
|
}): Promise<McpStdioConnection>;
|
|
35
42
|
startRunner(options: RunSuiteOptions): RunnerExecution;
|
|
36
43
|
finalize(options: FinalizeRunnerExecutionOptions): Promise<RunnerReport>;
|
|
@@ -62,6 +69,15 @@ interface TestCommandDependencies {
|
|
|
62
69
|
writeStderr(text: string): void;
|
|
63
70
|
}
|
|
64
71
|
declare function parseTestCommand(argv: readonly string[]): TestCommandInput;
|
|
72
|
+
/**
|
|
73
|
+
* `test` 실행에 External Record/Replay 수명주기를 씌운다.
|
|
74
|
+
*
|
|
75
|
+
* Coordinator 를 **먼저 열고 마지막에 닫는다**(ADR-0052). 그 사이에 들어가는 것이 기존 실행
|
|
76
|
+
* 전부라, 성공·실패·예외 어느 경로로 빠져나가도 `finish` 가 불려야 한다. 안 불리면 SQLite
|
|
77
|
+
* 파일 핸들이 남고 Record 세션이 `running` 인 채로 남아 다음 실행이 이어 쓸 수 없다.
|
|
78
|
+
*
|
|
79
|
+
* 세션 옵션이 없으면 배선을 아예 만들지 않는다 — 기존 실행 경로가 한 글자도 달라지지 않는다.
|
|
80
|
+
*/
|
|
65
81
|
declare function runCli(argv: readonly string[], dependencies: TestCommandDependencies): Promise<number>;
|
|
66
82
|
//#endregion
|
|
67
83
|
export { type CliErrorCode, type CliFailure, type GenerateCommandDependencies, type GenerateCommandInput, type RepairCommandDependencies, type RepairCommandInput, type ReplayCommandDependencies, type ReplayCommandInput, type ReviewIO, type TestCommandDependencies, type TestCommandInput, nodeGenerateDependencies, nodeReviewIO, parseRepairCommand, parseReplayCommand, parseTestCommand, runCli, runGenerateCommand, runRepairCommand, runReplayCommand };
|
package/dist/commands.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as runReplayCommand, c as nodeGenerateDependencies, i as parseReplayCommand, l as nodeReviewIO, n as runCli, o as parseRepairCommand, s as runRepairCommand, t as parseTestCommand, u as runGenerateCommand } from "./test-command-
|
|
1
|
+
import { a as runReplayCommand, c as nodeGenerateDependencies, i as parseReplayCommand, l as nodeReviewIO, n as runCli, o as parseRepairCommand, s as runRepairCommand, t as parseTestCommand, u as runGenerateCommand } from "./test-command-CtQbeyhy.mjs";
|
|
2
2
|
export { nodeGenerateDependencies, nodeReviewIO, parseRepairCommand, parseReplayCommand, parseTestCommand, runCli, runGenerateCommand, runRepairCommand, runReplayCommand };
|
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_src = require("./src-
|
|
2
|
+
const require_src = require("./src-CwhDFSWk.cjs");
|
|
3
3
|
exports.COMMANDS = require_src.COMMANDS;
|
|
4
4
|
exports.nodeRepairDependencies = require_src.nodeRepairDependencies;
|
|
5
5
|
exports.run = require_src.run;
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { i as unavailableReplayDependencies, n as nodeRepairDependencies, r as run, t as COMMANDS } from "./src-
|
|
1
|
+
import { i as unavailableReplayDependencies, n as nodeRepairDependencies, r as run, t as COMMANDS } from "./src-BzVS8KXQ.mjs";
|
|
2
2
|
export { COMMANDS, nodeRepairDependencies, run, unavailableReplayDependencies };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as runReplayCommand, c as nodeGenerateDependencies, d as GLOBAL_HELP, f as VERIFY_USAGE_HINT, l as nodeReviewIO, m as version, n as runCli, p as commandHelp, r as ReplayRuntimeUnavailableError, s as runRepairCommand, t as parseTestCommand, u as runGenerateCommand } from "./test-command-
|
|
1
|
+
import { a as runReplayCommand, c as nodeGenerateDependencies, d as GLOBAL_HELP, f as VERIFY_USAGE_HINT, l as nodeReviewIO, m as version, n as runCli, p as commandHelp, r as ReplayRuntimeUnavailableError, s as runRepairCommand, t as parseTestCommand, u as runGenerateCommand } from "./test-command-CtQbeyhy.mjs";
|
|
2
2
|
import { readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
//#region src/verify-command.ts
|
|
4
4
|
/**
|
|
@@ -6,7 +6,7 @@ let _mcpeak_record = require("@mcpeak/record");
|
|
|
6
6
|
let node_child_process = require("node:child_process");
|
|
7
7
|
let _mcpeak_core = require("@mcpeak/core");
|
|
8
8
|
//#region package.json
|
|
9
|
-
var version = "0.
|
|
9
|
+
var version = "0.10.0";
|
|
10
10
|
//#endregion
|
|
11
11
|
//#region src/cassette-wiring.ts
|
|
12
12
|
/**
|
|
@@ -320,7 +320,7 @@ const FINDING_GROUP = {
|
|
|
320
320
|
};
|
|
321
321
|
//#endregion
|
|
322
322
|
//#region src/help.ts
|
|
323
|
-
const TEST_USAGE = "사용법: mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>]";
|
|
323
|
+
const TEST_USAGE = "사용법: mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>] [--session <path> | --record-session <path>]";
|
|
324
324
|
/**
|
|
325
325
|
* 시험 실행 옵션 설명. `--determinism` 은 툴을 2회 호출하므로 부작용이 있는 서버에서 모르고
|
|
326
326
|
* 쓰면 사고가 난다. 그 경고가 사용법 한 줄에는 들어가지 않는다.
|
|
@@ -331,7 +331,31 @@ const TEST_OPTIONS = `옵션:
|
|
|
331
331
|
--reset-cmd 와 함께 쓰면 결정론성 확인이 되고, 없으면
|
|
332
332
|
"2회 결과가 같았다" 까지만 확인합니다
|
|
333
333
|
--reset-cmd <command> 각 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지
|
|
334
|
-
않으므로 파이프나 && 는 쓸 수
|
|
334
|
+
않으므로 파이프나 && 는 쓸 수 없습니다
|
|
335
|
+
--record-session <path>
|
|
336
|
+
서버가 \`globalThis.fetch\` 로 밖에 부른 HTTP 호출만 녹화합니다.
|
|
337
|
+
서버는 실제로 실행됩니다. \`node:http\`·\`node:https\` 같은 범위 밖 호출은
|
|
338
|
+
잡히지 않아 실제 네트워크로 나갈 수 있습니다
|
|
339
|
+
--session <path> 녹화한 외부 호출을 재생합니다. 서버는 실제로 실행되지만 외부
|
|
340
|
+
API 호출은 \`globalThis.fetch\` 범위 안에서만 가로챕니다.
|
|
341
|
+
범위 밖 호출은 실제 네트워크로 나갈 수 있으며, 범위 밖 호출이
|
|
342
|
+
의심되는 경우 종료 때 알립니다
|
|
343
|
+
|
|
344
|
+
두 세션 옵션은 서로, 그리고 \`--determinism\` 과 함께 쓸 수 없습니다. \`--determinism\` 은 서버에
|
|
345
|
+
2회 연결하는데 세션은 연결 하나에 묶여 있어, 2회차가 같은 세션을 쓰면 반복 호출 순번이
|
|
346
|
+
어긋나고 새 세션을 쓰면 비교 기준이 갈라집니다.
|
|
347
|
+
|
|
348
|
+
세션은 \`--cassette\` 카세트와 다릅니다. **카세트는 우리가 서버에게 물어본 결과**를 남기고,
|
|
349
|
+
**세션은 그 서버가 밖에 물어본 결과**를 남깁니다. 둘은 섞이지 않으며 파일도 따로입니다.
|
|
350
|
+
|
|
351
|
+
세션 파일에는 외부 API 응답이 저장되므로 .gitignore 를 확인하세요. \`token\`·\`apiKey\` 같은
|
|
352
|
+
이름의 값은 저장 전에 가려집니다.
|
|
353
|
+
|
|
354
|
+
런타임에 따라 세션 옵션을 쓴 실행에서 \`ExperimentalWarning: SQLite is an experimental
|
|
355
|
+
feature\` 가 stderr 에 한 줄 찍힙니다(실측: Node 22.18.0 에서 나오고 24.16.0 에서는 안 납니다).
|
|
356
|
+
\`node:sqlite\` 를 처음 로드할 때 한 번 나오므로 실행당 한 줄이며, 세션 옵션이 없는 실행에는
|
|
357
|
+
붙지 않습니다. Node 가 그 모듈을 아직 실험적으로 표시한다는 뜻이고, **저장된 녹화는 표준
|
|
358
|
+
SQLite 파일이라 영향받지 않습니다.**`;
|
|
335
359
|
const GENERATE_USAGE = "사용법: mcpeak generate --suite-id <id> --name <name> --out <suite.json> --command <executable> [--arg <value> ...] [--baseline-only] [--provider <codex|claude>] [--model <model>] [--no-dry-run] [--cassette <path>] [--record] [--reset-cmd <command>] [--no-repair] [--force]";
|
|
336
360
|
/**
|
|
337
361
|
* 시험 실행 옵션 설명. 사용법 한 줄로는 `--cassette` 가 무엇을 파일로 남기는지, `--reset-cmd`
|
|
@@ -403,7 +427,7 @@ auto 모드(\`--record\` 없는 실행)는 카세트에 있는 요청이면 서
|
|
|
403
427
|
마스킹해 비교하므로, 비밀값 자체만 바뀐 경우는 감지되지 않습니다.
|
|
404
428
|
|
|
405
429
|
불일치나 호출 실패가 하나라도 있으면 종료 코드 1 입니다. 확인불가는 실패로 보지 않습니다.`;
|
|
406
|
-
const GLOBAL_HELP = `
|
|
430
|
+
const GLOBAL_HELP = `MCPeak — MCP 서버 테스트 프레임워크
|
|
407
431
|
|
|
408
432
|
사용법: mcpeak <명령> [옵션]
|
|
409
433
|
mcpeak help [명령]
|
|
@@ -833,6 +857,18 @@ function dropSkippedTools(suite, skips) {
|
|
|
833
857
|
}
|
|
834
858
|
//#endregion
|
|
835
859
|
//#region src/process-diagnostics.ts
|
|
860
|
+
/**
|
|
861
|
+
* core 의 stdio 프로세스 진단인지 구조와 값의 범위로 확인한다.
|
|
862
|
+
* 일부 필드만 있거나 프로세스 상태로 표현할 수 없는 값은 렌더하지 않는다.
|
|
863
|
+
*/
|
|
864
|
+
function processDiagnostics(value) {
|
|
865
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
866
|
+
if (!("stderr" in value) || typeof value.stderr !== "string") return void 0;
|
|
867
|
+
if (!("stderrTruncated" in value) || typeof value.stderrTruncated !== "boolean") return void 0;
|
|
868
|
+
if (!("exitCode" in value) || !(value.exitCode === null || typeof value.exitCode === "number" && Number.isInteger(value.exitCode))) return void 0;
|
|
869
|
+
if (!("signal" in value) || !(value.signal === null || typeof value.signal === "string" && value.signal.length > 0)) return void 0;
|
|
870
|
+
return value;
|
|
871
|
+
}
|
|
836
872
|
/** TAB. 이 모듈만 이스케이프 대상에서 빼는 문자다. 아래 escapeTokens 주석에 근거가 있다. */
|
|
837
873
|
const TAB = 9;
|
|
838
874
|
/**
|
|
@@ -2793,8 +2829,14 @@ async function runGenerateCommand(argv, deps) {
|
|
|
2793
2829
|
else if (error instanceof OutputReplaceError) outputReplaceFailure(deps, error.path, error.code);
|
|
2794
2830
|
else if (error instanceof LinkUnsupportedError) linkUnsupportedFailure(deps, error.path, error.code);
|
|
2795
2831
|
else if (deps.GenerateTestsError !== void 0 && error instanceof deps.GenerateTestsError) generateTestsFailure(deps, error);
|
|
2796
|
-
else if (isCoreClientError(error))
|
|
2797
|
-
|
|
2832
|
+
else if (isCoreClientError(error)) {
|
|
2833
|
+
deps.writeStderr(`오류 [GENERATE_CONNECT_FAILED/${error.code}]: ${error.message}\n해결: ${error.hint}\n`);
|
|
2834
|
+
const diagnostics = processDiagnostics(error.diagnostics);
|
|
2835
|
+
if (diagnostics !== void 0 && hasDiagnosticContent(diagnostics)) {
|
|
2836
|
+
const block = renderProcessDiagnostics(diagnostics, { maxLines: DRY_RUN_STDERR_LINES });
|
|
2837
|
+
if (block !== "") deps.writeStderr(`\n${block}`);
|
|
2838
|
+
}
|
|
2839
|
+
} else deps.writeStderr("오류 [GENERATE_FAILED]: baseline suite를 생성하거나 저장하지 못했습니다.\n해결: MCP 서버와 출력 경로를 확인한 뒤 다시 실행하세요.\n");
|
|
2798
2840
|
return 1;
|
|
2799
2841
|
}
|
|
2800
2842
|
try {
|
|
@@ -3689,6 +3731,51 @@ function createDeterminismCapture(inner) {
|
|
|
3689
3731
|
};
|
|
3690
3732
|
}
|
|
3691
3733
|
//#endregion
|
|
3734
|
+
//#region src/external-wiring.ts
|
|
3735
|
+
/**
|
|
3736
|
+
* `@mcpeak/record/external` 은 **세션 옵션을 실제로 쓸 때만** 불러온다.
|
|
3737
|
+
*
|
|
3738
|
+
* 정적 import 로 두면 `node:sqlite` 가 CLI 를 띄우는 것만으로 로드되고, Node 22.x 는 그때
|
|
3739
|
+
* `ExperimentalWarning: SQLite is an experimental feature` 를 stderr 에 찍는다. 세션을 쓰지도
|
|
3740
|
+
* 않은 `mcpeak test` 실행마다 그 줄이 붙는다 — 터미널 출력이 곧 UI 인 도구에서 그냥 손해다.
|
|
3741
|
+
* (실제로 `dist-cli-e2e` 의 "stderr 가 비어 있다" 단언이 이걸 잡았다.)
|
|
3742
|
+
*
|
|
3743
|
+
* 지연 로딩이면 경고를 보는 사람은 External 을 직접 켠 사용자뿐이다. 그 경고를 아예 지울지는
|
|
3744
|
+
* ADR-0054 가 남겨 둔 별도 결정이며, 라이브러리가 아니라 CLI 가 정할 몫이다.
|
|
3745
|
+
*/
|
|
3746
|
+
const loadExternal = () => import("@mcpeak/record/external");
|
|
3747
|
+
/** 세션 파일 하나가 세션 하나이므로 식별자를 사용자에게 묻지 않는다. */
|
|
3748
|
+
const SESSION_ID = "default";
|
|
3749
|
+
async function startExternalWiring(options) {
|
|
3750
|
+
const { createSqliteSessionStore, startExternalCoordinator } = await loadExternal();
|
|
3751
|
+
const store = createSqliteSessionStore({ path: options.sessionPath });
|
|
3752
|
+
let handle;
|
|
3753
|
+
try {
|
|
3754
|
+
handle = options.mode === "record" ? await startExternalCoordinator({
|
|
3755
|
+
mode: "record",
|
|
3756
|
+
sessionId: SESSION_ID,
|
|
3757
|
+
store,
|
|
3758
|
+
...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
|
|
3759
|
+
}) : await startExternalCoordinator({
|
|
3760
|
+
mode: "replay",
|
|
3761
|
+
sourceSessionId: SESSION_ID,
|
|
3762
|
+
store,
|
|
3763
|
+
...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
|
|
3764
|
+
});
|
|
3765
|
+
} catch (error) {
|
|
3766
|
+
store.close();
|
|
3767
|
+
throw error;
|
|
3768
|
+
}
|
|
3769
|
+
let settled;
|
|
3770
|
+
return {
|
|
3771
|
+
env: handle.childEnvironment,
|
|
3772
|
+
finish(status) {
|
|
3773
|
+
settled ??= handle.finish(status).finally(() => store.close());
|
|
3774
|
+
return settled;
|
|
3775
|
+
}
|
|
3776
|
+
};
|
|
3777
|
+
}
|
|
3778
|
+
//#endregion
|
|
3692
3779
|
//#region src/test-command.ts
|
|
3693
3780
|
/** --stderr-lines 기본값. 설계 문서 §6. */
|
|
3694
3781
|
const DEFAULT_STDERR_LINES = 20;
|
|
@@ -3737,6 +3824,10 @@ const dictionary = {
|
|
|
3737
3824
|
message: "초기화 명령이 실패했습니다.",
|
|
3738
3825
|
hint: "`--reset-cmd` 명령이 단독으로 성공하는지 확인한 뒤 다시 실행하세요."
|
|
3739
3826
|
},
|
|
3827
|
+
EXTERNAL_SESSION_FAILED: {
|
|
3828
|
+
message: "External 세션 처리에 실패했습니다.",
|
|
3829
|
+
hint: "세션 파일 경로의 디렉터리가 있는지와 쓰기 권한을 확인하세요."
|
|
3830
|
+
},
|
|
3740
3831
|
CLI_INTERNAL_ERROR: {
|
|
3741
3832
|
message: "예상하지 못한 CLI 내부 오류가 발생했습니다.",
|
|
3742
3833
|
hint: "다시 실행한 뒤 재현 정보와 함께 이슈를 보고하세요."
|
|
@@ -3756,6 +3847,30 @@ const fail = (message) => {
|
|
|
3756
3847
|
hint: TEST_USAGE_HINT
|
|
3757
3848
|
});
|
|
3758
3849
|
};
|
|
3850
|
+
/**
|
|
3851
|
+
* `--name value` 와 `--name=value` 두 형태에서 값을 꺼낸다.
|
|
3852
|
+
*
|
|
3853
|
+
* 값이 `--` 로 시작하면 거절한다. `--session --json` 처럼 값을 빠뜨리면 다음 옵션이 경로로
|
|
3854
|
+
* 먹히고, 그러면 "그런 파일이 없습니다" 라는 엉뚱한 곳에서 실패한다.
|
|
3855
|
+
*/
|
|
3856
|
+
const readOptionValue = (argv, token, name, index) => {
|
|
3857
|
+
let value;
|
|
3858
|
+
let next = index;
|
|
3859
|
+
if (token === name) {
|
|
3860
|
+
const candidate = argv[++next];
|
|
3861
|
+
if (candidate === void 0) throw new CliCommandError({
|
|
3862
|
+
code: "CLI_USAGE",
|
|
3863
|
+
message: `\`${name}\` 옵션 값이 필요합니다.`,
|
|
3864
|
+
hint: TEST_USAGE_HINT
|
|
3865
|
+
});
|
|
3866
|
+
value = candidate;
|
|
3867
|
+
} else value = token.slice(`${name}=`.length);
|
|
3868
|
+
if (value.trim() === "" || value.startsWith("--")) fail(`\`${name}\` 옵션 값이 필요합니다.`);
|
|
3869
|
+
return {
|
|
3870
|
+
value,
|
|
3871
|
+
index: next
|
|
3872
|
+
};
|
|
3873
|
+
};
|
|
3759
3874
|
function parseTestCommand(argv) {
|
|
3760
3875
|
const suitePath = argv[0] ?? "";
|
|
3761
3876
|
if (suitePath === "") fail("테스트 명세 JSON 경로가 필요합니다.");
|
|
@@ -3766,6 +3881,8 @@ function parseTestCommand(argv) {
|
|
|
3766
3881
|
let determinism = false;
|
|
3767
3882
|
let resetCmd;
|
|
3768
3883
|
let stderrLines;
|
|
3884
|
+
let sessionPath;
|
|
3885
|
+
let recordSessionPath;
|
|
3769
3886
|
const args = [];
|
|
3770
3887
|
for (let index = 1; index < argv.length; index += 1) {
|
|
3771
3888
|
const token = argv[index] ?? "";
|
|
@@ -3859,6 +3976,16 @@ function parseTestCommand(argv) {
|
|
|
3859
3976
|
const parsedLines = Number.parseInt(value, 10);
|
|
3860
3977
|
if (!Number.isSafeInteger(parsedLines)) fail("`--stderr-lines` 값은 0 이상의 정수여야 합니다.");
|
|
3861
3978
|
stderrLines = parsedLines;
|
|
3979
|
+
} else if (token === "--session" || token.startsWith("--session=")) {
|
|
3980
|
+
if (sessionPath !== void 0) fail("`--session`은 한 번만 사용할 수 있습니다.");
|
|
3981
|
+
const read = readOptionValue(argv, token, "--session", index);
|
|
3982
|
+
sessionPath = read.value;
|
|
3983
|
+
index = read.index;
|
|
3984
|
+
} else if (token === "--record-session" || token.startsWith("--record-session=")) {
|
|
3985
|
+
if (recordSessionPath !== void 0) fail("`--record-session`은 한 번만 사용할 수 있습니다.");
|
|
3986
|
+
const read = readOptionValue(argv, token, "--record-session", index);
|
|
3987
|
+
recordSessionPath = read.value;
|
|
3988
|
+
index = read.index;
|
|
3862
3989
|
} else if (token === "--json") {
|
|
3863
3990
|
if (json) fail("`--json`은 한 번만 사용할 수 있습니다.");
|
|
3864
3991
|
json = true;
|
|
@@ -3871,6 +3998,8 @@ function parseTestCommand(argv) {
|
|
|
3871
3998
|
message: "`--command` 옵션이 필요합니다.",
|
|
3872
3999
|
hint: TEST_USAGE_HINT
|
|
3873
4000
|
});
|
|
4001
|
+
if (sessionPath !== void 0 && recordSessionPath !== void 0) fail("`--session`과 `--record-session`은 함께 쓸 수 없습니다. 재생과 녹화 중 하나만 고르세요.");
|
|
4002
|
+
if (determinism && (sessionPath !== void 0 || recordSessionPath !== void 0)) fail("`--determinism`은 External 세션 옵션과 함께 쓸 수 없습니다.\n→ `--determinism`은 서버에 2회 연결하지만 External 세션은 연결 하나에 묶여 있습니다.\n→ 2회차가 같은 세션을 쓰면 반복 호출 순번이 어긋나고, 새 세션을 쓰면 비교 기준이 갈라집니다.");
|
|
3874
4003
|
return Object.freeze({
|
|
3875
4004
|
suitePath,
|
|
3876
4005
|
command,
|
|
@@ -3880,6 +4009,8 @@ function parseTestCommand(argv) {
|
|
|
3880
4009
|
repairBundlePath,
|
|
3881
4010
|
determinism,
|
|
3882
4011
|
resetCmd,
|
|
4012
|
+
sessionPath,
|
|
4013
|
+
recordSessionPath,
|
|
3883
4014
|
stderrLines: stderrLines ?? DEFAULT_STDERR_LINES
|
|
3884
4015
|
});
|
|
3885
4016
|
}
|
|
@@ -3954,18 +4085,7 @@ function format(failure) {
|
|
|
3954
4085
|
for (const issue of failure.issues ?? []) result += `\n- [${escapeTerminalText(issue.code)}] ${escapeTerminalText(issue.path)}: ${escapeTerminalText(issue.message)}\n 해결: ${escapeTerminalText(issue.hint)}`;
|
|
3955
4086
|
return `${result}\n`;
|
|
3956
4087
|
}
|
|
3957
|
-
/**
|
|
3958
|
-
* core 의 McpProcessDiagnostics 인지 구조로 확인한다. core 를 import 하지 않는다.
|
|
3959
|
-
* 하나라도 어긋나면 undefined 다. 설계 문서 §4.3.1.
|
|
3960
|
-
*/
|
|
3961
|
-
function processDiagnostics(value) {
|
|
3962
|
-
if (typeof value !== "object" || value === null) return void 0;
|
|
3963
|
-
if (!("stderr" in value) || typeof value.stderr !== "string") return void 0;
|
|
3964
|
-
if (!("stderrTruncated" in value) || typeof value.stderrTruncated !== "boolean") return void 0;
|
|
3965
|
-
if (!("exitCode" in value) || !(typeof value.exitCode === "number" || value.exitCode === null)) return void 0;
|
|
3966
|
-
if (!("signal" in value) || !(typeof value.signal === "string" || value.signal === null)) return void 0;
|
|
3967
|
-
return value;
|
|
3968
|
-
}
|
|
4088
|
+
/** AggregateError 내부까지 내려가 core 오류와 검증된 프로세스 진단을 꺼낸다. */
|
|
3969
4089
|
function coreError(error) {
|
|
3970
4090
|
const seen = /* @__PURE__ */ new Set();
|
|
3971
4091
|
const visit = (value) => {
|
|
@@ -3989,7 +4109,7 @@ function writeFailure(dependencies, failure) {
|
|
|
3989
4109
|
dependencies.writeStderr(format(failure));
|
|
3990
4110
|
return 1;
|
|
3991
4111
|
}
|
|
3992
|
-
async function
|
|
4112
|
+
async function runCliCore(argv, dependencies, childEnv) {
|
|
3993
4113
|
if (argv.length === 0) return writeFailure(dependencies, {
|
|
3994
4114
|
code: "CLI_USAGE",
|
|
3995
4115
|
message: "실행할 CLI 명령이 없습니다.",
|
|
@@ -4003,7 +4123,7 @@ async function runCli(argv, dependencies) {
|
|
|
4003
4123
|
].includes(argv[0] ?? "")) return writeFailure(dependencies, {
|
|
4004
4124
|
code: "COMMAND_NOT_IMPLEMENTED",
|
|
4005
4125
|
message: `'${argv[0]}' 명령은 아직 구현되지 않았습니다.`,
|
|
4006
|
-
hint: "
|
|
4126
|
+
hint: "사용 가능한 명령: test, generate, repair, replay, verify. 전체 도움말: mcpeak --help"
|
|
4007
4127
|
});
|
|
4008
4128
|
return writeFailure(dependencies, {
|
|
4009
4129
|
code: "CLI_USAGE",
|
|
@@ -4088,7 +4208,8 @@ async function runCli(argv, dependencies) {
|
|
|
4088
4208
|
try {
|
|
4089
4209
|
connection = await dependencies.connect({
|
|
4090
4210
|
command: input.command,
|
|
4091
|
-
args: input.args
|
|
4211
|
+
args: input.args,
|
|
4212
|
+
...childEnv === void 0 ? {} : { env: childEnv }
|
|
4092
4213
|
});
|
|
4093
4214
|
} catch (error) {
|
|
4094
4215
|
const core = coreError(error);
|
|
@@ -4251,7 +4372,8 @@ async function runCli(argv, dependencies) {
|
|
|
4251
4372
|
try {
|
|
4252
4373
|
second = await dependencies.connect({
|
|
4253
4374
|
command: input.command,
|
|
4254
|
-
args: input.args
|
|
4375
|
+
args: input.args,
|
|
4376
|
+
...childEnv === void 0 ? {} : { env: childEnv }
|
|
4255
4377
|
});
|
|
4256
4378
|
} catch (error) {
|
|
4257
4379
|
const core = coreError(error);
|
|
@@ -4489,6 +4611,133 @@ async function runCli(argv, dependencies) {
|
|
|
4489
4611
|
}
|
|
4490
4612
|
return allPassed ? 0 : 1;
|
|
4491
4613
|
}
|
|
4614
|
+
/**
|
|
4615
|
+
* 재생 원본에서 찾지 못한 호출들을 그린다. `misses` 가 비어 있으면 빈 문자열이다.
|
|
4616
|
+
*
|
|
4617
|
+
* **MCP 오류 채널을 타지 않는다(#259).** `record` 의 `REPLAY_MISS` 진단은 실패한 툴 호출의
|
|
4618
|
+
* MCP 오류 메시지로도 나가는데, 그 채널은 `runner` 가 "테스트 대상 서버가 보낸 텍스트"로
|
|
4619
|
+
* 취급해 이스케이프(개행 포함)와 200자 절단을 건다 — 우리 자신이 여러 줄로 공들여 쓴 진단이
|
|
4620
|
+
* 서버 텍스트와 똑같이 망가진다. 이 함수는 `record` 가 `finish()` 요약에 구조화해 담아 준
|
|
4621
|
+
* 값을 CLI 가 직접 stderr 로 쓴다. `runner` 도 그 이스케이프 규칙도 거치지 않는다.
|
|
4622
|
+
*
|
|
4623
|
+
* `method`·`url`·`matchKeyPrefix` 는 값 자체는 `record` 가 만들지만 원본은 테스트 대상
|
|
4624
|
+
* 서버가 시도한 요청이라, 혹시 모를 제어 문자에 대비해 필드 단위로 이스케이프한다(줄 구조를
|
|
4625
|
+
* 만드는 정적 문구는 이스케이프 대상이 아니다 — `process-diagnostics.ts` 와 같은 원칙).
|
|
4626
|
+
*/
|
|
4627
|
+
function renderReplayMissDiagnostics(misses) {
|
|
4628
|
+
if (misses.length === 0) return "";
|
|
4629
|
+
const body = misses.map((miss) => ` ${escapeTerminalText(miss.method)} ${escapeTerminalText(miss.url)}\n occurrence ${miss.occurrence} · matchKey ${escapeTerminalText(miss.matchKeyPrefix)}…\n`).join("");
|
|
4630
|
+
return `\nExternal 진단: 재생 원본에서 찾지 못한 호출 ${misses.length}건\n` + body + "→ 이 호출이 녹화된 뒤에 추가되었거나, 요청이 녹화 때와 달라져 다른 matchKey가 되었습니다.\n→ 녹화를 다시 하거나, 요청이 실행마다 달라지는 값을 담고 있는지 확인하세요.\n";
|
|
4631
|
+
}
|
|
4632
|
+
/**
|
|
4633
|
+
* External 세션 요약을 사용자에게 보이는 한 문단으로 옮긴다. `undefined` 는 할 말이 없다는 뜻이다.
|
|
4634
|
+
*
|
|
4635
|
+
* **순서가 곧 계약이다**(ADR-0057). `consumedCount === 0` 과 `unusedCount > 0` 은 동시에 참일 수
|
|
4636
|
+
* 있어서, 조건을 각각 독립적으로 세우면 한 실행에 경고가 두 번 찍힌다. 아래 순서로 먼저 걸리는
|
|
4637
|
+
* 하나만 낸다.
|
|
4638
|
+
*
|
|
4639
|
+
* 갈래를 넷으로 나눈 이유는 **사용자가 다음에 볼 곳이 다르기 때문이다.** 원본이 비어 있는 것
|
|
4640
|
+
* (녹화 단계가 아무것도 못 잡았다)과 원본은 찼는데 하나도 안 쓴 것(세션 파일을 잘못 짚었거나
|
|
4641
|
+
* 서버의 호출 방식이 바뀌었다)은 같은 `consumedCount === 0` 이지만 원인이 정반대 방향에 있다.
|
|
4642
|
+
* 하나로 합치면 [#258](https://github.com/2026-Engineering-Contest/MCPeak/issues/258) 의 실제
|
|
4643
|
+
* 경로에서 "녹화된 호출이 재생되지 않았다" 고 말하게 되는데, 그 문장은 녹화가 있었다는 전제를
|
|
4644
|
+
* 깔아 사용자를 재생 쪽으로 보낸다. 정작 볼 곳은 그 앞 단계다.
|
|
4645
|
+
*
|
|
4646
|
+
* 판정만 하고 쓰기는 하지 않는다 — 순수 함수라 배타성 자체를 프로세스 없이 고정할 수 있다.
|
|
4647
|
+
*/
|
|
4648
|
+
function externalSessionNotice(summary) {
|
|
4649
|
+
if (summary.mode === "record") {
|
|
4650
|
+
if (summary.interactionCount > 0) return void 0;
|
|
4651
|
+
return "\n알림: 이 실행에서 외부 호출이 하나도 녹화되지 않았습니다.\n→ 서버가 외부 API를 호출했다면 지원 범위를 벗어났는지 확인하세요.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
4652
|
+
}
|
|
4653
|
+
if (summary.interactionCount === 0) return "\n알림: 이 세션에는 녹화된 외부 호출이 0건입니다. 재생할 것이 없었습니다.\n→ 녹화 실행이 외부 호출을 하나도 잡지 못했다는 뜻입니다. 이 세션은 아무 호출도 막지 못합니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
4654
|
+
if (summary.consumedCount === 0) return "\n알림: 이 실행에서 녹화된 외부 호출이 하나도 재생되지 않았습니다.\n→ 지원 범위 밖의 호출은 실제 네트워크로 나갔을 수 있습니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
4655
|
+
if (summary.unusedCount > 0) return `\n알림: 녹화된 외부 호출 ${summary.interactionCount}건 중 ${summary.unusedCount}건이 이번 실행에서 재생되지 않았습니다.\n→ 서버 코드나 실행 경로가 녹화 때와 달라졌을 수 있습니다.
|
|
4656
|
+
→ MCPeak은 서버가 \`globalThis.fetch\`로 부른 것만 잡습니다.
|
|
4657
|
+
`;
|
|
4658
|
+
}
|
|
4659
|
+
/**
|
|
4660
|
+
* `test` 실행에 External Record/Replay 수명주기를 씌운다.
|
|
4661
|
+
*
|
|
4662
|
+
* Coordinator 를 **먼저 열고 마지막에 닫는다**(ADR-0052). 그 사이에 들어가는 것이 기존 실행
|
|
4663
|
+
* 전부라, 성공·실패·예외 어느 경로로 빠져나가도 `finish` 가 불려야 한다. 안 불리면 SQLite
|
|
4664
|
+
* 파일 핸들이 남고 Record 세션이 `running` 인 채로 남아 다음 실행이 이어 쓸 수 없다.
|
|
4665
|
+
*
|
|
4666
|
+
* 세션 옵션이 없으면 배선을 아예 만들지 않는다 — 기존 실행 경로가 한 글자도 달라지지 않는다.
|
|
4667
|
+
*/
|
|
4668
|
+
async function runCli(argv, dependencies) {
|
|
4669
|
+
const mode = externalModeOf(argv);
|
|
4670
|
+
if (mode === void 0) return runCliCore(argv, dependencies);
|
|
4671
|
+
let wiring;
|
|
4672
|
+
try {
|
|
4673
|
+
wiring = await startExternalWiring({
|
|
4674
|
+
mode: mode.mode,
|
|
4675
|
+
sessionPath: mode.path,
|
|
4676
|
+
existingNodeOptions: process.env.NODE_OPTIONS
|
|
4677
|
+
});
|
|
4678
|
+
} catch (error) {
|
|
4679
|
+
return writeFailure(dependencies, {
|
|
4680
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4681
|
+
message: "External 세션을 열지 못했습니다.",
|
|
4682
|
+
hint: `${error instanceof Error ? error.message : String(error)}\n→ 세션 파일 경로의 디렉터리가 있는지와 쓰기 권한을 확인하세요.`
|
|
4683
|
+
});
|
|
4684
|
+
}
|
|
4685
|
+
let exitCode;
|
|
4686
|
+
try {
|
|
4687
|
+
exitCode = await runCliCore(argv, dependencies, wiring.env);
|
|
4688
|
+
} catch (error) {
|
|
4689
|
+
await wiring.finish("failed").catch(() => void 0);
|
|
4690
|
+
throw error;
|
|
4691
|
+
}
|
|
4692
|
+
try {
|
|
4693
|
+
const summary = await wiring.finish(exitCode === 0 ? "completed" : "failed");
|
|
4694
|
+
if (summary.mode === "replay") {
|
|
4695
|
+
const missBlock = renderReplayMissDiagnostics(summary.misses);
|
|
4696
|
+
if (missBlock !== "") dependencies.writeStderr(missBlock);
|
|
4697
|
+
}
|
|
4698
|
+
const notice = externalSessionNotice(summary);
|
|
4699
|
+
if (notice !== void 0) dependencies.writeStderr(notice);
|
|
4700
|
+
} catch (error) {
|
|
4701
|
+
return writeFailure(dependencies, {
|
|
4702
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4703
|
+
message: "External 세션을 닫지 못했습니다.",
|
|
4704
|
+
hint: error instanceof Error ? error.message : String(error)
|
|
4705
|
+
});
|
|
4706
|
+
}
|
|
4707
|
+
return exitCode;
|
|
4708
|
+
}
|
|
4709
|
+
/**
|
|
4710
|
+
* argv 에서 External 모드를 정한다. **파싱은 `parseTestCommand` 하나만 한다.**
|
|
4711
|
+
*
|
|
4712
|
+
* 한때 여기서 argv 를 따로 훑었다. 그러면 토큰 소비 규칙이 두 벌이 되고, 두 벌은 갈라진다.
|
|
4713
|
+
* 실제로 갈렸다 — `--arg` 는 하이픈으로 시작하는 값을 의도적으로 받으므로
|
|
4714
|
+
*
|
|
4715
|
+
* mcpeak test s.json --command node --arg --session=/tmp/x
|
|
4716
|
+
*
|
|
4717
|
+
* 에서 `parseTestCommand` 는 그 토큰을 **서버 인자**로 소비하는데, 따로 훑는 쪽은 같은 것을
|
|
4718
|
+
* replay 지시로 읽었다. 사용자가 요청한 적 없는 세션 파일이 열리고 Bootstrap 이 주입되며,
|
|
4719
|
+
* Replay 라 서버의 외부 호출이 전부 실패한다. 원인을 짐작할 방법이 없는 실패다.
|
|
4720
|
+
*
|
|
4721
|
+
* 파싱 오류는 여기서 삼킨다. 배선은 파싱보다 먼저 서지만 **오류 보고는 기존 경로의 몫**이다.
|
|
4722
|
+
* 여기서 던지면 `--session` 이 붙었다는 이유만으로 오류 문구가 달라진다.
|
|
4723
|
+
*/
|
|
4724
|
+
function externalModeOf(argv) {
|
|
4725
|
+
if (argv[0] !== "test") return void 0;
|
|
4726
|
+
let input;
|
|
4727
|
+
try {
|
|
4728
|
+
input = parseTestCommand(argv.slice(1));
|
|
4729
|
+
} catch {
|
|
4730
|
+
return;
|
|
4731
|
+
}
|
|
4732
|
+
if (input.recordSessionPath !== void 0) return {
|
|
4733
|
+
mode: "record",
|
|
4734
|
+
path: input.recordSessionPath
|
|
4735
|
+
};
|
|
4736
|
+
if (input.sessionPath !== void 0) return {
|
|
4737
|
+
mode: "replay",
|
|
4738
|
+
path: input.sessionPath
|
|
4739
|
+
};
|
|
4740
|
+
}
|
|
4492
4741
|
//#endregion
|
|
4493
4742
|
Object.defineProperty(exports, "GLOBAL_HELP", {
|
|
4494
4743
|
enumerable: true,
|
|
@@ -6,7 +6,7 @@ import { REDACTED as REDACTED$1, cassetteClient, loadCassette, matchKey, saveCas
|
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { CommandParseError, tokenizeCommand } from "@mcpeak/core";
|
|
8
8
|
//#region package.json
|
|
9
|
-
var version = "0.
|
|
9
|
+
var version = "0.10.0";
|
|
10
10
|
//#endregion
|
|
11
11
|
//#region src/cassette-wiring.ts
|
|
12
12
|
/**
|
|
@@ -320,7 +320,7 @@ const FINDING_GROUP = {
|
|
|
320
320
|
};
|
|
321
321
|
//#endregion
|
|
322
322
|
//#region src/help.ts
|
|
323
|
-
const TEST_USAGE = "사용법: mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>]";
|
|
323
|
+
const TEST_USAGE = "사용법: mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>] [--session <path> | --record-session <path>]";
|
|
324
324
|
/**
|
|
325
325
|
* 시험 실행 옵션 설명. `--determinism` 은 툴을 2회 호출하므로 부작용이 있는 서버에서 모르고
|
|
326
326
|
* 쓰면 사고가 난다. 그 경고가 사용법 한 줄에는 들어가지 않는다.
|
|
@@ -331,7 +331,31 @@ const TEST_OPTIONS = `옵션:
|
|
|
331
331
|
--reset-cmd 와 함께 쓰면 결정론성 확인이 되고, 없으면
|
|
332
332
|
"2회 결과가 같았다" 까지만 확인합니다
|
|
333
333
|
--reset-cmd <command> 각 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지
|
|
334
|
-
않으므로 파이프나 && 는 쓸 수
|
|
334
|
+
않으므로 파이프나 && 는 쓸 수 없습니다
|
|
335
|
+
--record-session <path>
|
|
336
|
+
서버가 \`globalThis.fetch\` 로 밖에 부른 HTTP 호출만 녹화합니다.
|
|
337
|
+
서버는 실제로 실행됩니다. \`node:http\`·\`node:https\` 같은 범위 밖 호출은
|
|
338
|
+
잡히지 않아 실제 네트워크로 나갈 수 있습니다
|
|
339
|
+
--session <path> 녹화한 외부 호출을 재생합니다. 서버는 실제로 실행되지만 외부
|
|
340
|
+
API 호출은 \`globalThis.fetch\` 범위 안에서만 가로챕니다.
|
|
341
|
+
범위 밖 호출은 실제 네트워크로 나갈 수 있으며, 범위 밖 호출이
|
|
342
|
+
의심되는 경우 종료 때 알립니다
|
|
343
|
+
|
|
344
|
+
두 세션 옵션은 서로, 그리고 \`--determinism\` 과 함께 쓸 수 없습니다. \`--determinism\` 은 서버에
|
|
345
|
+
2회 연결하는데 세션은 연결 하나에 묶여 있어, 2회차가 같은 세션을 쓰면 반복 호출 순번이
|
|
346
|
+
어긋나고 새 세션을 쓰면 비교 기준이 갈라집니다.
|
|
347
|
+
|
|
348
|
+
세션은 \`--cassette\` 카세트와 다릅니다. **카세트는 우리가 서버에게 물어본 결과**를 남기고,
|
|
349
|
+
**세션은 그 서버가 밖에 물어본 결과**를 남깁니다. 둘은 섞이지 않으며 파일도 따로입니다.
|
|
350
|
+
|
|
351
|
+
세션 파일에는 외부 API 응답이 저장되므로 .gitignore 를 확인하세요. \`token\`·\`apiKey\` 같은
|
|
352
|
+
이름의 값은 저장 전에 가려집니다.
|
|
353
|
+
|
|
354
|
+
런타임에 따라 세션 옵션을 쓴 실행에서 \`ExperimentalWarning: SQLite is an experimental
|
|
355
|
+
feature\` 가 stderr 에 한 줄 찍힙니다(실측: Node 22.18.0 에서 나오고 24.16.0 에서는 안 납니다).
|
|
356
|
+
\`node:sqlite\` 를 처음 로드할 때 한 번 나오므로 실행당 한 줄이며, 세션 옵션이 없는 실행에는
|
|
357
|
+
붙지 않습니다. Node 가 그 모듈을 아직 실험적으로 표시한다는 뜻이고, **저장된 녹화는 표준
|
|
358
|
+
SQLite 파일이라 영향받지 않습니다.**`;
|
|
335
359
|
const GENERATE_USAGE = "사용법: mcpeak generate --suite-id <id> --name <name> --out <suite.json> --command <executable> [--arg <value> ...] [--baseline-only] [--provider <codex|claude>] [--model <model>] [--no-dry-run] [--cassette <path>] [--record] [--reset-cmd <command>] [--no-repair] [--force]";
|
|
336
360
|
/**
|
|
337
361
|
* 시험 실행 옵션 설명. 사용법 한 줄로는 `--cassette` 가 무엇을 파일로 남기는지, `--reset-cmd`
|
|
@@ -403,7 +427,7 @@ auto 모드(\`--record\` 없는 실행)는 카세트에 있는 요청이면 서
|
|
|
403
427
|
마스킹해 비교하므로, 비밀값 자체만 바뀐 경우는 감지되지 않습니다.
|
|
404
428
|
|
|
405
429
|
불일치나 호출 실패가 하나라도 있으면 종료 코드 1 입니다. 확인불가는 실패로 보지 않습니다.`;
|
|
406
|
-
const GLOBAL_HELP = `
|
|
430
|
+
const GLOBAL_HELP = `MCPeak — MCP 서버 테스트 프레임워크
|
|
407
431
|
|
|
408
432
|
사용법: mcpeak <명령> [옵션]
|
|
409
433
|
mcpeak help [명령]
|
|
@@ -833,6 +857,18 @@ function dropSkippedTools(suite, skips) {
|
|
|
833
857
|
}
|
|
834
858
|
//#endregion
|
|
835
859
|
//#region src/process-diagnostics.ts
|
|
860
|
+
/**
|
|
861
|
+
* core 의 stdio 프로세스 진단인지 구조와 값의 범위로 확인한다.
|
|
862
|
+
* 일부 필드만 있거나 프로세스 상태로 표현할 수 없는 값은 렌더하지 않는다.
|
|
863
|
+
*/
|
|
864
|
+
function processDiagnostics(value) {
|
|
865
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
866
|
+
if (!("stderr" in value) || typeof value.stderr !== "string") return void 0;
|
|
867
|
+
if (!("stderrTruncated" in value) || typeof value.stderrTruncated !== "boolean") return void 0;
|
|
868
|
+
if (!("exitCode" in value) || !(value.exitCode === null || typeof value.exitCode === "number" && Number.isInteger(value.exitCode))) return void 0;
|
|
869
|
+
if (!("signal" in value) || !(value.signal === null || typeof value.signal === "string" && value.signal.length > 0)) return void 0;
|
|
870
|
+
return value;
|
|
871
|
+
}
|
|
836
872
|
/** TAB. 이 모듈만 이스케이프 대상에서 빼는 문자다. 아래 escapeTokens 주석에 근거가 있다. */
|
|
837
873
|
const TAB = 9;
|
|
838
874
|
/**
|
|
@@ -2793,8 +2829,14 @@ async function runGenerateCommand(argv, deps) {
|
|
|
2793
2829
|
else if (error instanceof OutputReplaceError) outputReplaceFailure(deps, error.path, error.code);
|
|
2794
2830
|
else if (error instanceof LinkUnsupportedError) linkUnsupportedFailure(deps, error.path, error.code);
|
|
2795
2831
|
else if (deps.GenerateTestsError !== void 0 && error instanceof deps.GenerateTestsError) generateTestsFailure(deps, error);
|
|
2796
|
-
else if (isCoreClientError(error))
|
|
2797
|
-
|
|
2832
|
+
else if (isCoreClientError(error)) {
|
|
2833
|
+
deps.writeStderr(`오류 [GENERATE_CONNECT_FAILED/${error.code}]: ${error.message}\n해결: ${error.hint}\n`);
|
|
2834
|
+
const diagnostics = processDiagnostics(error.diagnostics);
|
|
2835
|
+
if (diagnostics !== void 0 && hasDiagnosticContent(diagnostics)) {
|
|
2836
|
+
const block = renderProcessDiagnostics(diagnostics, { maxLines: DRY_RUN_STDERR_LINES });
|
|
2837
|
+
if (block !== "") deps.writeStderr(`\n${block}`);
|
|
2838
|
+
}
|
|
2839
|
+
} else deps.writeStderr("오류 [GENERATE_FAILED]: baseline suite를 생성하거나 저장하지 못했습니다.\n해결: MCP 서버와 출력 경로를 확인한 뒤 다시 실행하세요.\n");
|
|
2798
2840
|
return 1;
|
|
2799
2841
|
}
|
|
2800
2842
|
try {
|
|
@@ -3689,6 +3731,51 @@ function createDeterminismCapture(inner) {
|
|
|
3689
3731
|
};
|
|
3690
3732
|
}
|
|
3691
3733
|
//#endregion
|
|
3734
|
+
//#region src/external-wiring.ts
|
|
3735
|
+
/**
|
|
3736
|
+
* `@mcpeak/record/external` 은 **세션 옵션을 실제로 쓸 때만** 불러온다.
|
|
3737
|
+
*
|
|
3738
|
+
* 정적 import 로 두면 `node:sqlite` 가 CLI 를 띄우는 것만으로 로드되고, Node 22.x 는 그때
|
|
3739
|
+
* `ExperimentalWarning: SQLite is an experimental feature` 를 stderr 에 찍는다. 세션을 쓰지도
|
|
3740
|
+
* 않은 `mcpeak test` 실행마다 그 줄이 붙는다 — 터미널 출력이 곧 UI 인 도구에서 그냥 손해다.
|
|
3741
|
+
* (실제로 `dist-cli-e2e` 의 "stderr 가 비어 있다" 단언이 이걸 잡았다.)
|
|
3742
|
+
*
|
|
3743
|
+
* 지연 로딩이면 경고를 보는 사람은 External 을 직접 켠 사용자뿐이다. 그 경고를 아예 지울지는
|
|
3744
|
+
* ADR-0054 가 남겨 둔 별도 결정이며, 라이브러리가 아니라 CLI 가 정할 몫이다.
|
|
3745
|
+
*/
|
|
3746
|
+
const loadExternal = () => import("@mcpeak/record/external");
|
|
3747
|
+
/** 세션 파일 하나가 세션 하나이므로 식별자를 사용자에게 묻지 않는다. */
|
|
3748
|
+
const SESSION_ID = "default";
|
|
3749
|
+
async function startExternalWiring(options) {
|
|
3750
|
+
const { createSqliteSessionStore, startExternalCoordinator } = await loadExternal();
|
|
3751
|
+
const store = createSqliteSessionStore({ path: options.sessionPath });
|
|
3752
|
+
let handle;
|
|
3753
|
+
try {
|
|
3754
|
+
handle = options.mode === "record" ? await startExternalCoordinator({
|
|
3755
|
+
mode: "record",
|
|
3756
|
+
sessionId: SESSION_ID,
|
|
3757
|
+
store,
|
|
3758
|
+
...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
|
|
3759
|
+
}) : await startExternalCoordinator({
|
|
3760
|
+
mode: "replay",
|
|
3761
|
+
sourceSessionId: SESSION_ID,
|
|
3762
|
+
store,
|
|
3763
|
+
...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
|
|
3764
|
+
});
|
|
3765
|
+
} catch (error) {
|
|
3766
|
+
store.close();
|
|
3767
|
+
throw error;
|
|
3768
|
+
}
|
|
3769
|
+
let settled;
|
|
3770
|
+
return {
|
|
3771
|
+
env: handle.childEnvironment,
|
|
3772
|
+
finish(status) {
|
|
3773
|
+
settled ??= handle.finish(status).finally(() => store.close());
|
|
3774
|
+
return settled;
|
|
3775
|
+
}
|
|
3776
|
+
};
|
|
3777
|
+
}
|
|
3778
|
+
//#endregion
|
|
3692
3779
|
//#region src/test-command.ts
|
|
3693
3780
|
/** --stderr-lines 기본값. 설계 문서 §6. */
|
|
3694
3781
|
const DEFAULT_STDERR_LINES = 20;
|
|
@@ -3737,6 +3824,10 @@ const dictionary = {
|
|
|
3737
3824
|
message: "초기화 명령이 실패했습니다.",
|
|
3738
3825
|
hint: "`--reset-cmd` 명령이 단독으로 성공하는지 확인한 뒤 다시 실행하세요."
|
|
3739
3826
|
},
|
|
3827
|
+
EXTERNAL_SESSION_FAILED: {
|
|
3828
|
+
message: "External 세션 처리에 실패했습니다.",
|
|
3829
|
+
hint: "세션 파일 경로의 디렉터리가 있는지와 쓰기 권한을 확인하세요."
|
|
3830
|
+
},
|
|
3740
3831
|
CLI_INTERNAL_ERROR: {
|
|
3741
3832
|
message: "예상하지 못한 CLI 내부 오류가 발생했습니다.",
|
|
3742
3833
|
hint: "다시 실행한 뒤 재현 정보와 함께 이슈를 보고하세요."
|
|
@@ -3756,6 +3847,30 @@ const fail = (message) => {
|
|
|
3756
3847
|
hint: TEST_USAGE_HINT
|
|
3757
3848
|
});
|
|
3758
3849
|
};
|
|
3850
|
+
/**
|
|
3851
|
+
* `--name value` 와 `--name=value` 두 형태에서 값을 꺼낸다.
|
|
3852
|
+
*
|
|
3853
|
+
* 값이 `--` 로 시작하면 거절한다. `--session --json` 처럼 값을 빠뜨리면 다음 옵션이 경로로
|
|
3854
|
+
* 먹히고, 그러면 "그런 파일이 없습니다" 라는 엉뚱한 곳에서 실패한다.
|
|
3855
|
+
*/
|
|
3856
|
+
const readOptionValue = (argv, token, name, index) => {
|
|
3857
|
+
let value;
|
|
3858
|
+
let next = index;
|
|
3859
|
+
if (token === name) {
|
|
3860
|
+
const candidate = argv[++next];
|
|
3861
|
+
if (candidate === void 0) throw new CliCommandError({
|
|
3862
|
+
code: "CLI_USAGE",
|
|
3863
|
+
message: `\`${name}\` 옵션 값이 필요합니다.`,
|
|
3864
|
+
hint: TEST_USAGE_HINT
|
|
3865
|
+
});
|
|
3866
|
+
value = candidate;
|
|
3867
|
+
} else value = token.slice(`${name}=`.length);
|
|
3868
|
+
if (value.trim() === "" || value.startsWith("--")) fail(`\`${name}\` 옵션 값이 필요합니다.`);
|
|
3869
|
+
return {
|
|
3870
|
+
value,
|
|
3871
|
+
index: next
|
|
3872
|
+
};
|
|
3873
|
+
};
|
|
3759
3874
|
function parseTestCommand(argv) {
|
|
3760
3875
|
const suitePath = argv[0] ?? "";
|
|
3761
3876
|
if (suitePath === "") fail("테스트 명세 JSON 경로가 필요합니다.");
|
|
@@ -3766,6 +3881,8 @@ function parseTestCommand(argv) {
|
|
|
3766
3881
|
let determinism = false;
|
|
3767
3882
|
let resetCmd;
|
|
3768
3883
|
let stderrLines;
|
|
3884
|
+
let sessionPath;
|
|
3885
|
+
let recordSessionPath;
|
|
3769
3886
|
const args = [];
|
|
3770
3887
|
for (let index = 1; index < argv.length; index += 1) {
|
|
3771
3888
|
const token = argv[index] ?? "";
|
|
@@ -3859,6 +3976,16 @@ function parseTestCommand(argv) {
|
|
|
3859
3976
|
const parsedLines = Number.parseInt(value, 10);
|
|
3860
3977
|
if (!Number.isSafeInteger(parsedLines)) fail("`--stderr-lines` 값은 0 이상의 정수여야 합니다.");
|
|
3861
3978
|
stderrLines = parsedLines;
|
|
3979
|
+
} else if (token === "--session" || token.startsWith("--session=")) {
|
|
3980
|
+
if (sessionPath !== void 0) fail("`--session`은 한 번만 사용할 수 있습니다.");
|
|
3981
|
+
const read = readOptionValue(argv, token, "--session", index);
|
|
3982
|
+
sessionPath = read.value;
|
|
3983
|
+
index = read.index;
|
|
3984
|
+
} else if (token === "--record-session" || token.startsWith("--record-session=")) {
|
|
3985
|
+
if (recordSessionPath !== void 0) fail("`--record-session`은 한 번만 사용할 수 있습니다.");
|
|
3986
|
+
const read = readOptionValue(argv, token, "--record-session", index);
|
|
3987
|
+
recordSessionPath = read.value;
|
|
3988
|
+
index = read.index;
|
|
3862
3989
|
} else if (token === "--json") {
|
|
3863
3990
|
if (json) fail("`--json`은 한 번만 사용할 수 있습니다.");
|
|
3864
3991
|
json = true;
|
|
@@ -3871,6 +3998,8 @@ function parseTestCommand(argv) {
|
|
|
3871
3998
|
message: "`--command` 옵션이 필요합니다.",
|
|
3872
3999
|
hint: TEST_USAGE_HINT
|
|
3873
4000
|
});
|
|
4001
|
+
if (sessionPath !== void 0 && recordSessionPath !== void 0) fail("`--session`과 `--record-session`은 함께 쓸 수 없습니다. 재생과 녹화 중 하나만 고르세요.");
|
|
4002
|
+
if (determinism && (sessionPath !== void 0 || recordSessionPath !== void 0)) fail("`--determinism`은 External 세션 옵션과 함께 쓸 수 없습니다.\n→ `--determinism`은 서버에 2회 연결하지만 External 세션은 연결 하나에 묶여 있습니다.\n→ 2회차가 같은 세션을 쓰면 반복 호출 순번이 어긋나고, 새 세션을 쓰면 비교 기준이 갈라집니다.");
|
|
3874
4003
|
return Object.freeze({
|
|
3875
4004
|
suitePath,
|
|
3876
4005
|
command,
|
|
@@ -3880,6 +4009,8 @@ function parseTestCommand(argv) {
|
|
|
3880
4009
|
repairBundlePath,
|
|
3881
4010
|
determinism,
|
|
3882
4011
|
resetCmd,
|
|
4012
|
+
sessionPath,
|
|
4013
|
+
recordSessionPath,
|
|
3883
4014
|
stderrLines: stderrLines ?? DEFAULT_STDERR_LINES
|
|
3884
4015
|
});
|
|
3885
4016
|
}
|
|
@@ -3954,18 +4085,7 @@ function format(failure) {
|
|
|
3954
4085
|
for (const issue of failure.issues ?? []) result += `\n- [${escapeTerminalText(issue.code)}] ${escapeTerminalText(issue.path)}: ${escapeTerminalText(issue.message)}\n 해결: ${escapeTerminalText(issue.hint)}`;
|
|
3955
4086
|
return `${result}\n`;
|
|
3956
4087
|
}
|
|
3957
|
-
/**
|
|
3958
|
-
* core 의 McpProcessDiagnostics 인지 구조로 확인한다. core 를 import 하지 않는다.
|
|
3959
|
-
* 하나라도 어긋나면 undefined 다. 설계 문서 §4.3.1.
|
|
3960
|
-
*/
|
|
3961
|
-
function processDiagnostics(value) {
|
|
3962
|
-
if (typeof value !== "object" || value === null) return void 0;
|
|
3963
|
-
if (!("stderr" in value) || typeof value.stderr !== "string") return void 0;
|
|
3964
|
-
if (!("stderrTruncated" in value) || typeof value.stderrTruncated !== "boolean") return void 0;
|
|
3965
|
-
if (!("exitCode" in value) || !(typeof value.exitCode === "number" || value.exitCode === null)) return void 0;
|
|
3966
|
-
if (!("signal" in value) || !(typeof value.signal === "string" || value.signal === null)) return void 0;
|
|
3967
|
-
return value;
|
|
3968
|
-
}
|
|
4088
|
+
/** AggregateError 내부까지 내려가 core 오류와 검증된 프로세스 진단을 꺼낸다. */
|
|
3969
4089
|
function coreError(error) {
|
|
3970
4090
|
const seen = /* @__PURE__ */ new Set();
|
|
3971
4091
|
const visit = (value) => {
|
|
@@ -3989,7 +4109,7 @@ function writeFailure(dependencies, failure) {
|
|
|
3989
4109
|
dependencies.writeStderr(format(failure));
|
|
3990
4110
|
return 1;
|
|
3991
4111
|
}
|
|
3992
|
-
async function
|
|
4112
|
+
async function runCliCore(argv, dependencies, childEnv) {
|
|
3993
4113
|
if (argv.length === 0) return writeFailure(dependencies, {
|
|
3994
4114
|
code: "CLI_USAGE",
|
|
3995
4115
|
message: "실행할 CLI 명령이 없습니다.",
|
|
@@ -4003,7 +4123,7 @@ async function runCli(argv, dependencies) {
|
|
|
4003
4123
|
].includes(argv[0] ?? "")) return writeFailure(dependencies, {
|
|
4004
4124
|
code: "COMMAND_NOT_IMPLEMENTED",
|
|
4005
4125
|
message: `'${argv[0]}' 명령은 아직 구현되지 않았습니다.`,
|
|
4006
|
-
hint: "
|
|
4126
|
+
hint: "사용 가능한 명령: test, generate, repair, replay, verify. 전체 도움말: mcpeak --help"
|
|
4007
4127
|
});
|
|
4008
4128
|
return writeFailure(dependencies, {
|
|
4009
4129
|
code: "CLI_USAGE",
|
|
@@ -4088,7 +4208,8 @@ async function runCli(argv, dependencies) {
|
|
|
4088
4208
|
try {
|
|
4089
4209
|
connection = await dependencies.connect({
|
|
4090
4210
|
command: input.command,
|
|
4091
|
-
args: input.args
|
|
4211
|
+
args: input.args,
|
|
4212
|
+
...childEnv === void 0 ? {} : { env: childEnv }
|
|
4092
4213
|
});
|
|
4093
4214
|
} catch (error) {
|
|
4094
4215
|
const core = coreError(error);
|
|
@@ -4251,7 +4372,8 @@ async function runCli(argv, dependencies) {
|
|
|
4251
4372
|
try {
|
|
4252
4373
|
second = await dependencies.connect({
|
|
4253
4374
|
command: input.command,
|
|
4254
|
-
args: input.args
|
|
4375
|
+
args: input.args,
|
|
4376
|
+
...childEnv === void 0 ? {} : { env: childEnv }
|
|
4255
4377
|
});
|
|
4256
4378
|
} catch (error) {
|
|
4257
4379
|
const core = coreError(error);
|
|
@@ -4489,5 +4611,132 @@ async function runCli(argv, dependencies) {
|
|
|
4489
4611
|
}
|
|
4490
4612
|
return allPassed ? 0 : 1;
|
|
4491
4613
|
}
|
|
4614
|
+
/**
|
|
4615
|
+
* 재생 원본에서 찾지 못한 호출들을 그린다. `misses` 가 비어 있으면 빈 문자열이다.
|
|
4616
|
+
*
|
|
4617
|
+
* **MCP 오류 채널을 타지 않는다(#259).** `record` 의 `REPLAY_MISS` 진단은 실패한 툴 호출의
|
|
4618
|
+
* MCP 오류 메시지로도 나가는데, 그 채널은 `runner` 가 "테스트 대상 서버가 보낸 텍스트"로
|
|
4619
|
+
* 취급해 이스케이프(개행 포함)와 200자 절단을 건다 — 우리 자신이 여러 줄로 공들여 쓴 진단이
|
|
4620
|
+
* 서버 텍스트와 똑같이 망가진다. 이 함수는 `record` 가 `finish()` 요약에 구조화해 담아 준
|
|
4621
|
+
* 값을 CLI 가 직접 stderr 로 쓴다. `runner` 도 그 이스케이프 규칙도 거치지 않는다.
|
|
4622
|
+
*
|
|
4623
|
+
* `method`·`url`·`matchKeyPrefix` 는 값 자체는 `record` 가 만들지만 원본은 테스트 대상
|
|
4624
|
+
* 서버가 시도한 요청이라, 혹시 모를 제어 문자에 대비해 필드 단위로 이스케이프한다(줄 구조를
|
|
4625
|
+
* 만드는 정적 문구는 이스케이프 대상이 아니다 — `process-diagnostics.ts` 와 같은 원칙).
|
|
4626
|
+
*/
|
|
4627
|
+
function renderReplayMissDiagnostics(misses) {
|
|
4628
|
+
if (misses.length === 0) return "";
|
|
4629
|
+
const body = misses.map((miss) => ` ${escapeTerminalText(miss.method)} ${escapeTerminalText(miss.url)}\n occurrence ${miss.occurrence} · matchKey ${escapeTerminalText(miss.matchKeyPrefix)}…\n`).join("");
|
|
4630
|
+
return `\nExternal 진단: 재생 원본에서 찾지 못한 호출 ${misses.length}건\n` + body + "→ 이 호출이 녹화된 뒤에 추가되었거나, 요청이 녹화 때와 달라져 다른 matchKey가 되었습니다.\n→ 녹화를 다시 하거나, 요청이 실행마다 달라지는 값을 담고 있는지 확인하세요.\n";
|
|
4631
|
+
}
|
|
4632
|
+
/**
|
|
4633
|
+
* External 세션 요약을 사용자에게 보이는 한 문단으로 옮긴다. `undefined` 는 할 말이 없다는 뜻이다.
|
|
4634
|
+
*
|
|
4635
|
+
* **순서가 곧 계약이다**(ADR-0057). `consumedCount === 0` 과 `unusedCount > 0` 은 동시에 참일 수
|
|
4636
|
+
* 있어서, 조건을 각각 독립적으로 세우면 한 실행에 경고가 두 번 찍힌다. 아래 순서로 먼저 걸리는
|
|
4637
|
+
* 하나만 낸다.
|
|
4638
|
+
*
|
|
4639
|
+
* 갈래를 넷으로 나눈 이유는 **사용자가 다음에 볼 곳이 다르기 때문이다.** 원본이 비어 있는 것
|
|
4640
|
+
* (녹화 단계가 아무것도 못 잡았다)과 원본은 찼는데 하나도 안 쓴 것(세션 파일을 잘못 짚었거나
|
|
4641
|
+
* 서버의 호출 방식이 바뀌었다)은 같은 `consumedCount === 0` 이지만 원인이 정반대 방향에 있다.
|
|
4642
|
+
* 하나로 합치면 [#258](https://github.com/2026-Engineering-Contest/MCPeak/issues/258) 의 실제
|
|
4643
|
+
* 경로에서 "녹화된 호출이 재생되지 않았다" 고 말하게 되는데, 그 문장은 녹화가 있었다는 전제를
|
|
4644
|
+
* 깔아 사용자를 재생 쪽으로 보낸다. 정작 볼 곳은 그 앞 단계다.
|
|
4645
|
+
*
|
|
4646
|
+
* 판정만 하고 쓰기는 하지 않는다 — 순수 함수라 배타성 자체를 프로세스 없이 고정할 수 있다.
|
|
4647
|
+
*/
|
|
4648
|
+
function externalSessionNotice(summary) {
|
|
4649
|
+
if (summary.mode === "record") {
|
|
4650
|
+
if (summary.interactionCount > 0) return void 0;
|
|
4651
|
+
return "\n알림: 이 실행에서 외부 호출이 하나도 녹화되지 않았습니다.\n→ 서버가 외부 API를 호출했다면 지원 범위를 벗어났는지 확인하세요.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
4652
|
+
}
|
|
4653
|
+
if (summary.interactionCount === 0) return "\n알림: 이 세션에는 녹화된 외부 호출이 0건입니다. 재생할 것이 없었습니다.\n→ 녹화 실행이 외부 호출을 하나도 잡지 못했다는 뜻입니다. 이 세션은 아무 호출도 막지 못합니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
4654
|
+
if (summary.consumedCount === 0) return "\n알림: 이 실행에서 녹화된 외부 호출이 하나도 재생되지 않았습니다.\n→ 지원 범위 밖의 호출은 실제 네트워크로 나갔을 수 있습니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
|
|
4655
|
+
if (summary.unusedCount > 0) return `\n알림: 녹화된 외부 호출 ${summary.interactionCount}건 중 ${summary.unusedCount}건이 이번 실행에서 재생되지 않았습니다.\n→ 서버 코드나 실행 경로가 녹화 때와 달라졌을 수 있습니다.
|
|
4656
|
+
→ MCPeak은 서버가 \`globalThis.fetch\`로 부른 것만 잡습니다.
|
|
4657
|
+
`;
|
|
4658
|
+
}
|
|
4659
|
+
/**
|
|
4660
|
+
* `test` 실행에 External Record/Replay 수명주기를 씌운다.
|
|
4661
|
+
*
|
|
4662
|
+
* Coordinator 를 **먼저 열고 마지막에 닫는다**(ADR-0052). 그 사이에 들어가는 것이 기존 실행
|
|
4663
|
+
* 전부라, 성공·실패·예외 어느 경로로 빠져나가도 `finish` 가 불려야 한다. 안 불리면 SQLite
|
|
4664
|
+
* 파일 핸들이 남고 Record 세션이 `running` 인 채로 남아 다음 실행이 이어 쓸 수 없다.
|
|
4665
|
+
*
|
|
4666
|
+
* 세션 옵션이 없으면 배선을 아예 만들지 않는다 — 기존 실행 경로가 한 글자도 달라지지 않는다.
|
|
4667
|
+
*/
|
|
4668
|
+
async function runCli(argv, dependencies) {
|
|
4669
|
+
const mode = externalModeOf(argv);
|
|
4670
|
+
if (mode === void 0) return runCliCore(argv, dependencies);
|
|
4671
|
+
let wiring;
|
|
4672
|
+
try {
|
|
4673
|
+
wiring = await startExternalWiring({
|
|
4674
|
+
mode: mode.mode,
|
|
4675
|
+
sessionPath: mode.path,
|
|
4676
|
+
existingNodeOptions: process.env.NODE_OPTIONS
|
|
4677
|
+
});
|
|
4678
|
+
} catch (error) {
|
|
4679
|
+
return writeFailure(dependencies, {
|
|
4680
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4681
|
+
message: "External 세션을 열지 못했습니다.",
|
|
4682
|
+
hint: `${error instanceof Error ? error.message : String(error)}\n→ 세션 파일 경로의 디렉터리가 있는지와 쓰기 권한을 확인하세요.`
|
|
4683
|
+
});
|
|
4684
|
+
}
|
|
4685
|
+
let exitCode;
|
|
4686
|
+
try {
|
|
4687
|
+
exitCode = await runCliCore(argv, dependencies, wiring.env);
|
|
4688
|
+
} catch (error) {
|
|
4689
|
+
await wiring.finish("failed").catch(() => void 0);
|
|
4690
|
+
throw error;
|
|
4691
|
+
}
|
|
4692
|
+
try {
|
|
4693
|
+
const summary = await wiring.finish(exitCode === 0 ? "completed" : "failed");
|
|
4694
|
+
if (summary.mode === "replay") {
|
|
4695
|
+
const missBlock = renderReplayMissDiagnostics(summary.misses);
|
|
4696
|
+
if (missBlock !== "") dependencies.writeStderr(missBlock);
|
|
4697
|
+
}
|
|
4698
|
+
const notice = externalSessionNotice(summary);
|
|
4699
|
+
if (notice !== void 0) dependencies.writeStderr(notice);
|
|
4700
|
+
} catch (error) {
|
|
4701
|
+
return writeFailure(dependencies, {
|
|
4702
|
+
code: "EXTERNAL_SESSION_FAILED",
|
|
4703
|
+
message: "External 세션을 닫지 못했습니다.",
|
|
4704
|
+
hint: error instanceof Error ? error.message : String(error)
|
|
4705
|
+
});
|
|
4706
|
+
}
|
|
4707
|
+
return exitCode;
|
|
4708
|
+
}
|
|
4709
|
+
/**
|
|
4710
|
+
* argv 에서 External 모드를 정한다. **파싱은 `parseTestCommand` 하나만 한다.**
|
|
4711
|
+
*
|
|
4712
|
+
* 한때 여기서 argv 를 따로 훑었다. 그러면 토큰 소비 규칙이 두 벌이 되고, 두 벌은 갈라진다.
|
|
4713
|
+
* 실제로 갈렸다 — `--arg` 는 하이픈으로 시작하는 값을 의도적으로 받으므로
|
|
4714
|
+
*
|
|
4715
|
+
* mcpeak test s.json --command node --arg --session=/tmp/x
|
|
4716
|
+
*
|
|
4717
|
+
* 에서 `parseTestCommand` 는 그 토큰을 **서버 인자**로 소비하는데, 따로 훑는 쪽은 같은 것을
|
|
4718
|
+
* replay 지시로 읽었다. 사용자가 요청한 적 없는 세션 파일이 열리고 Bootstrap 이 주입되며,
|
|
4719
|
+
* Replay 라 서버의 외부 호출이 전부 실패한다. 원인을 짐작할 방법이 없는 실패다.
|
|
4720
|
+
*
|
|
4721
|
+
* 파싱 오류는 여기서 삼킨다. 배선은 파싱보다 먼저 서지만 **오류 보고는 기존 경로의 몫**이다.
|
|
4722
|
+
* 여기서 던지면 `--session` 이 붙었다는 이유만으로 오류 문구가 달라진다.
|
|
4723
|
+
*/
|
|
4724
|
+
function externalModeOf(argv) {
|
|
4725
|
+
if (argv[0] !== "test") return void 0;
|
|
4726
|
+
let input;
|
|
4727
|
+
try {
|
|
4728
|
+
input = parseTestCommand(argv.slice(1));
|
|
4729
|
+
} catch {
|
|
4730
|
+
return;
|
|
4731
|
+
}
|
|
4732
|
+
if (input.recordSessionPath !== void 0) return {
|
|
4733
|
+
mode: "record",
|
|
4734
|
+
path: input.recordSessionPath
|
|
4735
|
+
};
|
|
4736
|
+
if (input.sessionPath !== void 0) return {
|
|
4737
|
+
mode: "replay",
|
|
4738
|
+
path: input.sessionPath
|
|
4739
|
+
};
|
|
4740
|
+
}
|
|
4492
4741
|
//#endregion
|
|
4493
4742
|
export { runReplayCommand as a, nodeGenerateDependencies as c, GLOBAL_HELP as d, VERIFY_USAGE_HINT as f, parseReplayCommand as i, nodeReviewIO as l, version as m, runCli as n, parseRepairCommand as o, commandHelp as p, ReplayRuntimeUnavailableError as r, runRepairCommand as s, parseTestCommand as t, runGenerateCommand as u };
|
package/package.json
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcpeak/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "MCPeak 프레임워크의 CLI 진입점 (얇게 유지)",
|
|
7
7
|
"license": "MIT",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.18.0"
|
|
10
|
+
},
|
|
8
11
|
"repository": {
|
|
9
12
|
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/2026-Engineering-Contest/
|
|
13
|
+
"url": "git+https://github.com/2026-Engineering-Contest/MCPeak.git",
|
|
11
14
|
"directory": "packages/cli"
|
|
12
15
|
},
|
|
13
|
-
"homepage": "https://github.com/2026-Engineering-Contest/
|
|
14
|
-
"bugs": "https://github.com/2026-Engineering-Contest/
|
|
16
|
+
"homepage": "https://github.com/2026-Engineering-Contest/MCPeak#readme",
|
|
17
|
+
"bugs": "https://github.com/2026-Engineering-Contest/MCPeak/issues",
|
|
15
18
|
"publishConfig": {
|
|
16
19
|
"access": "public"
|
|
17
20
|
},
|
|
@@ -47,11 +50,11 @@
|
|
|
47
50
|
"dist"
|
|
48
51
|
],
|
|
49
52
|
"dependencies": {
|
|
50
|
-
"@mcpeak/core": "0.
|
|
51
|
-
"@mcpeak/runner": "0.
|
|
52
|
-
"@mcpeak/generate": "0.
|
|
53
|
-
"@mcpeak/record": "0.
|
|
54
|
-
"@mcpeak/mock": "0.
|
|
53
|
+
"@mcpeak/core": "0.4.0",
|
|
54
|
+
"@mcpeak/runner": "0.9.0",
|
|
55
|
+
"@mcpeak/generate": "0.6.0",
|
|
56
|
+
"@mcpeak/record": "0.3.0",
|
|
57
|
+
"@mcpeak/mock": "0.4.0"
|
|
55
58
|
},
|
|
56
59
|
"scripts": {
|
|
57
60
|
"build": "tsdown --config-loader native",
|