@team-semicolon/semo-cli 4.3.0 → 4.4.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/dist/commands/audit.d.ts +12 -4
- package/dist/commands/audit.js +219 -27
- package/dist/commands/bots.js +60 -37
- package/dist/commands/context.d.ts +2 -2
- package/dist/commands/context.js +11 -30
- package/dist/commands/get.js +31 -17
- package/dist/commands/memory.js +10 -8
- package/dist/commands/skill-sync.js +1 -1
- package/dist/commands/test.d.ts +11 -0
- package/dist/commands/test.js +520 -0
- package/dist/database.js +2 -2
- package/dist/index.js +333 -103
- package/dist/kb.d.ts +69 -0
- package/dist/kb.js +265 -16
- package/dist/slack-notify.d.ts +8 -0
- package/dist/slack-notify.js +45 -0
- package/dist/test-runners/workspace-audit.d.ts +17 -0
- package/dist/test-runners/workspace-audit.js +366 -0
- package/package.json +1 -1
package/dist/commands/get.js
CHANGED
|
@@ -51,11 +51,11 @@ function registerGetCommands(program) {
|
|
|
51
51
|
// ── semo get projects ───────────────────────────────────────
|
|
52
52
|
getCmd
|
|
53
53
|
.command("projects")
|
|
54
|
-
.description("
|
|
55
|
-
.option("--active", "활성
|
|
54
|
+
.description("서비스 인스턴스 목록 조회 (온톨로지 기반)")
|
|
55
|
+
.option("--active", "활성 서비스만 (status='active')")
|
|
56
56
|
.option("--format <type>", "출력 형식 (table|json|md)", "table")
|
|
57
57
|
.action(async (options) => {
|
|
58
|
-
const spinner = (0, ora_1.default)("
|
|
58
|
+
const spinner = (0, ora_1.default)("서비스 인스턴스 조회 중...").start();
|
|
59
59
|
const connected = await (0, database_1.isDbConnected)();
|
|
60
60
|
if (!connected) {
|
|
61
61
|
spinner.fail("DB 연결 실패");
|
|
@@ -64,29 +64,43 @@ function registerGetCommands(program) {
|
|
|
64
64
|
}
|
|
65
65
|
try {
|
|
66
66
|
const pool = (0, database_1.getPool)();
|
|
67
|
-
|
|
67
|
+
const client = await pool.connect();
|
|
68
|
+
// Query service instances from ontology + their status from KB
|
|
69
|
+
const result = await client.query(`
|
|
70
|
+
SELECT o.domain, o.description,
|
|
71
|
+
ks.content as status,
|
|
72
|
+
(SELECT COUNT(*)::int FROM semo.knowledge_base k WHERE k.domain = o.domain) as entry_count,
|
|
73
|
+
(SELECT k2.content FROM semo.knowledge_base k2 WHERE k2.domain = o.domain AND k2.key = 'po' LIMIT 1) as po
|
|
74
|
+
FROM semo.ontology o
|
|
75
|
+
LEFT JOIN semo.knowledge_base ks ON ks.domain = o.domain AND ks.key = 'status'
|
|
76
|
+
WHERE o.entity_type = 'service'
|
|
77
|
+
ORDER BY o.domain
|
|
78
|
+
`);
|
|
79
|
+
client.release();
|
|
80
|
+
let rows = result.rows;
|
|
68
81
|
if (options.active) {
|
|
69
|
-
|
|
70
|
-
const meta = e.metadata;
|
|
71
|
-
return meta?.status === "active";
|
|
72
|
-
});
|
|
82
|
+
rows = rows.filter((r) => r.status === "active");
|
|
73
83
|
}
|
|
74
84
|
spinner.stop();
|
|
75
85
|
if (options.format === "json") {
|
|
76
|
-
console.log(JSON.stringify(
|
|
86
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
77
87
|
}
|
|
78
88
|
else if (options.format === "md") {
|
|
79
|
-
for (const
|
|
80
|
-
console.log(`\n## ${
|
|
81
|
-
console.log(
|
|
89
|
+
for (const r of rows) {
|
|
90
|
+
console.log(`\n## ${r.domain}\n`);
|
|
91
|
+
console.log(`상태: ${r.status || "-"} | 담당: ${r.po || "-"}`);
|
|
92
|
+
if (r.description)
|
|
93
|
+
console.log(r.description);
|
|
82
94
|
}
|
|
83
95
|
}
|
|
84
96
|
else {
|
|
85
|
-
printTable(["
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
printTable(["service", "status", "po", "entries", "description"], rows.map((r) => [
|
|
98
|
+
r.domain,
|
|
99
|
+
r.status || "-",
|
|
100
|
+
r.po || "-",
|
|
101
|
+
String(r.entry_count || 0),
|
|
102
|
+
(r.description || "").substring(0, 40),
|
|
103
|
+
]), "📁 서비스 인스턴스");
|
|
90
104
|
}
|
|
91
105
|
}
|
|
92
106
|
catch (err) {
|
package/dist/commands/memory.js
CHANGED
|
@@ -148,7 +148,8 @@ async function syncMemories(candidates, state, force, dryRun) {
|
|
|
148
148
|
}
|
|
149
149
|
try {
|
|
150
150
|
const domain = "memory";
|
|
151
|
-
const
|
|
151
|
+
const flatKey = "memory";
|
|
152
|
+
const subKey = stateKey; // sourceId/date
|
|
152
153
|
const metadata = {
|
|
153
154
|
source_type: candidate.sourceType,
|
|
154
155
|
source_id: candidate.sourceId,
|
|
@@ -158,19 +159,20 @@ async function syncMemories(candidates, state, force, dryRun) {
|
|
|
158
159
|
synced_at: new Date().toISOString(),
|
|
159
160
|
};
|
|
160
161
|
// Generate embedding
|
|
161
|
-
const text =
|
|
162
|
+
const text = `memory/${stateKey}: ${candidate.content}`;
|
|
162
163
|
const embedding = await (0, kb_1.generateEmbedding)(text);
|
|
163
164
|
const embeddingStr = embedding ? `[${embedding.join(",")}]` : null;
|
|
164
165
|
const client = await pool.connect();
|
|
165
166
|
try {
|
|
166
|
-
await client.query(`INSERT INTO semo.knowledge_base (domain, key, content, metadata, created_by, embedding)
|
|
167
|
-
VALUES ($1, $2, $3, $4, $5, $6::vector)
|
|
168
|
-
ON CONFLICT (domain, key) DO UPDATE SET
|
|
167
|
+
await client.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
|
|
168
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::vector)
|
|
169
|
+
ON CONFLICT (domain, key, sub_key) DO UPDATE SET
|
|
169
170
|
content = EXCLUDED.content,
|
|
170
171
|
metadata = EXCLUDED.metadata,
|
|
171
172
|
embedding = EXCLUDED.embedding`, [
|
|
172
173
|
domain,
|
|
173
|
-
|
|
174
|
+
flatKey,
|
|
175
|
+
subKey,
|
|
174
176
|
candidate.content,
|
|
175
177
|
JSON.stringify(metadata),
|
|
176
178
|
"semo-memory-sync",
|
|
@@ -273,8 +275,8 @@ function registerMemoryCommands(program) {
|
|
|
273
275
|
}
|
|
274
276
|
// Group by source
|
|
275
277
|
const grouped = {};
|
|
276
|
-
for (const [
|
|
277
|
-
const [sourceId, date] =
|
|
278
|
+
for (const [stKey, val] of entries) {
|
|
279
|
+
const [sourceId, date] = stKey.split("/");
|
|
278
280
|
if (specificBot && sourceId !== specificBot)
|
|
279
281
|
continue;
|
|
280
282
|
if (!grouped[sourceId])
|
|
@@ -88,7 +88,7 @@ function scanSkills(semoSystemDir) {
|
|
|
88
88
|
async function syncSkillsToDB(client, semoSystemDir) {
|
|
89
89
|
const skills = scanSkills(semoSystemDir);
|
|
90
90
|
for (const skill of skills) {
|
|
91
|
-
await client.query(`INSERT INTO skill_definitions (name, prompt, package, metadata, is_active, office_id)
|
|
91
|
+
await client.query(`INSERT INTO semo.skill_definitions (name, prompt, package, metadata, is_active, office_id)
|
|
92
92
|
VALUES ($1, $2, $3, $4, true, NULL)
|
|
93
93
|
ON CONFLICT (name, office_id) DO UPDATE SET
|
|
94
94
|
prompt = EXCLUDED.prompt,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semo test — 테스트 관리
|
|
3
|
+
*
|
|
4
|
+
* semo test list — 등록된 스위트 + 최근 실행 상태
|
|
5
|
+
* semo test run [suite] — 스위트 실행 + DB 기록
|
|
6
|
+
* semo test run --all — 전체 스위트 실행
|
|
7
|
+
* semo test run --notify — 실패 시 Slack 알림
|
|
8
|
+
* semo test history [suite] — 실행 이력
|
|
9
|
+
*/
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
export declare function registerTestCommands(program: Command): void;
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* semo test — 테스트 관리
|
|
4
|
+
*
|
|
5
|
+
* semo test list — 등록된 스위트 + 최근 실행 상태
|
|
6
|
+
* semo test run [suite] — 스위트 실행 + DB 기록
|
|
7
|
+
* semo test run --all — 전체 스위트 실행
|
|
8
|
+
* semo test run --notify — 실패 시 Slack 알림
|
|
9
|
+
* semo test history [suite] — 실행 이력
|
|
10
|
+
*/
|
|
11
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
+
}
|
|
17
|
+
Object.defineProperty(o, k2, desc);
|
|
18
|
+
}) : (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
o[k2] = m[k];
|
|
21
|
+
}));
|
|
22
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
+
}) : function(o, v) {
|
|
25
|
+
o["default"] = v;
|
|
26
|
+
});
|
|
27
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
+
var ownKeys = function(o) {
|
|
29
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
+
var ar = [];
|
|
31
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
+
return ar;
|
|
33
|
+
};
|
|
34
|
+
return ownKeys(o);
|
|
35
|
+
};
|
|
36
|
+
return function (mod) {
|
|
37
|
+
if (mod && mod.__esModule) return mod;
|
|
38
|
+
var result = {};
|
|
39
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
+
__setModuleDefault(result, mod);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
45
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
|
+
};
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.registerTestCommands = registerTestCommands;
|
|
49
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
50
|
+
const ora_1 = __importDefault(require("ora"));
|
|
51
|
+
const child_process_1 = require("child_process");
|
|
52
|
+
const crypto_1 = require("crypto");
|
|
53
|
+
const path = __importStar(require("path"));
|
|
54
|
+
const os = __importStar(require("os"));
|
|
55
|
+
const database_1 = require("../database");
|
|
56
|
+
const slack_notify_1 = require("../slack-notify");
|
|
57
|
+
const workspace_audit_1 = require("../test-runners/workspace-audit");
|
|
58
|
+
// ============================================================
|
|
59
|
+
// JSONL Parser
|
|
60
|
+
// ============================================================
|
|
61
|
+
function parseTestOutputLine(line) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (!trimmed || !trimmed.startsWith("{"))
|
|
64
|
+
return null;
|
|
65
|
+
try {
|
|
66
|
+
const obj = JSON.parse(trimmed);
|
|
67
|
+
if (obj.type === "case" || obj.type === "summary")
|
|
68
|
+
return obj;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ============================================================
|
|
76
|
+
// Runner Path Resolution
|
|
77
|
+
// ============================================================
|
|
78
|
+
function resolveRunnerPath(runnerPath) {
|
|
79
|
+
// Expand ~ to HOME
|
|
80
|
+
if (runnerPath.startsWith("~/")) {
|
|
81
|
+
return path.join(os.homedir(), runnerPath.slice(2));
|
|
82
|
+
}
|
|
83
|
+
// Relative path → resolve from project root (cwd)
|
|
84
|
+
if (!path.isAbsolute(runnerPath)) {
|
|
85
|
+
return path.resolve(process.cwd(), runnerPath);
|
|
86
|
+
}
|
|
87
|
+
return runnerPath;
|
|
88
|
+
}
|
|
89
|
+
// ============================================================
|
|
90
|
+
// Test Suite Execution
|
|
91
|
+
// ============================================================
|
|
92
|
+
async function executeTestSuite(suite, triggeredBy) {
|
|
93
|
+
const pool = (0, database_1.getPool)();
|
|
94
|
+
const runId = (0, crypto_1.randomUUID)();
|
|
95
|
+
const startedAt = new Date();
|
|
96
|
+
// Create run record
|
|
97
|
+
await pool.query(`INSERT INTO semo.test_runs (run_id, suite_id, triggered_by, started_at, status)
|
|
98
|
+
VALUES ($1, $2, $3, $4, 'running')`, [runId, suite.suite_id, triggeredBy, startedAt.toISOString()]);
|
|
99
|
+
// ── Declarative runner: DB에서 규칙 로드 → 동적 TC 생성 ──
|
|
100
|
+
if (suite.runner_type === "declarative") {
|
|
101
|
+
return executeDeclarativeSuite(pool, suite, runId);
|
|
102
|
+
}
|
|
103
|
+
// ── Script runner: 외부 프로세스 spawn ──
|
|
104
|
+
const resolvedPath = resolveRunnerPath(suite.runner_path || "");
|
|
105
|
+
const cases = [];
|
|
106
|
+
let pass = 0;
|
|
107
|
+
let fail = 0;
|
|
108
|
+
let warn = 0;
|
|
109
|
+
const failedLabels = [];
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
let cmd;
|
|
112
|
+
let args;
|
|
113
|
+
let env;
|
|
114
|
+
if (suite.runner_type === "shell") {
|
|
115
|
+
cmd = "bash";
|
|
116
|
+
args = [resolvedPath];
|
|
117
|
+
env = { ...process.env, OUTPUT_FORMAT: "json" };
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
cmd = "npx";
|
|
121
|
+
args = ["tsx", resolvedPath, "--json"];
|
|
122
|
+
env = { ...process.env };
|
|
123
|
+
}
|
|
124
|
+
const child = (0, child_process_1.spawn)(cmd, args, {
|
|
125
|
+
cwd: process.cwd(),
|
|
126
|
+
env,
|
|
127
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
128
|
+
});
|
|
129
|
+
let buffer = "";
|
|
130
|
+
child.stdout.on("data", (chunk) => {
|
|
131
|
+
buffer += chunk.toString();
|
|
132
|
+
const lines = buffer.split("\n");
|
|
133
|
+
buffer = lines.pop() || ""; // Keep incomplete last line
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const parsed = parseTestOutputLine(line);
|
|
136
|
+
if (!parsed)
|
|
137
|
+
continue;
|
|
138
|
+
if (parsed.type === "case") {
|
|
139
|
+
cases.push(parsed);
|
|
140
|
+
if (parsed.status === "pass")
|
|
141
|
+
pass++;
|
|
142
|
+
else if (parsed.status === "fail") {
|
|
143
|
+
fail++;
|
|
144
|
+
if (parsed.label)
|
|
145
|
+
failedLabels.push(parsed.label);
|
|
146
|
+
}
|
|
147
|
+
else if (parsed.status === "warn")
|
|
148
|
+
warn++;
|
|
149
|
+
}
|
|
150
|
+
else if (parsed.type === "summary") {
|
|
151
|
+
// Use summary counts as authoritative if provided
|
|
152
|
+
if (typeof parsed.pass === "number")
|
|
153
|
+
pass = parsed.pass;
|
|
154
|
+
if (typeof parsed.fail === "number")
|
|
155
|
+
fail = parsed.fail;
|
|
156
|
+
if (typeof parsed.warn === "number")
|
|
157
|
+
warn = parsed.warn;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
child.stderr.on("data", () => {
|
|
162
|
+
/* discard stderr */
|
|
163
|
+
});
|
|
164
|
+
const timeout = setTimeout(() => {
|
|
165
|
+
child.kill("SIGTERM");
|
|
166
|
+
}, 600000); // 10 minute timeout
|
|
167
|
+
child.on("close", async (code) => {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
// Process remaining buffer
|
|
170
|
+
if (buffer.trim()) {
|
|
171
|
+
const parsed = parseTestOutputLine(buffer);
|
|
172
|
+
if (parsed?.type === "case") {
|
|
173
|
+
cases.push(parsed);
|
|
174
|
+
if (parsed.status === "pass")
|
|
175
|
+
pass++;
|
|
176
|
+
else if (parsed.status === "fail") {
|
|
177
|
+
fail++;
|
|
178
|
+
if (parsed.label)
|
|
179
|
+
failedLabels.push(parsed.label);
|
|
180
|
+
}
|
|
181
|
+
else if (parsed.status === "warn")
|
|
182
|
+
warn++;
|
|
183
|
+
}
|
|
184
|
+
else if (parsed?.type === "summary") {
|
|
185
|
+
if (typeof parsed.pass === "number")
|
|
186
|
+
pass = parsed.pass;
|
|
187
|
+
if (typeof parsed.fail === "number")
|
|
188
|
+
fail = parsed.fail;
|
|
189
|
+
if (typeof parsed.warn === "number")
|
|
190
|
+
warn = parsed.warn;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const status = code !== 0 || fail > 0 ? "failed" : cases.length === 0 ? "error" : "passed";
|
|
194
|
+
const summary = `${pass} passed, ${fail} failed, ${warn} warn`;
|
|
195
|
+
try {
|
|
196
|
+
// Insert individual results
|
|
197
|
+
for (const c of cases) {
|
|
198
|
+
if (c.type !== "case")
|
|
199
|
+
continue;
|
|
200
|
+
await pool.query(`INSERT INTO semo.test_results (run_id, case_id, suite_id, label, status, detail, duration_ms)
|
|
201
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
|
|
202
|
+
runId,
|
|
203
|
+
c.id || c.label || "unknown",
|
|
204
|
+
suite.suite_id,
|
|
205
|
+
c.label || c.id || "unknown",
|
|
206
|
+
c.status || "skip",
|
|
207
|
+
c.detail || null,
|
|
208
|
+
c.duration_ms || null,
|
|
209
|
+
]);
|
|
210
|
+
}
|
|
211
|
+
// Update run record
|
|
212
|
+
await pool.query(`UPDATE semo.test_runs
|
|
213
|
+
SET finished_at = NOW(), total_pass = $1, total_fail = $2, total_warn = $3,
|
|
214
|
+
status = $4, summary = $5
|
|
215
|
+
WHERE run_id = $6`, [pass, fail, warn, status, summary, runId]);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
console.error(chalk_1.default.red(` DB 기록 오류: ${err.message}`));
|
|
219
|
+
}
|
|
220
|
+
resolve({ runId, suiteId: suite.suite_id, status, pass, fail, warn, failedLabels });
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// ============================================================
|
|
225
|
+
// Declarative Runner
|
|
226
|
+
// ============================================================
|
|
227
|
+
async function executeDeclarativeSuite(pool, suite, runId) {
|
|
228
|
+
let outputs = [];
|
|
229
|
+
// Dispatch by rule_source
|
|
230
|
+
if (suite.rule_source === "bot_workspace_standard") {
|
|
231
|
+
outputs = await (0, workspace_audit_1.runDeclarativeWorkspaceAudit)(pool);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
return {
|
|
235
|
+
runId,
|
|
236
|
+
suiteId: suite.suite_id,
|
|
237
|
+
status: "error",
|
|
238
|
+
pass: 0,
|
|
239
|
+
fail: 0,
|
|
240
|
+
warn: 0,
|
|
241
|
+
failedLabels: [`unknown rule_source: ${suite.rule_source}`],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
let pass = 0;
|
|
245
|
+
let fail = 0;
|
|
246
|
+
let warn = 0;
|
|
247
|
+
const failedLabels = [];
|
|
248
|
+
for (const o of outputs) {
|
|
249
|
+
if (o.type === "summary") {
|
|
250
|
+
pass = o.pass ?? pass;
|
|
251
|
+
fail = o.fail ?? fail;
|
|
252
|
+
warn = o.warn ?? warn;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (o.type !== "case")
|
|
256
|
+
continue;
|
|
257
|
+
// Record to DB
|
|
258
|
+
await pool.query(`INSERT INTO semo.test_results (run_id, case_id, suite_id, label, status, detail)
|
|
259
|
+
VALUES ($1, $2, $3, $4, $5, $6)`, [
|
|
260
|
+
runId,
|
|
261
|
+
o.id || o.label || "unknown",
|
|
262
|
+
suite.suite_id,
|
|
263
|
+
o.label || o.id || "unknown",
|
|
264
|
+
o.status || "skip",
|
|
265
|
+
o.detail || null,
|
|
266
|
+
]);
|
|
267
|
+
if (o.status === "fail")
|
|
268
|
+
failedLabels.push(o.label || "");
|
|
269
|
+
}
|
|
270
|
+
const status = fail > 0 ? "failed" : "passed";
|
|
271
|
+
const summary = `${pass} passed, ${fail} failed, ${warn} warn`;
|
|
272
|
+
await pool.query(`UPDATE semo.test_runs
|
|
273
|
+
SET finished_at = NOW(), total_pass = $1, total_fail = $2, total_warn = $3,
|
|
274
|
+
status = $4, summary = $5
|
|
275
|
+
WHERE run_id = $6`, [pass, fail, warn, status, summary, runId]);
|
|
276
|
+
return { runId, suiteId: suite.suite_id, status, pass, fail, warn, failedLabels };
|
|
277
|
+
}
|
|
278
|
+
// ============================================================
|
|
279
|
+
// Commands
|
|
280
|
+
// ============================================================
|
|
281
|
+
function registerTestCommands(program) {
|
|
282
|
+
const testCmd = program
|
|
283
|
+
.command("test")
|
|
284
|
+
.description("테스트 관리 — 실행, 이력 조회");
|
|
285
|
+
// ── semo test list ──
|
|
286
|
+
testCmd
|
|
287
|
+
.command("list")
|
|
288
|
+
.description("등록된 테스트 스위트 목록 + 최근 실행 상태")
|
|
289
|
+
.action(async () => {
|
|
290
|
+
const pool = (0, database_1.getPool)();
|
|
291
|
+
try {
|
|
292
|
+
const { rows } = await pool.query(`
|
|
293
|
+
SELECT s.suite_id, s.name, s.layer, s.runner_type, s.enabled,
|
|
294
|
+
r.status AS last_status,
|
|
295
|
+
r.started_at::text AS last_run_at,
|
|
296
|
+
r.total_pass AS last_pass,
|
|
297
|
+
r.total_fail AS last_fail,
|
|
298
|
+
r.total_warn AS last_warn
|
|
299
|
+
FROM semo.test_suites s
|
|
300
|
+
LEFT JOIN LATERAL (
|
|
301
|
+
SELECT * FROM semo.test_runs
|
|
302
|
+
WHERE suite_id = s.suite_id
|
|
303
|
+
ORDER BY started_at DESC LIMIT 1
|
|
304
|
+
) r ON true
|
|
305
|
+
ORDER BY s.suite_id
|
|
306
|
+
`);
|
|
307
|
+
if (rows.length === 0) {
|
|
308
|
+
console.log(chalk_1.default.yellow("등록된 테스트 스위트가 없습니다."));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
console.log(chalk_1.default.bold("\n 테스트 스위트 목록\n"));
|
|
312
|
+
// Header
|
|
313
|
+
const hdr = padR("Suite ID", 22) +
|
|
314
|
+
padR("Layer", 14) +
|
|
315
|
+
padR("Runner", 8) +
|
|
316
|
+
padR("Last Run", 22) +
|
|
317
|
+
padR("Status", 10) +
|
|
318
|
+
"Result";
|
|
319
|
+
console.log(chalk_1.default.gray(` ${hdr}`));
|
|
320
|
+
console.log(chalk_1.default.gray(` ${"─".repeat(90)}`));
|
|
321
|
+
for (const row of rows) {
|
|
322
|
+
const statusColor = row.last_status === "passed"
|
|
323
|
+
? chalk_1.default.green
|
|
324
|
+
: row.last_status === "failed"
|
|
325
|
+
? chalk_1.default.red
|
|
326
|
+
: chalk_1.default.gray;
|
|
327
|
+
const lastRun = row.last_run_at
|
|
328
|
+
? timeAgo(new Date(row.last_run_at))
|
|
329
|
+
: "-";
|
|
330
|
+
const result = row.last_pass !== null
|
|
331
|
+
? `${row.last_pass}/${(row.last_pass || 0) + (row.last_fail || 0) + (row.last_warn || 0)}`
|
|
332
|
+
: "-";
|
|
333
|
+
console.log(` ${padR(row.suite_id, 22)}${padR(row.layer, 14)}${padR(row.runner_type, 8)}${padR(lastRun, 22)}${statusColor(padR(row.last_status || "no runs", 10))}${result}`);
|
|
334
|
+
}
|
|
335
|
+
console.log();
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
await (0, database_1.closeConnection)();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
// ── semo test run [suite] ──
|
|
342
|
+
testCmd
|
|
343
|
+
.command("run [suite]")
|
|
344
|
+
.description("테스트 스위트 실행 + DB 기록")
|
|
345
|
+
.option("--all", "비활성 포함 전체 실행")
|
|
346
|
+
.option("--notify", "실패 시 Slack 알림")
|
|
347
|
+
.option("--triggered-by <who>", "트리거 주체", "manual")
|
|
348
|
+
.action(async (suiteArg, options) => {
|
|
349
|
+
const pool = (0, database_1.getPool)();
|
|
350
|
+
try {
|
|
351
|
+
let suites;
|
|
352
|
+
if (suiteArg) {
|
|
353
|
+
// Single suite
|
|
354
|
+
const { rows } = await pool.query("SELECT * FROM semo.test_suites WHERE suite_id = $1", [suiteArg]);
|
|
355
|
+
if (rows.length === 0) {
|
|
356
|
+
console.error(chalk_1.default.red(`스위트 '${suiteArg}'를 찾을 수 없습니다.`));
|
|
357
|
+
const allSuites = await pool.query("SELECT suite_id FROM semo.test_suites");
|
|
358
|
+
console.log(chalk_1.default.gray(`등록된 스위트: ${allSuites.rows.map((r) => r.suite_id).join(", ")}`));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
suites = rows;
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
// All suites
|
|
365
|
+
const where = options.all ? "" : "WHERE enabled = true";
|
|
366
|
+
const { rows } = await pool.query(`SELECT * FROM semo.test_suites ${where} ORDER BY suite_id`);
|
|
367
|
+
suites = rows;
|
|
368
|
+
}
|
|
369
|
+
if (suites.length === 0) {
|
|
370
|
+
console.log(chalk_1.default.yellow("실행할 스위트가 없습니다."));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
console.log(chalk_1.default.bold(`\n ${suites.length}개 스위트 실행\n`));
|
|
374
|
+
const results = [];
|
|
375
|
+
for (const suite of suites) {
|
|
376
|
+
const spinner = (0, ora_1.default)(`${suite.suite_id} (${suite.name})`).start();
|
|
377
|
+
try {
|
|
378
|
+
const result = await executeTestSuite(suite, options.triggeredBy || "manual");
|
|
379
|
+
results.push(result);
|
|
380
|
+
if (result.status === "passed") {
|
|
381
|
+
spinner.succeed(`${suite.suite_id}: ${chalk_1.default.green("PASSED")} (${result.pass}/${result.pass + result.fail + result.warn})`);
|
|
382
|
+
}
|
|
383
|
+
else if (result.status === "failed") {
|
|
384
|
+
spinner.fail(`${suite.suite_id}: ${chalk_1.default.red("FAILED")} (pass: ${result.pass}, fail: ${result.fail}, warn: ${result.warn})`);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
spinner.warn(`${suite.suite_id}: ${chalk_1.default.yellow("ERROR")} (출력 파싱 실패)`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
spinner.fail(`${suite.suite_id}: ${chalk_1.default.red("ERROR")} — ${err.message}`);
|
|
392
|
+
results.push({
|
|
393
|
+
runId: "error",
|
|
394
|
+
suiteId: suite.suite_id,
|
|
395
|
+
status: "error",
|
|
396
|
+
pass: 0,
|
|
397
|
+
fail: 0,
|
|
398
|
+
warn: 0,
|
|
399
|
+
failedLabels: [],
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Summary
|
|
404
|
+
const totalPass = results.reduce((s, r) => s + r.pass, 0);
|
|
405
|
+
const totalFail = results.reduce((s, r) => s + r.fail, 0);
|
|
406
|
+
const totalWarn = results.reduce((s, r) => s + r.warn, 0);
|
|
407
|
+
const allPassed = results.every((r) => r.status === "passed");
|
|
408
|
+
console.log(`\n ${allPassed ? chalk_1.default.green("ALL PASSED") : chalk_1.default.red("FAILURES DETECTED")} — ${totalPass} pass, ${totalFail} fail, ${totalWarn} warn\n`);
|
|
409
|
+
// Slack notification
|
|
410
|
+
if (options.notify && !allPassed) {
|
|
411
|
+
const failedResults = results.filter((r) => r.status === "failed");
|
|
412
|
+
for (const fr of failedResults) {
|
|
413
|
+
const msg = (0, slack_notify_1.formatTestFailureMessage)(fr.suiteId, fr.runId, fr.pass, fr.fail, fr.warn, fr.failedLabels);
|
|
414
|
+
const sent = await (0, slack_notify_1.sendSlackNotification)(msg);
|
|
415
|
+
if (sent) {
|
|
416
|
+
console.log(chalk_1.default.gray(` Slack 알림 전송: ${fr.suiteId}`));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
await (0, database_1.closeConnection)();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
// ── semo test history [suite] ──
|
|
426
|
+
testCmd
|
|
427
|
+
.command("history [suite]")
|
|
428
|
+
.description("테스트 실행 이력 조회")
|
|
429
|
+
.option("--limit <n>", "최근 N건", "10")
|
|
430
|
+
.action(async (suiteArg, options) => {
|
|
431
|
+
const pool = (0, database_1.getPool)();
|
|
432
|
+
const limit = parseInt(options.limit) || 10;
|
|
433
|
+
try {
|
|
434
|
+
let query;
|
|
435
|
+
let params;
|
|
436
|
+
if (suiteArg) {
|
|
437
|
+
query = `
|
|
438
|
+
SELECT r.run_id, r.suite_id, s.name, r.triggered_by,
|
|
439
|
+
r.started_at::text, r.finished_at::text,
|
|
440
|
+
r.total_pass, r.total_fail, r.total_warn, r.status, r.summary
|
|
441
|
+
FROM semo.test_runs r
|
|
442
|
+
JOIN semo.test_suites s ON s.suite_id = r.suite_id
|
|
443
|
+
WHERE r.suite_id = $1
|
|
444
|
+
ORDER BY r.started_at DESC
|
|
445
|
+
LIMIT $2
|
|
446
|
+
`;
|
|
447
|
+
params = [suiteArg, limit];
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
query = `
|
|
451
|
+
SELECT r.run_id, r.suite_id, s.name, r.triggered_by,
|
|
452
|
+
r.started_at::text, r.finished_at::text,
|
|
453
|
+
r.total_pass, r.total_fail, r.total_warn, r.status, r.summary
|
|
454
|
+
FROM semo.test_runs r
|
|
455
|
+
JOIN semo.test_suites s ON s.suite_id = r.suite_id
|
|
456
|
+
ORDER BY r.started_at DESC
|
|
457
|
+
LIMIT $1
|
|
458
|
+
`;
|
|
459
|
+
params = [limit];
|
|
460
|
+
}
|
|
461
|
+
const { rows } = await pool.query(query, params);
|
|
462
|
+
if (rows.length === 0) {
|
|
463
|
+
console.log(chalk_1.default.yellow("실행 이력이 없습니다."));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
console.log(chalk_1.default.bold(`\n 실행 이력${suiteArg ? ` — ${suiteArg}` : ""}\n`));
|
|
467
|
+
const hdr = padR("Run ID", 10) +
|
|
468
|
+
padR("Suite", 22) +
|
|
469
|
+
padR("Triggered", 10) +
|
|
470
|
+
padR("Started", 22) +
|
|
471
|
+
padR("Status", 10) +
|
|
472
|
+
"Result";
|
|
473
|
+
console.log(chalk_1.default.gray(` ${hdr}`));
|
|
474
|
+
console.log(chalk_1.default.gray(` ${"─".repeat(90)}`));
|
|
475
|
+
for (const row of rows) {
|
|
476
|
+
const statusColor = row.status === "passed"
|
|
477
|
+
? chalk_1.default.green
|
|
478
|
+
: row.status === "failed"
|
|
479
|
+
? chalk_1.default.red
|
|
480
|
+
: chalk_1.default.yellow;
|
|
481
|
+
const started = row.started_at
|
|
482
|
+
? new Date(row.started_at).toLocaleString("ko-KR", {
|
|
483
|
+
timeZone: "Asia/Seoul",
|
|
484
|
+
month: "2-digit",
|
|
485
|
+
day: "2-digit",
|
|
486
|
+
hour: "2-digit",
|
|
487
|
+
minute: "2-digit",
|
|
488
|
+
})
|
|
489
|
+
: "-";
|
|
490
|
+
console.log(` ${padR(row.run_id.substring(0, 8), 10)}${padR(row.suite_id, 22)}${padR(row.triggered_by, 10)}${padR(started, 22)}${statusColor(padR(row.status, 10))}${row.summary || "-"}`);
|
|
491
|
+
}
|
|
492
|
+
console.log();
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
await (0, database_1.closeConnection)();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
// ============================================================
|
|
500
|
+
// Helpers
|
|
501
|
+
// ============================================================
|
|
502
|
+
function padR(s, len) {
|
|
503
|
+
if (s.length >= len)
|
|
504
|
+
return s.substring(0, len);
|
|
505
|
+
return s + " ".repeat(len - s.length);
|
|
506
|
+
}
|
|
507
|
+
function timeAgo(date) {
|
|
508
|
+
const now = Date.now();
|
|
509
|
+
const diff = now - date.getTime();
|
|
510
|
+
const mins = Math.floor(diff / 60000);
|
|
511
|
+
if (mins < 1)
|
|
512
|
+
return "just now";
|
|
513
|
+
if (mins < 60)
|
|
514
|
+
return `${mins}m ago`;
|
|
515
|
+
const hours = Math.floor(mins / 60);
|
|
516
|
+
if (hours < 24)
|
|
517
|
+
return `${hours}h ago`;
|
|
518
|
+
const days = Math.floor(hours / 24);
|
|
519
|
+
return `${days}d ago`;
|
|
520
|
+
}
|
package/dist/database.js
CHANGED
|
@@ -172,7 +172,7 @@ async function getActiveSkills() {
|
|
|
172
172
|
ARRAY[]::text[]
|
|
173
173
|
) AS bot_ids,
|
|
174
174
|
category, package, is_active, is_required, install_order, version
|
|
175
|
-
FROM skill_definitions
|
|
175
|
+
FROM semo.skill_definitions
|
|
176
176
|
WHERE is_active = true AND office_id IS NULL
|
|
177
177
|
ORDER BY install_order
|
|
178
178
|
`);
|
|
@@ -209,7 +209,7 @@ async function getActiveSkillsForBot(botId) {
|
|
|
209
209
|
) AS bot_ids,
|
|
210
210
|
sd.category, sd.package, sd.is_active, sd.is_required,
|
|
211
211
|
sd.install_order, sd.version
|
|
212
|
-
FROM skill_definitions sd
|
|
212
|
+
FROM semo.skill_definitions sd
|
|
213
213
|
WHERE sd.is_active = true
|
|
214
214
|
AND sd.office_id IS NULL
|
|
215
215
|
AND (NOT sd.metadata ? 'bot_ids' OR sd.metadata->'bot_ids' ? $1)
|