@tenonhq/sincronia-core 0.0.80 → 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/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);
@@ -369,13 +423,56 @@ const unwrapSNResponse = async (clientPromise) => {
369
423
  return resp.data.result;
370
424
  }
371
425
  catch (e) {
372
- let message;
373
- if (e instanceof Error)
374
- message = e.message;
375
- else
376
- message = String(e);
377
426
  const instance = process.env.SN_INSTANCE || "unknown";
378
- Logger_1.logger.error("Error from " + instance + ": " + message);
427
+ if (axios_1.default.isAxiosError(e) && e.response) {
428
+ const status = e.response.status;
429
+ const statusText = e.response.statusText || "";
430
+ const method = ((e.config && e.config.method) || "").toUpperCase();
431
+ const url = (e.config && e.config.url) || "";
432
+ const data = e.response.data;
433
+ // Extract ServiceNow-shaped error message if present (`{error: {message, detail}, status}`)
434
+ let snMessage = "";
435
+ if (data && typeof data === "object") {
436
+ if (data.error && typeof data.error === "object" && data.error.message) {
437
+ snMessage = " — " + String(data.error.message);
438
+ }
439
+ else if (typeof data.message === "string") {
440
+ snMessage = " — " + data.message;
441
+ }
442
+ }
443
+ // Pull scope out of /getManifest/:scope URLs for easy grepping
444
+ let scope;
445
+ const manifestMatch = url.match(/\/getManifest\/([^/?]+)/);
446
+ if (manifestMatch)
447
+ scope = manifestMatch[1];
448
+ Logger_1.logger.error("Error from " + instance + ": HTTP " + status + " " +
449
+ method + " " + url + snMessage);
450
+ FileLogger_1.fileLogger.debug("REST error detail", {
451
+ instance: instance,
452
+ scope: scope,
453
+ method: method,
454
+ url: url,
455
+ status: status,
456
+ statusText: statusText,
457
+ responseData: data,
458
+ responseHeaders: e.response.headers,
459
+ });
460
+ }
461
+ else {
462
+ // Non-Axios error: preserve today's behaviour, add debug detail
463
+ let message;
464
+ if (e instanceof Error)
465
+ message = e.message;
466
+ else
467
+ message = String(e);
468
+ Logger_1.logger.error("Error from " + instance + ": " + message);
469
+ FileLogger_1.fileLogger.debug("Non-Axios error detail", {
470
+ instance: instance,
471
+ errorName: e instanceof Error ? e.name : undefined,
472
+ errorStack: e instanceof Error ? e.stack : undefined,
473
+ message: message,
474
+ });
475
+ }
379
476
  throw e;
380
477
  }
381
478
  };
@@ -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
  };