dsh-api-runner 0.3.0 → 0.3.1
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 +35 -4
- package/client/client.js +673 -329
- package/package.json +6 -4
- package/scripts/check-leak.mjs +150 -0
- package/src/db.js +543 -37
- package/src/main.js +77 -13
- package/src/ops.js +46 -8
- package/test/client.mjs +41 -2
- package/test/db.mjs +190 -24
- package/test/plugin.mjs +76 -50
- package/test/security.mjs +200 -0
package/src/main.js
CHANGED
|
@@ -19,23 +19,48 @@ import { ApiRunnerService, asObject, toText } from './ops.js';
|
|
|
19
19
|
import { ProjectStore } from './project-store.js';
|
|
20
20
|
import { JavaRunner } from './java-runner.js';
|
|
21
21
|
import { SettingsStore } from './settings.js';
|
|
22
|
+
import { normalizeProjectKey } from './util.js';
|
|
22
23
|
|
|
23
24
|
export const inject = ['webServer', 'sessions', 'tools', 'connection', 'systemPrompt'];
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 会话 cwd → 项目 key。数据库授权按这个 key 归档。
|
|
28
|
+
* 没有 cwd 时回落 'default',和 ProjectStore 的约定一致。
|
|
29
|
+
*/
|
|
30
|
+
function projectKeyOf(cwd) {
|
|
31
|
+
return normalizeProjectKey(String(cwd || '')) || 'default';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TOOL_DESCRIPTION = `开发联调工具(dsh-api-runner):按配置调用后端 HTTP 接口、启停本地服务、增量读取日志、直接查数据库。
|
|
26
35
|
|
|
27
36
|
典型用法(改完后端代码后自测):
|
|
28
|
-
1. action=describe ——
|
|
37
|
+
1. action=describe —— 先看有哪些接口/服务/日志/数据库可用(含每个接口需要的参数名)。
|
|
29
38
|
2. action=service + service_action=restart —— 重启本地服务(managed 模式由插件托管进程;
|
|
30
39
|
observe 模式下插件不托管,需你到 IDEA 里重启,然后用 service_action=wait 等就绪)。
|
|
31
40
|
3. action=log + pattern —— 增量读日志;或 action=wait_log 等启动完成/报错特征出现。
|
|
32
41
|
4. action=call —— 调用接口,params 里的键会替换 URL 里的 {name} 并拼进 query/body。
|
|
33
42
|
5. action=verify —— 一条命令串起「重启 → 读日志 → 调接口」,适合改完后端后自检。
|
|
43
|
+
6. action=db —— 直接查数据库:db_action=list_connections 看有哪些库;
|
|
44
|
+
list_tables / describe_table 看结构;query 执行 SQL。
|
|
45
|
+
|
|
46
|
+
安全约束(务必遵守):
|
|
47
|
+
- 只有**已被当前项目授权**的连接才会出现在 describe 里;未授权的连接查不到也调不动。
|
|
48
|
+
- 模型侧**永远只读**:只允许 SELECT / SHOW / DESCRIBE / EXPLAIN 等查询。
|
|
49
|
+
传 db_readonly=false 也不会放开写操作——写数据只能由人在面板里做。
|
|
50
|
+
- DROP / TRUNCATE / 无 WHERE 的 DELETE 等毁灭性操作一律拒绝。
|
|
51
|
+
|
|
52
|
+
什么时候该用 db:
|
|
53
|
+
- 需要确认数据是否真的写进去了(接口返回成功但不确认落库)。
|
|
54
|
+
- 排查数据问题:某条记录状态不对、关联数据缺失、字段值与预期不符。
|
|
55
|
+
- 需要先了解表结构才能写对查询时,先 describe_table 再 query。
|
|
56
|
+
遇到「数据对不对」这类问题就主动查,不用等用户提醒。
|
|
34
57
|
|
|
35
58
|
注意:
|
|
36
59
|
- 日志游标按 logId 持久化,第一次读只取文件末尾一段;要全量请传 reset=true。
|
|
37
60
|
- 服务重启会 kill 整个进程树后重新拉起(managed);失败时返回 exit code 与健康检查明细。
|
|
38
|
-
- 接口响应体默认截断到 32KB,可用 max_bytes
|
|
61
|
+
- 接口响应体默认截断到 32KB,可用 max_bytes 调整;请求头在返回里已脱敏。
|
|
62
|
+
- SQL 只允许单条语句;用 db_params 传参,不要拼接字符串(防注入)。
|
|
63
|
+
- 数据库连接是跨项目的全局配置,但**访问权按项目隔离**。`;
|
|
39
64
|
|
|
40
65
|
const PARAMETERS = {
|
|
41
66
|
type: 'object',
|
|
@@ -88,12 +113,12 @@ const PARAMETERS = {
|
|
|
88
113
|
},
|
|
89
114
|
db_readonly: {
|
|
90
115
|
type: 'boolean',
|
|
91
|
-
description: '
|
|
116
|
+
description: '(已废弃,保留仅为兼容)模型侧永远只读,传 false 也不会放开写操作。写数据只能由人在面板里操作。',
|
|
92
117
|
},
|
|
93
118
|
connection: {
|
|
94
119
|
type: 'object',
|
|
95
120
|
additionalProperties: true,
|
|
96
|
-
description: '连接配置(db_action=save/test 用):{ id?, name, type(sqlite|mysql|postgres|oracle), host, port, database, username, password, extra }',
|
|
121
|
+
description: '连接配置(db_action=save/test 用):{ id?, name, type(sqlite|mysql|postgres|oracle|dm), host, port, database, username, password, connectString?, extra }。connectString 是 URL 模式(如 dm://user:pass@host:5236),填了就不用 host/port',
|
|
97
122
|
},
|
|
98
123
|
table: { type: 'string', description: '表名(db_action=describe_table 用)' },
|
|
99
124
|
database: { type: 'string', description: '库名/模式名(db_action=list_tables 用;MySQL 必填)' },
|
|
@@ -240,10 +265,15 @@ async function runTool(service, args, projectPath) {
|
|
|
240
265
|
}
|
|
241
266
|
case 'db': {
|
|
242
267
|
const dbAction = String(args.db_action || 'list_connections');
|
|
268
|
+
/**
|
|
269
|
+
* 项目(工作空间)key:授权是按项目走的,所有 db 动作都要带上它。
|
|
270
|
+
* runTool 的 projectPath 就是会话 cwd,用它派生 key。
|
|
271
|
+
*/
|
|
272
|
+
const projectKey = projectKeyOf(projectPath);
|
|
243
273
|
if (dbAction === 'list_connections') return service.dbConnections();
|
|
244
274
|
if (dbAction === 'list_databases') {
|
|
245
275
|
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=list_databases 需要 connection_id' };
|
|
246
|
-
return service.dbDatabases(String(args.connection_id));
|
|
276
|
+
return service.dbDatabases(String(args.connection_id), { projectKey });
|
|
247
277
|
}
|
|
248
278
|
if (dbAction === 'test') {
|
|
249
279
|
const target = args.connection || args.connection_id;
|
|
@@ -279,20 +309,25 @@ async function runTool(service, args, projectPath) {
|
|
|
279
309
|
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=query 需要 connection_id' };
|
|
280
310
|
if (!args.sql) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=query 需要 sql' };
|
|
281
311
|
return service.dbQuery(String(args.connection_id), String(args.sql), Array.isArray(args.db_params) ? args.db_params : [], {
|
|
282
|
-
|
|
283
|
-
|
|
312
|
+
/**
|
|
313
|
+
* 模型侧**永远只读**:不接受 args 里传的 db_readonly=false。
|
|
314
|
+
* 写操作只能由人在面板里做(面板走的是另一条带 UI 确认的路径)。
|
|
315
|
+
* 这样即使模型被诱导,也删不掉任何数据。
|
|
316
|
+
*/
|
|
317
|
+
readonly: true,
|
|
318
|
+
projectKey,
|
|
284
319
|
maxRows: args.max_lines ? Number(args.max_lines) : undefined,
|
|
285
320
|
timeoutMs,
|
|
286
321
|
});
|
|
287
322
|
}
|
|
288
323
|
if (dbAction === 'list_tables') {
|
|
289
324
|
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=list_tables 需要 connection_id' };
|
|
290
|
-
return service.dbTables(String(args.connection_id), args.database ? String(args.database) : '');
|
|
325
|
+
return service.dbTables(String(args.connection_id), args.database ? String(args.database) : '', { projectKey });
|
|
291
326
|
}
|
|
292
327
|
if (dbAction === 'describe_table') {
|
|
293
328
|
if (!args.connection_id) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=describe_table 需要 connection_id' };
|
|
294
329
|
if (!args.table) return { ok: false, code: 'MISSING_ARGUMENT', message: 'db_action=describe_table 需要 table' };
|
|
295
|
-
return service.dbDescribeTable(String(args.connection_id), String(args.table));
|
|
330
|
+
return service.dbDescribeTable(String(args.connection_id), String(args.table), { projectKey });
|
|
296
331
|
}
|
|
297
332
|
return { ok: false, code: 'BAD_ACTION', message: `不支持的 db_action: ${dbAction}` };
|
|
298
333
|
}
|
|
@@ -414,13 +449,15 @@ export function apply(ctx) {
|
|
|
414
449
|
const apis = info.apis.map((api) => `- ${api.id}(${api.method} ${api.url})${api.params.length ? ` 参数: ${api.params.join(', ')}` : ''}`).join('\n');
|
|
415
450
|
const services = info.services.map((item) => `- ${item.id}(${item.mode})${item.name}`).join('\n');
|
|
416
451
|
const logs = info.logs.map((item) => `- ${item.id} → ${item.path}`).join('\n');
|
|
417
|
-
|
|
452
|
+
const databases = (info.databases || []).map((item) => `- ${item.id}(${item.type})${item.name} → ${item.target}`).join('\n');
|
|
453
|
+
if (!apis && !services && !logs && !databases) return '';
|
|
418
454
|
return [
|
|
419
455
|
'# 本地开发联调(dsh-api-runner)',
|
|
420
456
|
'可用工具 ApiRunner 已配置以下资源;改完后端代码后可用它重启服务、读日志、直接调接口验证。',
|
|
421
457
|
apis ? `\n接口:\n${apis}` : '',
|
|
422
458
|
services ? `\n服务:\n${services}` : '',
|
|
423
459
|
logs ? `\n日志:\n${logs}` : '',
|
|
460
|
+
databases ? `\n数据库(可用 action=db 直接查询,默认只读):\n${databases}\n需要看表结构时用 db_action=list_tables / describe_table;查数据用 db_action=query + sql(用 db_params 传参,别拼字符串)。` : '',
|
|
424
461
|
].join('\n');
|
|
425
462
|
} catch {
|
|
426
463
|
return '';
|
|
@@ -673,7 +710,10 @@ async function handleHttp(req, res, service, projectOf) {
|
|
|
673
710
|
if (!target || (typeof target === 'string' && target === '')) {
|
|
674
711
|
return send(400, { ok: false, code: 'MISSING_ARGUMENT', message: '需要 connection_id 或 connection 配置' });
|
|
675
712
|
}
|
|
676
|
-
|
|
713
|
+
// timeout_ms 让前端能把等待压到可接受范围(默认 15s,见 DbManager.probeTimeoutMs)
|
|
714
|
+
return send(200, await service.dbTest(target, {
|
|
715
|
+
timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
|
|
716
|
+
}));
|
|
677
717
|
}
|
|
678
718
|
if (route === 'db/save' && req.method === 'POST') {
|
|
679
719
|
const body = await readBody();
|
|
@@ -690,25 +730,49 @@ async function handleHttp(req, res, service, projectOf) {
|
|
|
690
730
|
String(body.sql || ''),
|
|
691
731
|
Array.isArray(body.params) ? body.params : [],
|
|
692
732
|
{
|
|
733
|
+
/**
|
|
734
|
+
* 面板里的写操作需要「项目授权 = readwrite」才放行,
|
|
735
|
+
* 且 DROP/TRUNCATE/无 WHERE 的 DELETE 仍会被 DESTRUCTIVE_BLOCKED 拦住。
|
|
736
|
+
* readonly 由前端传,但真正的闸门是项目授权等级。
|
|
737
|
+
*/
|
|
693
738
|
readonly: body.readonly !== false,
|
|
739
|
+
projectKey: projectKeyOf(cwd),
|
|
694
740
|
maxRows: body.max_rows ? Number(body.max_rows) : undefined,
|
|
695
741
|
timeoutMs: body.timeout_ms ? Number(body.timeout_ms) : undefined,
|
|
696
742
|
},
|
|
697
743
|
));
|
|
698
744
|
}
|
|
699
745
|
if (route === 'db/databases' && req.method === 'GET') {
|
|
700
|
-
return send(200, await service.dbDatabases(
|
|
746
|
+
return send(200, await service.dbDatabases(
|
|
747
|
+
String(url.searchParams.get('connection_id') || ''),
|
|
748
|
+
{ projectKey: projectKeyOf(cwd) },
|
|
749
|
+
));
|
|
701
750
|
}
|
|
702
751
|
if (route === 'db/tables' && req.method === 'GET') {
|
|
703
752
|
return send(200, await service.dbTables(
|
|
704
753
|
String(url.searchParams.get('connection_id') || ''),
|
|
705
754
|
String(url.searchParams.get('database') || ''),
|
|
755
|
+
{ projectKey: projectKeyOf(cwd) },
|
|
706
756
|
));
|
|
707
757
|
}
|
|
708
758
|
if (route === 'db/table' && req.method === 'GET') {
|
|
709
759
|
return send(200, await service.dbDescribeTable(
|
|
710
760
|
String(url.searchParams.get('connection_id') || ''),
|
|
711
761
|
String(url.searchParams.get('name') || url.searchParams.get('table') || ''),
|
|
762
|
+
{ projectKey: projectKeyOf(cwd) },
|
|
763
|
+
));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ---- 数据库项目授权(默认拒绝,必须显式授权) ----
|
|
767
|
+
if (route === 'db/access' && req.method === 'GET') {
|
|
768
|
+
return send(200, service.dbAccess(projectKeyOf(cwd)));
|
|
769
|
+
}
|
|
770
|
+
if (route === 'db/access' && req.method === 'POST') {
|
|
771
|
+
const body = await readBody();
|
|
772
|
+
return send(200, service.dbSetAccess(
|
|
773
|
+
projectKeyOf(body.project_key ? String(body.project_key) : cwd),
|
|
774
|
+
String(body.connection_id || ''),
|
|
775
|
+
String(body.level || 'none'),
|
|
712
776
|
));
|
|
713
777
|
}
|
|
714
778
|
|
package/src/ops.js
CHANGED
|
@@ -105,6 +105,33 @@ export class ApiRunnerService {
|
|
|
105
105
|
exists: fileExists(log.path),
|
|
106
106
|
size: fileSize(log.path),
|
|
107
107
|
})),
|
|
108
|
+
/**
|
|
109
|
+
* 数据库连接也放进 describe:模型据此知道「有哪些库可查」。
|
|
110
|
+
*
|
|
111
|
+
* **只列当前项目已授权的连接**——未授权的连接模型不该看见,
|
|
112
|
+
* 否则它会反复尝试然后被拒,浪费轮次还误导用户。
|
|
113
|
+
* 只给 id / 名称 / 类型 / 目标 / 权限等级,不给账号密码。
|
|
114
|
+
*/
|
|
115
|
+
databases: this.dbManager
|
|
116
|
+
? (() => {
|
|
117
|
+
const access = this.dbManager.accessOf('default');
|
|
118
|
+
return this.dbManager.listConnections()
|
|
119
|
+
.map((item) => {
|
|
120
|
+
const level = access[item.id] || 'none';
|
|
121
|
+
return {
|
|
122
|
+
id: item.id,
|
|
123
|
+
name: item.name,
|
|
124
|
+
type: item.type,
|
|
125
|
+
target: item.urlMode
|
|
126
|
+
? item.connectString
|
|
127
|
+
: (item.type === 'sqlite' ? (item.file || ':memory:') : `${item.host}:${item.port}/${item.database}`),
|
|
128
|
+
username: item.username || '',
|
|
129
|
+
level,
|
|
130
|
+
};
|
|
131
|
+
})
|
|
132
|
+
.filter((item) => item.level !== 'none');
|
|
133
|
+
})()
|
|
134
|
+
: [],
|
|
108
135
|
};
|
|
109
136
|
}
|
|
110
137
|
|
|
@@ -1183,9 +1210,9 @@ export class ApiRunnerService {
|
|
|
1183
1210
|
}
|
|
1184
1211
|
}
|
|
1185
1212
|
|
|
1186
|
-
async dbTest(target) {
|
|
1213
|
+
async dbTest(target, options = {}) {
|
|
1187
1214
|
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1188
|
-
return this.dbManager.testConnection(target);
|
|
1215
|
+
return this.dbManager.testConnection(target, options);
|
|
1189
1216
|
}
|
|
1190
1217
|
|
|
1191
1218
|
dbSave(patch) {
|
|
@@ -1203,19 +1230,30 @@ export class ApiRunnerService {
|
|
|
1203
1230
|
return this.dbManager.query(id, sql, params, options);
|
|
1204
1231
|
}
|
|
1205
1232
|
|
|
1206
|
-
async dbDatabases(id) {
|
|
1233
|
+
async dbDatabases(id, options = {}) {
|
|
1234
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1235
|
+
return this.dbManager.listDatabases(id, options);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
async dbTables(id, database = '', options = {}) {
|
|
1239
|
+
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1240
|
+
return this.dbManager.listTables(id, database, options);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async dbDescribeTable(id, table, options = {}) {
|
|
1207
1244
|
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1208
|
-
return this.dbManager.
|
|
1245
|
+
return this.dbManager.describeTable(id, table, options);
|
|
1209
1246
|
}
|
|
1210
1247
|
|
|
1211
|
-
|
|
1248
|
+
// ---- 数据库项目授权(默认拒绝,必须显式授权) ----
|
|
1249
|
+
dbAccess(projectKey) {
|
|
1212
1250
|
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1213
|
-
return this.dbManager.
|
|
1251
|
+
return this.dbManager.accessView(projectKey);
|
|
1214
1252
|
}
|
|
1215
1253
|
|
|
1216
|
-
|
|
1254
|
+
dbSetAccess(projectKey, connectionId, level) {
|
|
1217
1255
|
if (!this.dbManager) return { ok: false, code: 'NO_DB', message: '数据库模块不可用' };
|
|
1218
|
-
return this.dbManager.
|
|
1256
|
+
return this.dbManager.setAccess(projectKey, connectionId, level);
|
|
1219
1257
|
}
|
|
1220
1258
|
|
|
1221
1259
|
dispose() {
|
package/test/client.mjs
CHANGED
|
@@ -164,8 +164,9 @@ assert.ok(/db\/query/.test(source), 'SQL 执行应走 db/query 路由');
|
|
|
164
164
|
assert.ok(/className: 'ar-tabs'/.test(source), '顶部应有页签容器 ar-tabs');
|
|
165
165
|
assert.ok(/className: 'ar-segmented'/.test(source), '一级标题应是小胶囊容器 ar-segmented');
|
|
166
166
|
assert.ok(/'ar-seg on' : 'ar-seg'/.test(source), '胶囊选中态应有独立样式');
|
|
167
|
-
assert.ok(/label: '
|
|
168
|
-
|
|
167
|
+
assert.ok(/label: '连接管理'/.test(source) && /label: '项目授权'/.test(source)
|
|
168
|
+
&& /label: 'SQL'/.test(source) && /label: '表结构'/.test(source),
|
|
169
|
+
'数据库子页应有「连接管理 / 项目授权 / SQL / 表结构」四个一级标题');
|
|
169
170
|
assert.ok(/const \[dbSection, setDbSection\]/.test(source), '数据库子页需要 dbSection 状态');
|
|
170
171
|
assert.ok(/\.ar-segmented \{/.test(source), '缺少 .ar-segmented 样式');
|
|
171
172
|
assert.ok(/--ar-elevated:/.test(source), '选中态需要 --ar-elevated 变量(深/浅色主题各一份)');
|
|
@@ -192,6 +193,44 @@ assert.ok(/el\.textContent !== CSS/.test(source), '样式内容变化时应替
|
|
|
192
193
|
assert.ok(!/if \(document\.getElementById\(STYLE_ID\)\) return;/.test(source), '不能因 id 已存在就提前返回');
|
|
193
194
|
assert.ok(/const STYLE_ID = '[^']*-v\d+'/.test(source), 'style id 应带版本后缀,避免被按 id 去重');
|
|
194
195
|
|
|
196
|
+
/**
|
|
197
|
+
* 数据库表单的三处修复(用户报的缺陷):
|
|
198
|
+
* 1. 端口输入框被写死 90px,和 host 挤一行时变形
|
|
199
|
+
* 2. 新增时填完没法试连
|
|
200
|
+
* 3. 支持 URL 直连
|
|
201
|
+
*/
|
|
202
|
+
assert.ok(!/flex: '0 0 90px'/.test(source), '端口输入框不能固定像素宽(窄面板会变形)');
|
|
203
|
+
assert.ok(/测试中…' : '测试'/.test(source), '表单应有「测试」按钮,新增时也能试连');
|
|
204
|
+
assert.ok(/label: 'URL'/.test(source), '表单应支持 URL 录入方式');
|
|
205
|
+
assert.ok(/connectString/.test(source), '应支持 connectString(URL 模式)');
|
|
206
|
+
assert.ok(/item\.urlMode \? h\('span'/.test(source), '列表应标出 URL 模式的连接');
|
|
207
|
+
assert.ok(/replace\(\/\[\^\\d\]\/g, ''\)/.test(source), '端口输入框应只接受数字');
|
|
208
|
+
|
|
209
|
+
// 项目授权 UI:三档切换 + 安全说明
|
|
210
|
+
assert.ok(/setDbAccessLevel/.test(source), '应有设置授权的处理函数');
|
|
211
|
+
assert.ok(/label: '未授权'/.test(source) && /label: '只读'/.test(source) && /label: '读写'/.test(source),
|
|
212
|
+
'授权应有「未授权 / 只读 / 读写」三档');
|
|
213
|
+
assert.ok(/毁灭性操作/.test(source), '授权页应说明毁灭性操作会被拒绝');
|
|
214
|
+
assert.ok(/refreshDbAccess/.test(source), '应能刷新授权状态');
|
|
215
|
+
|
|
216
|
+
// UI 重设计:命名空间分离 + 设计 token
|
|
217
|
+
assert.ok(/className: 'ar-switch'/.test(source), '响应切换条应使用 ar-switch(不与 ar-seg 胶囊冲突)');
|
|
218
|
+
assert.ok(!/className: 'ar-seg'/.test(source), 'ar-seg 只能当胶囊按钮,不能再被当容器');
|
|
219
|
+
assert.ok(/--ar-surface-1/.test(source) && /--ar-surface-2/.test(source) && /--ar-surface-3/.test(source),
|
|
220
|
+
'应有三级中性面 token,用于分层');
|
|
221
|
+
assert.ok(/--ar-r-lg/.test(source), '应有大圆角 token');
|
|
222
|
+
assert.ok(/dsh-api-runner-style-v5/.test(source), '样式版本应为 v5');
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 试连不能锁死整个面板:
|
|
226
|
+
* 达梦连不上要等十几秒,若共用全局 busy,期间保存/取消/编辑全被 disabled,
|
|
227
|
+
* 用户会以为界面卡死。试连必须用独立的 testing 状态。
|
|
228
|
+
*/
|
|
229
|
+
assert.ok(/const \[dbTesting, setDbTesting\]/.test(source), '试连应有独立状态 dbTesting');
|
|
230
|
+
assert.ok(/disabled: testing \|\| busy === 'save'/.test(source), '测试按钮只锁自己,不锁其它');
|
|
231
|
+
assert.ok(/busy === 'save' \? '保存中…' : '保存'/.test(source), '保存按钮只受保存中影响');
|
|
232
|
+
assert.ok(!/busy === 'test'/.test(source), '不应再用全局 busy 表示测试中');
|
|
233
|
+
|
|
195
234
|
/**
|
|
196
235
|
* 回归:useEffect 的依赖数组里引用了「在它之后才 const 声明」的变量,
|
|
197
236
|
* 会因暂时性死区抛 `Cannot access 'x' before initialization`,
|