dsh-api-runner 0.2.0 → 0.3.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/test/client.mjs CHANGED
@@ -43,8 +43,10 @@ const windowStub = {
43
43
  confirm: () => true,
44
44
  document: {
45
45
  getElementById: () => null,
46
- createElement: () => ({ set textContent(v) {}, id: '' }),
46
+ querySelectorAll: () => [],
47
+ createElement: () => ({ set textContent(v) {}, id: '', remove() {} }),
47
48
  head: { appendChild: () => {} },
49
+ documentElement: { appendChild: () => {} },
48
50
  },
49
51
  };
50
52
  // bundle 里会读 document / window,通过函数参数作用域注入
@@ -148,6 +150,105 @@ const rightPanel = byName('sidebar.right.pane.tab')[0].render({ sessionId: 'sess
148
150
  assert.ok(rightPanel, '右侧栏页签正文应返回元素');
149
151
  assert.equal(typeof rightPanel.type, 'function');
150
152
 
153
+ // 数据库子页:结构断言(直接扫源码,避免为渲染造一堆桩)
154
+ assert.ok(/id: 'db', label: '数据库'/.test(source), "面板 tabs 应包含「数据库」子页");
155
+ assert.ok(/tab === 'db' \? dbPane : null/.test(source), '应渲染 dbPane 分支');
156
+ assert.ok(/function EditDbForm/.test(source), '应有数据库连接编辑表单');
157
+ assert.ok(/function DbResultCard/.test(source), '应有 SQL 结果卡片');
158
+ assert.ok(/function TableDetail/.test(source), '应有表结构视图');
159
+ assert.ok(/type: 'password'/.test(source), '密码框必须是 password 类型,不能明文显示');
160
+ assert.ok(/只读模式(拒绝写操作)/.test(source), '应有只读模式开关');
161
+ assert.ok(/db\/query/.test(source), 'SQL 执行应走 db/query 路由');
162
+
163
+ // UI 层级:顶部页签用 ar-tabs,一级标题用独立胶囊 ar-segmented(参照宿主插件详情页)
164
+ assert.ok(/className: 'ar-tabs'/.test(source), '顶部应有页签容器 ar-tabs');
165
+ assert.ok(/className: 'ar-segmented'/.test(source), '一级标题应是小胶囊容器 ar-segmented');
166
+ assert.ok(/'ar-seg on' : 'ar-seg'/.test(source), '胶囊选中态应有独立样式');
167
+ assert.ok(/label: '连接'/.test(source) && /label: 'SQL'/.test(source) && /label: '表结构'/.test(source),
168
+ '数据库子页应有「连接 / SQL / 表结构」三个一级标题');
169
+ assert.ok(/const \[dbSection, setDbSection\]/.test(source), '数据库子页需要 dbSection 状态');
170
+ assert.ok(/\.ar-segmented \{/.test(source), '缺少 .ar-segmented 样式');
171
+ assert.ok(/--ar-elevated:/.test(source), '选中态需要 --ar-elevated 变量(深/浅色主题各一份)');
172
+
173
+ /**
174
+ * 布局回归:一级标题胶囊必须横向一排。
175
+ * 曾用 flex-wrap: wrap,结果在窄面板(右侧栏 ~300px)里被挤成竖排,
176
+ * 用户看到的就是「连接 / SQL / 表结构」三个上下堆叠的方块。
177
+ */
178
+ assert.ok(/\.ar-segmented \{[^}]*flex-wrap:\s*nowrap/s.test(source), 'ar-segmented 必须禁止换行');
179
+ assert.ok(/\.ar-segmented \{[^}]*overflow-x:\s*auto/s.test(source), 'ar-segmented 超出时应横向滚动');
180
+ assert.ok(/\.ar-seg \{[^}]*flex:\s*0 0 auto/s.test(source), '胶囊不应被压缩');
181
+ assert.ok(/\.ar-seg \{[^}]*white-space:\s*nowrap/s.test(source), '胶囊文字不应换行');
182
+ assert.ok(!/\.ar-segmented \{[^}]*flex-wrap:\s*wrap/s.test(source), 'ar-segmented 不能用 flex-wrap: wrap(会竖排)');
183
+ assert.ok(/\.ar-tabs \{[^}]*overflow-x:\s*auto/s.test(source), '顶部页签超出时应横向滚动');
184
+ assert.ok(/scrollIntoView/.test(source), '选中的胶囊应自动滚入视野');
185
+
186
+ /**
187
+ * 样式热更新回归:installStyles 不能因为「元素已存在」就跳过,
188
+ * 否则插件升级后页面里还是旧 CSS(胶囊退化成原生 button、竖排),刷新也无效。
189
+ */
190
+ assert.ok(/style\[id\^="dsh-api-runner-style"\]/.test(source), '应清理历史版本的 style 元素');
191
+ assert.ok(/el\.textContent !== CSS/.test(source), '样式内容变化时应替换,而不是跳过');
192
+ assert.ok(!/if \(document\.getElementById\(STYLE_ID\)\) return;/.test(source), '不能因 id 已存在就提前返回');
193
+ assert.ok(/const STYLE_ID = '[^']*-v\d+'/.test(source), 'style id 应带版本后缀,避免被按 id 去重');
194
+
195
+ /**
196
+ * 回归:useEffect 的依赖数组里引用了「在它之后才 const 声明」的变量,
197
+ * 会因暂时性死区抛 `Cannot access 'x' before initialization`,
198
+ * 整个 Panel 崩溃 -> 面板白屏。这个 bug 真实发生过(refreshDb)。
199
+ *
200
+ * 做法:把 Panel 函数体抽出来,逐行累积「已声明的标识符」,
201
+ * 遇到 `}, [...])` 形式的依赖数组就检查里面的名字是否都已声明。
202
+ */
203
+ const panelStart = source.indexOf('function Panel(props) {');
204
+ assert.ok(panelStart > 0, '应能定位 Panel 函数');
205
+ let depth = 0;
206
+ let panelEnd = -1;
207
+ for (let i = source.indexOf('{', panelStart); i < source.length; i++) {
208
+ if (source[i] === '{') depth += 1;
209
+ else if (source[i] === '}') { depth -= 1; if (depth === 0) { panelEnd = i + 1; break; } }
210
+ }
211
+ const panelBody = source.slice(panelStart, panelEnd);
212
+ const panelLines = panelBody.split('\n');
213
+ const declaredBefore = new Set();
214
+ const tdzIssues = [];
215
+ /** 全局可用的名字,不需要在 Panel 内声明 */
216
+ const GLOBALS = new Set(['visible', 'props', 'React', 'h', 'window', 'document', 'setInterval', 'clearInterval', 'setTimeout', 'console', 'undefined', 'null', 'true', 'false']);
217
+
218
+ for (let i = 0; i < panelLines.length; i += 1) {
219
+ const line = panelLines[i];
220
+ // 依赖数组行:`}, [a, b, c]);` 或 `}, [a, b]);`
221
+ const depMatch = line.match(/\},\s*\[([^\]]*)\]\s*\)/);
222
+ if (depMatch) {
223
+ for (const raw of depMatch[1].split(',')) {
224
+ const name = raw.trim();
225
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
226
+ if (GLOBALS.has(name)) continue;
227
+ if (!declaredBefore.has(name)) {
228
+ tdzIssues.push(`Panel 第 ${i + 1} 行:依赖 "${name}" 在该行之前未声明(TDZ 会导致面板白屏)`);
229
+ }
230
+ }
231
+ }
232
+ // 记录本行声明:const/let/var 单变量
233
+ for (const m of line.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) declaredBefore.add(m[1]);
234
+ // 记录解构声明:const [a, b] = ... / const { a, b } = ...
235
+ for (const m of line.matchAll(/\b(?:const|let|var)\s*\[([^\]]+)\]\s*=/g)) {
236
+ for (const part of m[1].split(',')) {
237
+ const name = part.trim().split(':').pop().trim();
238
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) declaredBefore.add(name);
239
+ }
240
+ }
241
+ for (const m of line.matchAll(/\b(?:const|let|var)\s*\{([^}]+)\}\s*=/g)) {
242
+ for (const part of m[1].split(',')) {
243
+ const name = part.trim().split(':').pop().trim();
244
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) declaredBefore.add(name);
245
+ }
246
+ }
247
+ // 记录函数声明
248
+ for (const m of line.matchAll(/\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/g)) declaredBefore.add(m[1]);
249
+ }
250
+ assert.deepEqual(tdzIssues, [], `useEffect 依赖里出现了「声明在之后」的变量(会导致面板白屏):\n${tdzIssues.join('\n')}`);
251
+
151
252
  // 宿主没提供 slots 时 apply 要安全退出,不能抛
