cloudcc-cli 2.2.6 → 2.2.8
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/.cloudcc-cache.json +24 -20
- package/README.md +24 -0
- package/bin/cc.js +7 -0
- package/cloudcc-cli-dev/BACKEND_CODE.md +114 -0
- package/cloudcc-cli-dev/CLI_CHEATSHEET.md +90 -0
- package/cloudcc-cli-dev/INSTALL_AND_BOOTSTRAP.md +59 -0
- package/cloudcc-cli-dev/OBJECTS_AND_FIELDS.md +120 -0
- package/cloudcc-cli-dev/REQUIREMENTS_BREAKDOWN.md +98 -0
- package/cloudcc-cli-dev/SKILL.md +39 -0
- package/cloudcc-cli-dev/VUE_CUSTOM_COMPONENT.md +50 -0
- package/java/com/cloudcc/core/BaseException.java +100 -0
- package/java/com/cloudcc/core/BusiException.java +43 -0
- package/java/com/cloudcc/core/CCService.java +3 -1
- package/java/com/cloudcc/core/StringUtils.java +7 -0
- package/java/com/cloudcc/core/TimeUtil.java +33 -0
- package/java/com/cloudcc/core/UserInfo.java +9 -0
- package/package.json +7 -1
- package/pom.xml +1 -1
- package/src/api/backend-sdk-java.md +427 -0
- package/src/api/ccdk-sdk.md +1039 -0
- package/src/classes/doc.js +486 -0
- package/src/classes/index.js +1 -0
- package/src/mcp/cliRunner.js +61 -0
- package/src/mcp/index.js +41 -3
- package/src/mcp/tools/Application Creator/handler.js +7 -9
- package/src/mcp/tools/Approval/handler.js +34 -151
- package/src/mcp/tools/Class Creator/handler.js +18 -15
- package/src/mcp/tools/Class Detail Retriever/handler.js +8 -9
- package/src/mcp/tools/Class Editor Guide/handler.js +5 -19
- package/src/mcp/tools/Class List Retriever/handler.js +8 -3
- package/src/mcp/tools/Class Publisher/handler.js +7 -9
- package/src/mcp/tools/Class Puller/handler.js +6 -65
- package/src/mcp/tools/Client Script Detail Retriever/handler.js +12 -18
- package/src/mcp/tools/Client Script Editor Guide/handler.js +9 -605
- package/src/mcp/tools/Client Script List Retriever/handler.js +30 -33
- package/src/mcp/tools/Client Script Publisher/handler.js +12 -11
- package/src/mcp/tools/Client Script Puller/handler.js +23 -30
- package/src/mcp/tools/CloudCC Development Overview/handler.js +11 -5
- package/src/mcp/tools/Component Creator/handler.js +12 -11
- package/src/mcp/tools/Component Detail Retriever/handler.js +12 -9
- package/src/mcp/tools/Component Editor Guide/handler.js +5 -22
- package/src/mcp/tools/Component List Retriever/handler.js +21 -18
- package/src/mcp/tools/Component Publisher/handler.js +25 -3
- package/src/mcp/tools/Component Puller/handler.js +13 -16
- package/src/mcp/tools/Dev Environment Creator/handler.js +5 -72
- package/src/mcp/tools/Dev Environment Validator/handler.js +5 -66
- package/src/mcp/tools/Developer Key Setup Guide/handler.js +11 -20
- package/src/mcp/tools/JSP Migrator/handler.js +842 -0
- package/src/mcp/tools/Menu Creator/handler.js +7 -30
- package/src/mcp/tools/Object Creator/handler.js +14 -6
- package/src/mcp/tools/Object Fields Creator/handler.js +9 -10
- package/src/mcp/tools/Object Fields Retriever/handler.js +6 -3
- package/src/mcp/tools/Object List Retriever/handler.js +10 -7
- package/src/mcp/tools/Scheduled Class Creator/handler.js +12 -16
- package/src/mcp/tools/Scheduled Class Detail Retriever/handler.js +7 -9
- package/src/mcp/tools/Scheduled Class List Retriever/handler.js +21 -23
- package/src/mcp/tools/Scheduled Class Publisher/handler.js +7 -9
- package/src/mcp/tools/Scheduled Class Puller/handler.js +6 -70
- package/src/mcp/tools/Trigger Creator/handler.js +12 -20
- package/src/mcp/tools/Trigger Detail Retriever/handler.js +7 -9
- package/src/mcp/tools/Trigger Editor Guide/handler.js +10 -35
- package/src/mcp/tools/Trigger List Retriever/handler.js +12 -4
- package/src/mcp/tools/Trigger Publisher/handler.js +8 -11
- package/src/mcp/tools/Trigger Puller/handler.js +12 -17
- package/src/plugin/doc.js +801 -0
- package/src/plugin/get.js +0 -1
- package/src/plugin/index.js +1 -0
- package/src/plugin/publish1.js +34 -20
- package/src/plugin/pull.js +69 -31
- package/src/project/doc.js +378 -0
- package/src/project/index.js +1 -0
- package/src/script/doc.js +259 -0
- package/src/script/index.js +1 -0
- package/src/timer/index.js +1 -0
- package/src/triggers/doc.js +342 -0
- package/src/triggers/index.js +5 -0
- package/target/ccopenapi-0.0.4-classes.jar +0 -0
- package/target/ccopenapi-0.0.4.jar +0 -0
- package/target/classes/com/cloudcc/core/BaseException.class +0 -0
- package/target/classes/com/cloudcc/core/BusiException.class +0 -0
- package/target/classes/com/cloudcc/core/CCService.class +0 -0
- package/target/classes/com/cloudcc/core/StringUtils.class +0 -0
- package/target/classes/com/cloudcc/core/TimeUtil.class +0 -0
- package/target/classes/com/cloudcc/core/UserInfo.class +0 -0
- package/target/maven-archiver/pom.properties +1 -1
- package/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +3 -1
- package/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +3 -3
- package/template/lib/ccopenapi-0.0.4.jar +0 -0
- package/test/application.cli.test.js +30 -0
- package/test/classes.cli.test.js +121 -0
- package/test/fields.cli.test.js +69 -0
- package/test/mcp.cli.test.js +21 -0
- package/test/menu.cli.test.js +41 -0
- package/test/object.cli.test.js +64 -0
- package/test/plugin.cli.test.js +109 -0
- package/test/script.cli.test.js +101 -0
- package/test/timer.cli.test.js +107 -0
- package/test/trigger.cli.test.js +146 -0
- package/.vscode/settings.json +0 -3
- package/bin/mcp-svc.js +0 -13
- package/src/mcp/MCP/345/234/272/346/231/257/346/250/241/346/213/237.md +0 -8
- package/src/mcp/index-sse-svc.js +0 -126
- package/src/mcp/index-streamable-svc.js +0 -180
- package/src/mcp/tools/Class Detail Retriever/prompt.js +0 -37
- package/src/mcp/tools/Class Editor Guide/prompt.js +0 -468
- package/src/mcp/tools/Class Publisher/prompt.js +0 -40
- package/src/mcp/tools/Class Puller/prompt.js +0 -49
- package/src/mcp/tools/Client Script Creator/handler.js +0 -179
- package/src/mcp/tools/CloudCC Development Overview/prompt.js +0 -871
- package/src/mcp/tools/Component Editor Guide/prompt.js +0 -519
- package/src/mcp/tools/Component Publisher/prompt.js +0 -659
- package/src/mcp/tools/Dev Environment Creator/prompt.js +0 -273
- package/src/mcp/tools/Dev Environment Validator/prompt.js +0 -193
- package/src/mcp/tools/Developer Key Setup Guide/prompt.js +0 -71
- package/src/mcp/tools/Object Fields Retriever/prompt.js +0 -10
- package/src/mcp/tools/Object List Retriever/prompt.js +0 -10
- package/src/mcp/tools/ccdk/fetcher.js +0 -18
- package/src/mcp/tools/ccdk/handler.js +0 -98
- package/src/mcp/tools/ccdk/prompt.js +0 -453
- package/target/ccopenapi-0.0.3-classes.jar +0 -0
- package/target/ccopenapi-0.0.3.jar +0 -0
- package/template/lib/ccopenapi-0.0.3.jar +0 -0
|
@@ -2,17 +2,19 @@ com/cloudcc/core/CCObject.class
|
|
|
2
2
|
com/cloudcc/core/TriggerTimeEnum.class
|
|
3
3
|
com/cloudcc/core/UserInfo.class
|
|
4
4
|
com/cloudcc/core/SendEmail.class
|
|
5
|
+
com/cloudcc/core/BusiException.class
|
|
5
6
|
com/cloudcc/core/PeakInterf.class
|
|
6
7
|
com/cloudcc/core/TimeUtil.class
|
|
7
8
|
com/cloudcc/core/CCTriggerHandler.class
|
|
8
9
|
com/cloudcc/core/Tool.class
|
|
9
10
|
com/cloudcc/core/TriggerInvoker.class
|
|
10
11
|
com/cloudcc/core/CCSchedule.class
|
|
12
|
+
com/cloudcc/core/BaseException.class
|
|
11
13
|
com/cloudcc/core/OperatationEnum.class
|
|
12
14
|
com/cloudcc/core/TriggerMethod.class
|
|
13
15
|
com/cloudcc/core/ServiceResult.class
|
|
16
|
+
com/cloudcc/core/StringUtils.class
|
|
14
17
|
com/cloudcc/core/DevLogger.class
|
|
15
18
|
com/cloudcc/core/CCService.class
|
|
16
19
|
com/cloudcc/core/CCTrigger.class
|
|
17
|
-
com/cloudcc/core/CCTriggerDemo.class
|
|
18
20
|
com/cloudcc/core/Tool$1.class
|
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/TriggerInvoker.java
|
|
3
3
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/UserInfo.java
|
|
4
4
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/Tool.java
|
|
5
|
-
/Users/xuhm/Documents/cloudcc-cli/classes/TestDtt/TestDttTest.java
|
|
6
5
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/CCService.java
|
|
7
6
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/CCSchedule.java
|
|
8
7
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/SendEmail.java
|
|
9
8
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/OperatationEnum.java
|
|
9
|
+
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/BaseException.java
|
|
10
10
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/CCTriggerHandler.java
|
|
11
11
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/TriggerMethod.java
|
|
12
|
-
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/CCTriggerDemo.java
|
|
13
12
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/TriggerTimeEnum.java
|
|
14
13
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/CCTrigger.java
|
|
14
|
+
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/StringUtils.java
|
|
15
|
+
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/BusiException.java
|
|
15
16
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/DevLogger.java
|
|
16
17
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/PeakInterf.java
|
|
17
18
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/TimeUtil.java
|
|
18
19
|
/Users/xuhm/Documents/cloudcc-cli/java/com/cloudcc/core/CCObject.java
|
|
19
|
-
/Users/xuhm/Documents/cloudcc-cli/classes/TestDtt/TestDtt.java
|
|
Binary file
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { exec } = require("node:child_process");
|
|
5
|
+
const { promisify } = require("node:util");
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
9
|
+
|
|
10
|
+
function quoteArg(value) {
|
|
11
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function runCc(args) {
|
|
15
|
+
const cmd = `npx --prefix "${repoRoot}" cc ${args.map(quoteArg).join(" ")}`;
|
|
16
|
+
return execAsync(cmd, { cwd: repoRoot });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("应用管理流程:创建 application", async () => {
|
|
20
|
+
const appName = `TestApp${Date.now()}`;
|
|
21
|
+
const appCode = `test_app_${Date.now()}`;
|
|
22
|
+
|
|
23
|
+
const { stdout, stderr } = await runCc(["create", "application", repoRoot, appName, appCode]);
|
|
24
|
+
const out = `${stdout}\n${stderr}`.trim();
|
|
25
|
+
|
|
26
|
+
// src/application/create.js 成功时会输出 `Success!`
|
|
27
|
+
assert.match(out, /Success|成功/i, "create application 应输出成功信息");
|
|
28
|
+
console.error(`\n[application-test] 已创建应用: ${appName} (code=${appCode})`);
|
|
29
|
+
});
|
|
30
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { exec } = require("node:child_process");
|
|
6
|
+
const { promisify } = require("node:util");
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
10
|
+
|
|
11
|
+
function quoteArg(value) {
|
|
12
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function runCc(args) {
|
|
16
|
+
const cmd = `npx --prefix "${repoRoot}" cc ${args.map(quoteArg).join(" ")}`;
|
|
17
|
+
return execAsync(cmd, { cwd: repoRoot });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("类管理流程:创建→doc→发布→拉取→线上详情→列表→批量拉取前2个→清空classes文件夹", async (t) => {
|
|
21
|
+
const className = `CCbbbbbbFlow${Date.now()}`;
|
|
22
|
+
const classDir = path.join(repoRoot, "classes", className);
|
|
23
|
+
const configPath = path.join(classDir, "config.json");
|
|
24
|
+
const javaPath = path.join(classDir, `${className}.java`);
|
|
25
|
+
const classesRoot = path.join(repoRoot, "classes");
|
|
26
|
+
|
|
27
|
+
let publishedId = null;
|
|
28
|
+
let topIds = [];
|
|
29
|
+
|
|
30
|
+
t.test("1) 创建类", async () => {
|
|
31
|
+
await runCc(["create", "classes", className]);
|
|
32
|
+
|
|
33
|
+
const javaTestPath = path.join(classDir, `${className}Test.java`);
|
|
34
|
+
assert.equal(fs.existsSync(classDir), true);
|
|
35
|
+
assert.equal(fs.existsSync(javaPath), true);
|
|
36
|
+
assert.equal(fs.existsSync(javaTestPath), true);
|
|
37
|
+
assert.equal(fs.existsSync(configPath), true);
|
|
38
|
+
|
|
39
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
40
|
+
assert.equal(config.name, className);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
t.test("2) 获取 doc(classes overview)", async () => {
|
|
44
|
+
const { stdout } = await runCc(["doc", "classes", "overview"]);
|
|
45
|
+
assert.match(stdout, /CloudCC 类编辑知识库/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
t.test("3) 发布类(publish,写回 config.id)", async () => {
|
|
49
|
+
await runCc(["publish", "classes", className]);
|
|
50
|
+
const configAfterPublish = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
51
|
+
publishedId = configAfterPublish.id || null;
|
|
52
|
+
assert.ok(publishedId, "publish 后应写入 config.id");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
t.test("4) 拉取类(pull:覆盖 SOURCE 区域)", async () => {
|
|
56
|
+
const localJava = fs.readFileSync(javaPath, "utf8");
|
|
57
|
+
const modifiedJava = localJava.replace(
|
|
58
|
+
/\/\/ @SOURCE_CONTENT_START[\s\S]*?\/\/ @SOURCE_CONTENT_END/,
|
|
59
|
+
"// @SOURCE_CONTENT_START\npublic String localOnly(){ return \"LOCAL_ONLY\"; }\n// @SOURCE_CONTENT_END"
|
|
60
|
+
);
|
|
61
|
+
fs.writeFileSync(javaPath, modifiedJava, "utf8");
|
|
62
|
+
|
|
63
|
+
await runCc(["pull", "classes", className]);
|
|
64
|
+
const javaAfterPull = fs.readFileSync(javaPath, "utf8");
|
|
65
|
+
assert.ok(!javaAfterPull.includes("LOCAL_ONLY"), "pull 后应使用远端源码替换本地标记内容");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
t.test("5) 查询线上类详情(detail:仅使用 id,不走本地)", async () => {
|
|
69
|
+
// detail(name,id) 内部逻辑:name 为空会跳过本地优先,从服务器查询 id
|
|
70
|
+
assert.ok(publishedId, "发布后应有 publishedId");
|
|
71
|
+
await assert.doesNotReject(() => runCc(["detail", "classes", "", publishedId]));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
t.test("6) get 获取线上类列表(取前2个类 id)", async () => {
|
|
75
|
+
const listQuery = encodeURI(
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
shownum: "2000",
|
|
78
|
+
showpage: "1",
|
|
79
|
+
fid: "",
|
|
80
|
+
sname: "",
|
|
81
|
+
rptcond: "",
|
|
82
|
+
rptorder: "",
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const { stdout } = await runCc(["get", "classes", listQuery]);
|
|
87
|
+
const list = JSON.parse(stdout.trim());
|
|
88
|
+
assert.ok(Array.isArray(list), "get classes 应返回数组");
|
|
89
|
+
|
|
90
|
+
// 保存前2个 id
|
|
91
|
+
topIds = list.slice(0, 2).map((x) => x.id).filter(Boolean);
|
|
92
|
+
assert.ok(topIds.length >= 1, "线上类列表至少应包含 1 个 id");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
t.test("7) 批量拉取前2个类(pullList)", async () => {
|
|
96
|
+
assert.ok(topIds.length >= 1, "pullList 需要至少 1 个线上类 id");
|
|
97
|
+
|
|
98
|
+
const beforeCount = fs.existsSync(classesRoot)
|
|
99
|
+
? fs.readdirSync(classesRoot).filter((n) => fs.statSync(path.join(classesRoot, n)).isDirectory()).length
|
|
100
|
+
: 0;
|
|
101
|
+
|
|
102
|
+
for (const id of topIds) {
|
|
103
|
+
await runCc(["pullList", "classes", id, repoRoot]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const afterCount = fs.readdirSync(classesRoot).filter((n) =>
|
|
107
|
+
fs.statSync(path.join(classesRoot, n)).isDirectory()
|
|
108
|
+
).length;
|
|
109
|
+
assert.ok(afterCount >= beforeCount, "pullList 之后 classes 目录应存在/可用");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
t.test("8) 清空 classes 文件夹", async () => {
|
|
113
|
+
if (fs.existsSync(classesRoot)) {
|
|
114
|
+
fs.rmSync(classesRoot, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
fs.mkdirSync(classesRoot, { recursive: true });
|
|
117
|
+
const remain = fs.readdirSync(classesRoot);
|
|
118
|
+
assert.equal(remain.length, 0);
|
|
119
|
+
console.error("");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { exec } = require("node:child_process");
|
|
5
|
+
const { promisify } = require("node:util");
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
9
|
+
|
|
10
|
+
function quoteArg(value) {
|
|
11
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function runCc(args) {
|
|
15
|
+
const cmd = `npx --prefix "${repoRoot}" cc ${args.map(quoteArg).join(" ")}`;
|
|
16
|
+
return execAsync(cmd, { cwd: repoRoot });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("字段管理流程:获取对象列表→create 字段→get 字段列表", async (t) => {
|
|
20
|
+
let objPrefix = null;
|
|
21
|
+
let objId = null;
|
|
22
|
+
let objName = null;
|
|
23
|
+
const fieldLabel = `AutoField_${Date.now()}`;
|
|
24
|
+
|
|
25
|
+
t.test("1) 获取对象列表(取第一个对象的 id 和 objprefix)", async () => {
|
|
26
|
+
// 只取自定义对象,避免标准对象在部分环境下字段接口异常
|
|
27
|
+
const { stdout } = await runCc(["get", "object", repoRoot, "custom"]);
|
|
28
|
+
const list = JSON.parse(stdout.trim());
|
|
29
|
+
assert.ok(Array.isArray(list) || (list && typeof list === "object"), "get object 应返回列表或对象");
|
|
30
|
+
const arr = Array.isArray(list) ? list : (list.objList || list.data || []);
|
|
31
|
+
assert.ok(arr.length > 0, "自定义对象列表应非空");
|
|
32
|
+
|
|
33
|
+
const first = arr[0];
|
|
34
|
+
objPrefix = first.objprefix || first.prefix || "account";
|
|
35
|
+
objId = first.id || first.objid || null;
|
|
36
|
+
objName = first.label || first.schemetableName || "UnknownObject";
|
|
37
|
+
|
|
38
|
+
assert.ok(objPrefix, "应有可用的 objprefix");
|
|
39
|
+
assert.ok(objId, "应有可用的对象 id 用于创建字段");
|
|
40
|
+
|
|
41
|
+
console.error(`\n[fields-test] 目标对象: ${objName} (prefix=${objPrefix}, id=${objId})`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
t.test("2) create 创建一个文本字段(S)", async () => {
|
|
45
|
+
assert.ok(objId, "需要对象 id");
|
|
46
|
+
// create fields 调用约定:cc create fields <path> <fieldType> <objid> <nameLabel> [...]
|
|
47
|
+
const { stdout, stderr } = await runCc(["create", "fields", repoRoot, "S", objId, fieldLabel]);
|
|
48
|
+
// create fields 会通过 console.error 输出创建结果(见 src/fields/create.js)
|
|
49
|
+
assert.match(
|
|
50
|
+
`${stdout}\n${stderr}`,
|
|
51
|
+
/Field created successfully|✓\s*Field created successfully/i,
|
|
52
|
+
"create fields 应输出创建成功信息"
|
|
53
|
+
);
|
|
54
|
+
console.error(`[fields-test] 已创建字段: ${fieldLabel}`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
t.test("3) get 获取指定对象的字段列表,并尽量校验新字段", async () => {
|
|
58
|
+
assert.ok(objPrefix, "需要 objprefix");
|
|
59
|
+
const { stdout } = await runCc(["get", "fields", repoRoot, objPrefix]);
|
|
60
|
+
const res = JSON.parse(stdout.trim());
|
|
61
|
+
assert.ok(res && (res.stdFields !== undefined || res.cusFields !== undefined), "get fields 应返回 stdFields 或 cusFields");
|
|
62
|
+
if (res.stdFields) assert.ok(Array.isArray(res.stdFields), "stdFields 应为数组");
|
|
63
|
+
if (res.cusFields) assert.ok(Array.isArray(res.cusFields), "cusFields 应为数组");
|
|
64
|
+
|
|
65
|
+
// 尽量校验新字段是否已出现(不强制)
|
|
66
|
+
const allCus = Array.isArray(res.cusFields) ? res.cusFields : [];
|
|
67
|
+
const hit = allCus.some((f) => f.fieldname === fieldLabel || f.apiname?.includes("_custom_s_field"));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
|
|
6
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
7
|
+
|
|
8
|
+
test("MCP 模块:服务可加载、bin 存在", async (t) => {
|
|
9
|
+
t.test("1) 加载 MCP 服务模块", async () => {
|
|
10
|
+
const mcpServer = require(path.join(repoRoot, "src/mcp/index.js"));
|
|
11
|
+
assert.ok(mcpServer, "MCP 模块应导出服务实例");
|
|
12
|
+
assert.ok(typeof mcpServer === "object", "导出应为对象");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
t.test("2) bin/mcp.js 存在且可读", async () => {
|
|
16
|
+
const binPath = path.join(repoRoot, "bin/mcp.js");
|
|
17
|
+
assert.ok(fs.existsSync(binPath), "bin/mcp.js 应存在");
|
|
18
|
+
const content = fs.readFileSync(binPath, "utf8");
|
|
19
|
+
assert.match(content, /mcp|MCP|StdioServerTransport/, "bin/mcp.js 应引用 MCP 相关逻辑");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { exec } = require("node:child_process");
|
|
5
|
+
const { promisify } = require("node:util");
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
9
|
+
|
|
10
|
+
function quoteArg(value) {
|
|
11
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function runCc(args) {
|
|
15
|
+
const cmd = `npx --prefix "${repoRoot}" cc ${args.map(quoteArg).join(" ")}`;
|
|
16
|
+
return execAsync(cmd, { cwd: repoRoot });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("菜单管理流程:获取对象列表→create menu object", async (t) => {
|
|
20
|
+
let objectId = null;
|
|
21
|
+
let objectName = null;
|
|
22
|
+
|
|
23
|
+
t.test("1) 获取自定义对象列表(取第一个自定义对象 id 用于创建菜单)", async () => {
|
|
24
|
+
const { stdout } = await runCc(["get", "object", repoRoot, "custom"]);
|
|
25
|
+
const list = JSON.parse(stdout.trim());
|
|
26
|
+
assert.ok(Array.isArray(list), "get object custom 应返回数组");
|
|
27
|
+
if (list.length > 0 && list[0].id) {
|
|
28
|
+
objectId = list[0].id;
|
|
29
|
+
objectName = list[0].label || list[0].schemetableName || list[0].objname || null;
|
|
30
|
+
}
|
|
31
|
+
assert.ok(objectId, "线上至少应有一个自定义对象 id 用于创建菜单");
|
|
32
|
+
console.error(`\n[menu-test] 目标自定义对象: ${objectName || "Unknown"} (id=${objectId})`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
t.test("2) 创建对象类型菜单(create menu object)", async () => {
|
|
36
|
+
assert.ok(objectId, "需要 objectId");
|
|
37
|
+
await assert.doesNotReject(() =>
|
|
38
|
+
runCc(["create", "menu", "object", repoRoot, objectId, "CC测试菜单Tab"])
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { exec } = require("node:child_process");
|
|
5
|
+
const { promisify } = require("node:util");
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
9
|
+
|
|
10
|
+
function quoteArg(value) {
|
|
11
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function runCc(args) {
|
|
15
|
+
const cmd = `npx --prefix "${repoRoot}" cc ${args.map(quoteArg).join(" ")}`;
|
|
16
|
+
return execAsync(cmd, { cwd: repoRoot });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("对象管理流程:get 对象列表→create 对象→get 列表校验", async (t) => {
|
|
20
|
+
const objectLabel = `TestObj${Date.now()}`;
|
|
21
|
+
const labelNeedle = "testobj";
|
|
22
|
+
let createdSlugHit = false;
|
|
23
|
+
|
|
24
|
+
t.test("1) create 创建自定义对象", async () => {
|
|
25
|
+
await assert.doesNotReject(() => runCc(["create", "object", repoRoot, objectLabel]));
|
|
26
|
+
console.error(`\n[object-test] 已创建自定义对象 label: ${objectLabel}`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
t.test("2) get 获取标准对象列表(standard)", async () => {
|
|
30
|
+
const { stdout } = await runCc(["get", "object", repoRoot, "standard"]);
|
|
31
|
+
const list = JSON.parse(stdout.trim());
|
|
32
|
+
assert.ok(Array.isArray(list), "get object standard 应返回数组");
|
|
33
|
+
assert.ok(list.length > 0, "标准对象列表应非空");
|
|
34
|
+
// 标准对象列表不要求包含新建自定义对象
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
t.test("3) get 获取自定义对象列表(custom)并校验包含新对象", async () => {
|
|
38
|
+
const { stdout } = await runCc(["get", "object", repoRoot, "custom"]);
|
|
39
|
+
const list = JSON.parse(stdout.trim());
|
|
40
|
+
assert.ok(Array.isArray(list), "get object custom 应返回数组");
|
|
41
|
+
assert.ok(list.length > 0, "自定义对象列表应非空");
|
|
42
|
+
|
|
43
|
+
createdSlugHit = list.some((o) => {
|
|
44
|
+
const s = `${o.schemetableName || ""}`.toLowerCase();
|
|
45
|
+
const l = `${o.label || o.objname || ""}`.toLowerCase();
|
|
46
|
+
return s.includes(labelNeedle) || l.includes(labelNeedle);
|
|
47
|
+
});
|
|
48
|
+
assert.ok(createdSlugHit, "custom 列表应包含刚创建的自定义对象(按 testobj 关键字匹配)");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
t.test("4) get 获取全部对象列表(both / 默认)并校验包含新对象", async () => {
|
|
52
|
+
const { stdout } = await runCc(["get", "object", repoRoot]);
|
|
53
|
+
const list = JSON.parse(stdout.trim());
|
|
54
|
+
assert.ok(Array.isArray(list), "get object(默认)应返回数组");
|
|
55
|
+
assert.ok(list.length > 0, "全部对象列表应非空");
|
|
56
|
+
|
|
57
|
+
const found = list.some((o) => {
|
|
58
|
+
const s = `${o.schemetableName || ""}`.toLowerCase();
|
|
59
|
+
const l = `${o.label || o.objname || ""}`.toLowerCase();
|
|
60
|
+
return s.includes(labelNeedle) || l.includes(labelNeedle);
|
|
61
|
+
});
|
|
62
|
+
assert.ok(found || createdSlugHit, "both 列表应包含刚创建的自定义对象");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { exec } = require("node:child_process");
|
|
6
|
+
const { promisify } = require("node:util");
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
10
|
+
const pluginsRoot = path.join(repoRoot, "plugins");
|
|
11
|
+
|
|
12
|
+
function quoteArg(value) {
|
|
13
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function runCc(args) {
|
|
17
|
+
const cmd = `npx --prefix "${repoRoot}" cc ${args.map(quoteArg).join(" ")}`;
|
|
18
|
+
return execAsync(cmd, { cwd: repoRoot });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("组件管理流程:创建→doc→发布→拉取→详情→列表→清空 plugins 文件夹", async (t) => {
|
|
22
|
+
const pluginName = `CCPlugin${Date.now()}`;
|
|
23
|
+
const pluginDir = path.join(pluginsRoot, pluginName);
|
|
24
|
+
const configPath = path.join(pluginDir, "config.json");
|
|
25
|
+
const vuePath = path.join(pluginDir, `${pluginName}.vue`);
|
|
26
|
+
let publishedId = null;
|
|
27
|
+
let publishOk = false;
|
|
28
|
+
let pulledPluginName = null;
|
|
29
|
+
let pulledPluginId = null;
|
|
30
|
+
|
|
31
|
+
t.test("1) 创建组件", async () => {
|
|
32
|
+
await runCc(["create", "plugin", pluginName]);
|
|
33
|
+
|
|
34
|
+
assert.equal(fs.existsSync(pluginDir), true);
|
|
35
|
+
assert.equal(fs.existsSync(configPath), true);
|
|
36
|
+
assert.equal(fs.existsSync(vuePath), true);
|
|
37
|
+
|
|
38
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
39
|
+
assert.ok(config.component && config.component.includes(pluginName));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
t.test("2) 获取 doc(plugin overview)", async () => {
|
|
43
|
+
const { stdout } = await runCc(["doc", "plugin", "overview"]);
|
|
44
|
+
assert.ok(stdout.length > 0, "doc plugin 应有输出");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
t.test("3) 发布组件(publish:以输出判断成功)", async () => {
|
|
48
|
+
const { stdout, stderr } = await runCc(["publish", "plugin", pluginName]);
|
|
49
|
+
const out = `${stdout}\n${stderr}`.trim();
|
|
50
|
+
// 注意:src/plugin/publish1.js 使用 child_process.exec 的回调触发上传,
|
|
51
|
+
// 在 CLI 进程退出前不一定能等到 build/upload 完成,因此这里用“编译阶段成功”作为发布成功的判定。
|
|
52
|
+
publishOk = /Compilation Successful|Compilation Successful!|Success|Successful|成功|returnCode\s*[:=]\s*200|200/i.test(out);
|
|
53
|
+
assert.ok(publishOk, "publish 应至少输出编译成功或发布成功信息");
|
|
54
|
+
|
|
55
|
+
// 兼容:部分环境不会写回 config.id,因此这里不再强依赖 publishedId
|
|
56
|
+
const configAfterPublish = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
57
|
+
publishedId = configAfterPublish.id || null;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
t.test("4) 拉取组件(先 get 列表,再按 id pull)", async () => {
|
|
61
|
+
// 先拿线上列表,再选择第一个可用的 id 拉取(不依赖 publish 是否写回 id)
|
|
62
|
+
const { stdout } = await runCc(["get", "plugin", repoRoot]);
|
|
63
|
+
const list = JSON.parse(stdout.trim());
|
|
64
|
+
assert.ok(Array.isArray(list), "get plugin 应返回数组");
|
|
65
|
+
assert.ok(list.length > 0, "线上组件列表应非空");
|
|
66
|
+
|
|
67
|
+
const first = list.find((x) => x && x.id) || null;
|
|
68
|
+
assert.ok(first && first.id, "应能从列表中取到组件 id");
|
|
69
|
+
pulledPluginId = first.id;
|
|
70
|
+
|
|
71
|
+
// pull 支持通过 id 拉取并自动创建本地目录
|
|
72
|
+
await runCc(["pull", "plugin", pulledPluginId, repoRoot]);
|
|
73
|
+
|
|
74
|
+
// 通过本地目录判断拉取是否成功:pull.js 会归一化名称(去掉 component- 前缀)
|
|
75
|
+
const guessName =
|
|
76
|
+
(first.name && String(first.name)) ||
|
|
77
|
+
(first.component && String(first.component)) ||
|
|
78
|
+
null;
|
|
79
|
+
pulledPluginName = guessName ? guessName.replace(/^component-/, "") : null;
|
|
80
|
+
assert.ok(pulledPluginName, "应能从列表推断出拉取后的本地插件目录名");
|
|
81
|
+
|
|
82
|
+
const pulledDir = path.join(pluginsRoot, pulledPluginName);
|
|
83
|
+
assert.ok(fs.existsSync(pulledDir), "pull 后应生成本地插件目录");
|
|
84
|
+
assert.ok(fs.existsSync(path.join(pulledDir, "config.json")), "pull 后应生成 config.json");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
t.test("5) 查询组件详情(detail:本地 name)", async () => {
|
|
88
|
+
// 优先对拉取到的插件做 detail(确保链路:get -> pull -> detail)
|
|
89
|
+
const pulledDir = path.join(pluginsRoot, pulledPluginName);
|
|
90
|
+
assert.ok(pulledPluginName, "需要 pulledPluginName");
|
|
91
|
+
assert.ok(fs.existsSync(pulledDir), "pull 后本地目录应存在");
|
|
92
|
+
await assert.doesNotReject(() => runCc(["detail", "plugin", pulledPluginName, "", repoRoot]));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
t.test("6) get 获取线上组件列表", async () => {
|
|
96
|
+
const { stdout } = await runCc(["get", "plugin", repoRoot]);
|
|
97
|
+
const list = JSON.parse(stdout.trim());
|
|
98
|
+
assert.ok(Array.isArray(list), "get plugin 应返回数组");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
t.test("7) 清空 plugins 文件夹", async () => {
|
|
102
|
+
if (fs.existsSync(pluginsRoot)) {
|
|
103
|
+
fs.rmSync(pluginsRoot, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
fs.mkdirSync(pluginsRoot, { recursive: true });
|
|
106
|
+
const remain = fs.readdirSync(pluginsRoot);
|
|
107
|
+
assert.equal(remain.length, 0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { exec } = require("node:child_process");
|
|
6
|
+
const { promisify } = require("node:util");
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
10
|
+
const scriptRoot = path.join(repoRoot, "script");
|
|
11
|
+
|
|
12
|
+
function quoteArg(value) {
|
|
13
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function runCc(args) {
|
|
17
|
+
const cmd = `npx --prefix "${repoRoot}" cc ${args.map(quoteArg).join(" ")}`;
|
|
18
|
+
return execAsync(cmd, { cwd: repoRoot });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("客户端脚本管理流程:创建→doc→发布→拉取→线上详情→列表→批量拉取前2个→清空 script 文件夹", async (t) => {
|
|
22
|
+
const objName = "Account";
|
|
23
|
+
const scriptName = `TestScript${Date.now()}`;
|
|
24
|
+
const namePath = `${objName}/${scriptName}`;
|
|
25
|
+
const scriptDir = path.join(scriptRoot, objName, scriptName);
|
|
26
|
+
const configPath = path.join(scriptDir, "config.json");
|
|
27
|
+
const jsPath = path.join(scriptDir, `${scriptName}.js`);
|
|
28
|
+
let topIds = [];
|
|
29
|
+
|
|
30
|
+
t.test("1) 创建客户端脚本", async () => {
|
|
31
|
+
const body = encodeURI(JSON.stringify({ objName, scriptName }));
|
|
32
|
+
await runCc(["create", "script", body]);
|
|
33
|
+
|
|
34
|
+
assert.equal(fs.existsSync(scriptDir), true);
|
|
35
|
+
assert.equal(fs.existsSync(configPath), true);
|
|
36
|
+
assert.equal(fs.existsSync(jsPath), true);
|
|
37
|
+
|
|
38
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
39
|
+
assert.equal(config.scriptName, scriptName);
|
|
40
|
+
assert.equal(config.objName, objName);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
t.test("2) 获取 doc(script overview)", async () => {
|
|
44
|
+
const { stdout } = await runCc(["doc", "script", "overview"]);
|
|
45
|
+
assert.match(stdout, /客户端脚本|CCDK|编辑指南/i);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
t.test("3) 发布脚本(publish,写回 config.id)", async () => {
|
|
49
|
+
await runCc(["publish", "script", namePath]);
|
|
50
|
+
const configAfterPublish = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
51
|
+
assert.ok(configAfterPublish.id, "publish 后应写入 config.id");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
t.test("4) 拉取脚本(pull:覆盖本地)", async () => {
|
|
55
|
+
const localJs = fs.readFileSync(jsPath, "utf8");
|
|
56
|
+
const modifiedJs = localJs + "\n// LOCAL_ONLY";
|
|
57
|
+
fs.writeFileSync(jsPath, modifiedJs, "utf8");
|
|
58
|
+
|
|
59
|
+
await runCc(["pull", "script", namePath, repoRoot]);
|
|
60
|
+
const jsAfterPull = fs.readFileSync(jsPath, "utf8");
|
|
61
|
+
assert.ok(!jsAfterPull.includes("LOCAL_ONLY"), "pull 后应使用远端内容覆盖本地");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
t.test("5) 查询线上脚本详情(detail:仅使用 id 或 namePath)", async () => {
|
|
65
|
+
await assert.doesNotReject(() => runCc(["detail", "script", namePath, "", repoRoot]));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
t.test("6) get 获取线上脚本列表(取前2个 id)", async () => {
|
|
69
|
+
const listQuery = encodeURI(JSON.stringify({ pageNo: 1, pageSize: 20, condition: {} }));
|
|
70
|
+
const { stdout } = await runCc(["get", "script", listQuery, repoRoot]);
|
|
71
|
+
const list = JSON.parse(stdout.trim());
|
|
72
|
+
assert.ok(Array.isArray(list), "get script 应返回数组");
|
|
73
|
+
topIds = list.slice(0, 2).map((x) => x.id).filter(Boolean);
|
|
74
|
+
assert.ok(topIds.length >= 1, "线上脚本列表至少应包含 1 个 id");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
t.test("7) 批量拉取前2个脚本(pullList)", async () => {
|
|
78
|
+
assert.ok(topIds.length >= 1, "pullList 需要至少 1 个线上脚本 id");
|
|
79
|
+
const beforeCount = fs.existsSync(scriptRoot)
|
|
80
|
+
? fs.readdirSync(scriptRoot).filter((n) => fs.statSync(path.join(scriptRoot, n)).isDirectory()).length
|
|
81
|
+
: 0;
|
|
82
|
+
|
|
83
|
+
for (const id of topIds) {
|
|
84
|
+
await runCc(["pullList", "script", id, repoRoot]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const afterCount = fs.existsSync(scriptRoot)
|
|
88
|
+
? fs.readdirSync(scriptRoot).filter((n) => fs.statSync(path.join(scriptRoot, n)).isDirectory()).length
|
|
89
|
+
: 0;
|
|
90
|
+
assert.ok(afterCount >= beforeCount, "pullList 之后 script 目录应存在/可用");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
t.test("8) 清空 script 文件夹", async () => {
|
|
94
|
+
if (fs.existsSync(scriptRoot)) {
|
|
95
|
+
fs.rmSync(scriptRoot, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
fs.mkdirSync(scriptRoot, { recursive: true });
|
|
98
|
+
const remain = fs.readdirSync(scriptRoot);
|
|
99
|
+
assert.equal(remain.length, 0);
|
|
100
|
+
});
|
|
101
|
+
});
|