@tenonhq/dovetail-core 0.0.125 → 0.0.127
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 +6 -1
- package/dist/MultiScopeWatcher.js +89 -0
- package/dist/allScopesCommands.js +12 -3
- package/dist/appUtils.js +29 -11
- package/dist/claudeSession.js +50 -5
- package/dist/commander.js +42 -1
- package/dist/commands.js +16 -2
- package/dist/createRecordCommand.js +30 -3
- package/dist/envArg.js +64 -15
- package/dist/gitHead.js +36 -0
- package/dist/pullRecord.js +91 -15
- package/dist/reconcileCommand.js +2 -0
- package/dist/snClient.js +45 -3
- package/dist/tests/claudeSession.test.js +22 -0
- package/dist/tests/commanderStrict.test.js +27 -0
- package/dist/tests/createRecordUpdateSet.test.js +40 -0
- package/dist/tests/dovetailUtilsMSTableOptions.test.js +109 -2
- package/dist/tests/envArg.test.js +90 -7
- package/dist/tests/gitHead.test.js +65 -0
- package/dist/tests/multi-scope-watcher.test.js +77 -0
- package/dist/tests/pullRecord.test.js +81 -0
- package/dist/tests/reconcileCreateGuard.test.js +60 -0
- package/dist/tests/refreshFailedScopes.test.js +194 -0
- package/dist/tests/snClient.createRecordGuards.test.js +35 -0
- package/dist/tests/snClient.getScopeId.test.js +67 -0
- package/dist/tests/switchUpdateSetSysId.test.js +83 -0
- package/dist/tests/watchClaudeGuard.test.js +132 -0
- package/dist/updateSetCommands.js +19 -2
- package/package.json +1 -1
- package/release-manifest.json +131 -95
- package/skills/dove-setup-project.md +1 -1
- package/skills/dove-troubleshoot-sync.md +1 -1
|
@@ -1,26 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// Tests for the per-command `--env` flag parser (envArg.ts). Covers the flag
|
|
3
3
|
// spellings (--env, --env-file, --envFile, -e), `=` and space-separated forms,
|
|
4
|
-
// last-wins precedence, absence, and
|
|
4
|
+
// last-wins precedence, absence, and resolution of bare instance names,
|
|
5
|
+
// explicit env filenames, and relative/absolute paths.
|
|
5
6
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
6
7
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
7
8
|
};
|
|
8
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const os_1 = __importDefault(require("os"));
|
|
9
12
|
const path_1 = __importDefault(require("path"));
|
|
10
13
|
const envArg_1 = require("../envArg");
|
|
14
|
+
// The conventional per-instance env-file prefix (`prod` → `<prefix>prod`).
|
|
15
|
+
var ENV_PREFIX = ".env.";
|
|
11
16
|
describe("parseEnvArg", function () {
|
|
12
17
|
it("returns undefined when no env flag is present", function () {
|
|
13
18
|
expect((0, envArg_1.parseEnvArg)(["push", "--diff", "main"])).toBeUndefined();
|
|
14
19
|
});
|
|
15
20
|
it("parses --env <path> (space-separated)", function () {
|
|
16
|
-
expect((0, envArg_1.parseEnvArg)(["push", "--env", "
|
|
21
|
+
expect((0, envArg_1.parseEnvArg)(["push", "--env", ENV_PREFIX + "prod"])).toBe(ENV_PREFIX + "prod");
|
|
22
|
+
});
|
|
23
|
+
it("parses --env <name> with a bare instance name", function () {
|
|
24
|
+
expect((0, envArg_1.parseEnvArg)(["refresh", "--env", "demo8"])).toBe("demo8");
|
|
17
25
|
});
|
|
18
26
|
it("parses --env=<path>", function () {
|
|
19
27
|
expect((0, envArg_1.parseEnvArg)(["status", "--env=../envs/workshop.env"])).toBe("../envs/workshop.env");
|
|
20
28
|
});
|
|
29
|
+
it("parses --env=<name> with a bare instance name", function () {
|
|
30
|
+
expect((0, envArg_1.parseEnvArg)(["push", "--env=demo8"])).toBe("demo8");
|
|
31
|
+
});
|
|
21
32
|
it("parses the -e short alias (space and = forms)", function () {
|
|
22
|
-
expect((0, envArg_1.parseEnvArg)(["status", "-e", "
|
|
23
|
-
expect((0, envArg_1.parseEnvArg)(["status", "-e
|
|
33
|
+
expect((0, envArg_1.parseEnvArg)(["status", "-e", ENV_PREFIX + "dev"])).toBe(ENV_PREFIX + "dev");
|
|
34
|
+
expect((0, envArg_1.parseEnvArg)(["status", "-e=" + ENV_PREFIX + "dev"])).toBe(ENV_PREFIX + "dev");
|
|
24
35
|
});
|
|
25
36
|
it("parses --env-file and --envFile spellings", function () {
|
|
26
37
|
expect((0, envArg_1.parseEnvArg)(["push", "--env-file", "a.env"])).toBe("a.env");
|
|
@@ -29,21 +40,93 @@ describe("parseEnvArg", function () {
|
|
|
29
40
|
it("takes the last occurrence when the flag is repeated", function () {
|
|
30
41
|
expect((0, envArg_1.parseEnvArg)(["push", "--env", "first.env", "--env", "second.env"])).toBe("second.env");
|
|
31
42
|
});
|
|
43
|
+
it("takes the last occurrence across mixed forms and spellings", function () {
|
|
44
|
+
expect((0, envArg_1.parseEnvArg)(["push", "--env=shop", "-e", "studio", "--env-file", "loft"])).toBe("loft");
|
|
45
|
+
expect((0, envArg_1.parseEnvArg)(["push", "-e", "studio", "--env=shop"])).toBe("shop");
|
|
46
|
+
});
|
|
32
47
|
it("ignores a bare --env with no following value", function () {
|
|
33
48
|
expect((0, envArg_1.parseEnvArg)(["push", "--env"])).toBeUndefined();
|
|
34
49
|
});
|
|
35
50
|
it("does not consume the next token when it looks like another flag", function () {
|
|
36
51
|
expect((0, envArg_1.parseEnvArg)(["push", "--env", "--ci"])).toBeUndefined();
|
|
37
52
|
});
|
|
38
|
-
it("treats an empty --env= as absent", function () {
|
|
53
|
+
it("treats an empty or whitespace-only --env= as absent", function () {
|
|
39
54
|
expect((0, envArg_1.parseEnvArg)(["push", "--env="])).toBeUndefined();
|
|
55
|
+
expect((0, envArg_1.parseEnvArg)(["push", "--env= "])).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
it("returns undefined for a non-array argv", function () {
|
|
58
|
+
expect((0, envArg_1.parseEnvArg)(undefined)).toBeUndefined();
|
|
40
59
|
});
|
|
41
60
|
});
|
|
42
61
|
describe("resolveEnvArgPath", function () {
|
|
62
|
+
var tmpDir;
|
|
63
|
+
beforeEach(function () {
|
|
64
|
+
tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "dove-envarg-"));
|
|
65
|
+
});
|
|
66
|
+
afterEach(function () {
|
|
67
|
+
fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
|
|
68
|
+
});
|
|
43
69
|
it("returns absolute paths unchanged", function () {
|
|
44
|
-
|
|
70
|
+
var abs = path_1.default.join(tmpDir, "abs", ENV_PREFIX + "prod");
|
|
71
|
+
expect((0, envArg_1.resolveEnvArgPath)(abs, "/cwd")).toBe(abs);
|
|
72
|
+
});
|
|
73
|
+
it("resolves an absolute path that has no dot as a path, not a bare name", function () {
|
|
74
|
+
var abs = path_1.default.join(tmpDir, "creds");
|
|
75
|
+
expect((0, envArg_1.resolveEnvArgPath)(abs, "/cwd")).toBe(abs);
|
|
45
76
|
});
|
|
46
77
|
it("resolves relative paths against the provided cwd", function () {
|
|
47
|
-
expect((0, envArg_1.resolveEnvArgPath)(".env
|
|
78
|
+
expect((0, envArg_1.resolveEnvArgPath)("../envs/workshop.env", path_1.default.join(tmpDir, "repo"))).toBe(path_1.default.join(tmpDir, "envs", "workshop.env"));
|
|
79
|
+
});
|
|
80
|
+
it("treats a relative path without a dot in the basename as a path", function () {
|
|
81
|
+
expect((0, envArg_1.resolveEnvArgPath)(path_1.default.join("envs", "prod"), tmpDir)).toBe(path_1.default.join(tmpDir, "envs", "prod"));
|
|
82
|
+
});
|
|
83
|
+
it("uses an explicit env filename as-is (the file need not exist)", function () {
|
|
84
|
+
expect((0, envArg_1.resolveEnvArgPath)(ENV_PREFIX + "prod", tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "prod"));
|
|
85
|
+
});
|
|
86
|
+
it("uses a dotted filename as-is even when it does not exist yet (dove login creates it)", function () {
|
|
87
|
+
expect((0, envArg_1.resolveEnvArgPath)("workshop.env", tmpDir)).toBe(path_1.default.join(tmpDir, "workshop.env"));
|
|
88
|
+
});
|
|
89
|
+
it("expands a bare instance name to the per-instance env file in cwd", function () {
|
|
90
|
+
expect((0, envArg_1.resolveEnvArgPath)("demo8", tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "demo8"));
|
|
91
|
+
});
|
|
92
|
+
it("expands a bare name whether or not the target file exists", function () {
|
|
93
|
+
var target = path_1.default.join(tmpDir, ENV_PREFIX + "loft");
|
|
94
|
+
fs_1.default.writeFileSync(target, "");
|
|
95
|
+
expect((0, envArg_1.resolveEnvArgPath)("loft", tmpDir)).toBe(target);
|
|
96
|
+
});
|
|
97
|
+
it("prefers an existing literal file in cwd over bare-name expansion", function () {
|
|
98
|
+
var literal = path_1.default.join(tmpDir, "shop");
|
|
99
|
+
fs_1.default.writeFileSync(literal, "");
|
|
100
|
+
expect((0, envArg_1.resolveEnvArgPath)("shop", tmpDir)).toBe(literal);
|
|
101
|
+
});
|
|
102
|
+
it("does not treat a same-named directory as an existing literal file", function () {
|
|
103
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, "studio"));
|
|
104
|
+
expect((0, envArg_1.resolveEnvArgPath)("studio", tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "studio"));
|
|
105
|
+
});
|
|
106
|
+
it("trims surrounding whitespace before resolving", function () {
|
|
107
|
+
expect((0, envArg_1.resolveEnvArgPath)(" demo8 ", tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "demo8"));
|
|
108
|
+
});
|
|
109
|
+
it("resolves a bare name parsed from the --env= form end to end", function () {
|
|
110
|
+
var raw = (0, envArg_1.parseEnvArg)(["refresh", "--env=demo8"]);
|
|
111
|
+
expect(raw).toBe("demo8");
|
|
112
|
+
expect((0, envArg_1.resolveEnvArgPath)(raw, tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "demo8"));
|
|
113
|
+
});
|
|
114
|
+
it("resolves the last-wins selection end to end", function () {
|
|
115
|
+
var raw = (0, envArg_1.parseEnvArg)(["push", "--env", ENV_PREFIX + "prod", "-e", "demo8"]);
|
|
116
|
+
expect((0, envArg_1.resolveEnvArgPath)(raw, tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "demo8"));
|
|
117
|
+
});
|
|
118
|
+
it("defaults to process.cwd() when no cwd is given", function () {
|
|
119
|
+
expect((0, envArg_1.resolveEnvArgPath)("demo8")).toBe(path_1.default.resolve(process.cwd(), ENV_PREFIX + "demo8"));
|
|
120
|
+
});
|
|
121
|
+
it("throws on an empty or non-string selector", function () {
|
|
122
|
+
expect(function () {
|
|
123
|
+
(0, envArg_1.resolveEnvArgPath)("", tmpDir);
|
|
124
|
+
}).toThrow(/non-empty/);
|
|
125
|
+
expect(function () {
|
|
126
|
+
(0, envArg_1.resolveEnvArgPath)(" ", tmpDir);
|
|
127
|
+
}).toThrow(/non-empty/);
|
|
128
|
+
expect(function () {
|
|
129
|
+
(0, envArg_1.resolveEnvArgPath)(undefined, tmpDir);
|
|
130
|
+
}).toThrow(/non-empty/);
|
|
48
131
|
});
|
|
49
132
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// readGitHead backs the watcher's branch-switch guard. It must resolve the
|
|
3
|
+
// HEAD commit inside a work tree and resolve null (never throw) elsewhere.
|
|
4
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
5
|
+
if (k2 === undefined) k2 = k;
|
|
6
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
7
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
8
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
9
|
+
}
|
|
10
|
+
Object.defineProperty(o, k2, desc);
|
|
11
|
+
}) : (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
o[k2] = m[k];
|
|
14
|
+
}));
|
|
15
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
16
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
17
|
+
}) : function(o, v) {
|
|
18
|
+
o["default"] = v;
|
|
19
|
+
});
|
|
20
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
21
|
+
var ownKeys = function(o) {
|
|
22
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
23
|
+
var ar = [];
|
|
24
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
25
|
+
return ar;
|
|
26
|
+
};
|
|
27
|
+
return ownKeys(o);
|
|
28
|
+
};
|
|
29
|
+
return function (mod) {
|
|
30
|
+
if (mod && mod.__esModule) return mod;
|
|
31
|
+
var result = {};
|
|
32
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
33
|
+
__setModuleDefault(result, mod);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
})();
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const os = __importStar(require("os"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const gitHead_1 = require("../gitHead");
|
|
42
|
+
describe("readGitHead", function () {
|
|
43
|
+
it("resolves the HEAD commit id inside a git work tree", async function () {
|
|
44
|
+
// This test file lives inside the repository's own work tree.
|
|
45
|
+
var head = await (0, gitHead_1.readGitHead)(__dirname);
|
|
46
|
+
expect(head).toMatch(/^[0-9a-f]{40}([0-9a-f]{24})?$/);
|
|
47
|
+
});
|
|
48
|
+
it("resolves null outside a git work tree", async function () {
|
|
49
|
+
var dir = fs.mkdtempSync(path.join(os.tmpdir(), "dove-githead-"));
|
|
50
|
+
try {
|
|
51
|
+
expect(await (0, gitHead_1.readGitHead)(dir)).toBeNull();
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
it("resolves null for a directory that does not exist", async function () {
|
|
58
|
+
var head = await (0, gitHead_1.readGitHead)(path.join(os.tmpdir(), "dove-githead-missing-" + Date.now()));
|
|
59
|
+
expect(head).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
it("resolves null for an empty or non-string dir", async function () {
|
|
62
|
+
expect(await (0, gitHead_1.readGitHead)("")).toBeNull();
|
|
63
|
+
expect(await (0, gitHead_1.readGitHead)(undefined)).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -103,6 +103,11 @@ jest.mock("../logMessages", () => ({
|
|
|
103
103
|
jest.mock("../recentEdits", () => ({
|
|
104
104
|
writeRecentEdit: jest.fn(),
|
|
105
105
|
}));
|
|
106
|
+
// Branch-switch guard: default to "not a git work tree" (guard off) so the
|
|
107
|
+
// existing tests keep today's behaviour; the guard block overrides it.
|
|
108
|
+
jest.mock("../gitHead", () => ({
|
|
109
|
+
readGitHead: jest.fn().mockResolvedValue(null),
|
|
110
|
+
}));
|
|
106
111
|
jest.mock("../Logger", () => ({
|
|
107
112
|
logger: {
|
|
108
113
|
info: jest.fn(),
|
|
@@ -152,6 +157,7 @@ const ConfigManager = __importStar(require("../config"));
|
|
|
152
157
|
const FileUtils_1 = require("../FileUtils");
|
|
153
158
|
const appUtils_1 = require("../appUtils");
|
|
154
159
|
const Logger_1 = require("../Logger");
|
|
160
|
+
const gitHead_1 = require("../gitHead");
|
|
155
161
|
const MultiScopeWatcher_1 = require("../MultiScopeWatcher");
|
|
156
162
|
// Helper to flush microtask queue (for async code in setInterval callbacks)
|
|
157
163
|
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
|
@@ -400,6 +406,77 @@ describe("MultiScopeWatcherManager", () => {
|
|
|
400
406
|
expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("Watcher error: FS error"));
|
|
401
407
|
});
|
|
402
408
|
});
|
|
409
|
+
describe("branch-switch guard (git HEAD)", () => {
|
|
410
|
+
var HEAD_A = "a".repeat(40);
|
|
411
|
+
var HEAD_B = "b".repeat(40);
|
|
412
|
+
beforeEach(async () => {
|
|
413
|
+
ConfigManager.getConfig.mockReturnValue({
|
|
414
|
+
...MOCK_CONFIG_TWO_SCOPES,
|
|
415
|
+
scopes: { x_test_core: { sourceDirectory: "src/x_test_core" } },
|
|
416
|
+
});
|
|
417
|
+
fs_1.default.existsSync.mockReturnValue(false);
|
|
418
|
+
FileUtils_1.getFileContextWithSkipReason.mockReturnValue({ context: makeFileContext() });
|
|
419
|
+
appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
|
|
420
|
+
appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
|
|
421
|
+
});
|
|
422
|
+
afterEach(() => {
|
|
423
|
+
gitHead_1.readGitHead.mockReset();
|
|
424
|
+
gitHead_1.readGitHead.mockResolvedValue(null);
|
|
425
|
+
});
|
|
426
|
+
it("reads HEAD in the project root at start", async () => {
|
|
427
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_A);
|
|
428
|
+
await (0, MultiScopeWatcher_1.startMultiScopeWatching)();
|
|
429
|
+
expect(gitHead_1.readGitHead).toHaveBeenCalledWith("/project");
|
|
430
|
+
});
|
|
431
|
+
it("pushes when HEAD is unchanged at flush time", async () => {
|
|
432
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_A);
|
|
433
|
+
await (0, MultiScopeWatcher_1.startMultiScopeWatching)();
|
|
434
|
+
mockWatchers[0]._emit("change", makeFileContext().filePath);
|
|
435
|
+
await capturedDebounceFns[0]();
|
|
436
|
+
expect(gitHead_1.readGitHead).toHaveBeenCalledTimes(2);
|
|
437
|
+
expect(appUtils_1.pushFiles).toHaveBeenCalledTimes(1);
|
|
438
|
+
expect(Logger_1.logger.error).not.toHaveBeenCalledWith(expect.stringContaining("Git HEAD changed"));
|
|
439
|
+
});
|
|
440
|
+
it("drops the queue, pushes nothing, logs an error and pauses when HEAD moves between enqueue and flush", async () => {
|
|
441
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_A);
|
|
442
|
+
await (0, MultiScopeWatcher_1.startMultiScopeWatching)();
|
|
443
|
+
mockWatchers[0]._emit("change", makeFileContext().filePath);
|
|
444
|
+
// A `git checkout` lands before the debounce fires.
|
|
445
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_B);
|
|
446
|
+
await capturedDebounceFns[0]();
|
|
447
|
+
expect(appUtils_1.pushFiles).not.toHaveBeenCalled();
|
|
448
|
+
expect(mockSNClient.changeScope).not.toHaveBeenCalled();
|
|
449
|
+
expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("Git HEAD changed"));
|
|
450
|
+
expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("restart"));
|
|
451
|
+
var internals = MultiScopeWatcher_1.multiScopeWatcher;
|
|
452
|
+
expect(internals.pendingScopes.size).toBe(0);
|
|
453
|
+
internals.scopeWatchers.forEach(function (w) {
|
|
454
|
+
expect(w.pushQueue).toEqual([]);
|
|
455
|
+
});
|
|
456
|
+
// Paused: even if HEAD goes back, later changes are not pushed until restart.
|
|
457
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_A);
|
|
458
|
+
mockWatchers[0]._emit("change", makeFileContext().filePath);
|
|
459
|
+
await capturedDebounceFns[0]();
|
|
460
|
+
expect(appUtils_1.pushFiles).not.toHaveBeenCalled();
|
|
461
|
+
});
|
|
462
|
+
it("treats an unreadable HEAD after start as a change (fail safe)", async () => {
|
|
463
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_A);
|
|
464
|
+
await (0, MultiScopeWatcher_1.startMultiScopeWatching)();
|
|
465
|
+
mockWatchers[0]._emit("change", makeFileContext().filePath);
|
|
466
|
+
gitHead_1.readGitHead.mockResolvedValue(null);
|
|
467
|
+
await capturedDebounceFns[0]();
|
|
468
|
+
expect(appUtils_1.pushFiles).not.toHaveBeenCalled();
|
|
469
|
+
expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("unreadable"));
|
|
470
|
+
});
|
|
471
|
+
it("behaves as before outside a git work tree (no HEAD at start)", async () => {
|
|
472
|
+
gitHead_1.readGitHead.mockResolvedValue(null);
|
|
473
|
+
await (0, MultiScopeWatcher_1.startMultiScopeWatching)();
|
|
474
|
+
mockWatchers[0]._emit("change", makeFileContext().filePath);
|
|
475
|
+
await capturedDebounceFns[0]();
|
|
476
|
+
expect(gitHead_1.readGitHead).toHaveBeenCalledTimes(1);
|
|
477
|
+
expect(appUtils_1.pushFiles).toHaveBeenCalledTimes(1);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
403
480
|
describe("processScopeQueue - empty queue", () => {
|
|
404
481
|
it("returns early when queue is empty", async () => {
|
|
405
482
|
ConfigManager.getConfig.mockReturnValue({
|
|
@@ -517,6 +517,87 @@ describe("planPull — renamed record", function () {
|
|
|
517
517
|
expect(plan.warnings.join("\n")).toMatch(/formatting differs/);
|
|
518
518
|
});
|
|
519
519
|
});
|
|
520
|
+
describe("planPull — manifest key collisions (never overwrite another sys_id)", function () {
|
|
521
|
+
const TABLE = "sys_one_extend_capability";
|
|
522
|
+
const ID_DUP = "ddddddddddddddddddddddddddddddd4";
|
|
523
|
+
const NAME = "Skill Capability";
|
|
524
|
+
function metaFolder(key) {
|
|
525
|
+
return path.join(tmpRoot, "src", SCOPE, TABLE, key, "metaData.json");
|
|
526
|
+
}
|
|
527
|
+
test("two same-named metaData-only records pulled one after the other → both entries + both folders survive", async function () {
|
|
528
|
+
mockClient.getMissingFiles.mockResolvedValue(bulkDownload(TABLE, ID_META_ONLY, NAME, []));
|
|
529
|
+
await (0, pullRecord_1.applyPull)(await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_META_ONLY }]));
|
|
530
|
+
mockClient.getMissingFiles.mockResolvedValue(bulkDownload(TABLE, ID_DUP, NAME, []));
|
|
531
|
+
const plan = await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_DUP }]);
|
|
532
|
+
const dupKey = NAME + " (" + ID_DUP.substring(0, 8) + ")";
|
|
533
|
+
expect(plan.records[0].key).toBe(dupKey);
|
|
534
|
+
expect(plan.records[0].manifestAction).toBe("add");
|
|
535
|
+
await (0, pullRecord_1.applyPull)(plan);
|
|
536
|
+
const records = JSON.parse(readLocalManifest()).tables[TABLE].records;
|
|
537
|
+
expect(Object.keys(records)).toEqual([NAME, dupKey]);
|
|
538
|
+
expect(records[NAME].sys_id).toBe(ID_META_ONLY);
|
|
539
|
+
expect(records[dupKey]).toEqual({ files: [], name: dupKey, sys_id: ID_DUP });
|
|
540
|
+
expect(JSON.parse(fs.readFileSync(metaFolder(NAME), "utf8"))._sys_id).toBe(ID_META_ONLY);
|
|
541
|
+
expect(JSON.parse(fs.readFileSync(metaFolder(dupKey), "utf8"))._sys_id).toBe(ID_DUP);
|
|
542
|
+
// re-pulling either record is a no-op: the suffixed key is stable
|
|
543
|
+
const again = await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_DUP }]);
|
|
544
|
+
expect(again.records[0].key).toBe(dupKey);
|
|
545
|
+
expect(again.records[0].manifestAction).toBe("unchanged");
|
|
546
|
+
mockClient.getMissingFiles.mockResolvedValue(bulkDownload(TABLE, ID_META_ONLY, NAME, []));
|
|
547
|
+
const first = await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_META_ONLY }]);
|
|
548
|
+
expect(first.records[0].key).toBe(NAME);
|
|
549
|
+
expect(first.records[0].manifestAction).toBe("unchanged");
|
|
550
|
+
});
|
|
551
|
+
test("two same-named metaData-only records in ONE batch → both entries + both folders survive", async function () {
|
|
552
|
+
const a = bulkDownload(TABLE, ID_META_ONLY, NAME, [])[TABLE].records[NAME];
|
|
553
|
+
const b = bulkDownload(TABLE, ID_DUP, NAME, [])[TABLE].records[NAME];
|
|
554
|
+
mockClient.getMissingFiles.mockResolvedValue({ [TABLE]: { records: { [ID_META_ONLY]: a, [ID_DUP]: b } } });
|
|
555
|
+
const plan = await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_META_ONLY }, { table: TABLE, sysId: ID_DUP }]);
|
|
556
|
+
const dupKey = NAME + " (" + ID_DUP.substring(0, 8) + ")";
|
|
557
|
+
expect(plan.records.map(function (r) { return r.key + ":" + r.manifestAction; }))
|
|
558
|
+
.toEqual([NAME + ":add", dupKey + ":add"]);
|
|
559
|
+
await (0, pullRecord_1.applyPull)(plan);
|
|
560
|
+
const records = JSON.parse(readLocalManifest()).tables[TABLE].records;
|
|
561
|
+
expect(Object.keys(records)).toEqual([NAME, dupKey]);
|
|
562
|
+
expect(records[NAME].sys_id).toBe(ID_META_ONLY);
|
|
563
|
+
expect(records[dupKey].sys_id).toBe(ID_DUP);
|
|
564
|
+
expect(JSON.parse(fs.readFileSync(metaFolder(NAME), "utf8"))._sys_id).toBe(ID_META_ONLY);
|
|
565
|
+
expect(JSON.parse(fs.readFileSync(metaFolder(dupKey), "utf8"))._sys_id).toBe(ID_DUP);
|
|
566
|
+
});
|
|
567
|
+
test("a fallback key never takes a key the server manifest gives another record", async function () {
|
|
568
|
+
mockClient.getManifest.mockResolvedValue({
|
|
569
|
+
scope: SCOPE,
|
|
570
|
+
tables: { [TABLE]: { records: { [NAME]: { name: NAME, sys_id: ID_META_ONLY, files: [{ name: "script", type: "js" }] } } } },
|
|
571
|
+
});
|
|
572
|
+
mockClient.getMissingFiles.mockResolvedValue(bulkDownload(TABLE, ID_DUP, NAME, []));
|
|
573
|
+
const plan = await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_DUP }]);
|
|
574
|
+
expect(plan.records[0].key).toBe(NAME + " (" + ID_DUP.substring(0, 8) + ")");
|
|
575
|
+
});
|
|
576
|
+
test("a server-provided key held locally by a DIFFERENT sys_id → refused, points at dove refresh -t, writes nothing", async function () {
|
|
577
|
+
// Local manifest says "Sibling" is ID_SIB; the server now hands "Sibling" to
|
|
578
|
+
// another record (its duplicate-suffix order moved since the last refresh).
|
|
579
|
+
seedSibling();
|
|
580
|
+
writeLocalManifest(localManifestWithSibling(), true);
|
|
581
|
+
const before = snapshot(tmpRoot);
|
|
582
|
+
mockClient.getManifest.mockResolvedValue({
|
|
583
|
+
scope: SCOPE,
|
|
584
|
+
tables: { sys_script_include: { records: {
|
|
585
|
+
Sibling: { name: "Sibling", sys_id: ID_NEW, files: [{ name: "script", type: "js" }] },
|
|
586
|
+
} } },
|
|
587
|
+
});
|
|
588
|
+
await expect((0, pullRecord_1.planPull)([{ table: "sys_script_include", sysId: ID_NEW }]))
|
|
589
|
+
.rejects.toThrow(/manifest key 'Sibling' already belongs to sys_id aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1[\s\S]*dove refresh -t sys_script_include/);
|
|
590
|
+
expect(mockClient.getMissingFiles).not.toHaveBeenCalled();
|
|
591
|
+
expect(snapshot(tmpRoot)).toEqual(before);
|
|
592
|
+
});
|
|
593
|
+
test("spliceManifestRecord refuses to overwrite a key held by a different sys_id", function () {
|
|
594
|
+
expect(function () {
|
|
595
|
+
(0, pullRecord_1.spliceManifestRecord)(localManifestWithSibling(), SCOPE, "sys_script_include", {
|
|
596
|
+
files: [], name: "Sibling", sys_id: ID_NEW,
|
|
597
|
+
});
|
|
598
|
+
}).toThrow(/already belongs to sys_id/);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
520
601
|
// ---------- --from-update-set ----------
|
|
521
602
|
describe("targetsFromUpdateSet", function () {
|
|
522
603
|
const SET = "d".repeat(32);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `dove reconcile --apply` creates net-new branch records through
|
|
3
|
+
// snClient.createRecord. A sys_update_set record in the branch must not crash
|
|
4
|
+
// the apply or be silently created in the session app: the createRecord guard
|
|
5
|
+
// refuses it and the refusal surfaces as a named, per-record failure (which
|
|
6
|
+
// applyMode lists and turns into exit code 1).
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
jest.mock("../Logger", function () {
|
|
9
|
+
return {
|
|
10
|
+
logger: {
|
|
11
|
+
info: jest.fn(),
|
|
12
|
+
debug: jest.fn(),
|
|
13
|
+
warn: jest.fn(),
|
|
14
|
+
error: jest.fn(),
|
|
15
|
+
success: jest.fn(),
|
|
16
|
+
getLogLevel: function () { return "debug"; },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
jest.mock("../FileLogger", function () {
|
|
21
|
+
return { fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } };
|
|
22
|
+
});
|
|
23
|
+
jest.mock("../config", function () {
|
|
24
|
+
return {
|
|
25
|
+
getSourcePathForScope: function () {
|
|
26
|
+
return "/nonexistent-reconcile-source";
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
var mockPost = jest.fn();
|
|
31
|
+
jest.mock("../snClient", function () {
|
|
32
|
+
var actual = jest.requireActual("../snClient");
|
|
33
|
+
var client = actual.snClient("https://x.service-now.com/", "u", "p");
|
|
34
|
+
client.client.post = mockPost;
|
|
35
|
+
return Object.assign({}, actual, {
|
|
36
|
+
defaultClient: function () {
|
|
37
|
+
return client;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
const reconcileCommand_1 = require("../reconcileCommand");
|
|
42
|
+
describe("reconcile applyCreates with a sys_update_set record", function () {
|
|
43
|
+
it("reports a clear per-record failure and never posts", async function () {
|
|
44
|
+
var change = {
|
|
45
|
+
kind: "create",
|
|
46
|
+
table: "sys_update_set",
|
|
47
|
+
scope: "x_s",
|
|
48
|
+
sys_id: "abc123",
|
|
49
|
+
name: "Foo",
|
|
50
|
+
fieldDeltas: [],
|
|
51
|
+
};
|
|
52
|
+
var outcomes = await (0, reconcileCommand_1.applyCreates)("x_s", [change], "us1");
|
|
53
|
+
expect(outcomes).toHaveLength(1);
|
|
54
|
+
expect(outcomes[0].ok).toBe(false);
|
|
55
|
+
expect(outcomes[0].change.sys_id).toBe("abc123");
|
|
56
|
+
expect(outcomes[0].error).toMatch(/Refusing to insert sys_update_set/);
|
|
57
|
+
expect(outcomes[0].error).toMatch(/dove createUpdateSet/);
|
|
58
|
+
expect(mockPost).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `dove refresh` must fail loudly when a scope fails.
|
|
4
|
+
*
|
|
5
|
+
* Regression target: against an instance without the sync API, every scope's
|
|
6
|
+
* getManifest returned HTTP 400, each failure was caught and logged inside
|
|
7
|
+
* syncManifest, and refreshCommand still printed "Refresh complete!" and
|
|
8
|
+
* exited 0 — so scripts and CI read a total failure as success.
|
|
9
|
+
*
|
|
10
|
+
* Contract pinned here:
|
|
11
|
+
* 1. An all-scopes refresh keeps going past a failed scope (the rest still
|
|
12
|
+
* refresh) and returns every failure in `failedScopes`.
|
|
13
|
+
* 2. refreshCommand prints a per-scope failure summary, sets a non-zero exit
|
|
14
|
+
* code, and does NOT print "Refresh complete!".
|
|
15
|
+
* 3. A clean refresh still prints "Refresh complete!" and leaves the exit
|
|
16
|
+
* code alone.
|
|
17
|
+
*/
|
|
18
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
21
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
22
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
23
|
+
}
|
|
24
|
+
Object.defineProperty(o, k2, desc);
|
|
25
|
+
}) : (function(o, m, k, k2) {
|
|
26
|
+
if (k2 === undefined) k2 = k;
|
|
27
|
+
o[k2] = m[k];
|
|
28
|
+
}));
|
|
29
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
30
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
31
|
+
}) : function(o, v) {
|
|
32
|
+
o["default"] = v;
|
|
33
|
+
});
|
|
34
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
35
|
+
var ownKeys = function(o) {
|
|
36
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
37
|
+
var ar = [];
|
|
38
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
39
|
+
return ar;
|
|
40
|
+
};
|
|
41
|
+
return ownKeys(o);
|
|
42
|
+
};
|
|
43
|
+
return function (mod) {
|
|
44
|
+
if (mod && mod.__esModule) return mod;
|
|
45
|
+
var result = {};
|
|
46
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
47
|
+
__setModuleDefault(result, mod);
|
|
48
|
+
return result;
|
|
49
|
+
};
|
|
50
|
+
})();
|
|
51
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
|
+
var mockLogger = {
|
|
53
|
+
info: jest.fn(),
|
|
54
|
+
debug: jest.fn(),
|
|
55
|
+
warn: jest.fn(),
|
|
56
|
+
error: jest.fn(),
|
|
57
|
+
success: jest.fn(),
|
|
58
|
+
setLogLevel: jest.fn(),
|
|
59
|
+
getLogLevel: function () { return "info"; },
|
|
60
|
+
};
|
|
61
|
+
var mockClient = {
|
|
62
|
+
getManifest: jest.fn(),
|
|
63
|
+
getMissingFiles: jest.fn().mockResolvedValue({}),
|
|
64
|
+
};
|
|
65
|
+
var mockFUtils = {
|
|
66
|
+
writeScopeManifest: jest.fn().mockResolvedValue(undefined),
|
|
67
|
+
writeFileForce: jest.fn().mockResolvedValue(undefined),
|
|
68
|
+
writeSNFileCurry: jest.fn(() => jest.fn().mockResolvedValue(undefined)),
|
|
69
|
+
createDirRecursively: jest.fn().mockResolvedValue(undefined),
|
|
70
|
+
};
|
|
71
|
+
jest.mock("../Logger", function () { return { logger: mockLogger }; });
|
|
72
|
+
jest.mock("../FileLogger", function () {
|
|
73
|
+
return { fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } };
|
|
74
|
+
});
|
|
75
|
+
jest.mock("../FileUtils", function () { return mockFUtils; });
|
|
76
|
+
jest.mock("../snClient", function () {
|
|
77
|
+
return {
|
|
78
|
+
defaultClient: function () { return mockClient; },
|
|
79
|
+
unwrapSNResponse: function (p) { return Promise.resolve(p); },
|
|
80
|
+
processPushResponse: jest.fn(),
|
|
81
|
+
retryOnErr: jest.fn(),
|
|
82
|
+
retryOnHttpErr: jest.fn(),
|
|
83
|
+
unwrapTableAPIFirstItem: jest.fn(),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
var mockConfig = {
|
|
87
|
+
getConfig: jest.fn(),
|
|
88
|
+
getManifest: jest.fn(),
|
|
89
|
+
getSourcePathForScope: jest.fn().mockReturnValue("/tmp/src"),
|
|
90
|
+
getSourcePath: jest.fn().mockReturnValue("/tmp/src"),
|
|
91
|
+
getManifestPath: jest.fn().mockReturnValue("/tmp/dove.manifest.json"),
|
|
92
|
+
resolveConfigForScope: jest.fn().mockReturnValue({
|
|
93
|
+
tables: ["sys_script_include"],
|
|
94
|
+
fieldOverrides: {},
|
|
95
|
+
apiIncludes: {},
|
|
96
|
+
apiExcludes: {},
|
|
97
|
+
}),
|
|
98
|
+
isMultiScopeManifest: jest.fn().mockReturnValue(true),
|
|
99
|
+
updateManifest: jest.fn(),
|
|
100
|
+
};
|
|
101
|
+
jest.mock("../config", function () { return mockConfig; });
|
|
102
|
+
jest.mock("progress", function () {
|
|
103
|
+
return jest.fn().mockImplementation(function () { return { tick: jest.fn() }; });
|
|
104
|
+
});
|
|
105
|
+
const AppUtils = __importStar(require("../appUtils"));
|
|
106
|
+
const commands_1 = require("../commands");
|
|
107
|
+
var SCOPES = ["x_cadso_core", "x_cadso_work", "x_cadso_journey"];
|
|
108
|
+
function declareScopes() {
|
|
109
|
+
var scopes = {};
|
|
110
|
+
var manifest = {};
|
|
111
|
+
SCOPES.forEach(function (s) {
|
|
112
|
+
scopes[s] = {};
|
|
113
|
+
manifest[s] = { scope: s, tables: {} };
|
|
114
|
+
});
|
|
115
|
+
mockConfig.getConfig.mockReturnValue({ scopes: scopes });
|
|
116
|
+
mockConfig.getManifest.mockResolvedValue(manifest);
|
|
117
|
+
}
|
|
118
|
+
function errorLines() {
|
|
119
|
+
return mockLogger.error.mock.calls.map(function (c) { return String(c[0]); });
|
|
120
|
+
}
|
|
121
|
+
describe("syncManifest — failed scopes are collected, not swallowed", function () {
|
|
122
|
+
beforeEach(function () {
|
|
123
|
+
jest.clearAllMocks();
|
|
124
|
+
mockConfig.isMultiScopeManifest.mockReturnValue(true);
|
|
125
|
+
declareScopes();
|
|
126
|
+
});
|
|
127
|
+
test("keeps refreshing past a failed scope and reports it", async function () {
|
|
128
|
+
mockClient.getManifest.mockImplementation(function (scope) {
|
|
129
|
+
if (scope === "x_cadso_work") {
|
|
130
|
+
return Promise.reject(new Error("HTTP 400 from api/cadso/dovetail_sync/getManifest/x_cadso_work"));
|
|
131
|
+
}
|
|
132
|
+
return Promise.resolve({ scope: scope, tables: {} });
|
|
133
|
+
});
|
|
134
|
+
var result = await AppUtils.syncManifest();
|
|
135
|
+
var attempted = mockClient.getManifest.mock.calls.map(function (c) { return c[0]; });
|
|
136
|
+
expect(attempted).toEqual(SCOPES);
|
|
137
|
+
expect(result.failedScopes).toHaveLength(1);
|
|
138
|
+
expect(result.failedScopes[0].scope).toBe("x_cadso_work");
|
|
139
|
+
expect(result.failedScopes[0].error).toContain("HTTP 400");
|
|
140
|
+
// The scopes either side of the failure still wrote their manifests.
|
|
141
|
+
expect(mockFUtils.writeScopeManifest).toHaveBeenCalledTimes(2);
|
|
142
|
+
});
|
|
143
|
+
test("every scope failing reports every scope", async function () {
|
|
144
|
+
mockClient.getManifest.mockRejectedValue(new Error("HTTP 400"));
|
|
145
|
+
var result = await AppUtils.syncManifest();
|
|
146
|
+
expect(result.failedScopes.map(function (f) { return f.scope; })).toEqual(SCOPES);
|
|
147
|
+
});
|
|
148
|
+
test("a clean refresh reports no failures", async function () {
|
|
149
|
+
mockClient.getManifest.mockImplementation(function (scope) {
|
|
150
|
+
return Promise.resolve({ scope: scope, tables: {} });
|
|
151
|
+
});
|
|
152
|
+
var result = await AppUtils.syncManifest();
|
|
153
|
+
expect(result.failedScopes).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
test("a top-level failure (no manifest) is reported too", async function () {
|
|
156
|
+
mockConfig.getManifest.mockResolvedValue(undefined);
|
|
157
|
+
var result = await AppUtils.syncManifest();
|
|
158
|
+
expect(result.failedScopes).toHaveLength(1);
|
|
159
|
+
expect(result.failedScopes[0].error).toContain("No manifest file loaded");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe("refreshCommand — exit code reflects failed scopes", function () {
|
|
163
|
+
var originalExitCode;
|
|
164
|
+
beforeEach(function () {
|
|
165
|
+
jest.clearAllMocks();
|
|
166
|
+
originalExitCode = process.exitCode;
|
|
167
|
+
process.exitCode = undefined;
|
|
168
|
+
});
|
|
169
|
+
afterEach(function () {
|
|
170
|
+
process.exitCode = originalExitCode;
|
|
171
|
+
jest.restoreAllMocks();
|
|
172
|
+
});
|
|
173
|
+
test("failed scopes → summary, non-zero exit, no 'Refresh complete!'", async function () {
|
|
174
|
+
jest.spyOn(AppUtils, "syncManifest").mockResolvedValue({
|
|
175
|
+
failedScopes: [
|
|
176
|
+
{ scope: "x_cadso_core", error: "HTTP 400" },
|
|
177
|
+
{ scope: "x_cadso_work", error: "HTTP 400" },
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
await (0, commands_1.refreshCommand)({});
|
|
181
|
+
expect(process.exitCode).toBe(1);
|
|
182
|
+
expect(mockLogger.success).not.toHaveBeenCalled();
|
|
183
|
+
var lines = errorLines();
|
|
184
|
+
expect(lines.some(function (l) { return l.indexOf("2 failed scopes") !== -1; })).toBe(true);
|
|
185
|
+
expect(lines.some(function (l) { return l.indexOf("x_cadso_core: HTTP 400") !== -1; })).toBe(true);
|
|
186
|
+
expect(lines.some(function (l) { return l.indexOf("x_cadso_work: HTTP 400") !== -1; })).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
test("clean refresh → 'Refresh complete!' and exit code untouched", async function () {
|
|
189
|
+
jest.spyOn(AppUtils, "syncManifest").mockResolvedValue({ failedScopes: [] });
|
|
190
|
+
await (0, commands_1.refreshCommand)({});
|
|
191
|
+
expect(process.exitCode).toBeUndefined();
|
|
192
|
+
expect(mockLogger.success).toHaveBeenCalledWith("Refresh complete!");
|
|
193
|
+
});
|
|
194
|
+
});
|