152
253
  recorded.exports.apply({ effect: () => {} });
153
254
 
package/test/db.mjs ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * 数据库功能的集成测试(node test/db.mjs)。
3
+ *
4
+ * 用 Node 内置的 node:sqlite 做真实数据库,不需要装任何原生模块:
5
+ * 建表、插入、参数化查询、元数据、只读模式、多语句拦截、结果截断、
6
+ * 密码加密存储与脱敏,全部在这里断言。
7
+ *
8
+ * 不依赖 cordis,纯 node 可跑。
9
+ */
10
+ import assert from 'node:assert/strict';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath, pathToFileURL } from 'node:url';
15
+
16
+ const here = path.dirname(fileURLToPath(import.meta.url));
17
+ const root = path.resolve(here, '..');
18
+ const load = (rel) => import(pathToFileURL(path.join(root, rel)).href);
19
+
20
+ const { DbManager, splitStatements, isReadOnlySql, encryptSecret, decryptSecret } = await load('src/db.js');
21
+ const { ApiRunnerService } = await load('src/ops.js');
22
+
23
+ let passed = 0;
24
+ function ok(name, fn) {
25
+ return Promise.resolve()
26
+ .then(fn)
27
+ .then(() => {
28
+ passed += 1;
29
+ console.log(` ✓ ${name}`);
30
+ })
31
+ .catch((error) => {
32
+ console.error(` ✗ ${name}\n ${error?.stack || error}`);
33
+ process.exitCode = 1;
34
+ });
35
+ }
36
+
37
+ // ---------- 临时工作区 ----------
38
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'api-runner-db-'));
39
+ const homeDir = path.join(tmp, 'home');
40
+ fs.mkdirSync(homeDir, { recursive: true });
41
+ const dbFile = path.join(tmp, 'test.db');
42
+
43
+ const manager = new DbManager({ homeDir });
44
+
45
+ console.log('SQL 解析:');
46
+ await ok('单条语句不误判', () => {
47
+ assert.deepEqual(splitStatements('SELECT 1'), ['SELECT 1']);
48
+ assert.deepEqual(splitStatements('SELECT 1;'), ['SELECT 1'], '末尾分号不算多语句');
49
+ });
50
+
51
+ await ok('字符串字面量里的分号不算分隔符', () => {
52
+ const sql = "SELECT * FROM t WHERE note = 'a;b'";
53
+ assert.deepEqual(splitStatements(sql), [sql], '引号内的分号不应拆开');
54
+ });
55
+
56
+ await ok('真正的多语句会被识别', () => {
57
+ assert.equal(splitStatements('SELECT 1; SELECT 2').length, 2);
58
+ assert.equal(splitStatements("SELECT 1; DELETE FROM t").length, 2);
59
+ });
60
+
61
+ await ok('注释里的分号不算分隔符', () => {
62
+ assert.deepEqual(splitStatements('-- a; b\nSELECT 1'), ['-- a; b\nSELECT 1']);
63
+ assert.deepEqual(splitStatements('/* x; y */ SELECT 1'), ['/* x; y */ SELECT 1']);
64
+ });
65
+
66
+ await ok('只读判定:SELECT/WITH/PRAGMA 算读,写操作不算', () => {
67
+ assert.equal(isReadOnlySql('SELECT * FROM t'), true);
68
+ assert.equal(isReadOnlySql(' select 1'), true);
69
+ assert.equal(isReadOnlySql('WITH x AS (SELECT 1) SELECT * FROM x'), true);
70
+ assert.equal(isReadOnlySql('PRAGMA table_info(t)'), true);
71
+ assert.equal(isReadOnlySql('INSERT INTO t VALUES (1)'), false);
72
+ assert.equal(isReadOnlySql('UPDATE t SET a = 1'), false);
73
+ assert.equal(isReadOnlySql('DELETE FROM t'), false);
74
+ assert.equal(isReadOnlySql('DROP TABLE t'), false);
75
+ assert.equal(isReadOnlySql('-- 注释\nSELECT 1'), true, '前置行注释不影响判定');
76
+ });
77
+
78
+ console.log('密码加密:');
79
+ await ok('加密后不是明文,且能解回原值', () => {
80
+ const cipher = encryptSecret('p@ssw0rd');
81
+ assert.notEqual(cipher, 'p@ssw0rd');
82
+ assert.ok(cipher.startsWith('v1:'));
83
+ assert.equal(decryptSecret(cipher), 'p@ssw0rd');
84
+ });
85
+
86
+ await ok('空密码保持空串', () => {
87
+ assert.equal(encryptSecret(''), '');
88
+ assert.equal(decryptSecret(''), '');
89
+ });
90
+
91
+ await ok('损坏的密文不抛异常,返回空串', () => {
92
+ assert.equal(decryptSecret('v1:not-a-valid-base64!!!'), '');
93
+ });
94
+
95
+ console.log('连接管理:');
96
+ let connId = '';
97
+ await ok('保存 SQLite 连接,密码不落明文', () => {
98
+ const saved = manager.saveConnection({
99
+ name: '本地测试库',
100
+ type: 'sqlite',
101
+ database: dbFile,
102
+ username: 'root',
103
+ password: 'secret-pw',
104
+ });
105
+ assert.equal(saved.ok, true, JSON.stringify(saved));
106
+ connId = saved.connection.id;
107
+ assert.ok(connId, '应生成连接 id');
108
+
109
+ // 返回给外部的视图不含明文密码
110
+ assert.equal(saved.connection.hasPassword, true);
111
+ assert.equal(JSON.stringify(saved.connection).includes('secret-pw'), false, '视图里不能出现明文密码');
112
+
113
+ // 落盘文件里也不能有明文
114
+ const raw = fs.readFileSync(path.join(homeDir, 'db-connections.json'), 'utf8');
115
+ assert.equal(raw.includes('secret-pw'), false, '落盘文件里不能有明文密码');
116
+ });
117
+
118
+ await ok('列表返回脱敏视图', () => {
119
+ const list = manager.listConnections();
120
+ assert.equal(list.length, 1);
121
+ assert.equal(list[0].name, '本地测试库');
122
+ assert.equal(JSON.stringify(list).includes('secret-pw'), false);
123
+ });
124
+
125
+ await ok('更新连接时不传密码则保留原密码', () => {
126
+ manager.saveConnection({ id: connId, name: '改了名字', type: 'sqlite', database: dbFile });
127
+ const full = manager._fullConfig(connId);
128
+ assert.equal(full.password, 'secret-pw', '编辑时不传密码不应清空');
129
+ assert.equal(manager.listConnections()[0].name, '改了名字');
130
+ });
131
+
132
+ await ok('缺少必填项时给出明确错误', () => {
133
+ const noName = manager.saveConnection({ type: 'sqlite', database: dbFile });
134
+ assert.equal(noName.ok, false);
135
+ assert.equal(noName.code, 'MISSING_NAME');
136
+
137
+ const noDb = manager.saveConnection({ name: 'x', type: 'sqlite' });
138
+ assert.equal(noDb.ok, false);
139
+ assert.equal(noDb.code, 'MISSING_DATABASE');
140
+
141
+ const noHost = manager.saveConnection({ name: 'y', type: 'mysql' });
142
+ assert.equal(noHost.ok, false);
143
+ assert.equal(noHost.code, 'MISSING_HOST');
144
+
145
+ const badType = manager.saveConnection({ name: 'z', type: 'mongodb' });
146
+ assert.equal(badType.ok, false);
147
+ assert.equal(badType.code, 'BAD_TYPE');
148
+ });
149
+
150
+ await ok('支持达梦(dm)类型,默认端口 5236', () => {
151
+ const saved = manager.saveConnection({
152
+ name: '达梦测试',
153
+ type: 'dm',
154
+ host: '127.0.0.1',
155
+ username: 'SYSDBA',
156
+ password: 'pw',
157
+ });
158
+ assert.equal(saved.ok, true, JSON.stringify(saved));
159
+ assert.equal(saved.connection.type, 'dm');
160
+ assert.equal(saved.connection.port, 5236, '达梦默认端口应是 5236');
161
+ manager.deleteConnection(saved.connection.id);
162
+ });
163
+
164
+ await ok('DB_TYPES 包含 dm 与 oracle', async () => {
165
+ const { DB_TYPES } = await load('src/db.js');
166
+ assert.ok(DB_TYPES.includes('dm'), '应支持达梦');
167
+ assert.ok(DB_TYPES.includes('oracle'));
168
+ assert.ok(DB_TYPES.includes('sqlite'));
169
+ assert.ok(DB_TYPES.includes('mysql'));
170
+ assert.ok(DB_TYPES.includes('postgres'));
171
+ });
172
+
173
+ await ok('缺少 dmdb 驱动时给出可操作的提示,不崩', async () => {
174
+ const saved = manager.saveConnection({
175
+ name: '达梦无驱动',
176
+ type: 'dm',
177
+ host: '127.0.0.1',
178
+ port: 5236,
179
+ username: 'SYSDBA',
180
+ });
181
+ assert.equal(saved.ok, true);
182
+ // 本机可能装了 dmdb 也可能没装;两种情况都必须是「结构化失败」而不是抛异常
183
+ const result = await manager.query(saved.connection.id, 'SELECT 1', [], {});
184
+ if (result.ok === false) {
185
+ assert.ok(['CONNECT_FAILED', 'DB_ERROR'].includes(result.code), JSON.stringify(result));
186
+ assert.equal(typeof result.message, 'string');
187
+ assert.ok(result.message.length > 0);
188
+ // 提示里不能出现明文密码
189
+ assert.equal(result.message.includes('pw'), false, '错误信息不应泄漏密码');
190
+ }
191
+ manager.deleteConnection(saved.connection.id);
192
+ });
193
+
194
+ console.log('连接与执行:');
195
+ await ok('testConnection 能试连成功', async () => {
196
+ const result = await manager.testConnection(connId);
197
+ assert.equal(result.ok, true, JSON.stringify(result));
198
+ assert.equal(typeof result.durationMs, 'number');
199
+ });
200
+
201
+ await ok('建表、插入、参数化查询', async () => {
202
+ const create = await manager.query(connId, 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)', [], { readonly: false });
203
+ assert.equal(create.ok, true, JSON.stringify(create));
204
+
205
+ const ins = await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', ['alice', 30], { readonly: false });
206
+ assert.equal(ins.ok, true, JSON.stringify(ins));
207
+ assert.equal(ins.affectedRows, 1);
208
+
209
+ await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', ['bob', 25], { readonly: false });
210
+
211
+ const sel = await manager.query(connId, 'SELECT * FROM users ORDER BY id', [], {});
212
+ assert.equal(sel.ok, true, JSON.stringify(sel));
213
+ assert.deepEqual(sel.columns, ['id', 'name', 'age']);
214
+ assert.equal(sel.rowCount, 2);
215
+ assert.equal(sel.rows[0].name, 'alice');
216
+ assert.equal(sel.rows[1].age, 25);
217
+ });
218
+
219
+ await ok('参数化能防注入(值里的引号不生效)', async () => {
220
+ const evil = "x' OR '1'='1";
221
+ const ins = await manager.query(connId, 'INSERT INTO users (name, age) VALUES (?, ?)', [evil, 1], { readonly: false });
222
+ assert.equal(ins.ok, true);
223
+ const sel = await manager.query(connId, 'SELECT * FROM users WHERE name = ?', [evil], {});
224
+ assert.equal(sel.rowCount, 1, '应精确匹配那个字面值,而不是被 OR 恒真放大');
225
+ assert.equal(sel.rows[0].name, evil);
226
+ });
227
+
228
+ await ok('只读模式拒绝写操作', async () => {
229
+ const del = await manager.query(connId, 'DELETE FROM users', [], { readonly: true });
230
+ assert.equal(del.ok, false);
231
+ assert.equal(del.code, 'READONLY_VIOLATION');
232
+
233
+ // 确认数据没被删
234
+ const count = await manager.query(connId, 'SELECT COUNT(*) AS n FROM users', [], {});
235
+ assert.equal(count.rows[0].n, 3);
236
+ });
237
+
238
+ await ok('多语句被拦截', async () => {
239
+ const multi = await manager.query(connId, 'SELECT 1; DELETE FROM users', [], { readonly: false });
240
+ assert.equal(multi.ok, false);
241
+ assert.equal(multi.code, 'MULTI_STATEMENT');
242
+ });
243
+
244
+ await ok('空 SQL 给出明确错误', async () => {
245
+ const empty = await manager.query(connId, ' ', [], {});
246
+ assert.equal(empty.ok, false);
247
+ assert.equal(empty.code, 'EMPTY_SQL');
248
+ });
249
+
250
+ await ok('语法错误转成 DB_ERROR,不抛异常', async () => {
251
+ const bad = await manager.query(connId, 'SELEC * FROM users', [], {});
252
+ assert.equal(bad.ok, false);
253
+ assert.equal(bad.code, 'DB_ERROR');
254
+ assert.equal(typeof bad.message, 'string');
255
+ assert.ok(bad.message.length > 0);
256
+ });
257
+
258
+ await ok('不存在的连接给出 NOT_FOUND', async () => {
259
+ const result = await manager.query('no-such-conn', 'SELECT 1', [], {});
260
+ assert.equal(result.ok, false);
261
+ assert.equal(result.code, 'NOT_FOUND');
262
+ });
263
+
264
+ await ok('结果集按上限截断并提示', async () => {
265
+ const many = await manager.query(connId, 'SELECT * FROM users', [], { maxRows: 1 });
266
+ assert.equal(many.ok, true);
267
+ assert.equal(many.rows.length, 1, '应只返回 1 行');
268
+ assert.equal(many.rowCount, 3, 'rowCount 仍是真实总数');
269
+ assert.equal(many.truncated, true);
270
+ assert.match(many.note, /已截断/);
271
+ });
272
+
273
+ console.log('元数据:');
274
+ await ok('listTables 能列出表', async () => {
275
+ const result = await manager.listTables(connId);
276
+ assert.equal(result.ok, true, JSON.stringify(result));
277
+ const names = result.tables.map((t) => t.name);
278
+ assert.ok(names.includes('users'), `表清单应包含 users,实际:${names.join(',')}`);
279
+ });
280
+
281
+ await ok('describeTable 返回字段与索引', async () => {
282
+ await manager.query(connId, 'CREATE INDEX idx_users_name ON users(name)', [], { readonly: false });
283
+ const result = await manager.describeTable(connId, 'users');
284
+ assert.equal(result.ok, true, JSON.stringify(result));
285
+ assert.equal(result.table, 'users');
286
+ const byName = new Map(result.columns.map((c) => [c.name, c]));
287
+ assert.ok(byName.has('id'));
288
+ assert.equal(byName.get('id').primaryKey, true);
289
+ assert.equal(byName.get('name').type, 'TEXT');
290
+ assert.ok(result.indexes.some((i) => i.name === 'idx_users_name'), `应有索引,实际:${JSON.stringify(result.indexes)}`);
291
+ });
292
+
293
+ await ok('describeTable 对不存在的表返回空字段而不是崩', async () => {
294
+ const result = await manager.describeTable(connId, 'no_such_table');
295
+ assert.equal(result.ok, true);
296
+ assert.equal(result.columns.length, 0);
297
+ });
298
+
299
+ console.log('ApiRunnerService 集成:');
300
+ await ok('service 暴露数据库方法,且 dispose 关闭连接池', async () => {
301
+ const service = new ApiRunnerService({ homeDir });
302
+ const saved = service.dbSave({ name: '服务层库', type: 'sqlite', database: dbFile });
303
+ assert.equal(saved.ok, true, JSON.stringify(saved));
304
+
305
+ const list = service.dbConnections();
306
+ assert.equal(list.ok, true);
307
+ assert.ok(list.connections.some((c) => c.name === '服务层库'));
308
+
309
+ const queried = await service.dbQuery(saved.connection.id, 'SELECT 1 AS one', [], {});
310
+ assert.equal(queried.ok, true);
311
+ assert.equal(queried.rows[0].one, 1);
312
+
313
+ service.dispose();
314
+ });
315
+
316
+ await ok('service 在数据库模块缺失时给出 NO_DB', async () => {
317
+ const service = new ApiRunnerService({ homeDir });
318
+ service.dbManager = undefined;
319
+ const result = service.dbConnections();
320
+ assert.equal(result.ok, false);
321
+ assert.equal(result.code, 'NO_DB');
322
+ });
323
+
324
+ console.log('清理:');
325
+ await ok('删除连接后不再出现在列表里', () => {
326
+ // 前面「服务层库」是另一个 DbManager 实例写的,同一文件里还在;
327
+ // 这里先把它清掉,只针对本实例创建的 connId 做断言
328
+ for (const item of manager.listConnections()) manager.deleteConnection(item.id);
329
+ assert.equal(manager.listConnections().length, 0);
330
+
331
+ // 再保存一个,验证删除语义
332
+ const saved = manager.saveConnection({ name: '待删除', type: 'sqlite', database: dbFile });
333
+ assert.equal(saved.ok, true);
334
+ const del = manager.deleteConnection(saved.connection.id);
335
+ assert.equal(del.ok, true);
336
+ assert.equal(manager.listConnections().length, 0, '删除后不应再出现在列表里');
337
+
338
+ const again = manager.deleteConnection(saved.connection.id);
339
+ assert.equal(again.ok, false);
340
+ assert.equal(again.code, 'NOT_FOUND');
341
+ });
342
+
343
+ manager.dispose();
344
+ fs.rmSync(tmp, { recursive: true, force: true });
345
+
346
+ console.log(`\n${process.exitCode ? 'FAILED' : 'PASSED'}: ${passed} checks`);
package/test/plugin.mjs CHANGED
@@ -438,6 +438,129 @@ await ok('工具 action 里有 login 与 java', () => {
438
438
  assert.ok(tool.parameters.properties.java_ids, '应有 java_ids 参数');
439
439
  });
