@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.
- package/README.md +1 -1
- package/dist/FileUtils.js +19 -13
- package/dist/MultiScopeWatcher.js +212 -84
- package/dist/allScopesCommands.js +4 -2
- package/dist/appUtils.js +23 -12
- package/dist/commander.js +17 -0
- package/dist/commands.js +38 -1
- package/dist/config.js +4 -4
- package/dist/snClient.js +81 -1
- package/dist/tests/ensureUpdateSetWarnings.test.js +218 -0
- package/dist/tests/errorLogLevels.test.js +273 -0
- package/dist/tests/fileContextSkipReason.test.js +116 -0
- package/dist/tests/globalDebounce.test.js +307 -0
- package/dist/tests/multi-scope-watcher.test.js +109 -7
- package/dist/tests/pushFiles.test.js +162 -0
- package/dist/tests/rateLimitCoordination.test.js +271 -0
- package/dist/tests/retryOnHttpErr.test.js +154 -0
- package/dist/tests/scopeCaching.test.js +124 -0
- package/dist/tests/serializeUpdateSetConfig.test.js +325 -0
- package/dist/tests/taskClear.test.js +170 -0
- package/dist/tests/taskStaleness.test.js +220 -0
- package/dist/tests/validateTaskId.test.js +304 -0
- package/dist/tests/verifyUpdateSetSwitch.test.js +277 -0
- package/dist/updateSetCommands.js +59 -2
- package/package.json +1 -1
- package/skills/sinc-configure-pipeline.md +19 -19
- package/skills/sinc-debug-build.md +7 -7
- package/skills/sinc-setup-project.md +2 -2
- package/skills/sinc-troubleshoot-sync.md +5 -5
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
+
});
|