@tenonhq/dovetail-core 0.0.85

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.
Files changed (78) hide show
  1. package/README.md +6 -0
  2. package/dist/FileLogger.js +207 -0
  3. package/dist/FileUtils.js +387 -0
  4. package/dist/Logger.js +88 -0
  5. package/dist/MultiScopeWatcher.js +615 -0
  6. package/dist/PluginManager.js +125 -0
  7. package/dist/_defaultOptionsOLD.js +44 -0
  8. package/dist/allScopesCommands.js +418 -0
  9. package/dist/appUtils.js +929 -0
  10. package/dist/benchmark.js +104 -0
  11. package/dist/bootstrap.js +62 -0
  12. package/dist/claudeCommand.js +60 -0
  13. package/dist/clickupCommands.js +472 -0
  14. package/dist/clickupPushHelper.js +33 -0
  15. package/dist/commander.js +466 -0
  16. package/dist/commands.js +378 -0
  17. package/dist/config.js +697 -0
  18. package/dist/constants.js +15 -0
  19. package/dist/createRecordCommand.js +314 -0
  20. package/dist/dashboardCommand.js +38 -0
  21. package/dist/defaultOptions.js +14 -0
  22. package/dist/deleteRecordCommand.js +282 -0
  23. package/dist/flowDesigner/values.js +55 -0
  24. package/dist/genericUtils.js +189 -0
  25. package/dist/gitUtils.js +99 -0
  26. package/dist/index.js +35 -0
  27. package/dist/initSystem/corePlugin.js +344 -0
  28. package/dist/initSystem/discovery.js +107 -0
  29. package/dist/initSystem/orchestrator.js +408 -0
  30. package/dist/logMessages.js +82 -0
  31. package/dist/loginCommand.js +20 -0
  32. package/dist/migrateCommand.js +123 -0
  33. package/dist/projectFiles.js +32 -0
  34. package/dist/recentEdits.js +66 -0
  35. package/dist/schemaCommand.js +85 -0
  36. package/dist/snClient.js +519 -0
  37. package/dist/tests/benchmarkRefresh.test.js +265 -0
  38. package/dist/tests/clickupCommands.test.js +384 -0
  39. package/dist/tests/clickupPushHelper.test.js +124 -0
  40. package/dist/tests/configFallback.test.js +68 -0
  41. package/dist/tests/cred.test.js +59 -0
  42. package/dist/tests/discovery.test.js +67 -0
  43. package/dist/tests/ensureUpdateSetWarnings.test.js +218 -0
  44. package/dist/tests/errorLogLevels.test.js +273 -0
  45. package/dist/tests/example.test.js +6 -0
  46. package/dist/tests/fileContextSkipReason.test.js +116 -0
  47. package/dist/tests/globalDebounce.test.js +308 -0
  48. package/dist/tests/migrateCommand.test.js +113 -0
  49. package/dist/tests/multi-scope-watcher.test.js +558 -0
  50. package/dist/tests/orchestrator.test.js +23 -0
  51. package/dist/tests/pushFiles.test.js +248 -0
  52. package/dist/tests/rateLimitCoordination.test.js +271 -0
  53. package/dist/tests/resolveConfigReadOnly.test.js +67 -0
  54. package/dist/tests/retryOnHttpErr.test.js +154 -0
  55. package/dist/tests/scopeCaching.test.js +115 -0
  56. package/dist/tests/serializeUpdateSetConfig.test.js +325 -0
  57. package/dist/tests/snClient.fallback.test.js +120 -0
  58. package/dist/tests/syncManifestRefresh.test.js +287 -0
  59. package/dist/tests/syncManifestWhitelist.test.js +192 -0
  60. package/dist/tests/taskClear.test.js +170 -0
  61. package/dist/tests/taskStaleness.test.js +220 -0
  62. package/dist/tests/unwrapSNResponseError.test.js +145 -0
  63. package/dist/tests/v2Values.test.js +79 -0
  64. package/dist/tests/validateTaskId.test.js +304 -0
  65. package/dist/tests/verifyUpdateSetSwitch.test.js +277 -0
  66. package/dist/tests/writeEnvVar.test.js +119 -0
  67. package/dist/updateSetCommands.js +702 -0
  68. package/dist/wizard.js +40 -0
  69. package/package.json +69 -0
  70. package/skills/dove-configure-pipeline.md +158 -0
  71. package/skills/dove-create-plugin.md +174 -0
  72. package/skills/dove-create-record.md +135 -0
  73. package/skills/dove-debug-build.md +117 -0
  74. package/skills/dove-delete-record.md +104 -0
  75. package/skills/dove-manage-tables.md +174 -0
  76. package/skills/dove-manage-update-sets.md +88 -0
  77. package/skills/dove-setup-project.md +154 -0
  78. package/skills/dove-troubleshoot-sync.md +113 -0
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ var mockLogger = {
4
+ info: jest.fn(),
5
+ debug: jest.fn(),
6
+ warn: jest.fn(),
7
+ error: jest.fn(),
8
+ getLogLevel: function () { return "debug"; },
9
+ };
10
+ jest.mock("../Logger", function () {
11
+ return { logger: mockLogger };
12
+ });
13
+ jest.mock("../FileLogger", function () {
14
+ return { fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } };
15
+ });
16
+ // Minimal mock for genericUtils — just need wait()
17
+ jest.mock("../genericUtils", function () {
18
+ return {
19
+ wait: jest.fn().mockResolvedValue(undefined),
20
+ };
21
+ });
22
+ const snClient_1 = require("../snClient");
23
+ const genericUtils_1 = require("../genericUtils");
24
+ function makeAxiosError(status, headers) {
25
+ var error = new Error("Request failed with status " + status);
26
+ error.isAxiosError = true;
27
+ error.response = {
28
+ status: status,
29
+ headers: headers || {},
30
+ data: {},
31
+ };
32
+ return error;
33
+ }
34
+ describe("retryOnHttpErr", function () {
35
+ beforeEach(function () {
36
+ jest.clearAllMocks();
37
+ });
38
+ it("returns successfully on first try when no error", async function () {
39
+ var fn = jest.fn().mockResolvedValue({ status: 200, data: {} });
40
+ var result = await (0, snClient_1.retryOnHttpErr)(fn, "test > rec1");
41
+ expect(result).toEqual({ status: 200, data: {} });
42
+ expect(fn).toHaveBeenCalledTimes(1);
43
+ });
44
+ // 401/403: Fail immediately
45
+ it("fails immediately on 401 with credential message", async function () {
46
+ var fn = jest.fn().mockRejectedValue(makeAxiosError(401));
47
+ await expect((0, snClient_1.retryOnHttpErr)(fn, "test > rec1")).rejects.toThrow();
48
+ expect(fn).toHaveBeenCalledTimes(1);
49
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Unauthorized (401)"));
50
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("credentials"));
51
+ });
52
+ it("fails immediately on 403 with credential message", async function () {
53
+ var fn = jest.fn().mockRejectedValue(makeAxiosError(403));
54
+ await expect((0, snClient_1.retryOnHttpErr)(fn, "test > rec1")).rejects.toThrow();
55
+ expect(fn).toHaveBeenCalledTimes(1);
56
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Forbidden (403)"));
57
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("credentials"));
58
+ });
59
+ // 404: Fail immediately
60
+ it("fails immediately on 404 with record not found message", async function () {
61
+ var fn = jest.fn().mockRejectedValue(makeAxiosError(404));
62
+ await expect((0, snClient_1.retryOnHttpErr)(fn, "test > rec1")).rejects.toThrow();
63
+ expect(fn).toHaveBeenCalledTimes(1);
64
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Record not found (404)"));
65
+ });
66
+ // 429: Rate limited — honor Retry-After
67
+ it("retries on 429 and honors Retry-After header", async function () {
68
+ var fn = jest.fn()
69
+ .mockRejectedValueOnce(makeAxiosError(429, { "retry-after": "5" }))
70
+ .mockResolvedValue({ status: 200, data: {} });
71
+ var result = await (0, snClient_1.retryOnHttpErr)(fn, "test > rec1");
72
+ expect(result).toEqual({ status: 200, data: {} });
73
+ expect(fn).toHaveBeenCalledTimes(2);
74
+ expect(genericUtils_1.wait).toHaveBeenCalledWith(5000); // 5 seconds from Retry-After
75
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Rate limited (429)"));
76
+ });
77
+ it("uses default 10s wait on 429 without Retry-After header", async function () {
78
+ var fn = jest.fn()
79
+ .mockRejectedValueOnce(makeAxiosError(429))
80
+ .mockResolvedValue({ status: 200, data: {} });
81
+ await (0, snClient_1.retryOnHttpErr)(fn, "test > rec1");
82
+ expect(genericUtils_1.wait).toHaveBeenCalledWith(10000);
83
+ });
84
+ // 500/502/503: Exponential backoff
85
+ it("retries 500 with exponential backoff (1s, 2s, 4s)", async function () {
86
+ var fn = jest.fn()
87
+ .mockRejectedValueOnce(makeAxiosError(500))
88
+ .mockRejectedValueOnce(makeAxiosError(500))
89
+ .mockRejectedValueOnce(makeAxiosError(500))
90
+ .mockResolvedValue({ status: 200, data: {} });
91
+ var result = await (0, snClient_1.retryOnHttpErr)(fn, "test > rec1");
92
+ expect(result).toEqual({ status: 200, data: {} });
93
+ expect(fn).toHaveBeenCalledTimes(4); // 3 failures + 1 success
94
+ expect(genericUtils_1.wait).toHaveBeenNthCalledWith(1, 1000); // 1s
95
+ expect(genericUtils_1.wait).toHaveBeenNthCalledWith(2, 2000); // 2s
96
+ expect(genericUtils_1.wait).toHaveBeenNthCalledWith(3, 4000); // 4s
97
+ });
98
+ it("gives up after 3 retries on 500", async function () {
99
+ var fn = jest.fn().mockRejectedValue(makeAxiosError(500));
100
+ await expect((0, snClient_1.retryOnHttpErr)(fn, "test > rec1")).rejects.toThrow();
101
+ expect(fn).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
102
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("after 3 retries"));
103
+ });
104
+ it("retries 502 with exponential backoff", async function () {
105
+ var fn = jest.fn()
106
+ .mockRejectedValueOnce(makeAxiosError(502))
107
+ .mockResolvedValue({ status: 200, data: {} });
108
+ await (0, snClient_1.retryOnHttpErr)(fn, "test > rec1");
109
+ expect(fn).toHaveBeenCalledTimes(2);
110
+ expect(genericUtils_1.wait).toHaveBeenCalledWith(1000);
111
+ });
112
+ it("retries 503 with exponential backoff", async function () {
113
+ var fn = jest.fn()
114
+ .mockRejectedValueOnce(makeAxiosError(503))
115
+ .mockResolvedValue({ status: 200, data: {} });
116
+ await (0, snClient_1.retryOnHttpErr)(fn, "test > rec1");
117
+ expect(fn).toHaveBeenCalledTimes(2);
118
+ expect(genericUtils_1.wait).toHaveBeenCalledWith(1000);
119
+ });
120
+ it("caps backoff at 8s for server errors", async function () {
121
+ // After 3 retries with backoff doubling: 1s, 2s, 4s
122
+ // If we could do a 4th it would be 8s (capped), but we stop at 3
123
+ var fn = jest.fn()
124
+ .mockRejectedValueOnce(makeAxiosError(500))
125
+ .mockRejectedValueOnce(makeAxiosError(500))
126
+ .mockRejectedValueOnce(makeAxiosError(500))
127
+ .mockResolvedValue({ status: 200, data: {} });
128
+ await (0, snClient_1.retryOnHttpErr)(fn, "test > rec1");
129
+ // Verify the 3rd wait is 4s (next would be 8s = cap)
130
+ expect(genericUtils_1.wait).toHaveBeenNthCalledWith(3, 4000);
131
+ });
132
+ // Unknown errors: retry once
133
+ it("retries unknown status code once then fails", async function () {
134
+ var fn = jest.fn().mockRejectedValue(makeAxiosError(418));
135
+ await expect((0, snClient_1.retryOnHttpErr)(fn, "test > rec1")).rejects.toThrow();
136
+ expect(fn).toHaveBeenCalledTimes(2); // 1 initial + 1 retry
137
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("HTTP 418"));
138
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("after 1 retry"));
139
+ });
140
+ it("retries non-HTTP error once then fails", async function () {
141
+ var fn = jest.fn().mockRejectedValue(new Error("ECONNRESET"));
142
+ await expect((0, snClient_1.retryOnHttpErr)(fn, "test > rec1")).rejects.toThrow("ECONNRESET");
143
+ expect(fn).toHaveBeenCalledTimes(2); // 1 initial + 1 retry
144
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("unknown error"));
145
+ });
146
+ it("succeeds on retry for unknown error", async function () {
147
+ var fn = jest.fn()
148
+ .mockRejectedValueOnce(makeAxiosError(418))
149
+ .mockResolvedValue({ status: 200, data: {} });
150
+ var result = await (0, snClient_1.retryOnHttpErr)(fn, "test > rec1");
151
+ expect(result).toEqual({ status: 200, data: {} });
152
+ expect(fn).toHaveBeenCalledTimes(2);
153
+ });
154
+ });
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const MultiScopeWatcher_1 = require("../MultiScopeWatcher");
4
+ // Track API calls
5
+ var apiCalls = [];
6
+ jest.mock("../snClient", function () {
7
+ return {
8
+ defaultClient: function () {
9
+ return {
10
+ getScopeId: function (scope) {
11
+ apiCalls.push("getScopeId:" + scope);
12
+ return Promise.resolve({ data: { result: [{ sys_id: "scope_" + scope }] } });
13
+ },
14
+ getUserSysId: function () {
15
+ apiCalls.push("getUserSysId");
16
+ return Promise.resolve({ data: { result: [{ sys_id: "user_abc123" }] } });
17
+ },
18
+ getCurrentAppUserPrefSysId: function (userSysId) {
19
+ apiCalls.push("getCurrentAppUserPrefSysId");
20
+ return Promise.resolve({ data: { result: [{ sys_id: "pref_123" }] } });
21
+ },
22
+ updateCurrentAppUserPref: function (scopeSysId, prefSysId) {
23
+ apiCalls.push("updateCurrentAppUserPref");
24
+ return Promise.resolve({});
25
+ },
26
+ createCurrentAppUserPref: function (scopeSysId, userSysId) {
27
+ apiCalls.push("createCurrentAppUserPref");
28
+ return Promise.resolve({});
29
+ },
30
+ changeScope: function (scope) {
31
+ apiCalls.push("changeScope:" + scope);
32
+ return Promise.resolve(undefined);
33
+ }
34
+ };
35
+ },
36
+ unwrapSNResponse: function (resp) {
37
+ return resp.then(function (r) { return r.data.result; });
38
+ }
39
+ };
40
+ });
41
+ jest.mock("../Logger", function () {
42
+ return {
43
+ logger: {
44
+ info: jest.fn(),
45
+ debug: jest.fn(),
46
+ warn: jest.fn(),
47
+ error: jest.fn(),
48
+ success: jest.fn()
49
+ }
50
+ };
51
+ });
52
+ jest.mock("../config", function () {
53
+ return {
54
+ loadConfigs: jest.fn().mockResolvedValue(undefined),
55
+ getConfig: jest.fn().mockReturnValue({ scopes: {} }),
56
+ getRootDir: jest.fn().mockReturnValue("/tmp"),
57
+ updateManifest: jest.fn()
58
+ };
59
+ });
60
+ describe("Scope Caching (US-013)", function () {
61
+ beforeEach(function () {
62
+ apiCalls = [];
63
+ // Reset cached state. switchToScope now uses the Claude REST API
64
+ // changeScope endpoint; the cache avoids redundant session switches.
65
+ MultiScopeWatcher_1.multiScopeWatcher.cachedScope = null;
66
+ });
67
+ it("should call changeScope on first switch to a scope", async function () {
68
+ await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
69
+ expect(apiCalls).toContain("changeScope:x_cadso_core");
70
+ });
71
+ it("should skip API calls when switching to the same scope (cache hit)", async function () {
72
+ await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
73
+ apiCalls = [];
74
+ await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
75
+ expect(apiCalls).toEqual([]);
76
+ });
77
+ it("should call changeScope when switching to a different scope (cache miss)", async function () {
78
+ await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
79
+ apiCalls = [];
80
+ await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_automate");
81
+ expect(apiCalls).toContain("changeScope:x_cadso_automate");
82
+ });
83
+ it("should update cachedScope after a successful switch", async function () {
84
+ expect(MultiScopeWatcher_1.multiScopeWatcher.cachedScope).toBeNull();
85
+ await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
86
+ expect(MultiScopeWatcher_1.multiScopeWatcher.cachedScope).toBe("x_cadso_core");
87
+ await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_automate");
88
+ expect(MultiScopeWatcher_1.multiScopeWatcher.cachedScope).toBe("x_cadso_automate");
89
+ });
90
+ it("should invalidate cachedScope on failure", async function () {
91
+ // First successful switch
92
+ await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("x_cadso_core");
93
+ expect(MultiScopeWatcher_1.multiScopeWatcher.cachedScope).toBe("x_cadso_core");
94
+ // Swap in a client whose changeScope rejects
95
+ var snClient = require("../snClient");
96
+ var origDefault = snClient.defaultClient;
97
+ snClient.defaultClient = function () {
98
+ var client = origDefault();
99
+ client.changeScope = function (scope) {
100
+ apiCalls.push("changeScope:" + scope + ":failed");
101
+ return Promise.reject(new Error("changeScope failed"));
102
+ };
103
+ return client;
104
+ };
105
+ try {
106
+ await MultiScopeWatcher_1.multiScopeWatcher.switchToScope("bad_scope");
107
+ }
108
+ catch (e) {
109
+ // Expected
110
+ }
111
+ expect(MultiScopeWatcher_1.multiScopeWatcher.cachedScope).toBeNull();
112
+ // Restore
113
+ snClient.defaultClient = origDefault;
114
+ });
115
+ });
@@ -0,0 +1,325 @@
1
+ "use strict";
2
+ /**
3
+ * US-005: Serialize update set config writes under scope lock
4
+ *
5
+ * Tests that:
6
+ * - ensureUpdateSetForScope() calls are serialized via scopeLock
7
+ * - 3 concurrent scope writes all persist correctly
8
+ * - After writing, config is verified by re-reading
9
+ * - No mapping is lost due to concurrent writes
10
+ */
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ // --- Mock setup ---
16
+ var mockFsStore = {};
17
+ jest.mock("fs", function () {
18
+ return {
19
+ existsSync: jest.fn(function (p) {
20
+ return p in mockFsStore;
21
+ }),
22
+ readFileSync: jest.fn(function (p) {
23
+ if (p in mockFsStore)
24
+ return mockFsStore[p];
25
+ throw new Error("ENOENT: " + p);
26
+ }),
27
+ writeFileSync: jest.fn(function (p, data) {
28
+ mockFsStore[p] = data;
29
+ }),
30
+ statSync: jest.fn(function () { return { mtimeMs: Date.now() }; }),
31
+ promises: {
32
+ readFile: jest.fn(function (p) {
33
+ if (p in mockFsStore)
34
+ return Promise.resolve(mockFsStore[p]);
35
+ return Promise.reject(new Error("ENOENT: " + p));
36
+ }),
37
+ writeFile: jest.fn(),
38
+ readdir: jest.fn(),
39
+ mkdir: jest.fn(),
40
+ access: jest.fn(),
41
+ stat: jest.fn(),
42
+ },
43
+ };
44
+ });
45
+ var mockSNClient = {
46
+ getScopeId: jest.fn(),
47
+ getUserSysId: jest.fn(),
48
+ getCurrentAppUserPrefSysId: jest.fn(),
49
+ updateCurrentAppUserPref: jest.fn(),
50
+ createCurrentAppUserPref: jest.fn(),
51
+ getCurrentUpdateSetUserPref: jest.fn(),
52
+ changeScope: jest.fn().mockResolvedValue(undefined),
53
+ client: {
54
+ get: jest.fn(),
55
+ },
56
+ changeUpdateSet: jest.fn().mockResolvedValue(undefined),
57
+ createUpdateSet: jest.fn(),
58
+ };
59
+ jest.mock("../snClient", function () {
60
+ return {
61
+ defaultClient: jest.fn(function () { return mockSNClient; }),
62
+ unwrapSNResponse: jest.fn(function (val) { return val; }),
63
+ };
64
+ });
65
+ jest.mock("chokidar", function () {
66
+ return {
67
+ watch: jest.fn(function () {
68
+ return {
69
+ on: jest.fn().mockReturnThis(),
70
+ close: jest.fn(),
71
+ };
72
+ }),
73
+ };
74
+ });
75
+ jest.mock("lodash", function () {
76
+ var actual = jest.requireActual("lodash");
77
+ return {
78
+ ...actual,
79
+ debounce: jest.fn(function (fn) {
80
+ var wrapper = jest.fn();
81
+ wrapper.cancel = jest.fn();
82
+ wrapper.flush = jest.fn(function () { return fn(); });
83
+ return wrapper;
84
+ }),
85
+ };
86
+ });
87
+ jest.mock("../FileUtils", function () {
88
+ return {
89
+ getFileContextFromPath: jest.fn(),
90
+ getFileContextWithSkipReason: jest.fn(),
91
+ };
92
+ });
93
+ jest.mock("../appUtils", function () {
94
+ return {
95
+ groupAppFiles: jest.fn(),
96
+ pushFiles: jest.fn(),
97
+ };
98
+ });
99
+ jest.mock("../logMessages", function () {
100
+ return { logFilePush: jest.fn() };
101
+ });
102
+ jest.mock("../recentEdits", function () {
103
+ return { writeRecentEdit: jest.fn() };
104
+ });
105
+ jest.mock("../Logger", function () {
106
+ return {
107
+ logger: {
108
+ info: jest.fn(),
109
+ error: jest.fn(),
110
+ warn: jest.fn(),
111
+ debug: jest.fn(),
112
+ success: jest.fn(),
113
+ getLogLevel: jest.fn().mockReturnValue("info"),
114
+ },
115
+ };
116
+ });
117
+ jest.mock("../config", function () {
118
+ return {
119
+ loadConfigs: jest.fn().mockResolvedValue(undefined),
120
+ getConfig: jest.fn().mockReturnValue({
121
+ sourceDirectory: "src",
122
+ buildDirectory: "build",
123
+ rules: [],
124
+ includes: {},
125
+ excludes: {},
126
+ tableOptions: {},
127
+ refreshInterval: 30,
128
+ scopes: {
129
+ x_scope_a: { sourceDirectory: "src/x_scope_a" },
130
+ x_scope_b: { sourceDirectory: "src/x_scope_b" },
131
+ x_scope_c: { sourceDirectory: "src/x_scope_c" },
132
+ },
133
+ }),
134
+ getRootDir: jest.fn().mockReturnValue("/project"),
135
+ updateManifest: jest.fn(),
136
+ getManifest: jest.fn(),
137
+ getSourcePath: jest.fn().mockReturnValue("/project/src"),
138
+ getScopeManifestPath: jest.fn(function (scope) { return "/project/dove.manifest." + scope + ".json"; }),
139
+ getManifestPath: jest.fn().mockReturnValue("/project/dove.manifest.json"),
140
+ };
141
+ });
142
+ jest.mock("axios", function () {
143
+ return {
144
+ default: {
145
+ create: jest.fn(function () {
146
+ return {
147
+ get: jest.fn().mockResolvedValue({ data: { result: null } }),
148
+ };
149
+ }),
150
+ },
151
+ };
152
+ });
153
+ // --- Imports ---
154
+ const fs_1 = __importDefault(require("fs"));
155
+ const Logger_1 = require("../Logger");
156
+ const MultiScopeWatcher_1 = require("../MultiScopeWatcher");
157
+ // --- Helpers ---
158
+ var CONFIG_PATH = "/fake/.dove-update-sets.json";
159
+ var TASK_PATH = "/fake/.dove-active-task.json";
160
+ function setActiveTask(task) {
161
+ var taskPath = require("path").resolve(process.cwd(), ".dove-active-task.json");
162
+ mockFsStore[taskPath] = JSON.stringify(task);
163
+ }
164
+ function getConfigPath() {
165
+ return require("path").resolve(process.cwd(), ".dove-update-sets.json");
166
+ }
167
+ function readPersistedConfig() {
168
+ var configPath = getConfigPath();
169
+ if (configPath in mockFsStore) {
170
+ return JSON.parse(mockFsStore[configPath]);
171
+ }
172
+ return {};
173
+ }
174
+ function clearFsStore() {
175
+ Object.keys(mockFsStore).forEach(function (k) { delete mockFsStore[k]; });
176
+ }
177
+ // --- Tests ---
178
+ describe("US-005: Serialize update set config writes under scope lock", function () {
179
+ beforeEach(function () {
180
+ jest.clearAllMocks();
181
+ clearFsStore();
182
+ (0, MultiScopeWatcher_1.stopMultiScopeWatching)();
183
+ // Default: scope switching succeeds
184
+ mockSNClient.getScopeId.mockResolvedValue([{ sys_id: "scope_sys_id" }]);
185
+ mockSNClient.getUserSysId.mockResolvedValue([{ sys_id: "user_sys_id" }]);
186
+ mockSNClient.getCurrentAppUserPrefSysId.mockResolvedValue([{ sys_id: "pref_sys_id" }]);
187
+ mockSNClient.updateCurrentAppUserPref.mockResolvedValue({});
188
+ mockSNClient.createCurrentAppUserPref.mockResolvedValue({});
189
+ mockSNClient.changeScope.mockResolvedValue(undefined);
190
+ mockSNClient.changeUpdateSet.mockResolvedValue(undefined);
191
+ // Mock: existing update set found for each scope
192
+ mockSNClient.client.get.mockResolvedValue({
193
+ data: {
194
+ result: [{ sys_id: "us_found_123", name: "CU-TASK123 Update Set", state: "in progress" }],
195
+ },
196
+ });
197
+ });
198
+ afterEach(function () {
199
+ (0, MultiScopeWatcher_1.stopMultiScopeWatching)();
200
+ });
201
+ it("3 concurrent ensureUpdateSetForScope calls all persist correctly", async function () {
202
+ // Set up active task so ensureUpdateSetForScope will create update sets
203
+ setActiveTask({
204
+ taskId: "TASK123",
205
+ taskName: "Test Task",
206
+ taskDescription: "desc",
207
+ updateSetName: "CU-TASK123 Update Set",
208
+ description: "desc",
209
+ taskUrl: "https://example.com",
210
+ scopes: {},
211
+ });
212
+ // Return different sys_ids for each scope's search
213
+ var callCount = 0;
214
+ mockSNClient.client.get.mockImplementation(function () {
215
+ callCount++;
216
+ return Promise.resolve({
217
+ data: {
218
+ result: [{ sys_id: "us_" + callCount, name: "CU-TASK123 Scope " + callCount, state: "in progress" }],
219
+ },
220
+ });
221
+ });
222
+ // Call all 3 concurrently — they should serialize under scopeLock
223
+ var watcher = MultiScopeWatcher_1.multiScopeWatcher;
224
+ var p1 = watcher.withScopeLock(function () { return watcher.ensureUpdateSetForScope("x_scope_a"); });
225
+ var p2 = watcher.withScopeLock(function () { return watcher.ensureUpdateSetForScope("x_scope_b"); });
226
+ var p3 = watcher.withScopeLock(function () { return watcher.ensureUpdateSetForScope("x_scope_c"); });
227
+ await Promise.all([p1, p2, p3]);
228
+ // All 3 scopes should be in the persisted config
229
+ var config = readPersistedConfig();
230
+ expect(config.x_scope_a).toBeDefined();
231
+ expect(config.x_scope_a.sys_id).toBe("us_1");
232
+ expect(config.x_scope_b).toBeDefined();
233
+ expect(config.x_scope_b.sys_id).toBe("us_2");
234
+ expect(config.x_scope_c).toBeDefined();
235
+ expect(config.x_scope_c.sys_id).toBe("us_3");
236
+ });
237
+ it("re-reads config before writing to preserve other scopes' mappings", async function () {
238
+ // Pre-populate config with scope_a already mapped
239
+ var configPath = getConfigPath();
240
+ mockFsStore[configPath] = JSON.stringify({
241
+ x_scope_a: { sys_id: "existing_a", name: "Existing A" },
242
+ });
243
+ setActiveTask({
244
+ taskId: "TASK456",
245
+ taskName: "Test",
246
+ taskDescription: "",
247
+ updateSetName: "CU-TASK456",
248
+ description: "",
249
+ taskUrl: "",
250
+ scopes: {},
251
+ });
252
+ mockSNClient.client.get.mockResolvedValue({
253
+ data: {
254
+ result: [{ sys_id: "us_new_b", name: "CU-TASK456 B", state: "in progress" }],
255
+ },
256
+ });
257
+ var watcher = MultiScopeWatcher_1.multiScopeWatcher;
258
+ await watcher.withScopeLock(function () { return watcher.ensureUpdateSetForScope("x_scope_b"); });
259
+ // Both scope_a (pre-existing) and scope_b (new) should be present
260
+ var config = readPersistedConfig();
261
+ expect(config.x_scope_a).toBeDefined();
262
+ expect(config.x_scope_a.sys_id).toBe("existing_a");
263
+ expect(config.x_scope_b).toBeDefined();
264
+ expect(config.x_scope_b.sys_id).toBe("us_new_b");
265
+ });
266
+ it("verifies write after saving config and logs debug confirmation", async function () {
267
+ setActiveTask({
268
+ taskId: "TASK789",
269
+ taskName: "Test",
270
+ taskDescription: "",
271
+ updateSetName: "CU-TASK789",
272
+ description: "",
273
+ taskUrl: "",
274
+ scopes: {},
275
+ });
276
+ mockSNClient.client.get.mockResolvedValue({
277
+ data: {
278
+ result: [{ sys_id: "us_verified", name: "CU-TASK789", state: "in progress" }],
279
+ },
280
+ });
281
+ var watcher = MultiScopeWatcher_1.multiScopeWatcher;
282
+ await watcher.withScopeLock(function () { return watcher.ensureUpdateSetForScope("x_scope_a"); });
283
+ // Should log a debug message confirming verification
284
+ expect(Logger_1.logger.debug).toHaveBeenCalledWith(expect.stringContaining("Update set config verified"));
285
+ });
286
+ it("throws and logs error when write verification fails", async function () {
287
+ setActiveTask({
288
+ taskId: "TASKFAIL",
289
+ taskName: "Test",
290
+ taskDescription: "",
291
+ updateSetName: "CU-TASKFAIL",
292
+ description: "",
293
+ taskUrl: "",
294
+ scopes: {},
295
+ });
296
+ mockSNClient.client.get.mockResolvedValue({
297
+ data: {
298
+ result: [{ sys_id: "us_fail", name: "CU-TASKFAIL", state: "in progress" }],
299
+ },
300
+ });
301
+ // Make writeFileSync succeed but readFileSync return empty on verification re-read
302
+ var writeCount = 0;
303
+ fs_1.default.writeFileSync.mockImplementation(function (p, data) {
304
+ // Write succeeds but data is "corrupted" — store empty config
305
+ mockFsStore[p] = JSON.stringify({});
306
+ });
307
+ var watcher = MultiScopeWatcher_1.multiScopeWatcher;
308
+ // The error is caught by ensureUpdateSetForScope's try/catch, which logs error + warn
309
+ await watcher.withScopeLock(function () { return watcher.ensureUpdateSetForScope("x_scope_a"); });
310
+ // Should log error about verification failure
311
+ expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("write verification failed"));
312
+ });
313
+ it("skips API calls when scope already has a mapping", async function () {
314
+ // Pre-populate config with scope_a already mapped
315
+ var configPath = getConfigPath();
316
+ mockFsStore[configPath] = JSON.stringify({
317
+ x_scope_a: { sys_id: "existing_a", name: "Existing A" },
318
+ });
319
+ var watcher = MultiScopeWatcher_1.multiScopeWatcher;
320
+ await watcher.withScopeLock(function () { return watcher.ensureUpdateSetForScope("x_scope_a"); });
321
+ // Should not have called any ServiceNow APIs
322
+ expect(mockSNClient.changeScope).not.toHaveBeenCalled();
323
+ expect(mockSNClient.client.get).not.toHaveBeenCalled();
324
+ });
325
+ });