440
440
 
441
+ await ok('工具支持 db action 与相关参数', () => {
442
+ const tool = registered.tools[0];
443
+ const props = tool.parameters.properties;
444
+ assert.ok(props.action.enum.includes('db'), 'action 应包含 db');
445
+ assert.ok(props.db_action, '应有 db_action 参数');
446
+ for (const action of ['list_connections', 'test', 'save', 'delete', 'query', 'list_tables', 'describe_table']) {
447
+ assert.ok(props.db_action.enum.includes(action), `db_action 应包含 ${action}`);
448
+ }
449
+ assert.ok(props.connection_id, '应有 connection_id');
450
+ assert.ok(props.sql, '应有 sql');
451
+ assert.ok(props.db_params, '应有 db_params');
452
+ assert.ok(props.db_readonly, '应有 db_readonly');
453
+ });
454
+
455
+ await ok('db 路由都已挂载', async () => {
456
+ const route = registered.routes[0];
457
+ const get = (path) => ({
458
+ url: path,
459
+ method: 'GET',
460
+ headers: {},
461
+ async *[Symbol.asyncIterator]() {},
462
+ });
463
+ const post = (path, payload) => ({
464
+ url: path,
465
+ method: 'POST',
466
+ headers: {},
467
+ async *[Symbol.asyncIterator]() {
468
+ yield Buffer.from(JSON.stringify(payload), 'utf8');
469
+ },
470
+ });
471
+
472
+ // 连接列表:空环境应返回空数组而不是 404
473
+ const list = createMockResponse();
474
+ await route.handler(get('/dsh-api-runner/api/db'), list);
475
+ const listPayload = JSON.parse(list.body);
476
+ assert.equal(listPayload.ok, true, list.body);
477
+ assert.ok(Array.isArray(listPayload.connections));
478
+
479
+ // 缺参数时给出明确错误,不是 404
480
+ const noId = createMockResponse();
481
+ await route.handler(post('/dsh-api-runner/api/db/query', { sql: 'SELECT 1' }), noId);
482
+ const noIdPayload = JSON.parse(noId.body);
483
+ assert.equal(noIdPayload.ok, false, noId.body);
484
+ assert.equal(noIdPayload.code, 'NOT_FOUND', '空 connection_id 应走 NOT_FOUND 而不是路由缺失');
485
+
486
+ // 表结构路由存在
487
+ const table = createMockResponse();
488
+ await route.handler(get('/dsh-api-runner/api/db/table?connection_id=nope&name=t'), table);
489
+ const tablePayload = JSON.parse(table.body);
490
+ assert.equal(tablePayload.ok, false, table.body);
491
+ assert.equal(tablePayload.code, 'NOT_FOUND');
492
+ });
493
+
494
+ await ok('db 工具动作能跑通 SQLite 全链路', async () => {
495
+ const tool = registered.tools[0];
496
+ const dbFile = path.join(tmp, 'tool-test.db');
497
+ const exec = (args) => tool.execute(args, { agent: { session: { id: 's1' } } });
498
+
499
+ // 保存连接
500
+ const saved = await exec({
501
+ action: 'db',
502
+ db_action: 'save',
503
+ connection: { name: '工具测试库', type: 'sqlite', database: dbFile },
504
+ });
505
+ assert.equal(saved.isError, false, saved.text);
506
+ const savedPayload = JSON.parse(saved.text);
507
+ assert.equal(savedPayload.ok, true, saved.text);
508
+ const connId = savedPayload.connection.id;
509
+ assert.ok(connId);
510
+ assert.equal(saved.text.includes('secret'), false, '输出里不应有密码字段');
511
+
512
+ // 建表(只读关闭)
513
+ const created = await exec({
514
+ action: 'db', db_action: 'query', connection_id: connId,
515
+ sql: 'CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT)',
516
+ db_readonly: false,
517
+ });
518
+ assert.equal(JSON.parse(created.text).ok, true, created.text);
519
+
520
+ // 写入
521
+ const inserted = await exec({
522
+ action: 'db', db_action: 'query', connection_id: connId,
523
+ sql: 'INSERT INTO items (label) VALUES (?)',
524
+ db_params: ['hello'],
525
+ db_readonly: false,
526
+ });
527
+ assert.equal(JSON.parse(inserted.text).ok, true, inserted.text);
528
+
529
+ // 只读查询
530
+ const queried = await exec({
531
+ action: 'db', db_action: 'query', connection_id: connId,
532
+ sql: 'SELECT * FROM items',
533
+ });
534
+ const q = JSON.parse(queried.text);
535
+ assert.equal(q.ok, true, queried.text);
536
+ assert.equal(q.rowCount, 1);
537
+ assert.equal(q.rows[0].label, 'hello');
538
+
539
+ // 默认只读:写操作被拒
540
+ const blocked = await exec({
541
+ action: 'db', db_action: 'query', connection_id: connId,
542
+ sql: 'DELETE FROM items',
543
+ });
544
+ const b = JSON.parse(blocked.text);
545
+ assert.equal(b.ok, false, blocked.text);
546
+ assert.equal(b.code, 'READONLY_VIOLATION');
547
+ assert.equal(blocked.isError, true, '失败时 isError 应为 true');
548
+
549
+ // 元数据
550
+ const tables = await exec({ action: 'db', db_action: 'list_tables', connection_id: connId });
551
+ const t = JSON.parse(tables.text);
552
+ assert.equal(t.ok, true, tables.text);
553
+ assert.ok(t.tables.some((x) => x.name === 'items'));
554
+
555
+ const detail = await exec({ action: 'db', db_action: 'describe_table', connection_id: connId, table: 'items' });
556
+ const d = JSON.parse(detail.text);
557
+ assert.equal(d.ok, true, detail.text);
558
+ assert.ok(d.columns.some((c) => c.name === 'label'));
559
+
560
+ // 清理
561
+ await exec({ action: 'db', db_action: 'delete', connection_id: connId });
562
+ });
563
+
441
564
  // ---- 清理 ----
442
565
  for (const effect of registered.effects) effect.dispose?.();
443
566
  server.close();