@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
package/dist/gitHead.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readGitHead = readGitHead;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
// A full object id: SHA-1 (40 hex) or SHA-256 (64 hex) repositories.
|
|
6
|
+
var OBJECT_ID_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
7
|
+
/**
|
|
8
|
+
* @description Resolves the commit HEAD points at for the git work tree that
|
|
9
|
+
* contains `dir` (`git rev-parse HEAD`, so linked worktrees and submodules
|
|
10
|
+
* resolve correctly). Never throws: resolves `null` when `dir` is not inside a
|
|
11
|
+
* git work tree, the repo has no commits yet, git is not installed, or the
|
|
12
|
+
* output is not an object id.
|
|
13
|
+
* @param {string} dir - Any directory inside the work tree.
|
|
14
|
+
* @returns {Promise<string | null>} The HEAD commit id, or null when unknown.
|
|
15
|
+
*/
|
|
16
|
+
function readGitHead(dir) {
|
|
17
|
+
return new Promise(function (resolve) {
|
|
18
|
+
if (typeof dir !== "string" || dir.trim() === "") {
|
|
19
|
+
resolve(null);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
(0, child_process_1.execFile)("git", ["rev-parse", "HEAD"], { cwd: dir, timeout: 5000, windowsHide: true }, function (error, stdout) {
|
|
24
|
+
if (error) {
|
|
25
|
+
resolve(null);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
var head = String(stdout || "").trim();
|
|
29
|
+
resolve(OBJECT_ID_PATTERN.test(head) ? head : null);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
resolve(null);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
package/dist/pullRecord.js
CHANGED
|
@@ -202,6 +202,13 @@ function spliceManifestRecord(manifest, scope, table, entry, previousKey) {
|
|
|
202
202
|
if (previousKey !== undefined && key === previousKey && key !== entry.name)
|
|
203
203
|
continue;
|
|
204
204
|
if (key === entry.name) {
|
|
205
|
+
const held = existing[key];
|
|
206
|
+
if (held && held.sys_id && held.sys_id !== entry.sys_id) {
|
|
207
|
+
// Never replace another record's entry: its folder would be overwritten
|
|
208
|
+
// and its sys_id dropped from the manifest.
|
|
209
|
+
throw new Error(table + ": manifest key '" + key + "' already belongs to sys_id " + held.sys_id +
|
|
210
|
+
", not " + entry.sys_id + ". Refusing to overwrite it.");
|
|
211
|
+
}
|
|
205
212
|
records[key] = entryShape(entry);
|
|
206
213
|
placed = true;
|
|
207
214
|
continue;
|
|
@@ -388,6 +395,18 @@ function tableIsWhitelisted(scope, table) {
|
|
|
388
395
|
return false;
|
|
389
396
|
}
|
|
390
397
|
}
|
|
398
|
+
/** sys_id holding `key` in `table` of a manifest, or undefined when the key is free. */
|
|
399
|
+
function sysIdAtKey(manifest, table, key) {
|
|
400
|
+
if (!manifest || !manifest.tables)
|
|
401
|
+
return undefined;
|
|
402
|
+
const tableEntry = manifest.tables[table];
|
|
403
|
+
if (!tableEntry || !tableEntry.records)
|
|
404
|
+
return undefined;
|
|
405
|
+
if (!Object.prototype.hasOwnProperty.call(tableEntry.records, key))
|
|
406
|
+
return undefined;
|
|
407
|
+
const rec = tableEntry.records[key];
|
|
408
|
+
return rec && rec.sys_id ? rec.sys_id : undefined;
|
|
409
|
+
}
|
|
391
410
|
async function readIfExists(p) {
|
|
392
411
|
try {
|
|
393
412
|
return await fs_1.promises.readFile(p, "utf8");
|
|
@@ -460,6 +479,40 @@ async function planPull(rawTargets, options = {}, client = (0, snClient_1.defaul
|
|
|
460
479
|
const requestFiles = hit ? toManifestFiles(hit.record.files) : filesFromFieldOverrides(r.scope, r.table);
|
|
461
480
|
return Object.assign({}, r, { serverKey: hit ? hit.key : undefined, requestFiles });
|
|
462
481
|
});
|
|
482
|
+
// Local manifests, read once per scope (the diff below and the key-collision
|
|
483
|
+
// checks both need them).
|
|
484
|
+
const manifestTexts = {};
|
|
485
|
+
const manifestsByScope = {};
|
|
486
|
+
for (const scope of scopes) {
|
|
487
|
+
const manifestPath = ConfigManager.getScopeManifestPath(scope);
|
|
488
|
+
manifestTexts[scope] = parseManifestText(await readIfExists(manifestPath));
|
|
489
|
+
manifestsByScope[scope] = {
|
|
490
|
+
scope,
|
|
491
|
+
manifestPath,
|
|
492
|
+
formatPreserved: manifestTexts[scope].formatPreserved,
|
|
493
|
+
existed: manifestTexts[scope].existed,
|
|
494
|
+
records: [],
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
// A server-provided key the local manifest gives to a DIFFERENT record means the
|
|
498
|
+
// server's duplicate-suffix order moved since the last refresh. Splicing it in
|
|
499
|
+
// would overwrite that record's entry and folder, so refuse before any content
|
|
500
|
+
// is fetched; only a table refresh can re-key both consistently.
|
|
501
|
+
const keyConflicts = [];
|
|
502
|
+
for (const p of pending) {
|
|
503
|
+
if (p.serverKey === undefined)
|
|
504
|
+
continue;
|
|
505
|
+
const holder = sysIdAtKey(manifestTexts[p.scope].manifest, p.table, p.serverKey);
|
|
506
|
+
if (holder !== undefined && holder !== p.sysId) {
|
|
507
|
+
keyConflicts.push(p.table + " " + p.sysId + ": manifest key '" + p.serverKey + "' already belongs to sys_id " +
|
|
508
|
+
holder + " in the local manifest for scope '" + p.scope + "'. Run 'dove refresh -t " + p.table +
|
|
509
|
+
"' to re-key the table. Nothing was written.");
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (keyConflicts.length > 0) {
|
|
513
|
+
throw new Error((keyConflicts.length === 1 ? "Refusing to pull:\n " : "Refusing to pull (" + keyConflicts.length + " problems):\n ") +
|
|
514
|
+
keyConflicts.join("\n "));
|
|
515
|
+
}
|
|
463
516
|
const missing = {};
|
|
464
517
|
for (const p of pending) {
|
|
465
518
|
if (!missing[p.table])
|
|
@@ -468,9 +521,9 @@ async function planPull(rawTargets, options = {}, client = (0, snClient_1.defaul
|
|
|
468
521
|
}
|
|
469
522
|
const downloaded = await (0, snClient_1.unwrapSNResponse)(client.getMissingFiles(missing, config.tableOptions || {}));
|
|
470
523
|
// 4. Diff against disk + local manifests.
|
|
471
|
-
const manifestTexts = {};
|
|
472
|
-
const manifestsByScope = {};
|
|
473
524
|
const records = [];
|
|
525
|
+
// Fallback keys claimed earlier in THIS batch: scope -> table -> key -> sys_id.
|
|
526
|
+
const claimed = {};
|
|
474
527
|
const forceWrite = !!options.force;
|
|
475
528
|
for (const p of pending) {
|
|
476
529
|
const label = p.table + " " + p.sysId;
|
|
@@ -479,21 +532,44 @@ async function planPull(rawTargets, options = {}, client = (0, snClient_1.defaul
|
|
|
479
532
|
throw new Error(label + ": the instance returned no content for this record (bulkDownload).");
|
|
480
533
|
}
|
|
481
534
|
const recWarnings = [];
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
535
|
+
const local = manifestTexts[p.scope].manifest;
|
|
536
|
+
let key;
|
|
537
|
+
if (p.serverKey !== undefined) {
|
|
538
|
+
key = p.serverKey;
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
// Not in the server manifest: the key is the bare display name, which may
|
|
542
|
+
// already belong to another record (locally, on the server, or earlier in
|
|
543
|
+
// this batch). Disambiguate the way normalizeManifestKeys does.
|
|
544
|
+
if (!claimed[p.scope])
|
|
545
|
+
claimed[p.scope] = {};
|
|
546
|
+
if (!claimed[p.scope][p.table])
|
|
547
|
+
claimed[p.scope][p.table] = {};
|
|
548
|
+
const batchClaims = claimed[p.scope][p.table];
|
|
549
|
+
const takenByOther = (k) => {
|
|
550
|
+
const holders = [
|
|
551
|
+
sysIdAtKey(local, p.table, k),
|
|
552
|
+
sysIdAtKey(serverManifests[p.scope], p.table, k),
|
|
553
|
+
Object.prototype.hasOwnProperty.call(batchClaims, k) ? batchClaims[k] : undefined,
|
|
554
|
+
];
|
|
555
|
+
for (const h of holders)
|
|
556
|
+
if (h !== undefined && h !== p.sysId)
|
|
557
|
+
return h;
|
|
558
|
+
return undefined;
|
|
494
559
|
};
|
|
560
|
+
key = (0, appUtils_1.toSafeFolderName)({ name: dl.record.name, sys_id: p.sysId, files: [] });
|
|
561
|
+
if (takenByOther(key) !== undefined) {
|
|
562
|
+
const suffixed = (0, appUtils_1.duplicateFolderName)(key, p.sysId);
|
|
563
|
+
const holder = takenByOther(suffixed);
|
|
564
|
+
if (holder !== undefined) {
|
|
565
|
+
throw new Error("Refusing to pull:\n " + label + ": manifest keys '" + key + "' and '" + suffixed +
|
|
566
|
+
"' both belong to other records (sys_id " + holder + "). Run 'dove refresh -t " + p.table +
|
|
567
|
+
"' to re-key the table. Nothing was written.");
|
|
568
|
+
}
|
|
569
|
+
key = suffixed;
|
|
570
|
+
}
|
|
571
|
+
batchClaims[key] = p.sysId;
|
|
495
572
|
}
|
|
496
|
-
const local = manifestTexts[p.scope].manifest;
|
|
497
573
|
const existing = findRecordBySysId(local ? local.tables : undefined, p.table, p.sysId);
|
|
498
574
|
const manifestEntry = { files: p.requestFiles.map((f) => ({ name: f.name, type: f.type })), name: key, sys_id: p.sysId };
|
|
499
575
|
let manifestAction = "add";
|
package/dist/reconcileCommand.js
CHANGED
|
@@ -53,6 +53,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
53
53
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
54
54
|
};
|
|
55
55
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
+
exports.applyCreates = applyCreates;
|
|
56
57
|
exports.reconcileCommand = reconcileCommand;
|
|
57
58
|
const path_1 = __importDefault(require("path"));
|
|
58
59
|
const os_1 = __importDefault(require("os"));
|
|
@@ -338,6 +339,7 @@ async function buildCreateFields(scope, change) {
|
|
|
338
339
|
}
|
|
339
340
|
return fields;
|
|
340
341
|
}
|
|
342
|
+
// Exported for tests.
|
|
341
343
|
async function applyCreates(scope, creates, updateSetSysId) {
|
|
342
344
|
if (creates.length === 0) {
|
|
343
345
|
return [];
|
package/dist/snClient.js
CHANGED
|
@@ -334,14 +334,28 @@ const snClient = (baseURL, username, password, apiKey) => {
|
|
|
334
334
|
const endpoint = `api/now/table/${table}/${recordId}`;
|
|
335
335
|
return client.patch(endpoint, fields);
|
|
336
336
|
};
|
|
337
|
-
|
|
337
|
+
// Resolve a scope name to its sys_scope sys_id. `scope=<name>` is not unique
|
|
338
|
+
// on every instance: `scope=global` also matches sys_app rows (their scope
|
|
339
|
+
// column is "global"), so the real Global scope is addressed by its fixed
|
|
340
|
+
// sys_id, and any other ambiguous name is refused rather than guessed.
|
|
341
|
+
const getScopeId = async (scopeName) => {
|
|
338
342
|
const endpoint = "api/now/table/sys_scope";
|
|
339
|
-
|
|
343
|
+
const query = scopeName === "global" ? "sys_id=global" : `scope=${scopeName}`;
|
|
344
|
+
const resp = await client.get(endpoint, {
|
|
340
345
|
params: {
|
|
341
|
-
sysparm_query:
|
|
346
|
+
sysparm_query: query,
|
|
342
347
|
sysparm_fields: "sys_id",
|
|
343
348
|
},
|
|
344
349
|
});
|
|
350
|
+
const rows = resp && resp.data && Array.isArray(resp.data.result)
|
|
351
|
+
? resp.data.result
|
|
352
|
+
: [];
|
|
353
|
+
if (rows.length > 1) {
|
|
354
|
+
const ids = rows.map((row) => (row && row.sys_id) || "?").join(", ");
|
|
355
|
+
throw new Error(`Scope "${scopeName}" is ambiguous: ${rows.length} sys_scope rows match ` +
|
|
356
|
+
`(${ids}). Refusing to guess which one owns the change.`);
|
|
357
|
+
}
|
|
358
|
+
return resp;
|
|
345
359
|
};
|
|
346
360
|
const getScopeById = (scopeSysId) => {
|
|
347
361
|
const endpoint = "api/now/table/sys_scope";
|
|
@@ -366,6 +380,23 @@ const snClient = (baseURL, username, password, apiKey) => {
|
|
|
366
380
|
},
|
|
367
381
|
});
|
|
368
382
|
};
|
|
383
|
+
/**
|
|
384
|
+
* In-progress update sets with exactly this name in one application, so a
|
|
385
|
+
* create can refuse a duplicate (a retry after a timeout would otherwise
|
|
386
|
+
* leave two same-named sets, and activate-by-name picks either). A `^` in
|
|
387
|
+
* the name is escaped as `^^` so it cannot split the encoded query.
|
|
388
|
+
*/
|
|
389
|
+
const getInProgressUpdateSetsByName = (updateSetName, applicationSysId) => {
|
|
390
|
+
const endpoint = "api/now/table/sys_update_set";
|
|
391
|
+
const safeName = String(updateSetName).replace(/\^/g, "^^");
|
|
392
|
+
return client.get(endpoint, {
|
|
393
|
+
params: {
|
|
394
|
+
sysparm_query: `name=${safeName}^application=${applicationSysId}` +
|
|
395
|
+
"^state=in progress",
|
|
396
|
+
sysparm_fields: "sys_id,name,application",
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
};
|
|
369
400
|
/**
|
|
370
401
|
* Reads ONE record by sys_id, selecting only its class and owning scope, so a
|
|
371
402
|
* per-record pull can resolve (and refuse) the target scope before it fetches
|
|
@@ -558,6 +589,16 @@ const snClient = (baseURL, username, password, apiKey) => {
|
|
|
558
589
|
"or in Studio then run `dove refresh -s <scope>` to materialise " +
|
|
559
590
|
"the new records locally."));
|
|
560
591
|
}
|
|
592
|
+
// Guard: the generic createRecord op never sets `application`, so an
|
|
593
|
+
// update set inserted through it lands in the session app (and a
|
|
594
|
+
// name-only read-back still passes). Update sets must go through the
|
|
595
|
+
// scope-correct createUpdateSet op.
|
|
596
|
+
if (params && params.table === "sys_update_set") {
|
|
597
|
+
return Promise.reject(new Error("Refusing to insert sys_update_set via createRecord: the generic " +
|
|
598
|
+
"createRecord op does not set the update set's application, so it " +
|
|
599
|
+
"would land in the session app. Use `dove createUpdateSet --name " +
|
|
600
|
+
"<name> --scope <scope>` instead."));
|
|
601
|
+
}
|
|
561
602
|
return _callDovetailApi("createRecord", (endpoint) => client.post(endpoint, params));
|
|
562
603
|
};
|
|
563
604
|
const deleteRecord = (params) => {
|
|
@@ -569,6 +610,7 @@ const snClient = (baseURL, username, password, apiKey) => {
|
|
|
569
610
|
getScopeId,
|
|
570
611
|
getScopeById,
|
|
571
612
|
getUpdateSetById,
|
|
613
|
+
getInProgressUpdateSetsByName,
|
|
572
614
|
getRecordScope,
|
|
573
615
|
getUpdateSetMembers,
|
|
574
616
|
getUserSysId,
|
|
@@ -4,6 +4,28 @@
|
|
|
4
4
|
// explicit env object is passed so the real process.env never leaks in.
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
const claudeSession_1 = require("../claudeSession");
|
|
7
|
+
describe("isClaudeCodeToolShell (hard-block signal)", function () {
|
|
8
|
+
it("is true only when CLAUDECODE has a value", function () {
|
|
9
|
+
expect((0, claudeSession_1.isClaudeCodeToolShell)({ CLAUDECODE: "1" })).toBe(true);
|
|
10
|
+
expect((0, claudeSession_1.isClaudeCodeToolShell)({ CLAUDECODE: " " })).toBe(false);
|
|
11
|
+
expect((0, claudeSession_1.isClaudeCodeToolShell)({})).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it("ignores CLAUDE_CODE_* config variables a human may have in their profile", function () {
|
|
14
|
+
expect((0, claudeSession_1.isClaudeCodeToolShell)({ CLAUDE_CODE_USE_BEDROCK: "1" })).toBe(false);
|
|
15
|
+
expect((0, claudeSession_1.isClaudeCodeSession)({ CLAUDE_CODE_USE_BEDROCK: "1" })).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe("isWatchInClaudeAllowed", function () {
|
|
19
|
+
it("accepts exactly 1", function () {
|
|
20
|
+
expect((0, claudeSession_1.isWatchInClaudeAllowed)({ DOVE_ALLOW_WATCH_IN_CLAUDE: "1" })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
it("rejects unset, empty, and other truthy-looking values", function () {
|
|
23
|
+
expect((0, claudeSession_1.isWatchInClaudeAllowed)({})).toBe(false);
|
|
24
|
+
expect((0, claudeSession_1.isWatchInClaudeAllowed)({ DOVE_ALLOW_WATCH_IN_CLAUDE: "" })).toBe(false);
|
|
25
|
+
expect((0, claudeSession_1.isWatchInClaudeAllowed)({ DOVE_ALLOW_WATCH_IN_CLAUDE: "true" })).toBe(false);
|
|
26
|
+
expect((0, claudeSession_1.isWatchInClaudeAllowed)({ DOVE_ALLOW_WATCH_IN_CLAUDE: "0" })).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
7
29
|
describe("isClaudeCodeSession", function () {
|
|
8
30
|
it("returns false for an empty environment", function () {
|
|
9
31
|
expect((0, claudeSession_1.isClaudeCodeSession)({})).toBe(false);
|
|
@@ -84,12 +84,17 @@ async function run(args) {
|
|
|
84
84
|
}
|
|
85
85
|
beforeEach(function () {
|
|
86
86
|
jest.clearAllMocks();
|
|
87
|
+
process.exitCode = undefined;
|
|
88
|
+
});
|
|
89
|
+
afterEach(function () {
|
|
90
|
+
process.exitCode = undefined;
|
|
87
91
|
});
|
|
88
92
|
describe("dove pull", function () {
|
|
89
93
|
it("routes `pull` to the refresh command", async function () {
|
|
90
94
|
const { error } = await run(["pull"]);
|
|
91
95
|
expect(error).toBeUndefined();
|
|
92
96
|
expect(commands_1.refreshCommand).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(process.exitCode).toBeUndefined();
|
|
93
98
|
});
|
|
94
99
|
it("still routes the canonical `refresh` and its `r` alias", async function () {
|
|
95
100
|
await run(["refresh"]);
|
|
@@ -136,6 +141,28 @@ describe("dove pull", function () {
|
|
|
136
141
|
expect(argv.sysIds).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2");
|
|
137
142
|
expect(pullRecordCommand_1.pullRecordCommand).toHaveBeenCalledTimes(1);
|
|
138
143
|
});
|
|
144
|
+
it("refuses a bare `pull --dry-run` instead of running a real full refresh", async function () {
|
|
145
|
+
const { error } = await run(["pull", "--dry-run"]);
|
|
146
|
+
expect(error).toBeDefined();
|
|
147
|
+
expect(String(error)).toMatch(/--dry-run needs a table/);
|
|
148
|
+
expect(String(error)).toMatch(/full refresh/);
|
|
149
|
+
expect(process.exitCode).toBe(1);
|
|
150
|
+
expect(commands_1.refreshCommand).not.toHaveBeenCalled();
|
|
151
|
+
expect(pullRecordCommand_1.pullRecordCommand).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
it("refuses `pull --sys-ids <id>` with no table instead of running a full refresh", async function () {
|
|
154
|
+
const { error } = await run(["pull", "--sys-ids", "94243ee5c3f78f10d4ddf1db050131a6"]);
|
|
155
|
+
expect(error).toBeDefined();
|
|
156
|
+
expect(String(error)).toMatch(/needs a table/);
|
|
157
|
+
expect(String(error)).toMatch(/full refresh/);
|
|
158
|
+
expect(commands_1.refreshCommand).not.toHaveBeenCalled();
|
|
159
|
+
expect(pullRecordCommand_1.pullRecordCommand).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
it("refuses a bare `pull -t <table> --dry-run` (refresh has no dry run)", async function () {
|
|
162
|
+
const { error } = await run(["pull", "-t", "sys_script_include", "--dry-run"]);
|
|
163
|
+
expect(error).toBeDefined();
|
|
164
|
+
expect(commands_1.refreshCommand).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
139
166
|
it("routes `pull --from-update-set` (no table) to the per-record pull", async function () {
|
|
140
167
|
const { error, argv } = await run(["pull", "--from-update-set", "dddddddddddddddddddddddddddddddd"]);
|
|
141
168
|
expect(error).toBeUndefined();
|
|
@@ -66,6 +66,7 @@ var mockClient = {
|
|
|
66
66
|
getScopeId: jest.fn(),
|
|
67
67
|
getScopeById: jest.fn(),
|
|
68
68
|
getUpdateSetById: jest.fn(),
|
|
69
|
+
getInProgressUpdateSetsByName: jest.fn(),
|
|
69
70
|
createUpdateSet: jest.fn(),
|
|
70
71
|
createRecord: jest.fn(),
|
|
71
72
|
};
|
|
@@ -120,6 +121,9 @@ describe("dove create sys_update_set (#231)", function () {
|
|
|
120
121
|
}
|
|
121
122
|
return axiosResult([]);
|
|
122
123
|
});
|
|
124
|
+
mockClient.getInProgressUpdateSetsByName.mockImplementation(function () {
|
|
125
|
+
return axiosResult([]);
|
|
126
|
+
});
|
|
123
127
|
mockClient.createUpdateSet.mockImplementation(function (name, scopeSysId) {
|
|
124
128
|
return axiosResult({ sys_id: SET_SYS_ID, name: name, application: scopeSysId });
|
|
125
129
|
});
|
|
@@ -159,6 +163,42 @@ describe("dove create sys_update_set (#231)", function () {
|
|
|
159
163
|
expect(success[0].msg).toContain(SET_SYS_ID);
|
|
160
164
|
expect(success[0].msg).toContain(REQUESTED_SCOPE);
|
|
161
165
|
});
|
|
166
|
+
it("prints an activation hint keyed on the new set's sys_id, not its name", async function () {
|
|
167
|
+
mockClient.getUpdateSetById.mockReturnValue(axiosResult([{ sys_id: SET_SYS_ID, application: { value: REQUESTED_SCOPE_SYS_ID } }]));
|
|
168
|
+
await (0, createRecordCommand_1.createRecordCommand)({
|
|
169
|
+
table: "sys_update_set",
|
|
170
|
+
name: "Journey - Fix 231",
|
|
171
|
+
scope: REQUESTED_SCOPE,
|
|
172
|
+
ci: true,
|
|
173
|
+
logLevel: "info",
|
|
174
|
+
});
|
|
175
|
+
expect(mockClient.getInProgressUpdateSetsByName).toHaveBeenCalledWith("Journey - Fix 231", REQUESTED_SCOPE_SYS_ID);
|
|
176
|
+
var hint = logMessages.filter(function (m) {
|
|
177
|
+
return m.level === "info" && m.msg.indexOf("switchUpdateSet") !== -1;
|
|
178
|
+
});
|
|
179
|
+
expect(hint).toHaveLength(1);
|
|
180
|
+
expect(hint[0].msg).toContain("--sysId " + SET_SYS_ID);
|
|
181
|
+
expect(hint[0].msg).toContain("-s " + REQUESTED_SCOPE);
|
|
182
|
+
expect(hint[0].msg).not.toContain("--name");
|
|
183
|
+
});
|
|
184
|
+
it("refuses (exit 1, no op call) when an in-progress set with the same name already exists in the scope", async function () {
|
|
185
|
+
mockClient.getInProgressUpdateSetsByName.mockImplementation(function () {
|
|
186
|
+
return axiosResult([{ sys_id: "existingSetSysId000000000000000cd", name: "Journey - Fix 231" }]);
|
|
187
|
+
});
|
|
188
|
+
await expect((0, createRecordCommand_1.createRecordCommand)({
|
|
189
|
+
table: "sys_update_set",
|
|
190
|
+
name: "Journey - Fix 231",
|
|
191
|
+
scope: REQUESTED_SCOPE,
|
|
192
|
+
ci: true,
|
|
193
|
+
logLevel: "info",
|
|
194
|
+
})).rejects.toBeInstanceOf(ExitSignal);
|
|
195
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
196
|
+
expect(mockClient.createUpdateSet).not.toHaveBeenCalled();
|
|
197
|
+
expect(mockClient.createRecord).not.toHaveBeenCalled();
|
|
198
|
+
expect(logMessages.filter(function (m) { return m.level === "success"; })).toHaveLength(0);
|
|
199
|
+
expect(errorText()).toContain("already exists");
|
|
200
|
+
expect(errorText()).toContain("existingSetSysId000000000000000cd");
|
|
201
|
+
});
|
|
162
202
|
it("fails loudly (exit 1, no success) when the created set's application is not the requested scope", async function () {
|
|
163
203
|
// The op "succeeded" but the set landed in the session's current app.
|
|
164
204
|
mockClient.createUpdateSet.mockImplementation(function (name) {
|
|
@@ -56,6 +56,7 @@ function makeGlideRecord(rows, captured, validFields) {
|
|
|
56
56
|
return validFields === null ? true : validFields.indexOf(field) !== -1;
|
|
57
57
|
};
|
|
58
58
|
this.query = function () {
|
|
59
|
+
captured.queryCalls += 1;
|
|
59
60
|
i = -1;
|
|
60
61
|
};
|
|
61
62
|
this.next = function () {
|
|
@@ -85,7 +86,11 @@ function makeGlideRecord(rows, captured, validFields) {
|
|
|
85
86
|
function loadUtils(opts) {
|
|
86
87
|
const rows = opts.rows || [];
|
|
87
88
|
const warnings = [];
|
|
88
|
-
const captured = {
|
|
89
|
+
const captured = {
|
|
90
|
+
encodedQueries: [],
|
|
91
|
+
fieldQueries: [],
|
|
92
|
+
queryCalls: 0,
|
|
93
|
+
};
|
|
89
94
|
const metadataClasses = opts.metadataClasses || [];
|
|
90
95
|
const sandbox = {
|
|
91
96
|
Class: {
|
|
@@ -185,7 +190,13 @@ describe("DovetailUtilsMS.buildTableMap — scopeQuery", () => {
|
|
|
185
190
|
expect(scopeFilters).toHaveLength(0);
|
|
186
191
|
});
|
|
187
192
|
it("renders {scopeId} as the scope sys_id", () => {
|
|
188
|
-
const out = buildChoices({ scopeQuery: "sys_package={scopeId}" }
|
|
193
|
+
const out = buildChoices({ scopeQuery: "sys_package={scopeId}" }, [
|
|
194
|
+
"name",
|
|
195
|
+
"element",
|
|
196
|
+
"value",
|
|
197
|
+
"label",
|
|
198
|
+
"sys_package",
|
|
199
|
+
]);
|
|
189
200
|
expect(out.captured.encodedQueries).toContain("sys_package=5e9f5f8b87420250369f33373cbb3559");
|
|
190
201
|
});
|
|
191
202
|
it("keeps the sys_scope filter when no scopeQuery is set (existing behaviour)", () => {
|
|
@@ -229,6 +240,102 @@ describe("DovetailUtilsMS.buildTableMap — scopeQuery", () => {
|
|
|
229
240
|
expect(out.captured.encodedQueries).toHaveLength(0);
|
|
230
241
|
});
|
|
231
242
|
});
|
|
243
|
+
describe("DovetailUtilsMS.buildTableMap — scopeQuery fails closed", () => {
|
|
244
|
+
// ServiceNow drops an encoded-query term on an unknown column, so an unchecked
|
|
245
|
+
// scopeQuery typo would return the WHOLE table (sys_choice: >200k rows) for every
|
|
246
|
+
// scope. buildTableMap must refuse such a query and never call query().
|
|
247
|
+
function expectRefused(out, reason) {
|
|
248
|
+
expect(out.captured.queryCalls).toBe(0);
|
|
249
|
+
expect(out.captured.encodedQueries).toHaveLength(0);
|
|
250
|
+
expect(Object.keys(out.records)).toHaveLength(0);
|
|
251
|
+
expect(out.warnings).toHaveLength(1);
|
|
252
|
+
expect(out.warnings[0]).toContain("sys_choice");
|
|
253
|
+
expect(out.warnings[0]).toContain("scopeQuery");
|
|
254
|
+
expect(out.warnings[0]).toMatch(reason);
|
|
255
|
+
}
|
|
256
|
+
// The fixture's three "Failed" labels also raise duplicate-name warnings; only
|
|
257
|
+
// scopeQuery refusals matter to the accept-path assertions.
|
|
258
|
+
function scopeQueryWarnings(warnings) {
|
|
259
|
+
return warnings.filter(function (w) {
|
|
260
|
+
return w.indexOf("scopeQuery") !== -1;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
it("refuses a scopeQuery whose field does not exist on the table (typo)", () => {
|
|
264
|
+
const out = buildChoices({ scopeQuery: "nmeSTARTSWITH{scope}_" });
|
|
265
|
+
expectRefused(out, /nme/);
|
|
266
|
+
});
|
|
267
|
+
it("refuses a scopeQuery with no {scope}/{scopeId} token", () => {
|
|
268
|
+
const out = buildChoices({ scopeQuery: "active=true" }, null);
|
|
269
|
+
expectRefused(out, /\{scope\}/);
|
|
270
|
+
});
|
|
271
|
+
it("refuses a scopeQuery containing ^NQ", () => {
|
|
272
|
+
const out = buildChoices({
|
|
273
|
+
scopeQuery: "nameSTARTSWITH{scope}_^NQelement=state",
|
|
274
|
+
});
|
|
275
|
+
expectRefused(out, /\^NQ/);
|
|
276
|
+
});
|
|
277
|
+
it("verifies every ^OR term, not just the first", () => {
|
|
278
|
+
const out = buildChoices({
|
|
279
|
+
scopeQuery: "nameSTARTSWITH{scope}_^ORelmentSTARTSWITH{scope}_",
|
|
280
|
+
});
|
|
281
|
+
expectRefused(out, /elment/);
|
|
282
|
+
});
|
|
283
|
+
it("refuses a dot-walked field it cannot verify", () => {
|
|
284
|
+
const out = buildChoices({ scopeQuery: "name.sys_scope={scopeId}" }, null);
|
|
285
|
+
expectRefused(out, /name\.sys_scope/);
|
|
286
|
+
});
|
|
287
|
+
it("refuses a term with no operator", () => {
|
|
288
|
+
const out = buildChoices({ scopeQuery: "nameSTARTSWITH{scope}_^element" });
|
|
289
|
+
expectRefused(out, /element/);
|
|
290
|
+
});
|
|
291
|
+
it("refuses an empty term (^^)", () => {
|
|
292
|
+
const out = buildChoices({
|
|
293
|
+
scopeQuery: "nameSTARTSWITH{scope}_^^element=x",
|
|
294
|
+
});
|
|
295
|
+
expectRefused(out, /empty/);
|
|
296
|
+
});
|
|
297
|
+
it("refuses when the scope token would render empty", () => {
|
|
298
|
+
const loaded = loadUtils({
|
|
299
|
+
rows: CHOICE_ROWS,
|
|
300
|
+
validFields: ["name", "element", "value", "label"],
|
|
301
|
+
});
|
|
302
|
+
const result = loaded.utils.buildTableMap({
|
|
303
|
+
tableName: "sys_choice",
|
|
304
|
+
scopeId: null,
|
|
305
|
+
scopeName: "",
|
|
306
|
+
getContents: false,
|
|
307
|
+
includes: { sys_choice: { label: { type: "txt" } } },
|
|
308
|
+
excludes: {},
|
|
309
|
+
tableOptions: { scopeQuery: "nameSTARTSWITH{scope}_" },
|
|
310
|
+
});
|
|
311
|
+
expect(loaded.captured.queryCalls).toBe(0);
|
|
312
|
+
expect(Object.keys(result.records)).toHaveLength(0);
|
|
313
|
+
expect(loaded.warnings[0]).toMatch(/empty/);
|
|
314
|
+
});
|
|
315
|
+
it("accepts a valid ^OR template unchanged and runs the query", () => {
|
|
316
|
+
const out = buildChoices({
|
|
317
|
+
scopeQuery: "nameSTARTSWITH{scope}_^ORelementSTARTSWITH{scope}_",
|
|
318
|
+
});
|
|
319
|
+
expect(out.captured.queryCalls).toBe(1);
|
|
320
|
+
expect(out.captured.encodedQueries).toEqual([
|
|
321
|
+
"nameSTARTSWITHx_cadso_automate_^ORelementSTARTSWITHx_cadso_automate_",
|
|
322
|
+
]);
|
|
323
|
+
expect(scopeQueryWarnings(out.warnings)).toHaveLength(0);
|
|
324
|
+
expect(Object.keys(out.records).length).toBeGreaterThan(0);
|
|
325
|
+
});
|
|
326
|
+
it("accepts symbol and word operators (!=, ISNOTEMPTY, IN, ORDERBY)", () => {
|
|
327
|
+
const out = buildChoices({
|
|
328
|
+
scopeQuery: "nameSTARTSWITH{scope}_^element!=x^valueISNOTEMPTY^labelINa,b^ORDERBYname",
|
|
329
|
+
});
|
|
330
|
+
expect(out.captured.queryCalls).toBe(1);
|
|
331
|
+
expect(scopeQueryWarnings(out.warnings)).toHaveLength(0);
|
|
332
|
+
});
|
|
333
|
+
it("leaves tables without a scopeQuery on the sys_scope path (no validation)", () => {
|
|
334
|
+
const out = buildChoices({}, []);
|
|
335
|
+
expect(out.captured.queryCalls).toBe(1);
|
|
336
|
+
expect(scopeQueryWarnings(out.warnings)).toHaveLength(0);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
232
339
|
describe("DovetailUtilsMS.generateRecordName — nameTemplate", () => {
|
|
233
340
|
it("names sys_choice folders {name}.{element}.{value} so same-label rows never collide", () => {
|
|
234
341
|
const out = buildChoices({ nameTemplate: "{name}.{element}.{value}" });
|