@tenonhq/sincronia-core 0.0.78 → 0.0.80

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.
@@ -0,0 +1,307 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ function createMockWatcher() {
40
+ var handlers = {};
41
+ var mock = {
42
+ _handlers: handlers,
43
+ on: jest.fn(function (event, handler) {
44
+ if (!handlers[event])
45
+ handlers[event] = [];
46
+ handlers[event].push(handler);
47
+ return mock;
48
+ }),
49
+ close: jest.fn(),
50
+ _emit: function (event) {
51
+ var args = Array.prototype.slice.call(arguments, 1);
52
+ (handlers[event] || []).forEach(function (h) { h.apply(null, args); });
53
+ },
54
+ };
55
+ return mock;
56
+ }
57
+ var mockWatchers = [];
58
+ jest.mock("chokidar", function () {
59
+ return {
60
+ watch: jest.fn(function () {
61
+ var w = createMockWatcher();
62
+ mockWatchers.push(w);
63
+ return w;
64
+ }),
65
+ };
66
+ });
67
+ // Capture debounce calls to verify single global debounce
68
+ var capturedDebounceFns = [];
69
+ jest.mock("lodash", function () {
70
+ var actual = jest.requireActual("lodash");
71
+ return {
72
+ ...actual,
73
+ debounce: jest.fn(function (fn) {
74
+ capturedDebounceFns.push(fn);
75
+ var wrapper = jest.fn();
76
+ wrapper.cancel = jest.fn();
77
+ wrapper.flush = jest.fn(function () { return fn(); });
78
+ return wrapper;
79
+ }),
80
+ };
81
+ });
82
+ var mockSNClient = {
83
+ getScopeId: jest.fn(),
84
+ getUserSysId: jest.fn(),
85
+ getCurrentAppUserPrefSysId: jest.fn(),
86
+ updateCurrentAppUserPref: jest.fn(),
87
+ createCurrentAppUserPref: jest.fn(),
88
+ getCurrentUpdateSetUserPref: jest.fn(),
89
+ };
90
+ jest.mock("../snClient", function () {
91
+ return {
92
+ defaultClient: jest.fn(function () { return mockSNClient; }),
93
+ unwrapSNResponse: jest.fn(function (val) { return val; }),
94
+ };
95
+ });
96
+ jest.mock("../FileUtils", function () {
97
+ return {
98
+ getFileContextFromPath: jest.fn(),
99
+ getFileContextWithSkipReason: jest.fn(),
100
+ };
101
+ });
102
+ jest.mock("../appUtils", function () {
103
+ return {
104
+ groupAppFiles: jest.fn(),
105
+ pushFiles: jest.fn(),
106
+ };
107
+ });
108
+ jest.mock("../logMessages", function () {
109
+ return { logFilePush: jest.fn() };
110
+ });
111
+ jest.mock("../recentEdits", function () {
112
+ return { writeRecentEdit: jest.fn() };
113
+ });
114
+ jest.mock("../Logger", function () {
115
+ return {
116
+ logger: {
117
+ info: jest.fn(),
118
+ error: jest.fn(),
119
+ warn: jest.fn(),
120
+ debug: jest.fn(),
121
+ success: jest.fn(),
122
+ getLogLevel: jest.fn().mockReturnValue("info"),
123
+ },
124
+ };
125
+ });
126
+ jest.mock("../config", function () {
127
+ return {
128
+ loadConfigs: jest.fn().mockResolvedValue(undefined),
129
+ getConfig: jest.fn(),
130
+ getRootDir: jest.fn().mockReturnValue("/project"),
131
+ updateManifest: jest.fn(),
132
+ getManifest: jest.fn(),
133
+ getSourcePath: jest.fn().mockReturnValue("/project/src"),
134
+ getScopeManifestPath: jest.fn(function (scope) { return "/project/sinc.manifest." + scope + ".json"; }),
135
+ getManifestPath: jest.fn().mockReturnValue("/project/sinc.manifest.json"),
136
+ };
137
+ });
138
+ jest.mock("fs", function () {
139
+ return {
140
+ existsSync: jest.fn(),
141
+ readFileSync: jest.fn(),
142
+ writeFileSync: jest.fn(),
143
+ statSync: jest.fn().mockReturnValue({ mtimeMs: Date.now() }),
144
+ promises: {
145
+ readFile: jest.fn(),
146
+ writeFile: jest.fn(),
147
+ readdir: jest.fn(),
148
+ mkdir: jest.fn(),
149
+ access: jest.fn(),
150
+ stat: jest.fn(),
151
+ },
152
+ };
153
+ });
154
+ jest.mock("axios", function () {
155
+ return {
156
+ default: {
157
+ create: jest.fn(function () {
158
+ return { get: jest.fn().mockResolvedValue({ data: { result: null } }) };
159
+ }),
160
+ },
161
+ };
162
+ });
163
+ // --- Imports ---
164
+ const fs_1 = __importDefault(require("fs"));
165
+ const ConfigManager = __importStar(require("../config"));
166
+ const FileUtils_1 = require("../FileUtils");
167
+ const appUtils_1 = require("../appUtils");
168
+ const MultiScopeWatcher_1 = require("../MultiScopeWatcher");
169
+ // --- Fixtures ---
170
+ var makeFileContext = function (overrides) {
171
+ return Object.assign({
172
+ filePath: "/project/src/x_test_core/sys_script_include/TestScript/script.js",
173
+ ext: ".js",
174
+ sys_id: "abc123",
175
+ name: "TestScript",
176
+ scope: "x_test_core",
177
+ tableName: "sys_script_include",
178
+ targetField: "script",
179
+ }, overrides || {});
180
+ };
181
+ var TWO_SCOPES_CONFIG = {
182
+ sourceDirectory: "src",
183
+ buildDirectory: "build",
184
+ rules: [],
185
+ includes: {},
186
+ excludes: {},
187
+ tableOptions: {},
188
+ refreshInterval: 30,
189
+ scopes: {
190
+ x_scope_a: { sourceDirectory: "src/x_scope_a" },
191
+ x_scope_b: { sourceDirectory: "src/x_scope_b" },
192
+ },
193
+ };
194
+ // --- Tests ---
195
+ describe("US-014: Global debounce for serialized scope processing", function () {
196
+ beforeEach(function () {
197
+ jest.clearAllMocks();
198
+ mockWatchers.length = 0;
199
+ capturedDebounceFns.length = 0;
200
+ (0, MultiScopeWatcher_1.stopMultiScopeWatching)();
201
+ MultiScopeWatcher_1.multiScopeWatcher.cachedScope = null;
202
+ MultiScopeWatcher_1.multiScopeWatcher.cachedUserSysId = null;
203
+ MultiScopeWatcher_1.multiScopeWatcher.pendingScopes = new Map();
204
+ MultiScopeWatcher_1.multiScopeWatcher.globalProcessQueue = null;
205
+ // Default scope switching succeeds
206
+ mockSNClient.getScopeId.mockResolvedValue([{ sys_id: "scope_sys_id" }]);
207
+ mockSNClient.getUserSysId.mockResolvedValue([{ sys_id: "user_sys_id" }]);
208
+ mockSNClient.getCurrentAppUserPrefSysId.mockResolvedValue([{ sys_id: "pref_sys_id" }]);
209
+ mockSNClient.updateCurrentAppUserPref.mockResolvedValue({});
210
+ mockSNClient.createCurrentAppUserPref.mockResolvedValue({});
211
+ fs_1.default.existsSync.mockReturnValue(false);
212
+ });
213
+ afterEach(function () {
214
+ (0, MultiScopeWatcher_1.stopMultiScopeWatching)();
215
+ jest.useRealTimers();
216
+ });
217
+ it("creates a single global debounce, not one per scope", async function () {
218
+ ConfigManager.getConfig.mockReturnValue(TWO_SCOPES_CONFIG);
219
+ await (0, MultiScopeWatcher_1.startMultiScopeWatching)({ monitorIntervalMs: 0 });
220
+ // Two scopes but only one debounce function captured
221
+ expect(capturedDebounceFns.length).toBe(1);
222
+ });
223
+ it("processes multiple scopes triggered within debounce window in a single batch", async function () {
224
+ ConfigManager.getConfig.mockReturnValue(TWO_SCOPES_CONFIG);
225
+ var ctxA = makeFileContext({ scope: "x_scope_a", filePath: "/project/src/x_scope_a/sys_script_include/A/script.js" });
226
+ var ctxB = makeFileContext({ scope: "x_scope_b", filePath: "/project/src/x_scope_b/sys_script_include/B/script.js" });
227
+ FileUtils_1.getFileContextWithSkipReason.mockImplementation(function (fp) {
228
+ if (fp.indexOf("x_scope_a") !== -1)
229
+ return { context: ctxA };
230
+ if (fp.indexOf("x_scope_b") !== -1)
231
+ return { context: ctxB };
232
+ return { skipReason: "unknown" };
233
+ });
234
+ appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
235
+ appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
236
+ await (0, MultiScopeWatcher_1.startMultiScopeWatching)({ monitorIntervalMs: 0 });
237
+ // Emit changes in both scopes before debounce fires
238
+ mockWatchers[0]._emit("change", ctxA.filePath);
239
+ mockWatchers[1]._emit("change", ctxB.filePath);
240
+ // Fire the single global debounce
241
+ await capturedDebounceFns[0]();
242
+ // Both scopes should have been processed (pushFiles called twice — once per scope)
243
+ expect(appUtils_1.pushFiles).toHaveBeenCalledTimes(2);
244
+ });
245
+ it("processes scopes in FIFO order by first file change timestamp", async function () {
246
+ ConfigManager.getConfig.mockReturnValue(TWO_SCOPES_CONFIG);
247
+ var ctxA = makeFileContext({ scope: "x_scope_a", filePath: "/project/src/x_scope_a/sys_script_include/A/script.js" });
248
+ var ctxB = makeFileContext({ scope: "x_scope_b", filePath: "/project/src/x_scope_b/sys_script_include/B/script.js" });
249
+ FileUtils_1.getFileContextWithSkipReason.mockImplementation(function (fp) {
250
+ if (fp.indexOf("x_scope_a") !== -1)
251
+ return { context: ctxA };
252
+ if (fp.indexOf("x_scope_b") !== -1)
253
+ return { context: ctxB };
254
+ return { skipReason: "unknown" };
255
+ });
256
+ appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
257
+ appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
258
+ await (0, MultiScopeWatcher_1.startMultiScopeWatching)({ monitorIntervalMs: 0 });
259
+ // Scope B changes first, then scope A
260
+ // Use pendingScopes directly to control timestamps for deterministic ordering
261
+ MultiScopeWatcher_1.multiScopeWatcher.pendingScopes.set("x_scope_b", 1000);
262
+ MultiScopeWatcher_1.multiScopeWatcher.pendingScopes.set("x_scope_a", 2000);
263
+ // Add files to queues manually
264
+ var watcherA = MultiScopeWatcher_1.multiScopeWatcher.scopeWatchers.get("x_scope_a");
265
+ var watcherB = MultiScopeWatcher_1.multiScopeWatcher.scopeWatchers.get("x_scope_b");
266
+ watcherA.pushQueue.push(ctxA.filePath);
267
+ watcherB.pushQueue.push(ctxB.filePath);
268
+ // Track the order of scope switches
269
+ var switchOrder = [];
270
+ mockSNClient.getScopeId.mockImplementation(function (scope) {
271
+ switchOrder.push(scope);
272
+ return Promise.resolve([{ sys_id: "scope_sys_id_" + scope }]);
273
+ });
274
+ // Fire the global debounce
275
+ await capturedDebounceFns[0]();
276
+ // Scope B (timestamp 1000) should be processed before scope A (timestamp 2000)
277
+ expect(switchOrder[0]).toBe("x_scope_b");
278
+ expect(switchOrder[1]).toBe("x_scope_a");
279
+ });
280
+ it("clears pendingScopes after processing", async function () {
281
+ ConfigManager.getConfig.mockReturnValue(TWO_SCOPES_CONFIG);
282
+ var ctx = makeFileContext({ scope: "x_scope_a", filePath: "/project/src/x_scope_a/sys_script_include/A/script.js" });
283
+ FileUtils_1.getFileContextWithSkipReason.mockReturnValue({ context: ctx });
284
+ appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
285
+ appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
286
+ await (0, MultiScopeWatcher_1.startMultiScopeWatching)({ monitorIntervalMs: 0 });
287
+ mockWatchers[0]._emit("change", ctx.filePath);
288
+ await capturedDebounceFns[0]();
289
+ expect(MultiScopeWatcher_1.multiScopeWatcher.pendingScopes.size).toBe(0);
290
+ });
291
+ it("only records first change timestamp per scope (not subsequent changes)", async function () {
292
+ ConfigManager.getConfig.mockReturnValue({
293
+ ...TWO_SCOPES_CONFIG,
294
+ scopes: { x_scope_a: { sourceDirectory: "src/x_scope_a" } },
295
+ });
296
+ await (0, MultiScopeWatcher_1.startMultiScopeWatching)({ monitorIntervalMs: 0 });
297
+ // Record the timestamp after first emit
298
+ mockWatchers[0]._emit("change", "/project/src/x_scope_a/sys_script_include/A/script.js");
299
+ var firstTimestamp = MultiScopeWatcher_1.multiScopeWatcher.pendingScopes.get("x_scope_a");
300
+ // Wait a bit and emit again
301
+ await new Promise(function (r) { setTimeout(r, 5); });
302
+ mockWatchers[0]._emit("change", "/project/src/x_scope_a/sys_script_include/B/script.js");
303
+ var secondTimestamp = MultiScopeWatcher_1.multiScopeWatcher.pendingScopes.get("x_scope_a");
304
+ // Timestamp should not have changed
305
+ expect(secondTimestamp).toBe(firstTimestamp);
306
+ });
307
+ });
@@ -61,7 +61,7 @@ jest.mock("chokidar", () => ({
61
61
  return w;
62
62
  }),
63
63
  }));
