@teamscale/coverage-collector 0.0.1-beta.41 → 0.0.1-beta.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +2 -1
- package/dist/src/main.d.ts +4 -0
- package/dist/src/main.js +60 -42
- package/dist/src/storage/DataStorage.d.ts +30 -7
- package/dist/src/storage/DataStorage.js +57 -22
- package/package.json +2 -1
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamscale/coverage-collector",
|
|
3
|
-
"version": "0.0.1-beta.
|
|
3
|
+
"version": "0.0.1-beta.42",
|
|
4
4
|
"description": "Collector for JavaScript code coverage information",
|
|
5
5
|
"main": "dist/src/main.js",
|
|
6
6
|
"bin": "dist/src/main.js",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"async": "^3.2.4",
|
|
28
28
|
"axios": "^0.24.0",
|
|
29
29
|
"bunyan": "^1.8.15",
|
|
30
|
+
"date-and-time": "^2.3.1",
|
|
30
31
|
"dotenv": "^14.1.0",
|
|
31
32
|
"form-data": "^4.0.0",
|
|
32
33
|
"mkdirp": "^1.0.4",
|
package/dist/src/main.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import 'dotenv/config';
|
|
|
5
5
|
* Used to start the collector for with a given configuration.
|
|
6
6
|
*/
|
|
7
7
|
export declare class Main {
|
|
8
|
+
private static readonly DEFAULT_COVERAGE_LOCATION;
|
|
8
9
|
/**
|
|
9
10
|
* Construct the object for parsing the command line arguments.
|
|
10
11
|
*/
|
|
@@ -31,4 +32,7 @@ export declare class Main {
|
|
|
31
32
|
private static maybeStartDumpTimer;
|
|
32
33
|
private static dumpCoverage;
|
|
33
34
|
private static uploadToTeamscale;
|
|
35
|
+
private static performTeamscaleUpload;
|
|
36
|
+
private static prepareQueryParameters;
|
|
37
|
+
private static prepareFormData;
|
|
34
38
|
}
|
package/dist/src/main.js
CHANGED
|
@@ -39,11 +39,15 @@ const axios_1 = __importDefault(require("axios"));
|
|
|
39
39
|
const form_data_1 = __importDefault(require("form-data"));
|
|
40
40
|
const QueryParameters_1 = __importDefault(require("./utils/QueryParameters"));
|
|
41
41
|
const util_1 = require("util");
|
|
42
|
-
const tmp_1 = __importDefault(require("tmp"));
|
|
43
42
|
const mkdirp_1 = __importDefault(require("mkdirp"));
|
|
44
43
|
const path_1 = __importDefault(require("path"));
|
|
45
44
|
const StdConsoleLogger_1 = require("./utils/StdConsoleLogger");
|
|
46
45
|
const PrettyFileLogger_1 = require("./utils/PrettyFileLogger");
|
|
46
|
+
/**
|
|
47
|
+
* Error that is thrown when the upload to Teamscale failed
|
|
48
|
+
*/
|
|
49
|
+
class TeamscaleUploadError extends Error {
|
|
50
|
+
}
|
|
47
51
|
/**
|
|
48
52
|
* The main class of the Teamscale JavaScript Collector.
|
|
49
53
|
* Used to start the collector for with a given configuration.
|
|
@@ -60,7 +64,15 @@ class Main {
|
|
|
60
64
|
});
|
|
61
65
|
parser.add_argument('-v', '--version', { action: 'version', version: package_json_1.version });
|
|
62
66
|
parser.add_argument('-p', '--port', { help: 'The port to receive coverage information on.', default: 54678 });
|
|
63
|
-
parser.add_argument('-f', '--dump-to-
|
|
67
|
+
parser.add_argument('-f', '--dump-to-folder', {
|
|
68
|
+
help: 'Target folder for coverage files.',
|
|
69
|
+
default: this.DEFAULT_COVERAGE_LOCATION
|
|
70
|
+
});
|
|
71
|
+
parser.add_argument('-k', '--keep-coverage-files', {
|
|
72
|
+
help: 'Whether to keep the coverage files on disk after a successful upload to Teamsacle',
|
|
73
|
+
action: 'store_true',
|
|
74
|
+
default: false
|
|
75
|
+
});
|
|
64
76
|
parser.add_argument('-l', '--log-to-file', { help: 'Log file', default: 'logs/collector-combined.log' });
|
|
65
77
|
parser.add_argument('-e', '--log-level', { help: 'Log level', default: 'info' });
|
|
66
78
|
parser.add_argument('-t', '--dump-after-mins', {
|
|
@@ -153,11 +165,6 @@ class Main {
|
|
|
153
165
|
const logger = this.buildLogger(config);
|
|
154
166
|
logger.info(`Starting collector in working directory "${process.cwd()}".`);
|
|
155
167
|
logger.info(`Logging "${config.log_level}" to "${config.log_to_file}".`);
|
|
156
|
-
// Check the command line arguments
|
|
157
|
-
if (!config.dump_to_file && !config.teamscale_server_url) {
|
|
158
|
-
logger.error('The Collector must be configured to either dump to a file or upload to Teamscale.');
|
|
159
|
-
process.exit(1);
|
|
160
|
-
}
|
|
161
168
|
// Prepare the storage and the server
|
|
162
169
|
const storage = new DataStorage_1.DataStorage(logger);
|
|
163
170
|
const server = new CollectingServer_1.WebSocketCollectingServer(config.port, storage, logger);
|
|
@@ -196,52 +203,48 @@ class Main {
|
|
|
196
203
|
}
|
|
197
204
|
}
|
|
198
205
|
static async dumpCoverage(config, storage, logger) {
|
|
199
|
-
var _a;
|
|
200
206
|
try {
|
|
201
|
-
|
|
202
|
-
const coverageFile = (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
if (config.
|
|
209
|
-
await this.uploadToTeamscale(config, logger, coverageFile, lines);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
finally {
|
|
213
|
-
if (deleteCoverageFileAfterUpload) {
|
|
207
|
+
// 1. Write coverage to a file
|
|
208
|
+
const [coverageFile, lines] = storage.dumpToSimpleCoverageFile(config.dump_to_folder, new Date());
|
|
209
|
+
logger.info(`Dumped ${lines} lines of coverage to ${coverageFile}.`);
|
|
210
|
+
// 2. Upload to Teamscale if configured
|
|
211
|
+
if (config.teamscale_server_url) {
|
|
212
|
+
await this.uploadToTeamscale(config, logger, coverageFile, lines);
|
|
213
|
+
// Delete coverage if upload was successful and keeping coverage files on disk was not configure by the user
|
|
214
|
+
if (!config.keep_coverage_files) {
|
|
214
215
|
fs.unlinkSync(coverageFile);
|
|
215
216
|
}
|
|
216
217
|
}
|
|
217
218
|
}
|
|
218
219
|
catch (e) {
|
|
219
|
-
|
|
220
|
+
if (e instanceof TeamscaleUploadError) {
|
|
221
|
+
logger.error(`Teamscale upload failed. The coverage files on disk (inside the folder "${config.dump_to_folder}") were not deleted.
|
|
222
|
+
You can still upload them manually.`, e);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
logger.error('Coverage dump failed.', e);
|
|
226
|
+
}
|
|
220
227
|
}
|
|
221
228
|
}
|
|
222
229
|
static async uploadToTeamscale(config, logger, coverageFile, lines) {
|
|
223
230
|
if (!(config.teamscale_access_token && config.teamscale_user && config.teamscale_server_url)) {
|
|
224
|
-
|
|
225
|
-
return;
|
|
231
|
+
throw new TeamscaleUploadError('API key and user name must be configured!');
|
|
226
232
|
}
|
|
227
233
|
if (lines === 0) {
|
|
228
234
|
return;
|
|
229
235
|
}
|
|
230
236
|
logger.info('Preparing upload to Teamscale');
|
|
231
|
-
const form =
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
parameters.addIfDefined('t', config.teamscale_commit);
|
|
238
|
-
parameters.addIfDefined('revision', config.teamscale_revision);
|
|
239
|
-
parameters.addIfDefined('partition', config.teamscale_partition);
|
|
237
|
+
const form = this.prepareFormData(coverageFile);
|
|
238
|
+
const queryParameters = this.prepareQueryParameters(config);
|
|
239
|
+
await this.performTeamscaleUpload(config, queryParameters, form, logger);
|
|
240
|
+
}
|
|
241
|
+
static async performTeamscaleUpload(config, parameters, form, logger) {
|
|
242
|
+
var _a, _b, _c;
|
|
240
243
|
await axios_1.default
|
|
241
|
-
.post(`${config.teamscale_server_url.replace(/\/$/, '')}/api/projects/${config.teamscale_project}/external-analysis/session/auto-create/report?${parameters.toString()}`, form, {
|
|
244
|
+
.post(`${(_a = config.teamscale_server_url) === null || _a === void 0 ? void 0 : _a.replace(/\/$/, '')}/api/projects/${config.teamscale_project}/external-analysis/session/auto-create/report?${parameters.toString()}`, form, {
|
|
242
245
|
auth: {
|
|
243
|
-
username: config.teamscale_user,
|
|
244
|
-
password: config.teamscale_access_token
|
|
246
|
+
username: (_b = config.teamscale_user) !== null && _b !== void 0 ? _b : 'no username provided',
|
|
247
|
+
password: (_c = config.teamscale_access_token) !== null && _c !== void 0 ? _c : 'no password provided'
|
|
245
248
|
},
|
|
246
249
|
headers: {
|
|
247
250
|
Accept: '*/*',
|
|
@@ -252,25 +255,40 @@ class Main {
|
|
|
252
255
|
if (error.response) {
|
|
253
256
|
const response = error.response;
|
|
254
257
|
if (response.status >= 400) {
|
|
255
|
-
|
|
256
|
-
logger.error(`Request failed with following response: ${response.data}`);
|
|
258
|
+
throw new TeamscaleUploadError(`Upload failed with code ${response.status}: ${response.statusText}. Response Data: ${response.data}`);
|
|
257
259
|
}
|
|
258
260
|
else {
|
|
259
261
|
logger.info(`Upload with status code ${response.status} finished.`);
|
|
260
262
|
}
|
|
261
263
|
}
|
|
262
264
|
else if (error.request) {
|
|
263
|
-
|
|
265
|
+
throw new TeamscaleUploadError(`Upload request did not receive a response.`);
|
|
264
266
|
}
|
|
265
267
|
if (error.message) {
|
|
266
|
-
logger.
|
|
267
|
-
|
|
268
|
+
logger.debug(`Something went wrong when uploading data: ${error.message}. Details of the error: ${(0, util_1.inspect)(error)}`);
|
|
269
|
+
throw new TeamscaleUploadError(`Something went wrong when uploading data: ${error.message}`);
|
|
268
270
|
}
|
|
269
271
|
else {
|
|
270
|
-
|
|
272
|
+
throw new TeamscaleUploadError(`Something went wrong when uploading data: ${(0, util_1.inspect)(error)}`);
|
|
271
273
|
}
|
|
272
274
|
});
|
|
273
275
|
}
|
|
276
|
+
static prepareQueryParameters(config) {
|
|
277
|
+
const parameters = new QueryParameters_1.default();
|
|
278
|
+
parameters.addIfDefined('format', 'SIMPLE');
|
|
279
|
+
parameters.addIfDefined('message', config.teamscale_message);
|
|
280
|
+
parameters.addIfDefined('repository', config.teamscale_repository);
|
|
281
|
+
parameters.addIfDefined('t', config.teamscale_commit);
|
|
282
|
+
parameters.addIfDefined('revision', config.teamscale_revision);
|
|
283
|
+
parameters.addIfDefined('partition', config.teamscale_partition);
|
|
284
|
+
return parameters;
|
|
285
|
+
}
|
|
286
|
+
static prepareFormData(coverageFile) {
|
|
287
|
+
const form = new form_data_1.default();
|
|
288
|
+
form.append('report', fs.createReadStream(coverageFile), 'coverage.simple');
|
|
289
|
+
return form;
|
|
290
|
+
}
|
|
274
291
|
}
|
|
275
292
|
exports.Main = Main;
|
|
293
|
+
Main.DEFAULT_COVERAGE_LOCATION = 'coverage';
|
|
276
294
|
Main.run();
|
|
@@ -21,11 +21,14 @@ export interface IReadableStorage {
|
|
|
21
21
|
*/
|
|
22
22
|
getCoverageBySourceFile(project: string): IterableIterator<FileCoverage> | undefined;
|
|
23
23
|
/**
|
|
24
|
-
* Write the coverage to the specified file.
|
|
24
|
+
* Write the coverage to the specified file. A timestamp will be appended to the provided file path.
|
|
25
25
|
*
|
|
26
|
-
* @param
|
|
26
|
+
* @param coverageFolder - Full path of the file to write the coverage to.
|
|
27
|
+
* @param date - Date to use for the appended timestamp
|
|
28
|
+
*
|
|
29
|
+
* @return The number of lines written
|
|
27
30
|
*/
|
|
28
|
-
dumpToSimpleCoverageFile(
|
|
31
|
+
dumpToSimpleCoverageFile(coverageFolder: string, date: Date): [string, number];
|
|
29
32
|
}
|
|
30
33
|
/**
|
|
31
34
|
* Storage interface for writing information.
|
|
@@ -89,7 +92,7 @@ export declare class DataStorage implements IDataStorage {
|
|
|
89
92
|
/**
|
|
90
93
|
* Coverage information by project.
|
|
91
94
|
*/
|
|
92
|
-
private
|
|
95
|
+
private coverageByProject;
|
|
93
96
|
/**
|
|
94
97
|
* Logger to use.
|
|
95
98
|
*/
|
|
@@ -98,6 +101,10 @@ export declare class DataStorage implements IDataStorage {
|
|
|
98
101
|
* Times unmapped coverage received.
|
|
99
102
|
*/
|
|
100
103
|
private timesUnmappedCoverage;
|
|
104
|
+
/**
|
|
105
|
+
* Date format for the timestamp appended to the coverage files
|
|
106
|
+
*/
|
|
107
|
+
readonly DATE_FORMAT = "YYYY-MM-DD-HH-mm-ss.SSS";
|
|
101
108
|
/**
|
|
102
109
|
* Constructs the data storage.
|
|
103
110
|
*
|
|
@@ -118,7 +125,7 @@ export declare class DataStorage implements IDataStorage {
|
|
|
118
125
|
*
|
|
119
126
|
* @param sourceFile - The file name to normalize, produced by the instrumenter.
|
|
120
127
|
*/
|
|
121
|
-
private normalizeSourceFileName;
|
|
128
|
+
private static normalizeSourceFileName;
|
|
122
129
|
/**
|
|
123
130
|
* {@inheritDoc IWriteableStorage.signalUnmappedCoverage}
|
|
124
131
|
*/
|
|
@@ -128,9 +135,25 @@ export declare class DataStorage implements IDataStorage {
|
|
|
128
135
|
*/
|
|
129
136
|
getCoverageBySourceFile(project: string): IterableIterator<FileCoverage> | undefined;
|
|
130
137
|
/**
|
|
131
|
-
*
|
|
138
|
+
* @inheritDoc
|
|
139
|
+
*/
|
|
140
|
+
dumpToSimpleCoverageFile(coverageFolder: string, date: Date): [string, number];
|
|
141
|
+
/**
|
|
142
|
+
* Set the collected coverage to 0 for all projects
|
|
143
|
+
* @private
|
|
144
|
+
*/
|
|
145
|
+
private resetCoverage;
|
|
146
|
+
/**
|
|
147
|
+
* Appends the timestamp given with date to the coverageFolder (before the file ending if there is one)
|
|
148
|
+
* @param coverageFolder Path to the coverage file
|
|
149
|
+
* @param date Represents the timestamp to be appended with the format {@link DataStorage.DATE_FORMAT}
|
|
150
|
+
* @private
|
|
151
|
+
*/
|
|
152
|
+
private initCoverageFile;
|
|
153
|
+
/**
|
|
154
|
+
* Generate simple coverage format for the collected coverage
|
|
132
155
|
*/
|
|
133
|
-
|
|
156
|
+
private toSimpleCoverage;
|
|
134
157
|
/**
|
|
135
158
|
* {@inheritDoc IReadableStorage.getProjects}
|
|
136
159
|
*/
|
|
@@ -22,10 +22,15 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
|
|
22
22
|
__setModuleDefault(result, mod);
|
|
23
23
|
return result;
|
|
24
24
|
};
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
25
28
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
29
|
exports.DataStorage = exports.ProjectCoverage = void 0;
|
|
27
30
|
const commons_1 = require("@cqse/commons");
|
|
28
31
|
const fs = __importStar(require("fs"));
|
|
32
|
+
const path_1 = __importDefault(require("path"));
|
|
33
|
+
const dat = __importStar(require("date-and-time"));
|
|
29
34
|
/**
|
|
30
35
|
* The coverage information received for one particular project.
|
|
31
36
|
*/
|
|
@@ -81,6 +86,10 @@ class DataStorage {
|
|
|
81
86
|
* @param logger - The logger to use.
|
|
82
87
|
*/
|
|
83
88
|
constructor(logger) {
|
|
89
|
+
/**
|
|
90
|
+
* Date format for the timestamp appended to the coverage files
|
|
91
|
+
*/
|
|
92
|
+
this.DATE_FORMAT = 'YYYY-MM-DD-HH-mm-ss.SSS';
|
|
84
93
|
this.coverageByProject = new Map();
|
|
85
94
|
this.logger = commons_1.Contract.requireDefined(logger);
|
|
86
95
|
this.timesUnmappedCoverage = 0;
|
|
@@ -93,7 +102,7 @@ class DataStorage {
|
|
|
93
102
|
* @param coveredOriginalLines - The lines covered in the file.
|
|
94
103
|
*/
|
|
95
104
|
putCoverage(project, sourceFilePath, coveredOriginalLines) {
|
|
96
|
-
const uniformPath =
|
|
105
|
+
const uniformPath = DataStorage.normalizeSourceFileName(sourceFilePath);
|
|
97
106
|
let projectCoverage = this.coverageByProject.get(project);
|
|
98
107
|
if (!projectCoverage) {
|
|
99
108
|
projectCoverage = new ProjectCoverage(project);
|
|
@@ -108,7 +117,7 @@ class DataStorage {
|
|
|
108
117
|
*
|
|
109
118
|
* @param sourceFile - The file name to normalize, produced by the instrumenter.
|
|
110
119
|
*/
|
|
111
|
-
normalizeSourceFileName(sourceFile) {
|
|
120
|
+
static normalizeSourceFileName(sourceFile) {
|
|
112
121
|
return (0, commons_1.removePrefix)('webpack:///', sourceFile.replace('\\', '/'));
|
|
113
122
|
}
|
|
114
123
|
/**
|
|
@@ -129,29 +138,55 @@ class DataStorage {
|
|
|
129
138
|
return projectCoverage === null || projectCoverage === void 0 ? void 0 : projectCoverage.getCoverage();
|
|
130
139
|
}
|
|
131
140
|
/**
|
|
132
|
-
*
|
|
141
|
+
* @inheritDoc
|
|
133
142
|
*/
|
|
134
|
-
dumpToSimpleCoverageFile(
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
dumpToSimpleCoverageFile(coverageFolder, date) {
|
|
144
|
+
const [lines, content] = this.toSimpleCoverage();
|
|
145
|
+
const coverageFolderTrimmed = coverageFolder.trim();
|
|
146
|
+
const finalFilePath = this.initCoverageFile(coverageFolderTrimmed, date);
|
|
147
|
+
fs.writeFileSync(finalFilePath, content, { flag: 'w', encoding: 'utf8' });
|
|
148
|
+
this.resetCoverage();
|
|
149
|
+
return [finalFilePath, lines];
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Set the collected coverage to 0 for all projects
|
|
153
|
+
* @private
|
|
154
|
+
*/
|
|
155
|
+
resetCoverage() {
|
|
156
|
+
this.coverageByProject = new Map();
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Appends the timestamp given with date to the coverageFolder (before the file ending if there is one)
|
|
160
|
+
* @param coverageFolder Path to the coverage file
|
|
161
|
+
* @param date Represents the timestamp to be appended with the format {@link DataStorage.DATE_FORMAT}
|
|
162
|
+
* @private
|
|
163
|
+
*/
|
|
164
|
+
initCoverageFile(coverageFolder, date) {
|
|
165
|
+
if (!fs.existsSync(coverageFolder)) {
|
|
166
|
+
fs.mkdirSync(coverageFolder);
|
|
167
|
+
}
|
|
168
|
+
const formattedDate = dat.format(date, this.DATE_FORMAT);
|
|
169
|
+
return path_1.default.join(coverageFolder, `coverage-${formattedDate}.simple`);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Generate simple coverage format for the collected coverage
|
|
173
|
+
*/
|
|
174
|
+
toSimpleCoverage() {
|
|
175
|
+
const result = [];
|
|
176
|
+
commons_1.Contract.require(this.getProjects().length < 2, 'Only one project supported to be handled in parallel.');
|
|
177
|
+
for (const project of this.getProjects()) {
|
|
178
|
+
const projectCoverage = this.getCoverageBySourceFile(project);
|
|
179
|
+
if (!projectCoverage) {
|
|
180
|
+
return [0, ''];
|
|
181
|
+
}
|
|
182
|
+
for (const entry of projectCoverage) {
|
|
183
|
+
result.push(DataStorage.normalizeSourceFileName(entry.sourceFile));
|
|
184
|
+
for (const lineNo of entry.coveredLines) {
|
|
185
|
+
result.push(String(lineNo));
|
|
148
186
|
}
|
|
149
187
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const [lines, content] = toSimpleCoverage();
|
|
153
|
-
fs.writeFileSync(filePath.trim(), content, { flag: 'w', encoding: 'utf8' });
|
|
154
|
-
return lines;
|
|
188
|
+
}
|
|
189
|
+
return [result.length, result.join('\n')];
|
|
155
190
|
}
|
|
156
191
|
/**
|
|
157
192
|
* {@inheritDoc IReadableStorage.getProjects}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamscale/coverage-collector",
|
|
3
|
-
"version": "0.0.1-beta.
|
|
3
|
+
"version": "0.0.1-beta.42",
|
|
4
4
|
"description": "Collector for JavaScript code coverage information",
|
|
5
5
|
"main": "dist/src/main.js",
|
|
6
6
|
"bin": "dist/src/main.js",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"async": "^3.2.4",
|
|
28
28
|
"axios": "^0.24.0",
|
|
29
29
|
"bunyan": "^1.8.15",
|
|
30
|
+
"date-and-time": "^2.3.1",
|
|
30
31
|
"dotenv": "^14.1.0",
|
|
31
32
|
"form-data": "^4.0.0",
|
|
32
33
|
"mkdirp": "^1.0.4",
|