@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 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 and current update set is Default. ` +
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 and could not query current update set. ` +
241
- `Changes will use direct Table API. Use sinc createUpdateSet or activate a task in the dashboard.`);
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
- await (0, exports.processMissingFiles)(newManifest, scopeSourcePath);
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"], "Refresh Manifest and download new files since last refresh", sharedOptions, commands_1.refreshCommand)
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 client = (0, axios_rate_limit_1.default)((0, axios_cookiejar_support_1.wrapper)(axios_1.default.create({
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
- })), { maxRPS: 20 });
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.getScopeId.mockImplementation(function (scope) {
271
+ mockSNClient.changeScope.mockImplementation(function (scope) {
271
272
  switchOrder.push(scope);
272
- return Promise.resolve([{ sys_id: "scope_sys_id_" + scope }]);
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
- it("updates existing preference when one exists", async () => {
419
- mockSNClient.getCurrentAppUserPrefSysId.mockResolvedValue([{ sys_id: "existing_pref" }]);
420
- // Access private method
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.getScopeId).toHaveBeenCalledWith("x_test_core");
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("creates new preference when none exists", async () => {
428
- mockSNClient.getCurrentAppUserPrefSysId.mockResolvedValue([]);
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 make API calls on first switch to a scope", async function () {
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("getScopeId:x_cadso_core");
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 make API calls when switching to a different scope (cache miss)", async function () {
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
- var userCalls = apiCalls.filter(function (c) { return c === "getUserSysId"; });
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
- // Mock getScopeId to fail for a bad scope
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.getScopeId = function () {
109
- apiCalls.push("getScopeId:bad_scope");
110
- return Promise.resolve({ data: { result: [] } });
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/sincronia-core",
3
- "version": "0.0.81",
3
+ "version": "0.0.82",
4
4
  "description": "Next-gen file syncer",
5
5
  "license": "GPL-3.0",
6
6
  "main": "./dist/index.js",