64
- // Debounce: capture per-scope processors, tests trigger manually
64
+ // Debounce: capture the global processor, tests trigger manually
65
65
  const capturedDebounceFns = [];
66
66
  jest.mock("lodash", () => {
67
67
  const actual = jest.requireActual("lodash");
@@ -90,6 +90,7 @@ jest.mock("../snClient", () => ({
90
90
  }));
91
91
  jest.mock("../FileUtils", () => ({
92
92
  getFileContextFromPath: jest.fn(),
93
+ getFileContextWithSkipReason: jest.fn(),
93
94
  }));
94
95
  jest.mock("../appUtils", () => ({
95
96
  groupAppFiles: jest.fn(),
@@ -98,6 +99,9 @@ jest.mock("../appUtils", () => ({
98
99
  jest.mock("../logMessages", () => ({
99
100
  logFilePush: jest.fn(),
100
101
  }));
102
+ jest.mock("../recentEdits", () => ({
103
+ writeRecentEdit: jest.fn(),
104
+ }));
101
105
  jest.mock("../Logger", () => ({
102
106
  logger: {
103
107
  info: jest.fn(),
@@ -118,9 +122,11 @@ jest.mock("../config", () => ({
118
122
  getScopeManifestPath: jest.fn((scope) => `/project/sinc.manifest.${scope}.json`),
119
123
  getManifestPath: jest.fn().mockReturnValue("/project/sinc.manifest.json"),
120
124
  }));
121
- // Mock fs for manifest loading (dynamic import in MultiScopeWatcher)
125
+ // Mock fs for manifest loading and config file access
122
126
  jest.mock("fs", () => ({
123
127
  existsSync: jest.fn(),
128
+ readFileSync: jest.fn(),
129
+ writeFileSync: jest.fn(),
124
130
  promises: {
125
131
  readFile: jest.fn(),
126
132
  writeFile: jest.fn(),
@@ -189,6 +195,12 @@ describe("MultiScopeWatcherManager", () => {
189
195
  capturedDebounceFns.length = 0;
190
196
  // Reset the internal state by stopping any previous watchers
191
197
  (0, MultiScopeWatcher_1.stopMultiScopeWatching)();
198
+ // Reset scope and user caches (US-013)
199
+ MultiScopeWatcher_1.multiScopeWatcher.cachedScope = null;
200
+ MultiScopeWatcher_1.multiScopeWatcher.cachedUserSysId = null;
201
+ // Reset global debounce state (US-014)
202
+ MultiScopeWatcher_1.multiScopeWatcher.pendingScopes = new Map();
203
+ MultiScopeWatcher_1.multiScopeWatcher.globalProcessQueue = null;
192
204
  // Default: scope switching succeeds
193
205
  mockSNClient.getScopeId.mockResolvedValue([{ sys_id: "scope_sys_id" }]);
194
206
  mockSNClient.getUserSysId.mockResolvedValue([{ sys_id: "user_sys_id" }]);
@@ -265,20 +277,20 @@ describe("MultiScopeWatcherManager", () => {
265
277
  });
266
278
  it("processes file change through scope switch and push pipeline", async () => {
267
279
  const ctx = makeFileContext();
268
- FileUtils_1.getFileContextFromPath.mockReturnValue(ctx);
280
+ FileUtils_1.getFileContextWithSkipReason.mockReturnValue({ context: ctx });
269
281
  appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
270
282
  appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
271
283
  // Emit change on the scope's watcher
272
284
  mockWatchers[0]._emit("change", ctx.filePath);
273
285
  // Trigger the debounced processor for this scope
274
286
  await capturedDebounceFns[0]();
275
- expect(FileUtils_1.getFileContextFromPath).toHaveBeenCalled();
287
+ expect(FileUtils_1.getFileContextWithSkipReason).toHaveBeenCalled();
276
288
  expect(appUtils_1.groupAppFiles).toHaveBeenCalled();
277
289
  expect(appUtils_1.pushFiles).toHaveBeenCalled();
278
290
  });
279
291
  it("processes file add through the same pipeline", async () => {
280
292
  const ctx = makeFileContext();
281
- FileUtils_1.getFileContextFromPath.mockReturnValue(ctx);
293
+ FileUtils_1.getFileContextWithSkipReason.mockReturnValue({ context: ctx });
282
294
  appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
283
295
  appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
284
296
  mockWatchers[0]._emit("add", ctx.filePath);
@@ -286,12 +298,102 @@ describe("MultiScopeWatcherManager", () => {
286
298
  expect(appUtils_1.pushFiles).toHaveBeenCalled();
287
299
  });
288
300
  it("warns when no valid file contexts found", async () => {
289
- FileUtils_1.getFileContextFromPath.mockReturnValue(undefined);
301
+ FileUtils_1.getFileContextWithSkipReason.mockReturnValue({ skipReason: "not in manifest" });
290
302
  mockWatchers[0]._emit("change", "/project/src/x_test_core/unknown/file.txt");
291
303
  await capturedDebounceFns[0]();
292
304
  expect(Logger_1.logger.warn).toHaveBeenCalledWith(expect.stringContaining("No valid file contexts found"));
293
305
  expect(appUtils_1.pushFiles).not.toHaveBeenCalled();
294
306
  });
307
+ it("logs warning for each skipped file with reason", async () => {
308
+ FileUtils_1.getFileContextWithSkipReason
309
+ .mockReturnValueOnce({ skipReason: "not in manifest" })
310
+ .mockReturnValueOnce({ context: makeFileContext() });
311
+ appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
312
+ appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
313
+ mockWatchers[0]._emit("change", "/project/src/x_test_core/unknown/file.txt");
314
+ mockWatchers[0]._emit("change", makeFileContext().filePath);
315
+ await capturedDebounceFns[0]();
316
+ expect(Logger_1.logger.warn).toHaveBeenCalledWith(expect.stringContaining("Skipped: /project/src/x_test_core/unknown/file.txt (not in manifest)"));
317
+ });
318
+ it("logs warning with scope not found reason", async () => {
319
+ FileUtils_1.getFileContextWithSkipReason.mockReturnValue({ skipReason: "scope not found" });
320
+ mockWatchers[0]._emit("change", "/project/src/x_unknown/sys_script/file.js");
321
+ await capturedDebounceFns[0]();
322
+ expect(Logger_1.logger.warn).toHaveBeenCalledWith(expect.stringContaining("Skipped: /project/src/x_unknown/sys_script/file.js (scope not found)"));
323
+ });
324
+ it("logs push summary with skipped file count", async () => {
325
+ var ctx = makeFileContext();
326
+ FileUtils_1.getFileContextWithSkipReason
327
+ .mockReturnValueOnce({ context: ctx })
328
+ .mockReturnValueOnce({ skipReason: "not in manifest" });
329
+ appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
330
+ appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
331
+ mockWatchers[0]._emit("change", ctx.filePath);
332
+ mockWatchers[0]._emit("change", "/project/src/x_test_core/unknown/other.txt");
333
+ await capturedDebounceFns[0]();
334
+ // Should log a summary with pushed/total counts and skipped files
335
+ var allCalls = [
336
+ ...Logger_1.logger.info.mock.calls,
337
+ ...Logger_1.logger.success.mock.calls,
338
+ ].map(function (c) { return c[0]; });
339
+ var summaryCall = allCalls.find(function (msg) {
340
+ return typeof msg === "string" && msg.indexOf("Pushed") !== -1 && msg.indexOf("files to") !== -1;
341
+ });
342
+ expect(summaryCall).toBeDefined();
343
+ expect(summaryCall).toContain("1/2");
344
+ expect(summaryCall).toContain("skipped");
345
+ });
346
+ it("allows file through when scope matches watcher scope", async () => {
347
+ const ctx = makeFileContext({ scope: "x_test_core" });
348
+ FileUtils_1.getFileContextWithSkipReason.mockReturnValue({ context: ctx });
349
+ appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
350
+ appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
351
+ mockWatchers[0]._emit("change", ctx.filePath);
352
+ await capturedDebounceFns[0]();
353
+ expect(appUtils_1.pushFiles).toHaveBeenCalled();
354
+ expect(Logger_1.logger.error).not.toHaveBeenCalledWith(expect.stringContaining("Scope mismatch"));
355
+ });
356
+ it("skips file with error when scope mismatches watcher scope", async () => {
357
+ const ctx = makeFileContext({ scope: "x_other_scope" });
358
+ FileUtils_1.getFileContextWithSkipReason.mockReturnValue({ context: ctx });
359
+ mockWatchers[0]._emit("change", ctx.filePath);
360
+ await capturedDebounceFns[0]();
361
+ // Should log an error about scope mismatch
362
+ expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("Scope mismatch"));
363
+ expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("x_other_scope"));
364
+ expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("x_test_core"));
365
+ // Should not push the file
366
+ expect(appUtils_1.pushFiles).not.toHaveBeenCalled();
367
+ // Should appear in the summary as skipped
368
+ expect(Logger_1.logger.warn).toHaveBeenCalledWith(expect.stringContaining("No valid file contexts found"));
369
+ });
370
+ it("includes scope-mismatched file in push summary alongside valid files", async () => {
371
+ const validCtx = makeFileContext({ scope: "x_test_core", filePath: "/project/src/x_test_core/sys_script_include/Valid/script.js" });
372
+ const mismatchCtx = makeFileContext({ scope: "x_other_scope", filePath: "/project/src/x_test_core/sys_script_include/Wrong/script.js" });
373
+ FileUtils_1.getFileContextWithSkipReason
374
+ .mockReturnValueOnce({ context: validCtx })
375
+ .mockReturnValueOnce({ context: mismatchCtx });
376
+ appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
377
+ appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
378
+ mockWatchers[0]._emit("change", validCtx.filePath);
379
+ mockWatchers[0]._emit("change", mismatchCtx.filePath);
380
+ await capturedDebounceFns[0]();
381
+ // Valid file should be pushed
382
+ expect(appUtils_1.pushFiles).toHaveBeenCalled();
383
+ // Mismatch should be logged as error
384
+ expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("Scope mismatch"));
385
+ // Push summary should include the skipped file
386
+ var allCalls = [
387
+ ...Logger_1.logger.info.mock.calls,
388
+ ...Logger_1.logger.success.mock.calls,
389
+ ].map(function (c) { return c[0]; });
390
+ var summaryCall = allCalls.find(function (msg) {
391
+ return typeof msg === "string" && msg.indexOf("Pushed") !== -1 && msg.indexOf("files to") !== -1;
392
+ });
393
+ expect(summaryCall).toBeDefined();
394
+ expect(summaryCall).toContain("1/2");
395
+ expect(summaryCall).toContain("skipped");
396
+ });
295
397
  it("logs error on watcher error event", () => {
296
398
  mockWatchers[0]._emit("error", new Error("FS error"));
297
399
  expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("Watcher error: FS error"));
@@ -437,7 +539,7 @@ describe("MultiScopeWatcherManager", () => {
437
539
  });
438
540
  });
439
541
  describe("update set monitoring", () => {
440
- it("sets up a 2-minute interval via setInterval", async () => {
542
+ it("sets up interval with default 120s when no options provided", async () => {
441
543
  const setIntervalSpy = jest.spyOn(global, "setInterval");
442
544
  ConfigManager.getConfig.mockReturnValue({
443
545
  ...MOCK_CONFIG_TWO_SCOPES,
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // Track how many times the update set config file is read
4
+ var configReadCount = 0;
5
+ var mockConfigData = {};
6
+ jest.mock("fs", function () {
7
+ var actualFs = jest.requireActual("fs");
8
+ return {
9
+ ...actualFs,
10
+ existsSync: function (p) {
11
+ if (typeof p === "string" && p.endsWith(".sinc-update-sets.json")) {
12
+ configReadCount++;
13
+ return Object.keys(mockConfigData).length > 0;
14
+ }
15
+ return actualFs.existsSync(p);
16
+ },
17
+ readFileSync: function (p, encoding) {
18
+ if (typeof p === "string" && p.endsWith(".sinc-update-sets.json")) {
19
+ return JSON.stringify(mockConfigData);
20
+ }
21
+ return actualFs.readFileSync(p, encoding);
22
+ },
23
+ };
24
+ });
25
+ // Mock dependencies that pushFiles needs
26
+ var mockPushWithUpdateSet = jest.fn().mockResolvedValue({
27
+ data: { result: { status: "success" } },
28
+ });
29
+ var mockUpdateRecord = jest.fn().mockResolvedValue({
30
+ data: { result: { status: "success" } },
31
+ });
32
+ jest.mock("../snClient", function () {
33
+ return {
34
+ defaultClient: function () {
35
+ return {
36
+ pushWithUpdateSet: mockPushWithUpdateSet,
37
+ updateRecord: mockUpdateRecord,
38
+ };
39
+ },
40
+ processPushResponse: function (_res, summary) {
41
+ return { success: true, message: summary };
42
+ },
43
+ retryOnErr: function (fn) {
44
+ return fn();
45
+ },
46
+ retryOnHttpErr: function (fn) {
47
+ return fn();
48
+ },
49
+ unwrapSNResponse: jest.fn(),
50
+ unwrapTableAPIFirstItem: jest.fn(),
51
+ SNClient: jest.fn(),
52
+ };
53
+ });
54
+ jest.mock("../PluginManager", function () {
55
+ return {
56
+ __esModule: true,
57
+ default: {
58
+ getFinalFileContents: function (ctx) {
59
+ return Promise.resolve("built-content");
60
+ },
61
+ },
62
+ };
63
+ });
64
+ jest.mock("../Logger", function () {
65
+ return {
66
+ logger: {
67
+ info: jest.fn(),
68
+ debug: jest.fn(),
69
+ warn: jest.fn(),
70
+ error: jest.fn(),
71
+ getLogLevel: function () { return "debug"; },
72
+ },
73
+ };
74
+ });
75
+ jest.mock("progress", function () {
76
+ return jest.fn().mockImplementation(function () {
77
+ return { tick: jest.fn() };
78
+ });
79
+ });
80
+ function makeRecord(table, sysId, scope) {
81
+ return {
82
+ table: table,
83
+ sysId: sysId,
84
+ fields: {
85
+ script: {
86
+ name: "TestRecord",
87
+ tableName: table,
88
+ targetField: "script",
89
+ ext: "js",
90
+ sys_id: sysId,
91
+ filePath: "/fake/path/script.js",
92
+ scope: scope,
93
+ },
94
+ },
95
+ };
96
+ }
97
+ describe("pushFiles", function () {
98
+ beforeEach(function () {
99
+ configReadCount = 0;
100
+ mockConfigData = {};
101
+ mockPushWithUpdateSet.mockClear();
102
+ mockUpdateRecord.mockClear();
103
+ });
104
+ it("reads update set config once at batch start, not per record", async function () {
105
+ // Set up config for two scopes
106
+ mockConfigData = {
107
+ "x_cadso_core": { sys_id: "us-core-123", name: "Core US" },
108
+ "x_cadso_work": { sys_id: "us-work-456", name: "Work US" },
109
+ };
110
+ var { pushFiles } = require("../appUtils");
111
+ var records = [
112
+ makeRecord("sys_script_include", "rec1", "x_cadso_core"),
113
+ makeRecord("sys_script_include", "rec2", "x_cadso_work"),
114
+ makeRecord("sys_script_include", "rec3", "x_cadso_core"),
115
+ ];
116
+ await pushFiles(records);
117
+ // Config should be read exactly once (existsSync check at batch start)
118
+ // not 3 times (once per record)
119
+ expect(configReadCount).toBe(1);
120
+ });
121
+ it("does not re-read config mid-batch even with multiple scopes", async function () {
122
+ mockConfigData = {
123
+ "x_cadso_core": { sys_id: "us-core-123", name: "Core US" },
124
+ };
125
+ var { pushFiles } = require("../appUtils");
126
+ var records = [
127
+ makeRecord("sys_script_include", "rec1", "x_cadso_core"),
128
+ makeRecord("sys_script_include", "rec2", "x_cadso_core"),
129
+ ];
130
+ await pushFiles(records);
131
+ // Only 1 read regardless of record count
132
+ expect(configReadCount).toBe(1);
133
+ });
134
+ it("routes records to correct update sets from cached config", async function () {
135
+ mockConfigData = {
136
+ "x_cadso_core": { sys_id: "us-core-123", name: "Core US" },
137
+ "x_cadso_work": { sys_id: "us-work-456", name: "Work US" },
138
+ };
139
+ var { pushFiles } = require("../appUtils");
140
+ var records = [
141
+ makeRecord("sys_script_include", "rec1", "x_cadso_core"),
142
+ makeRecord("sys_script_include", "rec2", "x_cadso_work"),
143
+ ];
144
+ await pushFiles(records);
145
+ // Both should use pushWithUpdateSet with their respective update set IDs
146
+ expect(mockPushWithUpdateSet).toHaveBeenCalledTimes(2);
147
+ var calls = mockPushWithUpdateSet.mock.calls;
148
+ var updateSetIds = calls.map(function (call) { return call[0]; });
149
+ expect(updateSetIds).toContain("us-core-123");
150
+ expect(updateSetIds).toContain("us-work-456");
151
+ });
152
+ it("uses updateRecord when no update set config exists", async function () {
153
+ mockConfigData = {};
154
+ var { pushFiles } = require("../appUtils");
155
+ var records = [
156
+ makeRecord("sys_script_include", "rec1", "x_cadso_core"),
157
+ ];
158
+ await pushFiles(records);
159
+ expect(mockUpdateRecord).toHaveBeenCalledTimes(1);
160
+ expect(mockPushWithUpdateSet).not.toHaveBeenCalled();
161
+ });
162
+ });