ssh-client-wrapper 1.5.4 → 1.7.9
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/.eslintrc.json +1 -0
- package/.github/workflows/run_test.yml +23 -1
- package/.husky/pre-commit +4 -0
- package/README.md +4 -0
- package/lib/index.js +7 -9
- package/lib/sshExec.js +45 -66
- package/lib/util.js +18 -1
- package/package.json +9 -10
- package/test/rsyncExec.js +7 -4
- package/test/sshExec.js +7 -4
package/.eslintrc.json
CHANGED
|
@@ -5,6 +5,7 @@ on:
|
|
|
5
5
|
- 'v*'
|
|
6
6
|
branches:
|
|
7
7
|
- '*'
|
|
8
|
+
- '!main'
|
|
8
9
|
workflow_call:
|
|
9
10
|
concurrency:
|
|
10
11
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
@@ -33,4 +34,25 @@ jobs:
|
|
|
33
34
|
node-version: ${{ matrix.node-version }}
|
|
34
35
|
- run: npm ci
|
|
35
36
|
- run: npm run test
|
|
36
|
-
- run: npm
|
|
37
|
+
- run: npm run coverage
|
|
38
|
+
- uses: actions/upload-artifact@v3
|
|
39
|
+
with:
|
|
40
|
+
name: code-coverage-report
|
|
41
|
+
path: coverage-report.lcov
|
|
42
|
+
retention-days: 1
|
|
43
|
+
coverage:
|
|
44
|
+
needs: [test]
|
|
45
|
+
name: coverage report
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
env:
|
|
48
|
+
CC_TEST_REPORTER_ID: 52ae52d099fd6546c12030f23b20e2a4e0b485c6a1b139ec11e3615ab073e689
|
|
49
|
+
steps:
|
|
50
|
+
- run: echo ${CC_TEST_REPORTER_ID}
|
|
51
|
+
- run: echo '${{ toJSON(secrets.ccid) }}'
|
|
52
|
+
- uses: actions/checkout@v3
|
|
53
|
+
- uses: actions/download-artifact@v3
|
|
54
|
+
with:
|
|
55
|
+
name: code-coverage-report
|
|
56
|
+
- uses: paambaati/codeclimate-action@v5.0.0
|
|
57
|
+
with:
|
|
58
|
+
coverageLocations: "coverage-report.lcov:lcov"
|
package/README.md
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
+
[](https://badge.fury.io/js/ssh-client-wrapper)
|
|
2
|
+
[](https://snyk.io/test/github/so5/ssh-client-wrapper)
|
|
3
|
+
[](https://codeclimate.com/github/so5/ssh-client-wrapper/test_coverage)
|
|
4
|
+
[](https://codeclimate.com/github/so5/ssh-client-wrapper/maintainability)
|
|
1
5
|
# ssh-client-wrapper
|
|
2
6
|
open ssh client wrapper library for nodejs
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ const debugLib = require("debug");
|
|
|
3
3
|
const debug = debugLib("sshClientWrapper:interface");
|
|
4
4
|
const { sshExec, canConnect, disconnect, ls } = require("./sshExec.js");
|
|
5
5
|
const { send, recv } = require("./rsyncExec.js");
|
|
6
|
-
const { isArrayOfString } = require("./util.js");
|
|
6
|
+
const { isArrayOfString, sanityCheck } = require("./util.js");
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Integer means integer number but it is defined as Object for now
|
|
@@ -36,9 +36,7 @@ const { isArrayOfString } = require("./util.js");
|
|
|
36
36
|
*/
|
|
37
37
|
class SshClientWrapper {
|
|
38
38
|
constructor(hostInfo) {
|
|
39
|
-
|
|
40
|
-
return new Error("hostname must be specified!");
|
|
41
|
-
}
|
|
39
|
+
sanityCheck(hostInfo);
|
|
42
40
|
this.hostInfo = { ...hostInfo };
|
|
43
41
|
this.hostInfo.masterPty = null;
|
|
44
42
|
}
|
|
@@ -78,8 +76,6 @@ class SshClientWrapper {
|
|
|
78
76
|
* @param {number} retryDelay - Duration between each try (in sec).
|
|
79
77
|
* @param {number} maxRetry - Max retry count.
|
|
80
78
|
* @returns {Integer} - Return code of cmd.
|
|
81
|
-
*
|
|
82
|
-
* If stdout and stderr is array, last 10 line of stdout and stderr is stored in them.
|
|
83
79
|
*/
|
|
84
80
|
async watch(cmd, regexp, retryDelay = 3, maxRetry = null) {
|
|
85
81
|
debug("watch called", cmd, regexp, retryDelay, maxRetry);
|
|
@@ -95,7 +91,9 @@ class SshClientWrapper {
|
|
|
95
91
|
err.maxRetry = maxRetry;
|
|
96
92
|
return Promise.reject(err);
|
|
97
93
|
}
|
|
98
|
-
//
|
|
94
|
+
//not implemented yet !!!
|
|
95
|
+
return this.exec(cmd, ()=>{
|
|
96
|
+
});
|
|
99
97
|
}
|
|
100
98
|
|
|
101
99
|
/**
|
|
@@ -159,7 +157,7 @@ class SshClientWrapper {
|
|
|
159
157
|
* @param {Integer} timeout - timeout in secconds
|
|
160
158
|
* @returns {Promise} - Resolved with true on success, otherwise rejected with Error.
|
|
161
159
|
*/
|
|
162
|
-
canConnect(timeout = 60) {
|
|
160
|
+
async canConnect(timeout = 60) {
|
|
163
161
|
debug("canConnect called timeout=", timeout);
|
|
164
162
|
|
|
165
163
|
if (!Number.isInteger(timeout) || timeout <= 0) {
|
|
@@ -171,7 +169,7 @@ class SshClientWrapper {
|
|
|
171
169
|
/**
|
|
172
170
|
* Disconnect master session
|
|
173
171
|
*/
|
|
174
|
-
disconnect() {
|
|
172
|
+
async disconnect() {
|
|
175
173
|
debug("disconnect called");
|
|
176
174
|
disconnect(this.hostInfo);
|
|
177
175
|
}
|
package/lib/sshExec.js
CHANGED
|
@@ -4,7 +4,7 @@ const crypto = require("crypto");
|
|
|
4
4
|
const promiseRetry = require("promise-retry");
|
|
5
5
|
const debug = require("debug")("sshClientWrapper:sshExec");
|
|
6
6
|
const debugVerbose = require("debug")("sshClientWrapper:sshExec_verbose");
|
|
7
|
-
const { sshCmd, getSshOption } = require("./util.js");
|
|
7
|
+
const { sshCmd, getSshOption, sanityCheck } = require("./util.js");
|
|
8
8
|
const { fork, getMasterPty } = require("./fork.js");
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -35,7 +35,7 @@ async function sshExec(hostInfo, cmd, outputCallback) {
|
|
|
35
35
|
debug(`${cmd} not found but ignored`);
|
|
36
36
|
return e.rt;
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
return e.rt;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -45,60 +45,15 @@ async function sshExec(hostInfo, cmd, outputCallback) {
|
|
|
45
45
|
* @param {Integer} timeout - timeout in secconds
|
|
46
46
|
*/
|
|
47
47
|
async function canConnect(hostInfo, timeout = 60) {
|
|
48
|
-
debug("check
|
|
49
|
-
|
|
50
|
-
const signal = ac.signal;
|
|
48
|
+
debug("sanity check");
|
|
49
|
+
sanityCheck(hostInfo);
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
.then(()=>{
|
|
54
|
-
const err = new Error(`can not connect within ${timeout} sec`);
|
|
55
|
-
err.host = hostInfo.host;
|
|
56
|
-
err.user = hostInfo.user;
|
|
57
|
-
err.port = hostInfo.port;
|
|
58
|
-
throw err;
|
|
59
|
-
})
|
|
60
|
-
.catch((err)=>{
|
|
61
|
-
if (err.name === "AbortError") {
|
|
62
|
-
debug("timeout was aborted because connection check has done");
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
if (await existsMaster(hostInfo)) {
|
|
67
|
-
const words = crypto.randomUUID();
|
|
68
|
-
const args = getSshOption(hostInfo);
|
|
69
|
-
if (Number.isInteger(timeout) && timeout > 0) {
|
|
70
|
-
args.push(`-oConnectTimeout=${timeout}`);
|
|
71
|
-
}
|
|
72
|
-
args.push(`echo ${words}`);
|
|
73
|
-
let output = "";
|
|
74
|
-
const cb = (e)=>{
|
|
75
|
-
output += e;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
debugVerbose(`canConnect: ${sshCmd} ${args.join(" ")}`);
|
|
79
|
-
const rt = await retryWrapper(fork.bind(null, hostInfo, sshCmd, args, cb), hostInfo);
|
|
80
|
-
ac.abort();
|
|
51
|
+
debug("check connectibity");
|
|
81
52
|
|
|
82
|
-
if (rt !== 0) {
|
|
83
|
-
const err = new Error(`can not connect to ${hostInfo.host}. return code=${rt}`);
|
|
84
|
-
err.rt = rt;
|
|
85
|
-
err.host = hostInfo.host;
|
|
86
|
-
err.user = hostInfo.user;
|
|
87
|
-
err.port = hostInfo.port;
|
|
88
|
-
return err;
|
|
89
|
-
}
|
|
90
|
-
const re = new RegExp(words);
|
|
91
|
-
if (!re.test(output)) {
|
|
92
|
-
throw new Error("can not get echo result correctly");
|
|
93
|
-
}
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
96
|
-
//do NOT catch following try
|
|
97
|
-
//if connect throw error, canConnect must be throw error
|
|
98
53
|
try {
|
|
99
|
-
await connect(hostInfo);
|
|
54
|
+
await connect(hostInfo, timeout);
|
|
100
55
|
} finally {
|
|
101
|
-
|
|
56
|
+
await disconnect(hostInfo);
|
|
102
57
|
}
|
|
103
58
|
return true;
|
|
104
59
|
}
|
|
@@ -116,11 +71,13 @@ async function existsMaster(hostInfo) {
|
|
|
116
71
|
const cb = (e)=>{
|
|
117
72
|
output += e;
|
|
118
73
|
};
|
|
74
|
+
|
|
119
75
|
try {
|
|
120
76
|
debug(`check master session: ${sshCmd} ${args.join(" ")}`);
|
|
121
77
|
await fork(hostInfo, sshCmd, args, cb);
|
|
122
78
|
} catch (err) {
|
|
123
79
|
if (err.rt !== 255) {
|
|
80
|
+
debug("check master session failed", err);
|
|
124
81
|
throw err;
|
|
125
82
|
}
|
|
126
83
|
}
|
|
@@ -131,27 +88,48 @@ async function existsMaster(hostInfo) {
|
|
|
131
88
|
* create master ssh connection
|
|
132
89
|
* @param {Object} hostInfo - hostinfo object
|
|
133
90
|
*/
|
|
134
|
-
async function connect(hostInfo) {
|
|
91
|
+
async function connect(hostInfo, timeout = 60) {
|
|
135
92
|
if (await existsMaster(hostInfo)) {
|
|
136
93
|
debugVerbose("master connection exists");
|
|
137
94
|
return;
|
|
138
95
|
}
|
|
139
|
-
|
|
140
96
|
debug(`create master ssh session to ${hostInfo.host}`);
|
|
141
|
-
const pty =
|
|
97
|
+
const pty = getMasterPty(hostInfo);
|
|
142
98
|
hostInfo.masterPty = pty;
|
|
143
99
|
|
|
100
|
+
const ac = new AbortController();
|
|
101
|
+
const signal = ac.signal;
|
|
102
|
+
setTimeoutPromise(timeout * 1000, "neverUsedValue", { signal })
|
|
103
|
+
.then(()=>{
|
|
104
|
+
const err = new Error(`can not connect within ${timeout} sec`);
|
|
105
|
+
err.host = hostInfo.host;
|
|
106
|
+
err.user = hostInfo.user;
|
|
107
|
+
err.port = hostInfo.port;
|
|
108
|
+
pty.write("\n");
|
|
109
|
+
pty.write("exit\n");
|
|
110
|
+
throw err;
|
|
111
|
+
})
|
|
112
|
+
.catch((err)=>{
|
|
113
|
+
if (err.name === "AbortError") {
|
|
114
|
+
debug("timeout was aborted because connection check has done");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
144
118
|
const words = crypto.randomUUID();
|
|
145
119
|
const re = new RegExp(`^${words}`);
|
|
146
|
-
|
|
147
120
|
const p = new Promise((resolve, reject)=>{
|
|
121
|
+
let done = false;
|
|
148
122
|
pty.onData((data)=>{
|
|
123
|
+
if (done) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
149
126
|
const output = data.toString();
|
|
150
127
|
debugVerbose(output);
|
|
151
128
|
|
|
152
129
|
if (re.test(output)) {
|
|
153
|
-
debug("call exit on remotehost");
|
|
130
|
+
debug("test done. call exit on remotehost");
|
|
154
131
|
pty.write("exit\n");
|
|
132
|
+
done = true;
|
|
155
133
|
resolve();
|
|
156
134
|
} else if (/Permission denied \(/.test(output)) {
|
|
157
135
|
reject(new Error("Permission denied"));
|
|
@@ -175,6 +153,7 @@ async function connect(hostInfo) {
|
|
|
175
153
|
debugVerbose(cmd);
|
|
176
154
|
pty.write(cmd);
|
|
177
155
|
await p;
|
|
156
|
+
ac.abort();
|
|
178
157
|
debug(`connected to ${hostInfo.host}`);
|
|
179
158
|
}
|
|
180
159
|
|
|
@@ -234,12 +213,12 @@ async function ls(hostInfo, target, lsOpt = []) {
|
|
|
234
213
|
throw e;
|
|
235
214
|
}
|
|
236
215
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
216
|
+
return outputArray.length === 0 ? []
|
|
217
|
+
: outputArray
|
|
218
|
+
.join("\n")
|
|
219
|
+
.replace(/\n+/g, "\n")
|
|
220
|
+
.replace(/\n$/, "")
|
|
221
|
+
.split("\n");
|
|
243
222
|
}
|
|
244
223
|
|
|
245
224
|
/**
|
|
@@ -247,9 +226,9 @@ async function ls(hostInfo, target, lsOpt = []) {
|
|
|
247
226
|
* @param {Function} func - function to be wrapped
|
|
248
227
|
*/
|
|
249
228
|
async function retryWrapper(func, hostInfo) {
|
|
250
|
-
const minTimeout = hostInfo.retryMinTimeout * 1000 ||
|
|
251
|
-
const maxTimeout = hostInfo.retryMaxTimeout * 1000 ||
|
|
252
|
-
const retries = hostInfo.maxRetry ||
|
|
229
|
+
const minTimeout = hostInfo.retryMinTimeout * 1000 || 1000;
|
|
230
|
+
const maxTimeout = hostInfo.retryMaxTimeout * 1000 || 5000;
|
|
231
|
+
const retries = hostInfo.maxRetry || 1;
|
|
253
232
|
return promiseRetry({ retries, minTimeout, maxTimeout }, (retry, number)=>{
|
|
254
233
|
return func()
|
|
255
234
|
.catch((e)=>{
|
package/lib/util.js
CHANGED
|
@@ -17,6 +17,22 @@ const buff2String = (func, data)=>{
|
|
|
17
17
|
func(data.toString().replace(/\r\n/g, EOL));
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* check if given hostInfo object is OK
|
|
22
|
+
* @param { Object} hostInfo - host information object defined in index.js
|
|
23
|
+
*/
|
|
24
|
+
const sanityCheck = (hostInfo)=>{
|
|
25
|
+
if (typeof hostInfo.host !== "string" || hostInfo.host === "") {
|
|
26
|
+
throw new Error("host must be non-empty string");
|
|
27
|
+
}
|
|
28
|
+
if (Object.prototype.hasOwnProperty.call(hostInfo, "port") && (typeof hostInfo.port === "string" || Number.isInteger(hostInfo.port))) {
|
|
29
|
+
const port = Number(hostInfo.port);
|
|
30
|
+
if (port < 0 || port > 65535) {
|
|
31
|
+
throw new Error("port number must be in the range of 0 to 65535");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
20
36
|
/**
|
|
21
37
|
* return ssh option with some hard corded options
|
|
22
38
|
* @param { Object} hostInfo - host information object defined in index.js
|
|
@@ -41,7 +57,7 @@ const getSshOption = (hostInfo, withoutDestination)=>{
|
|
|
41
57
|
args.push("-l");
|
|
42
58
|
args.push(hostInfo.loginname);
|
|
43
59
|
}
|
|
44
|
-
if (typeof hostInfo.port === "string" ||
|
|
60
|
+
if (typeof hostInfo.port === "string" || Number.isInteger(hostInfo.port)) {
|
|
45
61
|
args.push("-p");
|
|
46
62
|
args.push(hostInfo.port);
|
|
47
63
|
}
|
|
@@ -88,5 +104,6 @@ module.exports = {
|
|
|
88
104
|
reNewHostPrompt,
|
|
89
105
|
buff2String,
|
|
90
106
|
getSshOption,
|
|
107
|
+
sanityCheck,
|
|
91
108
|
isArrayOfString
|
|
92
109
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-client-wrapper",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.9",
|
|
4
4
|
"description": "wrapper library for open ssh client and rsync",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ssh",
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
],
|
|
14
14
|
"main": "lib/index.js",
|
|
15
15
|
"scripts": {
|
|
16
|
-
"
|
|
16
|
+
"mocha": "mocha --recursive test",
|
|
17
17
|
"lint": "eslint --fix lib test",
|
|
18
|
-
"
|
|
18
|
+
"test": "nyc --reporter=html --reporter=text mocha",
|
|
19
|
+
"coverage": "nyc report --reporter=text-lcov > coverage-report.lcov",
|
|
20
|
+
"postversion": "git push && git push origin $(git describe --abbrev=0 --tags)",
|
|
21
|
+
"prepare": "husky install"
|
|
19
22
|
},
|
|
20
23
|
"engines": {
|
|
21
24
|
"node": ">=12.9"
|
|
@@ -27,18 +30,14 @@
|
|
|
27
30
|
"url": "https://github.com/so5/ssh-client-wrapper"
|
|
28
31
|
},
|
|
29
32
|
"homepage": "https://github.com/so5/ssh-client-wrapper#readme",
|
|
30
|
-
"husky": {
|
|
31
|
-
"hooks": {
|
|
32
|
-
"pre-commit": "lint-staged"
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
33
|
"lint-staged": {
|
|
36
34
|
"*.js": "eslint --fix"
|
|
37
35
|
},
|
|
38
36
|
"dependencies": {
|
|
39
37
|
"debug": "^4.3.4",
|
|
40
|
-
"glob": "^10.
|
|
38
|
+
"glob": "^10.3.1",
|
|
41
39
|
"node-pty": "^0.10.1",
|
|
40
|
+
"nyc": "^15.1.0",
|
|
42
41
|
"promise-retry": "^2.0.1"
|
|
43
42
|
},
|
|
44
43
|
"devDependencies": {
|
|
@@ -52,7 +51,7 @@
|
|
|
52
51
|
"eslint-plugin-jsdoc": "^43.0.6",
|
|
53
52
|
"eslint-plugin-node": "^11.1.0",
|
|
54
53
|
"fs-extra": "^11.1.1",
|
|
55
|
-
"husky": "^8.0.
|
|
54
|
+
"husky": "^8.0.0",
|
|
56
55
|
"lint-staged": "^13.2.1",
|
|
57
56
|
"mocha": "^10.2.0",
|
|
58
57
|
"sinon": "^15.0.3",
|
package/test/rsyncExec.js
CHANGED
|
@@ -31,10 +31,13 @@ const {
|
|
|
31
31
|
} = require("./util/testFiles");
|
|
32
32
|
|
|
33
33
|
const formatLsOutput = (array)=>{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.
|
|
37
|
-
|
|
34
|
+
const rt = [];
|
|
35
|
+
for (const e of array) {
|
|
36
|
+
rt.push(...e.split("\n"));
|
|
37
|
+
}
|
|
38
|
+
return rt.filter((e)=>{
|
|
39
|
+
return e !== "";
|
|
40
|
+
});
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
|
package/test/sshExec.js
CHANGED
|
@@ -18,11 +18,10 @@ const { sshExec, canConnect, disconnect, ls } = require("../lib/sshExec.js");
|
|
|
18
18
|
|
|
19
19
|
//test helpers
|
|
20
20
|
const hostInfo = require("./util/hostInfo.js");
|
|
21
|
-
const { clearRemoteTestFiles, createRemoteFiles, nonExisting, remoteRoot } = require("./util/testFiles.js");
|
|
22
|
-
|
|
21
|
+
const { clearRemoteTestFiles, clearLocalTestFiles, createRemoteFiles, nonExisting, remoteRoot, remoteEmptyDir } = require("./util/testFiles.js");
|
|
23
22
|
|
|
24
23
|
describe("test for ssh execution", function() {
|
|
25
|
-
this.timeout(
|
|
24
|
+
this.timeout(65000);//eslint-disable-line no-invalid-this
|
|
26
25
|
beforeEach(async ()=>{
|
|
27
26
|
sshout.reset();
|
|
28
27
|
await clearRemoteTestFiles(hostInfo);
|
|
@@ -30,6 +29,7 @@ describe("test for ssh execution", function() {
|
|
|
30
29
|
after(async ()=>{
|
|
31
30
|
if (!process.env.TEST_KEEP_FILES) {
|
|
32
31
|
await clearRemoteTestFiles(hostInfo);
|
|
32
|
+
await clearLocalTestFiles();
|
|
33
33
|
}
|
|
34
34
|
await disconnect(hostInfo);
|
|
35
35
|
});
|
|
@@ -73,7 +73,7 @@ describe("test for ssh execution", function() {
|
|
|
73
73
|
await disconnect(hostInfo2);
|
|
74
74
|
});
|
|
75
75
|
it("should be resolved with true", async ()=>{
|
|
76
|
-
expect(await canConnect(hostInfo2, 2)).to.be.true;
|
|
76
|
+
return expect(await canConnect(hostInfo2, 2)).to.be.true;
|
|
77
77
|
});
|
|
78
78
|
it("should be rejected if user does not exist", async ()=>{
|
|
79
79
|
hostInfo2.user = "xxxx";
|
|
@@ -129,5 +129,8 @@ describe("test for ssh execution", function() {
|
|
|
129
129
|
return path.posix.join(remoteRoot, "hoge", e);
|
|
130
130
|
}));
|
|
131
131
|
});
|
|
132
|
+
it("should return empty array if specified target is empty directory", async ()=>{
|
|
133
|
+
expect(await ls(hostInfo, remoteEmptyDir)).to.be.an("array").that.is.empty;
|
|
134
|
+
});
|
|
132
135
|
});
|
|
133
136
|
});
|