@tenonhq/sincronia-core 0.0.81 → 0.0.82
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/FileUtils.js +40 -1
- package/dist/MultiScopeWatcher.js +6 -3
- package/dist/appUtils.js +178 -6
- package/dist/benchmark.js +104 -0
- package/dist/commander.js +25 -1
- package/dist/commands.js +5 -2
- package/dist/snClient.js +56 -2
- package/dist/tests/benchmarkRefresh.test.js +265 -0
- package/dist/tests/globalDebounce.test.js +3 -2
- package/dist/tests/multi-scope-watcher.test.js +10 -20
- package/dist/tests/scopeCaching.test.js +14 -23
- package/dist/tests/syncManifestRefresh.test.js +287 -0
- package/package.json +1 -1
package/dist/FileUtils.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.writeFileForce = exports.writeSNFileForce = exports.writeSNFileIfNotExists = exports.writeBuildFile = exports.summarizeFile = exports.encodedPathsToFilePaths = exports.isValidPath = exports.splitEncodedPaths = exports.getPathsInPath = exports.isDirectory = exports.toAbsolutePath = exports.getFileContextFromPath = exports.getFileContextWithSkipReason = exports.getBuildExt = exports.isUnderPath = exports.appendToPath = exports.pathExists = exports.createDirRecursively = exports.writeSNFileCurry = exports.writeScopeManifest = exports.writeManifestFile = exports.SNFileExists = void 0;
|
|
39
|
+
exports.writeFileForce = exports.writeSNFileForce = exports.writeSNFileIfNotExists = exports.writeBuildFile = exports.summarizeFile = exports.encodedPathsToFilePaths = exports.isValidPath = exports.splitEncodedPaths = exports.getPathsInPath = exports.isDirectory = exports.toAbsolutePath = exports.getFileContextFromPath = exports.getFileContextWithSkipReason = exports.getBuildExt = exports.isUnderPath = exports.appendToPath = exports.pathExists = exports.createDirRecursively = exports.writeSNFileIfDifferent = exports.writeSNFileCurry = exports.writeScopeManifest = exports.writeManifestFile = exports.SNFileExists = void 0;
|
|
40
40
|
exports.writeEnvVar = writeEnvVar;
|
|
41
41
|
exports.writeEnvVars = writeEnvVars;
|
|
42
42
|
const constants_1 = require("./constants");
|
|
@@ -103,6 +103,45 @@ const writeSNFileCurry = (checkExists) => async (file, parentPath) => {
|
|
|
103
103
|
}
|
|
104
104
|
};
|
|
105
105
|
exports.writeSNFileCurry = writeSNFileCurry;
|
|
106
|
+
/**
|
|
107
|
+
* Writes the SN file to disk only if the local content differs from `file.content`.
|
|
108
|
+
* Returns `true` if the file was written (missing or differed), `false` if skipped.
|
|
109
|
+
*
|
|
110
|
+
* Used by `sinc refresh` to pull ServiceNow-side edits without churning unchanged
|
|
111
|
+
* files (preserves mtime, avoids noisy watchers, keeps diff output clean).
|
|
112
|
+
*/
|
|
113
|
+
const writeSNFileIfDifferent = async (file, parentPath) => {
|
|
114
|
+
let { name, type, content = "" } = file;
|
|
115
|
+
if (!content)
|
|
116
|
+
content = "";
|
|
117
|
+
const fullPath = path_1.default.join(parentPath, `${name}.${type}`);
|
|
118
|
+
let localContent = null;
|
|
119
|
+
try {
|
|
120
|
+
localContent = await fs_1.promises.readFile(fullPath, "utf8");
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
localContent = null;
|
|
124
|
+
}
|
|
125
|
+
if (localContent === content) {
|
|
126
|
+
FileLogger_1.fileLogger.debug("Unchanged: " + fullPath);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (localContent !== null) {
|
|
130
|
+
FileLogger_1.fileLogger.debug("Overwriting local content (differs from instance): " + fullPath);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
FileLogger_1.fileLogger.debug("Writing (missing locally): " + fullPath);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
await fs_1.promises.writeFile(fullPath, content);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
FileLogger_1.fileLogger.error("Failed to write " + fullPath + ":", error);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
exports.writeSNFileIfDifferent = writeSNFileIfDifferent;
|
|
106
145
|
const createDirRecursively = async (path) => {
|
|
107
146
|
await fs_1.promises.mkdir(path, { recursive: true });
|
|
108
147
|
};
|
|
@@ -220,7 +220,8 @@ class MultiScopeWatcherManager {
|
|
|
220
220
|
if (curSysId && curName) {
|
|
221
221
|
var isDefault = curName === "Default" || curName.toLowerCase().indexOf("default") !== -1;
|
|
222
222
|
if (isDefault) {
|
|
223
|
-
Logger_1.logger.warn(`[${scopeName}] No update set configured
|
|
223
|
+
Logger_1.logger.warn(`[${scopeName}] No update set configured for scope ${scopeName}. ` +
|
|
224
|
+
`Changes will go to Default. ` +
|
|
224
225
|
`Use sinc createUpdateSet or activate a task in the dashboard.`);
|
|
225
226
|
}
|
|
226
227
|
else {
|
|
@@ -233,12 +234,14 @@ class MultiScopeWatcherManager {
|
|
|
233
234
|
}
|
|
234
235
|
else {
|
|
235
236
|
Logger_1.logger.warn(`[${scopeName}] No update set configured for scope ${scopeName}. ` +
|
|
237
|
+
`Changes will go to Default. ` +
|
|
236
238
|
`Use sinc createUpdateSet or activate a task in the dashboard.`);
|
|
237
239
|
}
|
|
238
240
|
}
|
|
239
241
|
catch (queryErr) {
|
|
240
|
-
Logger_1.logger.warn(`[${scopeName}] No update set configured
|
|
241
|
-
`Changes will
|
|
242
|
+
Logger_1.logger.warn(`[${scopeName}] No update set configured for scope ${scopeName}. ` +
|
|
243
|
+
`Changes will go to Default (could not query current update set). ` +
|
|
244
|
+
`Use sinc createUpdateSet or activate a task in the dashboard.`);
|
|
242
245
|
}
|
|
243
246
|
return;
|
|
244
247
|
}
|
package/dist/appUtils.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.createAndAssignUpdateSet = exports.swapScope = exports.buildFiles = exports.summarizeRecord = exports.pushFiles = exports.getAppFileList = exports.groupAppFiles = exports.processMissingFiles = exports.findMissingFiles = exports.syncManifest = exports.processManifest = exports.normalizeManifestKeys = void 0;
|
|
39
|
+
exports.createAndAssignUpdateSet = exports.swapScope = exports.buildFiles = exports.summarizeRecord = exports.pushFiles = exports.getAppFileList = exports.groupAppFiles = exports.processMissingFiles = exports.refreshAllFiles = exports.findMissingFiles = exports.syncManifest = exports.processManifest = exports.normalizeManifestKeys = void 0;
|
|
40
40
|
const path_1 = __importDefault(require("path"));
|
|
41
41
|
const fs_1 = __importDefault(require("fs"));
|
|
42
42
|
const progress_1 = __importDefault(require("progress"));
|
|
@@ -46,6 +46,7 @@ const constants_1 = require("./constants");
|
|
|
46
46
|
const PluginManager_1 = __importDefault(require("./PluginManager"));
|
|
47
47
|
const FileLogger_1 = require("./FileLogger");
|
|
48
48
|
const snClient_1 = require("./snClient");
|
|
49
|
+
const benchmark_1 = require("./benchmark");
|
|
49
50
|
const Logger_1 = require("./Logger");
|
|
50
51
|
const genericUtils_1 = require("./genericUtils");
|
|
51
52
|
const getUpdateSetConfig = () => {
|
|
@@ -186,7 +187,16 @@ const processManifest = async (manifest, forceWrite = false, sourcePath) => {
|
|
|
186
187
|
}
|
|
187
188
|
};
|
|
188
189
|
exports.processManifest = processManifest;
|
|
189
|
-
const syncManifest = async (scope) => {
|
|
190
|
+
const syncManifest = async (scope, options = {}) => {
|
|
191
|
+
// Top-level entry owns the collector lifecycle. Recursive calls (all-scopes
|
|
192
|
+
// → per-scope) inherit the collector via options._benchmarkCollector.
|
|
193
|
+
var isBenchmarkOwner = false;
|
|
194
|
+
var collector = options._benchmarkCollector;
|
|
195
|
+
if (options.benchmark && !collector) {
|
|
196
|
+
collector = new benchmark_1.BenchmarkCollector();
|
|
197
|
+
(0, snClient_1.setBenchmarkSink)(collector);
|
|
198
|
+
isBenchmarkOwner = true;
|
|
199
|
+
}
|
|
190
200
|
try {
|
|
191
201
|
const curManifest = await ConfigManager.getManifest();
|
|
192
202
|
if (!curManifest)
|
|
@@ -239,7 +249,12 @@ const syncManifest = async (scope) => {
|
|
|
239
249
|
const refreshTableCount = Object.keys(newManifest.tables).length;
|
|
240
250
|
FileLogger_1.fileLogger.debug("Refreshed manifest for " + scope + ": " + refreshTableCount + " tables");
|
|
241
251
|
await fUtils.writeScopeManifest(scope, newManifest);
|
|
242
|
-
|
|
252
|
+
if (collector)
|
|
253
|
+
collector.startScope(scope);
|
|
254
|
+
await (0, exports.refreshAllFiles)(newManifest, scopeSourcePath, {
|
|
255
|
+
force: options.force,
|
|
256
|
+
benchmarkCollector: collector,
|
|
257
|
+
});
|
|
243
258
|
// Update the in-memory manifest for this scope
|
|
244
259
|
if (ConfigManager.isMultiScopeManifest(curManifest)) {
|
|
245
260
|
curManifest[scope] = newManifest;
|
|
@@ -250,20 +265,24 @@ const syncManifest = async (scope) => {
|
|
|
250
265
|
// Sync all scopes. Prefer the declared-scopes list (config.scopes) over
|
|
251
266
|
// the persisted manifest keys — the manifest may contain stale undeclared
|
|
252
267
|
// scopes that leaked in before the whitelist gate existed.
|
|
268
|
+
var childOptions = {
|
|
269
|
+
force: options.force,
|
|
270
|
+
_benchmarkCollector: collector,
|
|
271
|
+
};
|
|
253
272
|
if (declaredScopes.length > 0) {
|
|
254
273
|
for (var d = 0; d < declaredScopes.length; d++) {
|
|
255
|
-
await (0, exports.syncManifest)(declaredScopes[d]);
|
|
274
|
+
await (0, exports.syncManifest)(declaredScopes[d], childOptions);
|
|
256
275
|
}
|
|
257
276
|
}
|
|
258
277
|
else if (ConfigManager.isMultiScopeManifest(curManifest)) {
|
|
259
278
|
// No declared scopes — fall back to the persisted manifest's scopes.
|
|
260
279
|
for (const scopeName of Object.keys(curManifest)) {
|
|
261
|
-
await (0, exports.syncManifest)(scopeName);
|
|
280
|
+
await (0, exports.syncManifest)(scopeName, childOptions);
|
|
262
281
|
}
|
|
263
282
|
}
|
|
264
283
|
else if (curManifest.scope) {
|
|
265
284
|
// Single scope manifest
|
|
266
|
-
await (0, exports.syncManifest)(curManifest.scope);
|
|
285
|
+
await (0, exports.syncManifest)(curManifest.scope, childOptions);
|
|
267
286
|
}
|
|
268
287
|
}
|
|
269
288
|
}
|
|
@@ -275,6 +294,12 @@ const syncManifest = async (scope) => {
|
|
|
275
294
|
message = String(e);
|
|
276
295
|
Logger_1.logger.error("Refresh failed: " + message);
|
|
277
296
|
}
|
|
297
|
+
finally {
|
|
298
|
+
if (isBenchmarkOwner && collector) {
|
|
299
|
+
(0, snClient_1.setBenchmarkSink)(null);
|
|
300
|
+
Logger_1.logger.info(collector.formatSummary());
|
|
301
|
+
}
|
|
302
|
+
}
|
|
278
303
|
};
|
|
279
304
|
exports.syncManifest = syncManifest;
|
|
280
305
|
const markFileMissing = (missingObj) => (table) => (recordId) => (file) => {
|
|
@@ -351,6 +376,153 @@ exports.findMissingFiles = findMissingFiles;
|
|
|
351
376
|
// Must mirror the chunk size used by allScopesCommands.ts (watch path) so behaviour
|
|
352
377
|
// is consistent across `refresh` and `watch`.
|
|
353
378
|
const BULK_DOWNLOAD_TABLE_CHUNK_SIZE = 5;
|
|
379
|
+
/**
|
|
380
|
+
* Builds a MissingFileTableMap containing EVERY file in the manifest — ignores
|
|
381
|
+
* local disk state. Used by `sinc refresh` to pull instance-side edits down.
|
|
382
|
+
*/
|
|
383
|
+
const buildAllFilesMap = (manifest) => {
|
|
384
|
+
const result = {};
|
|
385
|
+
const { tables } = manifest;
|
|
386
|
+
const tableNames = Object.keys(tables);
|
|
387
|
+
for (var t = 0; t < tableNames.length; t++) {
|
|
388
|
+
var tableName = tableNames[t];
|
|
389
|
+
var records = tables[tableName].records;
|
|
390
|
+
var recNames = Object.keys(records);
|
|
391
|
+
if (recNames.length === 0)
|
|
392
|
+
continue;
|
|
393
|
+
var recMap = {};
|
|
394
|
+
for (var r = 0; r < recNames.length; r++) {
|
|
395
|
+
var rec = records[recNames[r]];
|
|
396
|
+
if (!rec.files || rec.files.length === 0)
|
|
397
|
+
continue;
|
|
398
|
+
// Strip any content that may be lingering on manifest file entries; the
|
|
399
|
+
// bulkDownload endpoint only needs name + type to resolve each field.
|
|
400
|
+
recMap[rec.sys_id] = rec.files.map(function (f) {
|
|
401
|
+
return { name: f.name, type: f.type };
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
if (Object.keys(recMap).length > 0)
|
|
405
|
+
result[tableName] = recMap;
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
};
|
|
409
|
+
/**
|
|
410
|
+
* Refreshes local files against the ServiceNow instance for every file in the
|
|
411
|
+
* given manifest. Unlike `processMissingFiles` (which only writes files absent
|
|
412
|
+
* from disk), this walks ALL manifest files, fetches their current content from
|
|
413
|
+
* the instance, and writes when content differs.
|
|
414
|
+
*
|
|
415
|
+
* @param options.force — when true, always overwrite local files even if their
|
|
416
|
+
* content matches the instance. Use for deliberate "reset local to instance".
|
|
417
|
+
*/
|
|
418
|
+
const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
|
|
419
|
+
try {
|
|
420
|
+
const allFiles = buildAllFilesMap(newManifest);
|
|
421
|
+
const tableNames = Object.keys(allFiles);
|
|
422
|
+
if (tableNames.length === 0)
|
|
423
|
+
return;
|
|
424
|
+
FileLogger_1.fileLogger.debug("Refreshing file content for " + tableNames.length + " tables (force=" + !!options.force + ")");
|
|
425
|
+
const { tableOptions = {} } = ConfigManager.getConfig();
|
|
426
|
+
const client = (0, snClient_1.defaultClient)();
|
|
427
|
+
const totalChunks = Math.ceil(tableNames.length / BULK_DOWNLOAD_TABLE_CHUNK_SIZE);
|
|
428
|
+
const filesToProcess = {};
|
|
429
|
+
for (var i = 0; i < tableNames.length; i += BULK_DOWNLOAD_TABLE_CHUNK_SIZE) {
|
|
430
|
+
const chunkTableNames = tableNames.slice(i, i + BULK_DOWNLOAD_TABLE_CHUNK_SIZE);
|
|
431
|
+
const chunkMissing = {};
|
|
432
|
+
for (var j = 0; j < chunkTableNames.length; j++) {
|
|
433
|
+
chunkMissing[chunkTableNames[j]] = allFiles[chunkTableNames[j]];
|
|
434
|
+
}
|
|
435
|
+
const batchNum = Math.floor(i / BULK_DOWNLOAD_TABLE_CHUNK_SIZE) + 1;
|
|
436
|
+
FileLogger_1.fileLogger.debug("Refresh download batch " + batchNum + "/" + totalChunks +
|
|
437
|
+
" (" + chunkTableNames.length + " tables): " + chunkTableNames.join(", "));
|
|
438
|
+
const chunkResult = await (0, snClient_1.unwrapSNResponse)(client.getMissingFiles(chunkMissing, tableOptions));
|
|
439
|
+
for (var tableName in chunkResult) {
|
|
440
|
+
filesToProcess[tableName] = chunkResult[tableName];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
var basePath = sourcePath || ConfigManager.getSourcePath();
|
|
444
|
+
var recordCount = countRecordsInTables(filesToProcess);
|
|
445
|
+
var progress = createScopeProgress(Logger_1.logger.getLogLevel(), {
|
|
446
|
+
scope: newManifest.scope || "default",
|
|
447
|
+
total: recordCount,
|
|
448
|
+
});
|
|
449
|
+
var writtenCount = 0;
|
|
450
|
+
var unchangedCount = 0;
|
|
451
|
+
const forceWrite = !!options.force;
|
|
452
|
+
const forceWriter = fUtils.writeSNFileCurry(false);
|
|
453
|
+
const processedTableNames = Object.keys(filesToProcess);
|
|
454
|
+
await (0, genericUtils_1.processBatched)(processedTableNames, constants_1.CONCURRENCY_TABLES, async function (tableName) {
|
|
455
|
+
var tablePath = path_1.default.join(basePath, tableName);
|
|
456
|
+
var recs = filesToProcess[tableName].records;
|
|
457
|
+
var recKeys = Object.keys(recs);
|
|
458
|
+
await Promise.all(recKeys.map(function (k) {
|
|
459
|
+
return fUtils.createDirRecursively(path_1.default.join(tablePath, recs[k].name));
|
|
460
|
+
}));
|
|
461
|
+
await (0, genericUtils_1.processBatched)(recKeys, constants_1.CONCURRENCY_RECORDS, async function (recKey) {
|
|
462
|
+
var rec = recs[recKey];
|
|
463
|
+
var recPath = path_1.default.join(tablePath, rec.name);
|
|
464
|
+
var results = await (0, genericUtils_1.allSettledBatched)(rec.files, constants_1.CONCURRENCY_FILES, async function (file) {
|
|
465
|
+
if (forceWrite) {
|
|
466
|
+
await forceWriter(file, recPath);
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
return fUtils.writeSNFileIfDifferent(file, recPath);
|
|
470
|
+
});
|
|
471
|
+
var anyChanged = false;
|
|
472
|
+
for (var f = 0; f < results.length; f++) {
|
|
473
|
+
var res = results[f];
|
|
474
|
+
if (res.status === "rejected") {
|
|
475
|
+
FileLogger_1.fileLogger.error("File write failed: " + res.reason);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (res.value) {
|
|
479
|
+
anyChanged = true;
|
|
480
|
+
writtenCount++;
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
unchangedCount++;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// Only touch metaData when at least one file in the record actually
|
|
487
|
+
// changed. Avoids rewriting _lastUpdatedOn for records that were
|
|
488
|
+
// already in sync with the instance.
|
|
489
|
+
if (anyChanged || forceWrite) {
|
|
490
|
+
const metadataFile = {
|
|
491
|
+
name: "metaData",
|
|
492
|
+
type: "json",
|
|
493
|
+
content: JSON.stringify({ _lastUpdatedOn: new Date().toISOString() }, null, 2),
|
|
494
|
+
};
|
|
495
|
+
await forceWriter(metadataFile, recPath);
|
|
496
|
+
}
|
|
497
|
+
// Strip content from manifest entries to keep memory bounded.
|
|
498
|
+
rec.files = rec.files.map(function (file) {
|
|
499
|
+
var copy = Object.assign({}, file);
|
|
500
|
+
delete copy.content;
|
|
501
|
+
return copy;
|
|
502
|
+
});
|
|
503
|
+
progress.tick();
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
FileLogger_1.fileLogger.debug("Refresh complete: " + writtenCount + " written, " + unchangedCount + " unchanged");
|
|
507
|
+
if (writtenCount > 0) {
|
|
508
|
+
Logger_1.logger.info("Refreshed " + writtenCount + " file(s) from instance" +
|
|
509
|
+
(unchangedCount > 0 ? " (" + unchangedCount + " already in sync)" : ""));
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
Logger_1.logger.debug("No file changes detected from instance (" + unchangedCount + " checked)");
|
|
513
|
+
}
|
|
514
|
+
if (options.benchmarkCollector) {
|
|
515
|
+
options.benchmarkCollector.endScope(writtenCount, unchangedCount);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
if (options.benchmarkCollector) {
|
|
520
|
+
options.benchmarkCollector.endScope(0, 0);
|
|
521
|
+
}
|
|
522
|
+
throw e;
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
exports.refreshAllFiles = refreshAllFiles;
|
|
354
526
|
const processMissingFiles = async (newManifest, sourcePath) => {
|
|
355
527
|
try {
|
|
356
528
|
const missing = await (0, exports.findMissingFiles)(newManifest, sourcePath);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Opt-in benchmark collector for `sinc refresh`. Wired into snClient via
|
|
4
|
+
* setBenchmarkSink — when null (the default), there is zero overhead.
|
|
5
|
+
*
|
|
6
|
+
* PR #36 changed refresh from "download files absent from disk" to "bulk-download
|
|
7
|
+
* every manifest file and compare before writing". That shifted the request
|
|
8
|
+
* profile silently. This collector surfaces the numbers so regressions are
|
|
9
|
+
* visible.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.BenchmarkCollector = void 0;
|
|
13
|
+
class BenchmarkCollector {
|
|
14
|
+
httpSamples = [];
|
|
15
|
+
scopeSamples = [];
|
|
16
|
+
currentScope = null;
|
|
17
|
+
recordHttp(sample) {
|
|
18
|
+
this.httpSamples.push(sample);
|
|
19
|
+
}
|
|
20
|
+
startScope(scopeName) {
|
|
21
|
+
this.currentScope = {
|
|
22
|
+
name: scopeName,
|
|
23
|
+
startedAt: Date.now(),
|
|
24
|
+
httpStart: this.httpSamples.length,
|
|
25
|
+
bytesStart: this.totalBytes(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
endScope(filesWritten, filesUnchanged) {
|
|
29
|
+
if (!this.currentScope)
|
|
30
|
+
return;
|
|
31
|
+
var scope = this.currentScope;
|
|
32
|
+
this.scopeSamples.push({
|
|
33
|
+
scopeName: scope.name,
|
|
34
|
+
wallTimeMs: Date.now() - scope.startedAt,
|
|
35
|
+
httpRequests: this.httpSamples.length - scope.httpStart,
|
|
36
|
+
filesWritten: filesWritten,
|
|
37
|
+
filesUnchanged: filesUnchanged,
|
|
38
|
+
totalResponseBytes: this.totalBytes() - scope.bytesStart,
|
|
39
|
+
});
|
|
40
|
+
this.currentScope = null;
|
|
41
|
+
}
|
|
42
|
+
getHttpSamples() {
|
|
43
|
+
return this.httpSamples.slice();
|
|
44
|
+
}
|
|
45
|
+
getScopeSamples() {
|
|
46
|
+
return this.scopeSamples.slice();
|
|
47
|
+
}
|
|
48
|
+
totalBytes() {
|
|
49
|
+
var total = 0;
|
|
50
|
+
for (var i = 0; i < this.httpSamples.length; i++) {
|
|
51
|
+
total += this.httpSamples[i].responseBytes;
|
|
52
|
+
}
|
|
53
|
+
return total;
|
|
54
|
+
}
|
|
55
|
+
formatSummary() {
|
|
56
|
+
var lines = [];
|
|
57
|
+
lines.push("");
|
|
58
|
+
lines.push("=".repeat(72));
|
|
59
|
+
lines.push("Refresh Benchmark");
|
|
60
|
+
lines.push("=".repeat(72));
|
|
61
|
+
if (this.httpSamples.length === 0) {
|
|
62
|
+
lines.push("(no samples recorded)");
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
var latencies = this.httpSamples
|
|
66
|
+
.map(function (s) { return s.durationMs; })
|
|
67
|
+
.sort(function (a, b) { return a - b; });
|
|
68
|
+
var p50 = percentile(latencies, 0.5);
|
|
69
|
+
var p95 = percentile(latencies, 0.95);
|
|
70
|
+
var max = latencies[latencies.length - 1];
|
|
71
|
+
var totalBytes = this.totalBytes();
|
|
72
|
+
lines.push("Overall: " + this.httpSamples.length + " HTTP requests, " +
|
|
73
|
+
formatBytes(totalBytes) + " received");
|
|
74
|
+
lines.push("Latency: p50 " + p50 + "ms | p95 " + p95 + "ms | max " + max + "ms");
|
|
75
|
+
if (this.scopeSamples.length > 0) {
|
|
76
|
+
lines.push("");
|
|
77
|
+
lines.push("Per-scope:");
|
|
78
|
+
for (var i = 0; i < this.scopeSamples.length; i++) {
|
|
79
|
+
var s = this.scopeSamples[i];
|
|
80
|
+
lines.push(" " + s.scopeName + ": " +
|
|
81
|
+
s.wallTimeMs + "ms wall, " +
|
|
82
|
+
s.httpRequests + " req, " +
|
|
83
|
+
formatBytes(s.totalResponseBytes) + ", " +
|
|
84
|
+
s.filesWritten + " written / " + s.filesUnchanged + " unchanged");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
lines.push("=".repeat(72));
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
exports.BenchmarkCollector = BenchmarkCollector;
|
|
92
|
+
function percentile(sortedAsc, p) {
|
|
93
|
+
if (sortedAsc.length === 0)
|
|
94
|
+
return 0;
|
|
95
|
+
var idx = Math.min(sortedAsc.length - 1, Math.floor(sortedAsc.length * p));
|
|
96
|
+
return sortedAsc[idx];
|
|
97
|
+
}
|
|
98
|
+
function formatBytes(n) {
|
|
99
|
+
if (n < 1024)
|
|
100
|
+
return n + "B";
|
|
101
|
+
if (n < 1024 * 1024)
|
|
102
|
+
return (n / 1024).toFixed(1) + "KB";
|
|
103
|
+
return (n / (1024 * 1024)).toFixed(2) + "MB";
|
|
104
|
+
}
|
package/dist/commander.js
CHANGED
|
@@ -50,7 +50,31 @@ async function initCommands() {
|
|
|
50
50
|
}, async (args) => {
|
|
51
51
|
await (0, allScopesCommands_1.watchAllScopesCommand)(args);
|
|
52
52
|
})
|
|
53
|
-
.command(["refresh", "r"], "
|
|
53
|
+
.command(["refresh", "r"], "Pull latest manifest and file contents from the ServiceNow instance", (cmdArgs) => {
|
|
54
|
+
cmdArgs.options({
|
|
55
|
+
...sharedOptions,
|
|
56
|
+
force: {
|
|
57
|
+
alias: "f",
|
|
58
|
+
type: "boolean",
|
|
59
|
+
default: false,
|
|
60
|
+
describe: "Overwrite local files even when content matches the instance",
|
|
61
|
+
},
|
|
62
|
+
scope: {
|
|
63
|
+
alias: "s",
|
|
64
|
+
type: "string",
|
|
65
|
+
describe: "Refresh a single scope (default: all declared scopes)",
|
|
66
|
+
},
|
|
67
|
+
benchmark: {
|
|
68
|
+
alias: "b",
|
|
69
|
+
type: "boolean",
|
|
70
|
+
default: false,
|
|
71
|
+
describe: "Log per-scope and aggregate HTTP latency, bytes, and file counts",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
return cmdArgs;
|
|
75
|
+
}, async (args) => {
|
|
76
|
+
await (0, commands_1.refreshCommand)(args);
|
|
77
|
+
})
|
|
54
78
|
.command(["push [target]"], "[DESTRUCTIVE] Push all files from current local files to ServiceNow instance.", (cmdArgs) => {
|
|
55
79
|
cmdArgs.options({
|
|
56
80
|
...sharedOptions,
|
package/dist/commands.js
CHANGED
|
@@ -65,8 +65,11 @@ async function refreshCommand(args, log = true) {
|
|
|
65
65
|
try {
|
|
66
66
|
if (!log)
|
|
67
67
|
setLogLevel({ logLevel: "warn" });
|
|
68
|
-
FileLogger_1.fileLogger.debug("Syncing manifest from instance");
|
|
69
|
-
await AppUtils.syncManifest(
|
|
68
|
+
FileLogger_1.fileLogger.debug("Syncing manifest from instance (force=" + !!args.force + ", benchmark=" + !!args.benchmark + ")");
|
|
69
|
+
await AppUtils.syncManifest(args.scope, {
|
|
70
|
+
force: !!args.force,
|
|
71
|
+
benchmark: !!args.benchmark,
|
|
72
|
+
});
|
|
70
73
|
Logger_1.logger.success("Refresh complete!");
|
|
71
74
|
setLogLevel(args);
|
|
72
75
|
}
|
package/dist/snClient.js
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.unwrapSNResponse = exports.defaultClient = exports.snClient = exports.processPushResponse = exports.retryOnHttpErr = exports.retryOnErr = void 0;
|
|
7
|
+
exports.setBenchmarkSink = setBenchmarkSink;
|
|
7
8
|
exports.unwrapTableAPIFirstItem = unwrapTableAPIFirstItem;
|
|
8
9
|
const axios_1 = __importDefault(require("axios"));
|
|
9
10
|
const axios_cookiejar_support_1 = require("axios-cookiejar-support");
|
|
@@ -12,6 +13,11 @@ const tough_cookie_1 = require("tough-cookie");
|
|
|
12
13
|
const genericUtils_1 = require("./genericUtils");
|
|
13
14
|
const Logger_1 = require("./Logger");
|
|
14
15
|
const FileLogger_1 = require("./FileLogger");
|
|
16
|
+
// Benchmark sink. Null when --benchmark is off; zero overhead in that case.
|
|
17
|
+
let _benchmarkSink = null;
|
|
18
|
+
function setBenchmarkSink(sink) {
|
|
19
|
+
_benchmarkSink = sink;
|
|
20
|
+
}
|
|
15
21
|
// Local helper to strip _ directive keys before sending to ServiceNow API.
|
|
16
22
|
// Defined here (not imported from config.ts) to avoid circular dependencies.
|
|
17
23
|
function _stripUnderscoreKeys(obj) {
|
|
@@ -143,7 +149,7 @@ const processPushResponse = (response, recSummary) => {
|
|
|
143
149
|
exports.processPushResponse = processPushResponse;
|
|
144
150
|
const snClient = (baseURL, username, password) => {
|
|
145
151
|
const jar = new tough_cookie_1.CookieJar();
|
|
146
|
-
const
|
|
152
|
+
const rawAxios = (0, axios_cookiejar_support_1.wrapper)(axios_1.default.create({
|
|
147
153
|
withCredentials: true,
|
|
148
154
|
auth: {
|
|
149
155
|
username,
|
|
@@ -154,7 +160,55 @@ const snClient = (baseURL, username, password) => {
|
|
|
154
160
|
},
|
|
155
161
|
baseURL,
|
|
156
162
|
jar,
|
|
157
|
-
}))
|
|
163
|
+
}));
|
|
164
|
+
// Request interceptor: stamp startedAt so the response interceptor can
|
|
165
|
+
// compute durationMs. Only when a benchmark sink is attached — when it
|
|
166
|
+
// isn't, this is a single property assignment and does not change shape.
|
|
167
|
+
rawAxios.interceptors.request.use(function (cfg) {
|
|
168
|
+
if (_benchmarkSink) {
|
|
169
|
+
cfg._benchStartedAt = Date.now();
|
|
170
|
+
}
|
|
171
|
+
return cfg;
|
|
172
|
+
});
|
|
173
|
+
rawAxios.interceptors.response.use(function (response) {
|
|
174
|
+
if (!_benchmarkSink)
|
|
175
|
+
return response;
|
|
176
|
+
var startedAt = response.config && response.config._benchStartedAt;
|
|
177
|
+
if (!startedAt)
|
|
178
|
+
return response;
|
|
179
|
+
var url = (response.config && response.config.url) || "";
|
|
180
|
+
var payload = response.data;
|
|
181
|
+
var responseBytes = 0;
|
|
182
|
+
try {
|
|
183
|
+
responseBytes = payload ? JSON.stringify(payload).length : 0;
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
responseBytes = 0;
|
|
187
|
+
}
|
|
188
|
+
var tableCount = 0;
|
|
189
|
+
var result = payload && payload.result;
|
|
190
|
+
if (result && typeof result === "object") {
|
|
191
|
+
if (url.indexOf("bulkDownload") !== -1) {
|
|
192
|
+
tableCount = Object.keys(result).length;
|
|
193
|
+
}
|
|
194
|
+
else if (url.indexOf("getManifest") !== -1 && result.tables) {
|
|
195
|
+
tableCount = Object.keys(result.tables).length;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
_benchmarkSink.recordHttp({
|
|
199
|
+
path: url,
|
|
200
|
+
tableCount: tableCount,
|
|
201
|
+
durationMs: Date.now() - startedAt,
|
|
202
|
+
statusCode: response.status,
|
|
203
|
+
responseBytes: responseBytes,
|
|
204
|
+
});
|
|
205
|
+
return response;
|
|
206
|
+
}, function (error) {
|
|
207
|
+
// Preserve original rejection; don't record errored requests (they skew
|
|
208
|
+
// latency tails without adding signal for the "scales the same" question).
|
|
209
|
+
return Promise.reject(error);
|
|
210
|
+
});
|
|
211
|
+
const client = (0, axios_rate_limit_1.default)(rawAxios, { maxRPS: 20 });
|
|
158
212
|
const getAppList = () => {
|
|
159
213
|
const endpoint = "api/sinc/sincronia/getAppList";
|
|
160
214
|
return client.get(endpoint);
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for the --benchmark flag on `sinc refresh`.
|
|
4
|
+
*
|
|
5
|
+
* Two layers:
|
|
6
|
+
* 1. BenchmarkCollector in isolation — covers p50/p95/max math and the
|
|
7
|
+
* formatSummary string the CLI prints.
|
|
8
|
+
* 2. refreshAllFiles wired to a collector — asserts startScope/endScope
|
|
9
|
+
* bookends the scope and that filesWritten/filesUnchanged match what
|
|
10
|
+
* refresh actually did.
|
|
11
|
+
*
|
|
12
|
+
* The axios interceptor hook (snClient.setBenchmarkSink) is exercised by the
|
|
13
|
+
* real-run integration against workstudio — not here. This suite covers the
|
|
14
|
+
* collector contract and the appUtils lifecycle.
|
|
15
|
+
*/
|
|
16
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(o, k2, desc);
|
|
23
|
+
}) : (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
o[k2] = m[k];
|
|
26
|
+
}));
|
|
27
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
+
}) : function(o, v) {
|
|
30
|
+
o["default"] = v;
|
|
31
|
+
});
|
|
32
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
+
var ownKeys = function(o) {
|
|
34
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
+
var ar = [];
|
|
36
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
+
return ar;
|
|
38
|
+
};
|
|
39
|
+
return ownKeys(o);
|
|
40
|
+
};
|
|
41
|
+
return function (mod) {
|
|
42
|
+
if (mod && mod.__esModule) return mod;
|
|
43
|
+
var result = {};
|
|
44
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
+
__setModuleDefault(result, mod);
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
const fs = __importStar(require("fs"));
|
|
51
|
+
const os = __importStar(require("os"));
|
|
52
|
+
const path = __importStar(require("path"));
|
|
53
|
+
const benchmark_1 = require("../benchmark");
|
|
54
|
+
// ---------- mocks for refreshAllFiles test (must come before importing) ----
|
|
55
|
+
var mockLogger = {
|
|
56
|
+
info: jest.fn(),
|
|
57
|
+
debug: jest.fn(),
|
|
58
|
+
warn: jest.fn(),
|
|
59
|
+
error: jest.fn(),
|
|
60
|
+
success: jest.fn(),
|
|
61
|
+
getLogLevel: function () { return "warn"; },
|
|
62
|
+
};
|
|
63
|
+
var mockFileLogger = {
|
|
64
|
+
debug: jest.fn(),
|
|
65
|
+
info: jest.fn(),
|
|
66
|
+
warn: jest.fn(),
|
|
67
|
+
error: jest.fn(),
|
|
68
|
+
};
|
|
69
|
+
var mockClient = {
|
|
70
|
+
getManifest: jest.fn(),
|
|
71
|
+
getMissingFiles: jest.fn(),
|
|
72
|
+
};
|
|
73
|
+
jest.mock("../Logger", function () { return { logger: mockLogger }; });
|
|
74
|
+
jest.mock("../FileLogger", function () { return { fileLogger: mockFileLogger }; });
|
|
75
|
+
jest.mock("../snClient", function () {
|
|
76
|
+
return {
|
|
77
|
+
defaultClient: function () { return mockClient; },
|
|
78
|
+
unwrapSNResponse: function (p) { return Promise.resolve(p).then(function (r) { return r; }); },
|
|
79
|
+
processPushResponse: jest.fn(),
|
|
80
|
+
retryOnErr: jest.fn(),
|
|
81
|
+
retryOnHttpErr: jest.fn(),
|
|
82
|
+
unwrapTableAPIFirstItem: jest.fn(),
|
|
83
|
+
setBenchmarkSink: jest.fn(),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
var mockConfig = {
|
|
87
|
+
getConfig: jest.fn().mockReturnValue({ scopes: { x_cadso_core: {} }, tableOptions: {} }),
|
|
88
|
+
getManifest: jest.fn().mockResolvedValue({
|
|
89
|
+
x_cadso_core: { scope: "x_cadso_core", tables: {} },
|
|
90
|
+
}),
|
|
91
|
+
getSourcePathForScope: jest.fn(),
|
|
92
|
+
getSourcePath: jest.fn(),
|
|
93
|
+
getManifestPath: jest.fn().mockReturnValue("/tmp/sinc.manifest.json"),
|
|
94
|
+
resolveConfigForScope: jest.fn().mockImplementation(function () {
|
|
95
|
+
return { tables: ["sys_script_include"], fieldOverrides: {}, apiIncludes: {}, apiExcludes: {} };
|
|
96
|
+
}),
|
|
97
|
+
isMultiScopeManifest: jest.fn().mockReturnValue(true),
|
|
98
|
+
updateManifest: jest.fn(),
|
|
99
|
+
};
|
|
100
|
+
jest.mock("../config", function () { return mockConfig; });
|
|
101
|
+
jest.mock("progress", function () {
|
|
102
|
+
return jest.fn().mockImplementation(function () { return { tick: jest.fn() }; });
|
|
103
|
+
});
|
|
104
|
+
jest.mock("../FileUtils", function () {
|
|
105
|
+
var actual = jest.requireActual("../FileUtils");
|
|
106
|
+
return Object.assign({}, actual, {
|
|
107
|
+
writeScopeManifest: jest.fn().mockResolvedValue(undefined),
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
const AppUtils = __importStar(require("../appUtils"));
|
|
111
|
+
// ---------- collector unit tests ----------
|
|
112
|
+
describe("BenchmarkCollector", function () {
|
|
113
|
+
test("formatSummary reports empty state when nothing recorded", function () {
|
|
114
|
+
var collector = new benchmark_1.BenchmarkCollector();
|
|
115
|
+
var summary = collector.formatSummary();
|
|
116
|
+
expect(summary).toContain("Refresh Benchmark");
|
|
117
|
+
expect(summary).toContain("(no samples recorded)");
|
|
118
|
+
});
|
|
119
|
+
test("percentiles reflect the recorded latencies", function () {
|
|
120
|
+
var collector = new benchmark_1.BenchmarkCollector();
|
|
121
|
+
// 10, 20, 30, ..., 100 — p50 at index 5 = 60, p95 at index 9 = 100.
|
|
122
|
+
for (var i = 1; i <= 10; i++) {
|
|
123
|
+
collector.recordHttp({
|
|
124
|
+
path: "/api/test",
|
|
125
|
+
tableCount: 1,
|
|
126
|
+
durationMs: i * 10,
|
|
127
|
+
statusCode: 200,
|
|
128
|
+
responseBytes: 100,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
var summary = collector.formatSummary();
|
|
132
|
+
expect(summary).toContain("p50 60ms");
|
|
133
|
+
expect(summary).toContain("p95 100ms");
|
|
134
|
+
expect(summary).toContain("max 100ms");
|
|
135
|
+
expect(summary).toContain("10 HTTP requests");
|
|
136
|
+
});
|
|
137
|
+
test("scope samples record wall time, request counts, and file counts", function () {
|
|
138
|
+
var collector = new benchmark_1.BenchmarkCollector();
|
|
139
|
+
collector.startScope("x_cadso_core");
|
|
140
|
+
collector.recordHttp({
|
|
141
|
+
path: "/api/bulkDownload",
|
|
142
|
+
tableCount: 3,
|
|
143
|
+
durationMs: 50,
|
|
144
|
+
statusCode: 200,
|
|
145
|
+
responseBytes: 2048,
|
|
146
|
+
});
|
|
147
|
+
collector.recordHttp({
|
|
148
|
+
path: "/api/bulkDownload",
|
|
149
|
+
tableCount: 3,
|
|
150
|
+
durationMs: 80,
|
|
151
|
+
statusCode: 200,
|
|
152
|
+
responseBytes: 4096,
|
|
153
|
+
});
|
|
154
|
+
collector.endScope(7, 42);
|
|
155
|
+
var scopes = collector.getScopeSamples();
|
|
156
|
+
expect(scopes).toHaveLength(1);
|
|
157
|
+
expect(scopes[0].scopeName).toBe("x_cadso_core");
|
|
158
|
+
expect(scopes[0].httpRequests).toBe(2);
|
|
159
|
+
expect(scopes[0].totalResponseBytes).toBe(2048 + 4096);
|
|
160
|
+
expect(scopes[0].filesWritten).toBe(7);
|
|
161
|
+
expect(scopes[0].filesUnchanged).toBe(42);
|
|
162
|
+
expect(scopes[0].wallTimeMs).toBeGreaterThanOrEqual(0);
|
|
163
|
+
var summary = collector.formatSummary();
|
|
164
|
+
expect(summary).toContain("x_cadso_core:");
|
|
165
|
+
expect(summary).toContain("7 written / 42 unchanged");
|
|
166
|
+
});
|
|
167
|
+
test("formatBytes switches units at KB and MB thresholds", function () {
|
|
168
|
+
var collector = new benchmark_1.BenchmarkCollector();
|
|
169
|
+
collector.recordHttp({ path: "/a", tableCount: 1, durationMs: 1, statusCode: 200, responseBytes: 500 });
|
|
170
|
+
expect(collector.formatSummary()).toContain("500B received");
|
|
171
|
+
var collector2 = new benchmark_1.BenchmarkCollector();
|
|
172
|
+
collector2.recordHttp({ path: "/a", tableCount: 1, durationMs: 1, statusCode: 200, responseBytes: 2048 });
|
|
173
|
+
expect(collector2.formatSummary()).toContain("2.0KB received");
|
|
174
|
+
var collector3 = new benchmark_1.BenchmarkCollector();
|
|
175
|
+
collector3.recordHttp({ path: "/a", tableCount: 1, durationMs: 1, statusCode: 200, responseBytes: 2 * 1024 * 1024 });
|
|
176
|
+
expect(collector3.formatSummary()).toContain("2.00MB received");
|
|
177
|
+
});
|
|
178
|
+
test("endScope is a no-op when no scope is active", function () {
|
|
179
|
+
var collector = new benchmark_1.BenchmarkCollector();
|
|
180
|
+
collector.endScope(5, 5);
|
|
181
|
+
expect(collector.getScopeSamples()).toHaveLength(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
// ---------- refreshAllFiles integration with collector ----------
|
|
185
|
+
describe("refreshAllFiles — benchmarkCollector lifecycle", function () {
|
|
186
|
+
var tmpRoot;
|
|
187
|
+
beforeEach(function () {
|
|
188
|
+
jest.clearAllMocks();
|
|
189
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sinc-bench-test-"));
|
|
190
|
+
mockConfig.getSourcePathForScope.mockReturnValue(tmpRoot);
|
|
191
|
+
mockConfig.getSourcePath.mockReturnValue(tmpRoot);
|
|
192
|
+
});
|
|
193
|
+
afterEach(function () {
|
|
194
|
+
try {
|
|
195
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
catch (e) { }
|
|
198
|
+
});
|
|
199
|
+
test("endScope captures filesWritten and filesUnchanged for a refresh pass", async function () {
|
|
200
|
+
// "Stale" file — content diverges from what the mock returns → will be written.
|
|
201
|
+
fs.mkdirSync(path.join(tmpRoot, "sys_script_include", "StaleRec"), { recursive: true });
|
|
202
|
+
fs.writeFileSync(path.join(tmpRoot, "sys_script_include", "StaleRec", "script.js"), "var x = 1;");
|
|
203
|
+
// "Matching" file — content matches → counted as unchanged.
|
|
204
|
+
fs.mkdirSync(path.join(tmpRoot, "sys_script_include", "SameRec"), { recursive: true });
|
|
205
|
+
var matching = "var same = true;";
|
|
206
|
+
fs.writeFileSync(path.join(tmpRoot, "sys_script_include", "SameRec", "script.js"), matching);
|
|
207
|
+
var manifest = {
|
|
208
|
+
scope: "x_cadso_core",
|
|
209
|
+
tables: {
|
|
210
|
+
sys_script_include: {
|
|
211
|
+
records: {
|
|
212
|
+
StaleRec: { name: "StaleRec", sys_id: "sysid_Stale", files: [{ name: "script", type: "js" }] },
|
|
213
|
+
SameRec: { name: "SameRec", sys_id: "sysid_Same", files: [{ name: "script", type: "js" }] },
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
mockClient.getManifest.mockResolvedValue(manifest);
|
|
219
|
+
mockClient.getMissingFiles.mockResolvedValue({
|
|
220
|
+
sys_script_include: {
|
|
221
|
+
records: {
|
|
222
|
+
StaleRec: {
|
|
223
|
+
name: "StaleRec", sys_id: "sysid_Stale",
|
|
224
|
+
files: [{ name: "script", type: "js", content: "var x = 99; // new from instance" }],
|
|
225
|
+
},
|
|
226
|
+
SameRec: {
|
|
227
|
+
name: "SameRec", sys_id: "sysid_Same",
|
|
228
|
+
files: [{ name: "script", type: "js", content: matching }],
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
var collector = new benchmark_1.BenchmarkCollector();
|
|
234
|
+
collector.startScope("x_cadso_core");
|
|
235
|
+
await AppUtils.refreshAllFiles(manifest, tmpRoot, {
|
|
236
|
+
benchmarkCollector: collector,
|
|
237
|
+
});
|
|
238
|
+
var scopes = collector.getScopeSamples();
|
|
239
|
+
expect(scopes).toHaveLength(1);
|
|
240
|
+
expect(scopes[0].scopeName).toBe("x_cadso_core");
|
|
241
|
+
// One file had divergent content → written. One matched → unchanged.
|
|
242
|
+
expect(scopes[0].filesWritten).toBe(1);
|
|
243
|
+
expect(scopes[0].filesUnchanged).toBe(1);
|
|
244
|
+
});
|
|
245
|
+
test("endScope(0, 0) on error path so the collector still closes cleanly", async function () {
|
|
246
|
+
mockClient.getMissingFiles.mockRejectedValue(new Error("boom"));
|
|
247
|
+
var manifest = {
|
|
248
|
+
scope: "x_cadso_core",
|
|
249
|
+
tables: {
|
|
250
|
+
sys_script_include: {
|
|
251
|
+
records: {
|
|
252
|
+
Rec: { name: "Rec", sys_id: "sysid_Rec", files: [{ name: "script", type: "js" }] },
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
var collector = new benchmark_1.BenchmarkCollector();
|
|
258
|
+
collector.startScope("x_cadso_core");
|
|
259
|
+
await expect(AppUtils.refreshAllFiles(manifest, tmpRoot, { benchmarkCollector: collector })).rejects.toThrow("boom");
|
|
260
|
+
var scopes = collector.getScopeSamples();
|
|
261
|
+
expect(scopes).toHaveLength(1);
|
|
262
|
+
expect(scopes[0].filesWritten).toBe(0);
|
|
263
|
+
expect(scopes[0].filesUnchanged).toBe(0);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -86,6 +86,7 @@ var mockSNClient = {
|
|
|
86
86
|
updateCurrentAppUserPref: jest.fn(),
|
|
87
87
|
createCurrentAppUserPref: jest.fn(),
|
|
88
88
|
getCurrentUpdateSetUserPref: jest.fn(),
|
|
89
|
+
changeScope: jest.fn().mockResolvedValue(undefined),
|
|
89
90
|
};
|
|
90
91
|
jest.mock("../snClient", function () {
|
|
91
92
|
return {
|
|
@@ -267,9 +268,9 @@ describe("US-014: Global debounce for serialized scope processing", function ()
|
|
|
267
268
|
watcherB.pushQueue.push(ctxB.filePath);
|
|
268
269
|
// Track the order of scope switches
|
|
269
270
|
var switchOrder = [];
|
|
270
|
-
mockSNClient.
|
|
271
|
+
mockSNClient.changeScope.mockImplementation(function (scope) {
|
|
271
272
|
switchOrder.push(scope);
|
|
272
|
-
return Promise.resolve(
|
|
273
|
+
return Promise.resolve(undefined);
|
|
273
274
|
});
|
|
274
275
|
// Fire the global debounce
|
|
275
276
|
await capturedDebounceFns[0]();
|
|
@@ -83,6 +83,7 @@ const mockSNClient = {
|
|
|
83
83
|
updateCurrentAppUserPref: jest.fn(),
|
|
84
84
|
createCurrentAppUserPref: jest.fn(),
|
|
85
85
|
getCurrentUpdateSetUserPref: jest.fn(),
|
|
86
|
+
changeScope: jest.fn().mockResolvedValue(undefined),
|
|
86
87
|
};
|
|
87
88
|
jest.mock("../snClient", () => ({
|
|
88
89
|
defaultClient: jest.fn(() => mockSNClient),
|
|
@@ -415,28 +416,17 @@ describe("MultiScopeWatcherManager", () => {
|
|
|
415
416
|
});
|
|
416
417
|
});
|
|
417
418
|
describe("switchToScope", () => {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
419
|
+
// switchToScope now uses the Claude REST API changeScope endpoint
|
|
420
|
+
// (gs.setCurrentApplicationId) to switch the active REST session scope.
|
|
421
|
+
// The previous user-preference approach only wrote a DB record and did
|
|
422
|
+
// not affect the session, causing updateRecord() to operate in the wrong scope.
|
|
423
|
+
it("invokes client.changeScope with the target scope", async () => {
|
|
421
424
|
await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_test_core");
|
|
422
|
-
expect(mockSNClient.
|
|
423
|
-
expect(mockSNClient.getUserSysId).toHaveBeenCalled();
|
|
424
|
-
expect(mockSNClient.updateCurrentAppUserPref).toHaveBeenCalledWith("scope_sys_id", "existing_pref");
|
|
425
|
-
expect(mockSNClient.createCurrentAppUserPref).not.toHaveBeenCalled();
|
|
425
|
+
expect(mockSNClient.changeScope).toHaveBeenCalledWith("x_test_core");
|
|
426
426
|
});
|
|
427
|
-
it("
|
|
428
|
-
mockSNClient.
|
|
429
|
-
await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_test_core");
|
|
430
|
-
expect(mockSNClient.createCurrentAppUserPref).toHaveBeenCalledWith("scope_sys_id", "user_sys_id");
|
|
431
|
-
expect(mockSNClient.updateCurrentAppUserPref).not.toHaveBeenCalled();
|
|
432
|
-
});
|
|
433
|
-
it("throws when scope not found", async () => {
|
|
434
|
-
mockSNClient.getScopeId.mockResolvedValue([]);
|
|
435
|
-
await expect(MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_missing")).rejects.toThrow("Scope x_missing not found");
|
|
436
|
-
});
|
|
437
|
-
it("throws when user sys_id cannot be retrieved", async () => {
|
|
438
|
-
mockSNClient.getUserSysId.mockResolvedValue([]);
|
|
439
|
-
await expect(MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_test_core")).rejects.toThrow("Could not get user sys_id");
|
|
427
|
+
it("propagates errors from changeScope and invalidates cached scope", async () => {
|
|
428
|
+
mockSNClient.changeScope.mockRejectedValueOnce(new Error("changeScope failed"));
|
|
429
|
+
await expect(MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_test_core")).rejects.toThrow("changeScope failed");
|
|
440
430
|
});
|
|
441
431
|
});
|
|
442
432
|
describe("loadScopeManifest", () => {
|
|
@@ -26,6 +26,10 @@ jest.mock("../snClient", function () {
|
|
|
26
26
|
createCurrentAppUserPref: function (scopeSysId, userSysId) {
|
|
27
27
|
apiCalls.push("createCurrentAppUserPref");
|
|
28
28
|
return Promise.resolve({});
|
|
29
|
+
},
|
|
30
|
+
changeScope: function (scope) {
|
|
31
|
+
apiCalls.push("changeScope:" + scope);
|
|
32
|
+
return Promise.resolve(undefined);
|
|
29
33
|
}
|
|
30
34
|
};
|
|
31
35
|
},
|
|
@@ -56,16 +60,13 @@ jest.mock("../config", function () {
|
|
|
56
60
|
describe("Scope Caching (US-013)", function () {
|
|
57
61
|
beforeEach(function () {
|
|
58
62
|
apiCalls = [];
|
|
59
|
-
// Reset cached state
|
|
63
|
+
// Reset cached state. switchToScope now uses the Claude REST API
|
|
64
|
+
// changeScope endpoint; the cache avoids redundant session switches.
|
|
60
65
|
MultiScopeWatcher_1.multiScopeWatcher.cachedScope = null;
|
|
61
|
-
MultiScopeWatcher_1.multiScopeWatcher.cachedUserSysId = null;
|
|
62
66
|
});
|
|
63
|
-
it("should
|
|
67
|
+
it("should call changeScope on first switch to a scope", async function () {
|
|
64
68
|
await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
|
|
65
|
-
expect(apiCalls).toContain("
|
|
66
|
-
expect(apiCalls).toContain("getUserSysId");
|
|
67
|
-
expect(apiCalls).toContain("getCurrentAppUserPrefSysId");
|
|
68
|
-
expect(apiCalls).toContain("updateCurrentAppUserPref");
|
|
69
|
+
expect(apiCalls).toContain("changeScope:x_cadso_core");
|
|
69
70
|
});
|
|
70
71
|
it("should skip API calls when switching to the same scope (cache hit)", async function () {
|
|
71
72
|
await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
|
|
@@ -73,21 +74,11 @@ describe("Scope Caching (US-013)", function () {
|
|
|
73
74
|
await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
|
|
74
75
|
expect(apiCalls).toEqual([]);
|
|
75
76
|
});
|
|
76
|
-
it("should
|
|
77
|
-
await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
|
|
78
|
-
apiCalls = [];
|
|
79
|
-
await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_automate");
|
|
80
|
-
expect(apiCalls).toContain("getScopeId:x_cadso_automate");
|
|
81
|
-
expect(apiCalls).toContain("getCurrentAppUserPrefSysId");
|
|
82
|
-
expect(apiCalls).toContain("updateCurrentAppUserPref");
|
|
83
|
-
});
|
|
84
|
-
it("should cache getUserSysId and call it at most once across multiple scope switches", async function () {
|
|
77
|
+
it("should call changeScope when switching to a different scope (cache miss)", async function () {
|
|
85
78
|
await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
|
|
86
79
|
apiCalls = [];
|
|
87
80
|
await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_automate");
|
|
88
|
-
|
|
89
|
-
expect(userCalls).toHaveLength(0);
|
|
90
|
-
expect(MultiScopeWatcher_1.multiScopeWatcher.cachedUserSysId).toBe("user_abc123");
|
|
81
|
+
expect(apiCalls).toContain("changeScope:x_cadso_automate");
|
|
91
82
|
});
|
|
92
83
|
it("should update cachedScope after a successful switch", async function () {
|
|
93
84
|
expect(MultiScopeWatcher_1.multiScopeWatcher.cachedScope).toBeNull();
|
|
@@ -100,14 +91,14 @@ describe("Scope Caching (US-013)", function () {
|
|
|
100
91
|
// First successful switch
|
|
101
92
|
await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
|
|
102
93
|
expect(MultiScopeWatcher_1.multiScopeWatcher.cachedScope).toBe("x_cadso_core");
|
|
103
|
-
//
|
|
94
|
+
// Swap in a client whose changeScope rejects
|
|
104
95
|
var snClient = require("../snClient");
|
|
105
96
|
var origDefault = snClient.defaultClient;
|
|
106
97
|
snClient.defaultClient = function () {
|
|
107
98
|
var client = origDefault();
|
|
108
|
-
client.
|
|
109
|
-
apiCalls.push("
|
|
110
|
-
return Promise.
|
|
99
|
+
client.changeScope = function (scope) {
|
|
100
|
+
apiCalls.push("changeScope:" + scope + ":failed");
|
|
101
|
+
return Promise.reject(new Error("changeScope failed"));
|
|
111
102
|
};
|
|
112
103
|
return client;
|
|
113
104
|
};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the refresh content-compare path.
|
|
4
|
+
*
|
|
5
|
+
* Regression target: `npx sinc refresh` previously only downloaded files that
|
|
6
|
+
* were absent from disk. Files edited on the ServiceNow instance would never
|
|
7
|
+
* propagate to a developer's local copy — the refresh command silently
|
|
8
|
+
* skipped existing files without checking content.
|
|
9
|
+
*
|
|
10
|
+
* These tests assert:
|
|
11
|
+
* 1. refresh fetches content for EVERY file in the manifest (not just missing)
|
|
12
|
+
* 2. Files whose local content differs are overwritten
|
|
13
|
+
* 3. Files whose local content matches are left alone (no rewrite)
|
|
14
|
+
* 4. Files missing locally are created
|
|
15
|
+
* 5. --force bypasses the content check and writes all files unconditionally
|
|
16
|
+
*/
|
|
17
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
20
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
21
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
22
|
+
}
|
|
23
|
+
Object.defineProperty(o, k2, desc);
|
|
24
|
+
}) : (function(o, m, k, k2) {
|
|
25
|
+
if (k2 === undefined) k2 = k;
|
|
26
|
+
o[k2] = m[k];
|
|
27
|
+
}));
|
|
28
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
29
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
30
|
+
}) : function(o, v) {
|
|
31
|
+
o["default"] = v;
|
|
32
|
+
});
|
|
33
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
34
|
+
var ownKeys = function(o) {
|
|
35
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
36
|
+
var ar = [];
|
|
37
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
38
|
+
return ar;
|
|
39
|
+
};
|
|
40
|
+
return ownKeys(o);
|
|
41
|
+
};
|
|
42
|
+
return function (mod) {
|
|
43
|
+
if (mod && mod.__esModule) return mod;
|
|
44
|
+
var result = {};
|
|
45
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
46
|
+
__setModuleDefault(result, mod);
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
})();
|
|
50
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
|
+
const fs = __importStar(require("fs"));
|
|
52
|
+
const os = __importStar(require("os"));
|
|
53
|
+
const path = __importStar(require("path"));
|
|
54
|
+
// ---------- mocks (must come before importing the module under test) ----------
|
|
55
|
+
var mockLogger = {
|
|
56
|
+
info: jest.fn(),
|
|
57
|
+
debug: jest.fn(),
|
|
58
|
+
warn: jest.fn(),
|
|
59
|
+
error: jest.fn(),
|
|
60
|
+
success: jest.fn(),
|
|
61
|
+
getLogLevel: function () { return "warn"; },
|
|
62
|
+
};
|
|
63
|
+
var mockFileLogger = {
|
|
64
|
+
debug: jest.fn(),
|
|
65
|
+
info: jest.fn(),
|
|
66
|
+
warn: jest.fn(),
|
|
67
|
+
error: jest.fn(),
|
|
68
|
+
};
|
|
69
|
+
var mockClient = {
|
|
70
|
+
getManifest: jest.fn(),
|
|
71
|
+
getMissingFiles: jest.fn(),
|
|
72
|
+
};
|
|
73
|
+
jest.mock("../Logger", function () { return { logger: mockLogger }; });
|
|
74
|
+
jest.mock("../FileLogger", function () { return { fileLogger: mockFileLogger }; });
|
|
75
|
+
jest.mock("../snClient", function () {
|
|
76
|
+
return {
|
|
77
|
+
defaultClient: function () { return mockClient; },
|
|
78
|
+
unwrapSNResponse: function (p) { return Promise.resolve(p).then(function (r) { return r; }); },
|
|
79
|
+
processPushResponse: jest.fn(),
|
|
80
|
+
retryOnErr: jest.fn(),
|
|
81
|
+
retryOnHttpErr: jest.fn(),
|
|
82
|
+
unwrapTableAPIFirstItem: jest.fn(),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
var mockConfig = {
|
|
86
|
+
getConfig: jest.fn().mockReturnValue({
|
|
87
|
+
scopes: { x_cadso_core: {} },
|
|
88
|
+
tableOptions: {},
|
|
89
|
+
}),
|
|
90
|
+
getManifest: jest.fn().mockResolvedValue({
|
|
91
|
+
x_cadso_core: { scope: "x_cadso_core", tables: {} },
|
|
92
|
+
}),
|
|
93
|
+
getSourcePathForScope: jest.fn(),
|
|
94
|
+
getSourcePath: jest.fn(),
|
|
95
|
+
getManifestPath: jest.fn().mockReturnValue("/tmp/sinc.manifest.json"),
|
|
96
|
+
resolveConfigForScope: jest.fn().mockImplementation(function () {
|
|
97
|
+
return {
|
|
98
|
+
tables: ["sys_script_include"],
|
|
99
|
+
fieldOverrides: {},
|
|
100
|
+
apiIncludes: {},
|
|
101
|
+
apiExcludes: {},
|
|
102
|
+
};
|
|
103
|
+
}),
|
|
104
|
+
isMultiScopeManifest: jest.fn().mockReturnValue(true),
|
|
105
|
+
updateManifest: jest.fn(),
|
|
106
|
+
};
|
|
107
|
+
jest.mock("../config", function () { return mockConfig; });
|
|
108
|
+
// Silence ProgressBar's stdout writes.
|
|
109
|
+
jest.mock("progress", function () {
|
|
110
|
+
return jest.fn().mockImplementation(function () {
|
|
111
|
+
return { tick: jest.fn() };
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
// Stub writeScopeManifest so we don't actually write a manifest file. Leave
|
|
115
|
+
// the other FileUtils exports (writeSNFileCurry, writeSNFileIfDifferent,
|
|
116
|
+
// createDirRecursively, etc.) as real implementations so we can assert
|
|
117
|
+
// against the file system.
|
|
118
|
+
jest.mock("../FileUtils", function () {
|
|
119
|
+
var actual = jest.requireActual("../FileUtils");
|
|
120
|
+
return Object.assign({}, actual, {
|
|
121
|
+
writeScopeManifest: jest.fn().mockResolvedValue(undefined),
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
const AppUtils = __importStar(require("../appUtils"));
|
|
125
|
+
// ---------- tmp dir scaffolding ----------
|
|
126
|
+
var tmpRoot;
|
|
127
|
+
function setupScope(files) {
|
|
128
|
+
// Build a manifest whose single table (sys_script_include) contains the
|
|
129
|
+
// supplied records/files. Content is stripped (manifests do not carry it).
|
|
130
|
+
var records = {};
|
|
131
|
+
files.forEach(function (f) {
|
|
132
|
+
if (!records[f.record]) {
|
|
133
|
+
records[f.record] = { name: f.record, sys_id: "sysid_" + f.record, files: [] };
|
|
134
|
+
}
|
|
135
|
+
records[f.record].files.push({ name: f.name, type: f.type });
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
scope: "x_cadso_core",
|
|
139
|
+
tables: {
|
|
140
|
+
sys_script_include: { records: records },
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function writeLocal(record, name, type, content) {
|
|
145
|
+
var recDir = path.join(tmpRoot, "sys_script_include", record);
|
|
146
|
+
fs.mkdirSync(recDir, { recursive: true });
|
|
147
|
+
fs.writeFileSync(path.join(recDir, name + "." + type), content);
|
|
148
|
+
}
|
|
149
|
+
function readLocal(record, name, type) {
|
|
150
|
+
var p = path.join(tmpRoot, "sys_script_include", record, name + "." + type);
|
|
151
|
+
try {
|
|
152
|
+
return fs.readFileSync(p, "utf8");
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function statMtime(record, name, type) {
|
|
159
|
+
var p = path.join(tmpRoot, "sys_script_include", record, name + "." + type);
|
|
160
|
+
return fs.statSync(p).mtimeMs;
|
|
161
|
+
}
|
|
162
|
+
describe("syncManifest — refresh pulls instance edits down", function () {
|
|
163
|
+
beforeEach(function () {
|
|
164
|
+
jest.clearAllMocks();
|
|
165
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sinc-refresh-test-"));
|
|
166
|
+
mockConfig.getSourcePathForScope.mockReturnValue(tmpRoot);
|
|
167
|
+
mockConfig.getSourcePath.mockReturnValue(tmpRoot);
|
|
168
|
+
});
|
|
169
|
+
afterEach(function () {
|
|
170
|
+
try {
|
|
171
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
catch (e) { }
|
|
174
|
+
});
|
|
175
|
+
test("overwrites existing file when instance content differs", async function () {
|
|
176
|
+
writeLocal("Foo", "script", "js", "var x = 1;"); // stale local
|
|
177
|
+
var manifest = setupScope([{ record: "Foo", name: "script", type: "js", content: "" }]);
|
|
178
|
+
mockClient.getManifest.mockResolvedValue(manifest);
|
|
179
|
+
mockClient.getMissingFiles.mockResolvedValue({
|
|
180
|
+
sys_script_include: {
|
|
181
|
+
records: {
|
|
182
|
+
Foo: {
|
|
183
|
+
name: "Foo",
|
|
184
|
+
sys_id: "sysid_Foo",
|
|
185
|
+
files: [{ name: "script", type: "js", content: "var x = 2; // from instance" }],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
await AppUtils.syncManifest("x_cadso_core");
|
|
191
|
+
expect(mockClient.getMissingFiles).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(readLocal("Foo", "script", "js")).toBe("var x = 2; // from instance");
|
|
193
|
+
});
|
|
194
|
+
test("does not rewrite a file when content already matches", async function () {
|
|
195
|
+
var identical = "var answer = 42;";
|
|
196
|
+
writeLocal("Bar", "script", "js", identical);
|
|
197
|
+
var beforeMtime = statMtime("Bar", "script", "js");
|
|
198
|
+
var manifest = setupScope([{ record: "Bar", name: "script", type: "js", content: "" }]);
|
|
199
|
+
mockClient.getManifest.mockResolvedValue(manifest);
|
|
200
|
+
mockClient.getMissingFiles.mockResolvedValue({
|
|
201
|
+
sys_script_include: {
|
|
202
|
+
records: {
|
|
203
|
+
Bar: {
|
|
204
|
+
name: "Bar",
|
|
205
|
+
sys_id: "sysid_Bar",
|
|
206
|
+
files: [{ name: "script", type: "js", content: identical }],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
// Give the fs a moment so any write would register a different mtime.
|
|
212
|
+
await new Promise(function (r) { setTimeout(r, 10); });
|
|
213
|
+
await AppUtils.syncManifest("x_cadso_core");
|
|
214
|
+
expect(readLocal("Bar", "script", "js")).toBe(identical);
|
|
215
|
+
expect(statMtime("Bar", "script", "js")).toBe(beforeMtime);
|
|
216
|
+
// metaData should NOT be written when nothing changed in the record
|
|
217
|
+
expect(readLocal("Bar", "metaData", "json")).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
test("creates the file when it is missing locally", async function () {
|
|
220
|
+
var manifest = setupScope([{ record: "Baz", name: "script", type: "js", content: "" }]);
|
|
221
|
+
mockClient.getManifest.mockResolvedValue(manifest);
|
|
222
|
+
mockClient.getMissingFiles.mockResolvedValue({
|
|
223
|
+
sys_script_include: {
|
|
224
|
+
records: {
|
|
225
|
+
Baz: {
|
|
226
|
+
name: "Baz",
|
|
227
|
+
sys_id: "sysid_Baz",
|
|
228
|
+
files: [{ name: "script", type: "js", content: "// fresh file" }],
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
await AppUtils.syncManifest("x_cadso_core");
|
|
234
|
+
expect(readLocal("Baz", "script", "js")).toBe("// fresh file");
|
|
235
|
+
expect(readLocal("Baz", "metaData", "json")).not.toBeNull();
|
|
236
|
+
});
|
|
237
|
+
test("--force overwrites even when local content already matches", async function () {
|
|
238
|
+
var identical = "var same = true;";
|
|
239
|
+
writeLocal("Qux", "script", "js", identical);
|
|
240
|
+
var manifest = setupScope([{ record: "Qux", name: "script", type: "js", content: "" }]);
|
|
241
|
+
mockClient.getManifest.mockResolvedValue(manifest);
|
|
242
|
+
mockClient.getMissingFiles.mockResolvedValue({
|
|
243
|
+
sys_script_include: {
|
|
244
|
+
records: {
|
|
245
|
+
Qux: {
|
|
246
|
+
name: "Qux",
|
|
247
|
+
sys_id: "sysid_Qux",
|
|
248
|
+
files: [{ name: "script", type: "js", content: identical }],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
var beforeMtime = statMtime("Qux", "script", "js");
|
|
254
|
+
await new Promise(function (r) { setTimeout(r, 10); });
|
|
255
|
+
await AppUtils.syncManifest("x_cadso_core", { force: true });
|
|
256
|
+
// Force path rewrites regardless — mtime should advance.
|
|
257
|
+
expect(statMtime("Qux", "script", "js")).toBeGreaterThan(beforeMtime);
|
|
258
|
+
expect(readLocal("Qux", "metaData", "json")).not.toBeNull();
|
|
259
|
+
});
|
|
260
|
+
test("bulkDownload is requested for EVERY file in the manifest, not just missing ones", async function () {
|
|
261
|
+
writeLocal("RecA", "script", "js", "local A");
|
|
262
|
+
writeLocal("RecB", "script", "js", "local B");
|
|
263
|
+
var manifest = setupScope([
|
|
264
|
+
{ record: "RecA", name: "script", type: "js", content: "" },
|
|
265
|
+
{ record: "RecB", name: "script", type: "js", content: "" },
|
|
266
|
+
]);
|
|
267
|
+
mockClient.getManifest.mockResolvedValue(manifest);
|
|
268
|
+
mockClient.getMissingFiles.mockResolvedValue({
|
|
269
|
+
sys_script_include: {
|
|
270
|
+
records: {
|
|
271
|
+
RecA: {
|
|
272
|
+
name: "RecA", sys_id: "sysid_RecA",
|
|
273
|
+
files: [{ name: "script", type: "js", content: "local A" }],
|
|
274
|
+
},
|
|
275
|
+
RecB: {
|
|
276
|
+
name: "RecB", sys_id: "sysid_RecB",
|
|
277
|
+
files: [{ name: "script", type: "js", content: "local B" }],
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
await AppUtils.syncManifest("x_cadso_core");
|
|
283
|
+
expect(mockClient.getMissingFiles).toHaveBeenCalledTimes(1);
|
|
284
|
+
var missingArg = mockClient.getMissingFiles.mock.calls[0][0];
|
|
285
|
+
expect(Object.keys(missingArg.sys_script_include).sort()).toEqual(["sysid_RecA", "sysid_RecB"]);
|
|
286
|
+
});
|
|
287
|
+
});
|