@tenonhq/dovetail-core 0.0.120 → 0.0.122

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 CHANGED
@@ -24,7 +24,7 @@ The full command surface is documented in the [root CLAUDE.md](../../CLAUDE.md#e
24
24
  Quick reference:
25
25
 
26
26
  ```bash
27
- npx dove watch # multi-scope watch + dashboard
27
+ npx dove watch # HUMAN-ONLY local dev: multi-scope watch + dashboard (hidden from --help; stop before git checkout — see #155)
28
28
  npx dove push # build + push current files
29
29
  npx dove refresh # pull manifest + new files (aliases: pull, r)
30
30
  npx dove build # local build only
@@ -47,6 +47,7 @@ const AppUtils = __importStar(require("./appUtils"));
47
47
  const wizard_1 = require("./wizard");
48
48
  const snClient_1 = require("./snClient");
49
49
  const commands_1 = require("./commands");
50
+ const claudeSession_1 = require("./claudeSession");
50
51
  const path = __importStar(require("path"));
51
52
  const fs = __importStar(require("fs"));
52
53
  const child_process_1 = require("child_process");
@@ -371,6 +372,13 @@ async function initScopesCommand(args) {
371
372
  }
372
373
  async function watchAllScopesCommand(args) {
373
374
  (0, commands_1.setLogLevel)(args);
375
+ // Soft guard, not a hard fail: `watch` is a human-only local-dev tool and a
376
+ // branch switch mid-watch overwrites instance records. Warn an agent off it
377
+ // but leave the human path untouched. See TenonHQ/Dovetail#155.
378
+ if ((0, claudeSession_1.isClaudeCodeSession)()) {
379
+ Logger_1.logger.warn(claudeSession_1.WATCH_HUMAN_ONLY_WARNING);
380
+ FileLogger_1.fileLogger.warn("dove watch started inside a Claude Code session (TenonHQ/Dovetail#155)");
381
+ }
374
382
  var dashboardProcess = null;
375
383
  try {
376
384
  // First check if we have environment variables set. SN_USER stays
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ /**
3
+ * Claude Code session detection for human-only CLI surfaces.
4
+ *
5
+ * `dove watch` auto-syncs the working tree to the instance, and a git branch
6
+ * switch mid-watch pushes the post-switch file state over live records. An
7
+ * agent can't guarantee it stops the watcher before every branch operation, so
8
+ * the command is hidden from `--help` and warns when it detects it is running
9
+ * inside a Claude Code session. See TenonHQ/Dovetail#155.
10
+ *
11
+ * Pure functions — no I/O — so the detection is unit-testable without touching
12
+ * `process.env` directly.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.WATCH_HUMAN_ONLY_WARNING = void 0;
16
+ exports.isClaudeCodeSession = isClaudeCodeSession;
17
+ // Claude Code exports `CLAUDECODE=1` into every tool shell, plus a family of
18
+ // `CLAUDE_CODE_*` variables (e.g. `CLAUDE_CODE_SESSION_ID`, which claude-plans
19
+ // already keys on). Any one of them set to a non-empty value is a session.
20
+ var CLAUDE_SESSION_FLAG = "CLAUDECODE";
21
+ var CLAUDE_SESSION_PREFIX = "CLAUDE_CODE_";
22
+ /**
23
+ * @description Human-only warning printed when `dove watch` starts inside a
24
+ * Claude Code session. Non-blocking by design (TenonHQ/Dovetail#155).
25
+ */
26
+ exports.WATCH_HUMAN_ONLY_WARNING = "⚠ `dove watch` is a human-only local-dev tool — it auto-syncs to the " +
27
+ "instance and a branch switch mid-watch overwrites records. Don't run this " +
28
+ "inside Claude Code. Use `npx dove push --diff <branch>` / `npx dove refresh` / " +
29
+ "`npx dove status` instead. See TenonHQ/Dovetail#155.";
30
+ /**
31
+ * @description Reports whether the given environment looks like a Claude Code
32
+ * session: `CLAUDECODE` is set, or any `CLAUDE_CODE_*` variable is set, to a
33
+ * non-empty value. Empty strings do not count — an unset-but-declared variable
34
+ * is not a session.
35
+ * @param {NodeJS.ProcessEnv} env - Environment to inspect (defaults to `process.env`).
36
+ * @returns {boolean} True when a Claude Code session is detected.
37
+ */
38
+ function isClaudeCodeSession(env = process.env) {
39
+ if (!env || typeof env !== "object")
40
+ return false;
41
+ if (hasValue(env[CLAUDE_SESSION_FLAG]))
42
+ return true;
43
+ var keys = Object.keys(env);
44
+ for (var i = 0; i < keys.length; i++) {
45
+ var key = keys[i];
46
+ if (key.indexOf(CLAUDE_SESSION_PREFIX) === 0 && hasValue(env[key]))
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+ /**
52
+ * @description True when an env value is a non-empty string.
53
+ * @param {string|undefined} value - Raw env value.
54
+ * @returns {boolean} Whether the value counts as "set".
55
+ */
56
+ function hasValue(value) {
57
+ return typeof value === "string" && value.trim().length > 0;
58
+ }
package/dist/commander.js CHANGED
@@ -49,7 +49,17 @@ function configureCli(cli) {
49
49
  describe: "Write a dovetail-debug-*.log file for this command (off by default; also enabled by DOVETAIL_DEBUG=1).",
50
50
  })
51
51
  .global("debug")
52
- .command(["watch", "w", "watchAllScopes"], "Watch all scopes for file changes and sync to ServiceNow", (cmdArgs) => {
52
+ // INTENTIONAL: 'watch' is hidden from help + Claude-facing docs. It
53
+ // auto-syncs to the instance and a branch switch mid-watch silently
54
+ // overwrites records. Human-only local-dev tool. Do not re-document for
55
+ // Claude. See TenonHQ/Dovetail#155.
56
+ //
57
+ // A `false` description is yargs' hidden-command pattern: the command stays
58
+ // fully wired (a human typing `dove watch` / `w` / `watchAllScopes` still
59
+ // gets it, and `dove watch --help` still prints its flags) but it is omitted
60
+ // from the `dove --help` command index. The handler itself prints a
61
+ // human-only warning when it detects a Claude Code session (claudeSession.ts).
62
+ .command(["watch", "w", "watchAllScopes"], false, (cmdArgs) => {
53
63
  cmdArgs.options({
54
64
  ...sharedOptions,
55
65
  noDashboard: {
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ // Tests for the Claude Code session detector behind the `dove watch` soft
3
+ // guard (claudeSession.ts, TenonHQ/Dovetail#155). Pure-function tests — an
4
+ // explicit env object is passed so the real process.env never leaks in.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const claudeSession_1 = require("../claudeSession");
7
+ describe("isClaudeCodeSession", function () {
8
+ it("returns false for an empty environment", function () {
9
+ expect((0, claudeSession_1.isClaudeCodeSession)({})).toBe(false);
10
+ });
11
+ it("returns false when only unrelated variables are set", function () {
12
+ expect((0, claudeSession_1.isClaudeCodeSession)({ HOME: "/tmp", SN_INSTANCE: "x.service-now.com" })).toBe(false);
13
+ });
14
+ it("returns true when CLAUDECODE is set", function () {
15
+ expect((0, claudeSession_1.isClaudeCodeSession)({ CLAUDECODE: "1" })).toBe(true);
16
+ });
17
+ it("returns true when any CLAUDE_CODE_* variable is set", function () {
18
+ expect((0, claudeSession_1.isClaudeCodeSession)({ CLAUDE_CODE_SESSION_ID: "abc123" })).toBe(true);
19
+ expect((0, claudeSession_1.isClaudeCodeSession)({ CLAUDE_CODE_ENTRYPOINT: "cli" })).toBe(true);
20
+ });
21
+ it("treats empty or whitespace-only values as unset", function () {
22
+ expect((0, claudeSession_1.isClaudeCodeSession)({ CLAUDECODE: "" })).toBe(false);
23
+ expect((0, claudeSession_1.isClaudeCodeSession)({ CLAUDE_CODE_SESSION_ID: " " })).toBe(false);
24
+ });
25
+ it("does not match look-alike prefixes", function () {
26
+ expect((0, claudeSession_1.isClaudeCodeSession)({ CLAUDE: "1", CLAUDECODEX: "1", MY_CLAUDE_CODE_X: "1" })).toBe(false);
27
+ });
28
+ it("defaults to process.env and agrees with an explicit copy of it", function () {
29
+ expect((0, claudeSession_1.isClaudeCodeSession)()).toBe((0, claudeSession_1.isClaudeCodeSession)({ ...process.env }));
30
+ });
31
+ });
32
+ describe("WATCH_HUMAN_ONLY_WARNING", function () {
33
+ it("names the hazard, the safe headless verbs, and the tracking issue", function () {
34
+ expect(claudeSession_1.WATCH_HUMAN_ONLY_WARNING).toContain("human-only");
35
+ expect(claudeSession_1.WATCH_HUMAN_ONLY_WARNING).toContain("branch switch");
36
+ expect(claudeSession_1.WATCH_HUMAN_ONLY_WARNING).toContain("Claude Code");
37
+ expect(claudeSession_1.WATCH_HUMAN_ONLY_WARNING).toContain("dove push");
38
+ expect(claudeSession_1.WATCH_HUMAN_ONLY_WARNING).toContain("dove refresh");
39
+ expect(claudeSession_1.WATCH_HUMAN_ONLY_WARNING).toContain("dove status");
40
+ expect(claudeSession_1.WATCH_HUMAN_ONLY_WARNING).toContain("TenonHQ/Dovetail#155");
41
+ });
42
+ });
@@ -0,0 +1,460 @@
1
+ "use strict";
2
+ /**
3
+ * Tests the SERVER half of the sync engine — servicenow/dovetail/sys_script_include/
4
+ * DovetailUtilsMS.js — for the two table options that make platform-config tables
5
+ * syncable per scope:
6
+ *
7
+ * - `scopeQuery`: sys_choice rows carry NO sys_scope (verified on tenonworkstudio:
8
+ * `sys_scopeISEMPTY` returns every x_cadso_automate_* choice), so the table is never
9
+ * a sys_metadata child of any app — getTableNames can't discover it and the
10
+ * sys_scope filter in buildTableMap would return zero rows. scopeQuery replaces that
11
+ * filter with an encoded query ("nameSTARTSWITH{scope}_") and lists the table.
12
+ * - `nameTemplate`: sys_choice.label / sys_dictionary.column_label collide across tables
13
+ * and fields, so the display-value folder name would be "Failed (1a2b3c4d)" ×N.
14
+ * "{name}.{element}.{value}" names the folder from the fields that ARE unique.
15
+ *
16
+ * Same sandbox approach as dovetailUtilsMSCollision.test.ts: the real script include is
17
+ * loaded with minimal ServiceNow stubs so the test fails if the deployed source regresses.
18
+ */
19
+ var __importDefault = (this && this.__importDefault) || function (mod) {
20
+ return (mod && mod.__esModule) ? mod : { "default": mod };
21
+ };
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const path_1 = __importDefault(require("path"));
25
+ const vm_1 = __importDefault(require("vm"));
26
+ const SOURCE = path_1.default.join(__dirname, "../../../../servicenow/dovetail/sys_script_include/DovetailUtilsMS.js");
27
+ /**
28
+ * GlideRecord stub over a fixture list. Records the queries it receives so a test can
29
+ * assert WHICH filter buildTableMap applied (sys_scope vs the rendered scopeQuery).
30
+ * `validFields` controls isValidField — sys_choice has no sys_class_name column.
31
+ */
32
+ function makeGlideRecord(rows, captured, validFields) {
33
+ return function GlideRecordStub(_table) {
34
+ var i = -1;
35
+ this.get = function (sysIdOrField, value) {
36
+ if (value !== undefined) {
37
+ // sys_app lookup by scope name → one row with a sys_id
38
+ i = rows.findIndex(function (r) {
39
+ return r[sysIdOrField] === value;
40
+ });
41
+ return i !== -1;
42
+ }
43
+ i = rows.findIndex(function (r) {
44
+ return r.sys_id === sysIdOrField;
45
+ });
46
+ return i !== -1;
47
+ };
48
+ this.addQuery = function (field, value) {
49
+ captured.fieldQueries.push({ field: field, value: value });
50
+ return { addOrCondition: function () { } };
51
+ };
52
+ this.addEncodedQuery = function (q) {
53
+ captured.encodedQueries.push(q);
54
+ };
55
+ this.isValidField = function (field) {
56
+ return validFields === null ? true : validFields.indexOf(field) !== -1;
57
+ };
58
+ this.query = function () {
59
+ i = -1;
60
+ };
61
+ this.next = function () {
62
+ i += 1;
63
+ return i < rows.length;
64
+ };
65
+ this.getValue = function (field) {
66
+ var v = rows[i][field];
67
+ return v === undefined ? null : v;
68
+ };
69
+ this.getDisplayValue = function (field) {
70
+ return field ? rows[i][field] : rows[i].label || rows[i].name;
71
+ };
72
+ this.getElement = function (field) {
73
+ var v = rows[i][field];
74
+ return {
75
+ getDisplayValue: function () {
76
+ return v === undefined ? "" : v;
77
+ },
78
+ };
79
+ };
80
+ this.getElements = function () {
81
+ return null;
82
+ };
83
+ };
84
+ }
85
+ function loadUtils(opts) {
86
+ const rows = opts.rows || [];
87
+ const warnings = [];
88
+ const captured = { encodedQueries: [], fieldQueries: [] };
89
+ const metadataClasses = opts.metadataClasses || [];
90
+ const sandbox = {
91
+ Class: {
92
+ create: function () {
93
+ return function () {
94
+ if (this.initialize)
95
+ this.initialize.apply(this, arguments);
96
+ };
97
+ },
98
+ },
99
+ GlideRecord: makeGlideRecord(rows, captured, opts.validFields === undefined ? null : opts.validFields),
100
+ // getTableNames aggregates sys_metadata by sys_class_name — one "row" per class.
101
+ GlideAggregate: function GlideAggregateStub() {
102
+ var i = -1;
103
+ this.addQuery = function () { };
104
+ this.groupBy = function () { };
105
+ this.query = function () {
106
+ i = -1;
107
+ };
108
+ this.next = function () {
109
+ i += 1;
110
+ return i < metadataClasses.length;
111
+ };
112
+ this.getValue = function () {
113
+ return metadataClasses[i];
114
+ };
115
+ },
116
+ gs: {
117
+ warn: function (msg) {
118
+ warnings.push(msg);
119
+ },
120
+ getProperty: function () {
121
+ return "https://example.service-now.com/";
122
+ },
123
+ },
124
+ };
125
+ vm_1.default.createContext(sandbox);
126
+ vm_1.default.runInContext(fs_1.default.readFileSync(SOURCE, "utf8"), sandbox);
127
+ return {
128
+ utils: new sandbox.DovetailUtilsMS(),
129
+ warnings: warnings,
130
+ captured: captured,
131
+ };
132
+ }
133
+ const CHOICE_ROWS = [
134
+ {
135
+ sys_id: "a21fd5c0c3bf0750d4ddf1db05013100",
136
+ name: "x_cadso_automate_message_batch_recipient",
137
+ element: "last_status",
138
+ value: "delivered",
139
+ label: "Delivered",
140
+ },
141
+ {
142
+ sys_id: "2e1f598c33ff07507b18bc534d5c7b46",
143
+ name: "x_cadso_automate_message_batch_recipient",
144
+ element: "last_status",
145
+ value: "failed",
146
+ label: "Failed",
147
+ },
148
+ {
149
+ sys_id: "b94a7244c3ec8750d4ddf1db0501315e",
150
+ name: "x_cadso_automate_message_batch_recipient",
151
+ element: "state",
152
+ value: "Failed",
153
+ label: "Failed",
154
+ },
155
+ {
156
+ sys_id: "11327be5c3384f90d4ddf1db050131a3",
157
+ name: "x_cadso_automate_message_batch",
158
+ element: "state",
159
+ value: "Failed",
160
+ label: "Failed",
161
+ },
162
+ ];
163
+ function buildChoices(tableOptions, validFields = ["name", "element", "value", "label"]) {
164
+ const loaded = loadUtils({ rows: CHOICE_ROWS, validFields: validFields });
165
+ const result = loaded.utils.buildTableMap({
166
+ tableName: "sys_choice",
167
+ scopeId: "5e9f5f8b87420250369f33373cbb3559",
168
+ scopeName: "x_cadso_automate",
169
+ getContents: false,
170
+ includes: { sys_choice: { label: { type: "txt" } } },
171
+ excludes: {},
172
+ tableOptions: tableOptions,
173
+ });
174
+ return {
175
+ records: result.records,
176
+ warnings: loaded.warnings,
177
+ captured: loaded.captured,
178
+ };
179
+ }
180
+ describe("DovetailUtilsMS.buildTableMap — scopeQuery", () => {
181
+ it("replaces the sys_scope filter with the rendered scopeQuery ({scope} → scope name)", () => {
182
+ const out = buildChoices({ scopeQuery: "nameSTARTSWITH{scope}_" });
183
+ expect(out.captured.encodedQueries).toContain("nameSTARTSWITHx_cadso_automate_");
184
+ const scopeFilters = out.captured.fieldQueries.filter((q) => q.field === "sys_scope");
185
+ expect(scopeFilters).toHaveLength(0);
186
+ });
187
+ it("renders {scopeId} as the scope sys_id", () => {
188
+ const out = buildChoices({ scopeQuery: "sys_package={scopeId}" });
189
+ expect(out.captured.encodedQueries).toContain("sys_package=5e9f5f8b87420250369f33373cbb3559");
190
+ });
191
+ it("keeps the sys_scope filter when no scopeQuery is set (existing behaviour)", () => {
192
+ const out = buildChoices({});
193
+ const scopeFilters = out.captured.fieldQueries.filter((q) => q.field === "sys_scope");
194
+ expect(scopeFilters).toEqual([
195
+ { field: "sys_scope", value: "5e9f5f8b87420250369f33373cbb3559" },
196
+ ]);
197
+ });
198
+ it("still ANDs the plain `query` option on top of scopeQuery", () => {
199
+ const out = buildChoices({
200
+ scopeQuery: "nameSTARTSWITH{scope}_",
201
+ query: "language=en",
202
+ });
203
+ expect(out.captured.encodedQueries).toEqual([
204
+ "nameSTARTSWITHx_cadso_automate_",
205
+ "language=en",
206
+ ]);
207
+ });
208
+ it("skips the sys_class_name query on a table that has no such column (sys_choice)", () => {
209
+ const out = buildChoices({ scopeQuery: "nameSTARTSWITH{scope}_" }, [
210
+ "name",
211
+ "element",
212
+ "value",
213
+ "label",
214
+ ]);
215
+ const classFilters = out.captured.fieldQueries.filter((q) => q.field === "sys_class_name");
216
+ expect(classFilters).toHaveLength(0);
217
+ });
218
+ it("keeps the sys_class_name query on a table that has the column", () => {
219
+ const out = buildChoices({}, null);
220
+ const classFilters = out.captured.fieldQueries.filter((q) => q.field === "sys_class_name");
221
+ expect(classFilters).toEqual([
222
+ { field: "sys_class_name", value: "sys_choice" },
223
+ ]);
224
+ });
225
+ it("treats an empty scopeQuery as unset", () => {
226
+ const out = buildChoices({ scopeQuery: "" });
227
+ const scopeFilters = out.captured.fieldQueries.filter((q) => q.field === "sys_scope");
228
+ expect(scopeFilters).toHaveLength(1);
229
+ expect(out.captured.encodedQueries).toHaveLength(0);
230
+ });
231
+ });
232
+ describe("DovetailUtilsMS.generateRecordName — nameTemplate", () => {
233
+ it("names sys_choice folders {name}.{element}.{value} so same-label rows never collide", () => {
234
+ const out = buildChoices({ nameTemplate: "{name}.{element}.{value}" });
235
+ expect(Object.keys(out.records).sort()).toEqual([
236
+ "x_cadso_automate_message_batch.state.Failed",
237
+ "x_cadso_automate_message_batch_recipient.last_status.delivered",
238
+ "x_cadso_automate_message_batch_recipient.last_status.failed",
239
+ "x_cadso_automate_message_batch_recipient.state.Failed",
240
+ ]);
241
+ expect(out.warnings).toHaveLength(0);
242
+ });
243
+ it("without a template, three 'Failed' labels collapse into the sys_id-suffixed collision set", () => {
244
+ const out = buildChoices({});
245
+ const keys = Object.keys(out.records).sort();
246
+ expect(keys).toEqual([
247
+ "Delivered",
248
+ "Failed (11327be5)",
249
+ "Failed (2e1f598c)",
250
+ "Failed (b94a7244)",
251
+ ]);
252
+ });
253
+ it("keeps record.name identical to the map key", () => {
254
+ const out = buildChoices({ nameTemplate: "{name}.{element}.{value}" });
255
+ Object.keys(out.records).forEach((key) => {
256
+ expect(out.records[key].name).toBe(key);
257
+ });
258
+ });
259
+ it("drops an empty token together with the literal before it (sys_dictionary collection row)", () => {
260
+ const loaded = loadUtils({
261
+ rows: [
262
+ {
263
+ sys_id: "c85856cec3a44b10d4ddf1db05013158",
264
+ name: "x_cadso_automate_message_batch_recipient",
265
+ element: "last_status",
266
+ },
267
+ {
268
+ sys_id: "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",
269
+ name: "x_cadso_automate_message_batch_recipient",
270
+ element: "",
271
+ },
272
+ {
273
+ sys_id: "1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b",
274
+ name: "x_cadso_automate_message_batch",
275
+ },
276
+ ],
277
+ });
278
+ const result = loaded.utils.buildTableMap({
279
+ tableName: "sys_dictionary",
280
+ scopeId: "scope1",
281
+ scopeName: "x_cadso_automate",
282
+ getContents: false,
283
+ includes: { sys_dictionary: { column_label: { type: "txt" } } },
284
+ excludes: {},
285
+ tableOptions: { nameTemplate: "{name}.{element}" },
286
+ });
287
+ expect(Object.keys(result.records).sort()).toEqual([
288
+ "x_cadso_automate_message_batch",
289
+ "x_cadso_automate_message_batch_recipient",
290
+ "x_cadso_automate_message_batch_recipient.last_status",
291
+ ]);
292
+ });
293
+ it("drops a leading empty token and its following literal", () => {
294
+ const loaded = loadUtils({
295
+ rows: [{ sys_id: "aaaaaaaa11111111", element: "state", value: "x" }],
296
+ });
297
+ const result = loaded.utils.buildTableMap({
298
+ tableName: "t",
299
+ scopeId: "s",
300
+ scopeName: "x",
301
+ getContents: false,
302
+ includes: { t: { value: { type: "txt" } } },
303
+ excludes: {},
304
+ tableOptions: { nameTemplate: "{name}.{element}.{value}" },
305
+ });
306
+ expect(Object.keys(result.records)).toEqual(["state.x"]);
307
+ });
308
+ it("falls back to sys_id when every token is empty", () => {
309
+ const loaded = loadUtils({ rows: [{ sys_id: "aaaaaaaa11111111" }] });
310
+ const result = loaded.utils.buildTableMap({
311
+ tableName: "t",
312
+ scopeId: "s",
313
+ scopeName: "x",
314
+ getContents: false,
315
+ includes: { t: { value: { type: "txt" } } },
316
+ excludes: {},
317
+ tableOptions: { nameTemplate: "{name}.{element}" },
318
+ });
319
+ expect(Object.keys(result.records)).toEqual(["aaaaaaaa11111111"]);
320
+ });
321
+ it("sanitizes path separators in template values", () => {
322
+ const loaded = loadUtils({
323
+ rows: [{ sys_id: "aaaaaaaa11111111", name: "t", value: "a/b\\c" }],
324
+ });
325
+ const result = loaded.utils.buildTableMap({
326
+ tableName: "t",
327
+ scopeId: "s",
328
+ scopeName: "x",
329
+ getContents: false,
330
+ includes: { t: { value: { type: "txt" } } },
331
+ excludes: {},
332
+ tableOptions: { nameTemplate: "{name}.{value}" },
333
+ });
334
+ expect(Object.keys(result.records)).toEqual(["t.a〳b〳c"]);
335
+ });
336
+ it("wins over displayField / differentiatorField when both are set", () => {
337
+ const out = buildChoices({
338
+ nameTemplate: "{element}.{value}",
339
+ displayField: "label",
340
+ differentiatorField: "sys_id",
341
+ });
342
+ expect(Object.keys(out.records).sort()).toEqual([
343
+ "last_status.delivered",
344
+ "last_status.failed",
345
+ "state.Failed (11327be5)",
346
+ "state.Failed (b94a7244)",
347
+ ]);
348
+ // Two tables share element+value here — the collision guard still catches it.
349
+ expect(out.warnings).toHaveLength(2);
350
+ });
351
+ it("applies the template in processMissingFiles (bulkDownload) too", () => {
352
+ const loaded = loadUtils({ rows: CHOICE_ROWS });
353
+ const recordMap = {};
354
+ CHOICE_ROWS.forEach((r) => {
355
+ recordMap[r.sys_id] = [{ name: "label", type: "txt" }];
356
+ });
357
+ const result = loaded.utils.processMissingFiles({ sys_choice: recordMap }, { sys_choice: { nameTemplate: "{name}.{element}.{value}" } });
358
+ expect(Object.keys(result.sys_choice.records).sort()).toEqual([
359
+ "x_cadso_automate_message_batch.state.Failed",
360
+ "x_cadso_automate_message_batch_recipient.last_status.delivered",
361
+ "x_cadso_automate_message_batch_recipient.last_status.failed",
362
+ "x_cadso_automate_message_batch_recipient.state.Failed",
363
+ ]);
364
+ });
365
+ });
366
+ describe("DovetailUtilsMS.getTableNames — scopeQuery tables are listed explicitly", () => {
367
+ it("adds a scopeQuery table that sys_metadata cannot surface", () => {
368
+ const loaded = loadUtils({
369
+ metadataClasses: ["sys_script_include", "sys_dictionary"],
370
+ });
371
+ const tables = loaded.utils.getTableNames({
372
+ scopeId: "scope1",
373
+ includes: { sys_choice: { label: { type: "txt" } } },
374
+ excludes: {},
375
+ tableOptions: { sys_choice: { scopeQuery: "nameSTARTSWITH{scope}_" } },
376
+ });
377
+ expect(tables).toEqual([
378
+ "sys_script_include",
379
+ "sys_dictionary",
380
+ "sys_choice",
381
+ ]);
382
+ });
383
+ it("does not duplicate a table sys_metadata already surfaced", () => {
384
+ const loaded = loadUtils({ metadataClasses: ["sys_dictionary"] });
385
+ const tables = loaded.utils.getTableNames({
386
+ scopeId: "scope1",
387
+ includes: {},
388
+ excludes: {},
389
+ tableOptions: { sys_dictionary: { scopeQuery: "nameSTARTSWITHx_" } },
390
+ });
391
+ expect(tables).toEqual(["sys_dictionary"]);
392
+ });
393
+ it("respects excludes for scopeQuery tables", () => {
394
+ const loaded = loadUtils({ metadataClasses: [] });
395
+ const tables = loaded.utils.getTableNames({
396
+ scopeId: "scope1",
397
+ includes: {},
398
+ excludes: { sys_choice: true },
399
+ tableOptions: { sys_choice: { scopeQuery: "nameSTARTSWITH{scope}_" } },
400
+ });
401
+ expect(tables).toEqual([]);
402
+ });
403
+ it("ignores tableOptions without a scopeQuery (query / nameTemplate alone do not list a table)", () => {
404
+ const loaded = loadUtils({ metadataClasses: [] });
405
+ const tables = loaded.utils.getTableNames({
406
+ scopeId: "scope1",
407
+ includes: {},
408
+ excludes: {},
409
+ tableOptions: {
410
+ sys_choice: { nameTemplate: "{name}" },
411
+ sys_dictionary: { query: "a=b" },
412
+ },
413
+ });
414
+ expect(tables).toEqual([]);
415
+ });
416
+ it("tolerates a missing tableOptions argument (existing callers)", () => {
417
+ const loaded = loadUtils({ metadataClasses: ["sys_script"] });
418
+ const tables = loaded.utils.getTableNames({
419
+ scopeId: "scope1",
420
+ includes: {},
421
+ excludes: {},
422
+ });
423
+ expect(tables).toEqual(["sys_script"]);
424
+ });
425
+ });
426
+ describe("DovetailUtilsMS.getManifest — wiring", () => {
427
+ it("passes scopeName into buildTableMap so scopeQuery renders, and lists the scope-less table", () => {
428
+ const loaded = loadUtils({
429
+ // sys_app lookup row (getScopeId does appGR.get("scope", name)) + the choice rows
430
+ rows: [
431
+ {
432
+ sys_id: "5e9f5f8b87420250369f33373cbb3559",
433
+ scope: "x_cadso_automate",
434
+ },
435
+ ].concat(CHOICE_ROWS),
436
+ metadataClasses: [],
437
+ validFields: ["name", "element", "value", "label", "scope"],
438
+ });
439
+ const manifest = loaded.utils.getManifest({
440
+ scopeName: "x_cadso_automate",
441
+ includes: { sys_choice: { label: { type: "txt" } } },
442
+ excludes: {},
443
+ tableOptions: {
444
+ sys_choice: {
445
+ scopeQuery: "nameSTARTSWITH{scope}_",
446
+ nameTemplate: "{name}.{element}.{value}",
447
+ },
448
+ },
449
+ getContents: false,
450
+ });
451
+ expect(loaded.captured.encodedQueries).toContain("nameSTARTSWITHx_cadso_automate_");
452
+ expect(Object.keys(manifest.tables)).toEqual(["sys_choice"]);
453
+ // The stub GlideRecord iterates every fixture row regardless of query, so the
454
+ // sys_app row shows up too (named by sys_id: no name/element/value). What matters
455
+ // here is that the four choice rows are present under their templated names.
456
+ const keys = Object.keys(manifest.tables.sys_choice.records);
457
+ expect(keys).toContain("x_cadso_automate_message_batch_recipient.last_status.delivered");
458
+ expect(keys).toContain("x_cadso_automate_message_batch.state.Failed");
459
+ });
460
+ });