@tenonhq/dovetail-core 0.0.116 → 0.0.118
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/FileUtils.js +31 -1
- package/dist/appUtils.js +78 -18
- package/dist/tests/allScopesMetadata.test.js +13 -4
- package/dist/tests/refreshFlagGuards.test.js +170 -0
- package/dist/tests/stampMetadataContent.test.js +108 -17
- package/dist/tests/syncManifestRefresh.test.js +2 -1
- package/package.json +1 -1
- package/release-manifest.json +53 -53
package/dist/FileUtils.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.writeFileForce = exports.writeSNFileForce = exports.writeSNFileIfNotExists = exports.writeBuildFile = exports.summarizeFile = exports.encodedPathsToFilePaths = exports.isValidPath = exports.splitEncodedPaths = exports.getPathsInPath = exports.isDirectory = exports.toAbsolutePath = exports.getFileContextFromPath = exports.getFileContextWithSkipReason = exports.getBuildExt = exports.isUnderPath = exports.appendToPath = exports.pathExists = exports.createDirRecursively = exports.writeSNFileIfDifferent = exports.writeSNFileCurry = exports.writeScopeManifest = exports.writeManifestFile = exports.SNFileExists = void 0;
|
|
39
|
+
exports.writeFileForce = exports.writeSNFileForce = exports.writeSNFileIfNotExists = exports.writeBuildFile = exports.summarizeFile = exports.encodedPathsToFilePaths = exports.isValidPath = exports.splitEncodedPaths = exports.getPathsInPath = exports.isExistingDirectory = exports.isDirectory = exports.toAbsolutePath = exports.getFileContextFromPath = exports.getFileContextWithSkipReason = exports.getBuildExt = exports.isUnderPath = exports.appendToPath = exports.pathExists = exports.createDirRecursively = exports.writeSNFileIfDifferent = exports.writeSNFileCurry = exports.writeScopeManifest = exports.writeManifestFile = exports.SNFileExists = void 0;
|
|
40
40
|
exports.writeEnvVar = writeEnvVar;
|
|
41
41
|
exports.writeEnvVars = writeEnvVars;
|
|
42
42
|
const constants_1 = require("./constants");
|
|
@@ -263,11 +263,41 @@ const getFileContextFromPath = (filePath) => {
|
|
|
263
263
|
exports.getFileContextFromPath = getFileContextFromPath;
|
|
264
264
|
const toAbsolutePath = (p) => path_1.default.isAbsolute(p) ? p : path_1.default.join(process.cwd(), p);
|
|
265
265
|
exports.toAbsolutePath = toAbsolutePath;
|
|
266
|
+
/**
|
|
267
|
+
* True when `p` is a directory. THROWS if `p` does not exist — `fsp.stat`
|
|
268
|
+
* rejects with ENOENT and that rejection is deliberately not swallowed, because
|
|
269
|
+
* `getPathsInPath` relies on a missing path being an error rather than being
|
|
270
|
+
* silently treated as a file.
|
|
271
|
+
*
|
|
272
|
+
* If you are asking "is there a directory here?" about a path that may legally
|
|
273
|
+
* be absent, you want `isExistingDirectory` instead. Using this one for that
|
|
274
|
+
* shipped a real bug: the `--metadata-only` refresh aborted an entire scope on
|
|
275
|
+
* the first record present on the instance but absent from the branch, which is
|
|
276
|
+
* the common case, not an edge one.
|
|
277
|
+
*/
|
|
266
278
|
const isDirectory = async (p) => {
|
|
267
279
|
const stats = await fs_1.promises.stat(p);
|
|
268
280
|
return stats.isDirectory();
|
|
269
281
|
};
|
|
270
282
|
exports.isDirectory = isDirectory;
|
|
283
|
+
/**
|
|
284
|
+
* True when `p` exists AND is a directory; false for a missing path, a regular
|
|
285
|
+
* file, or an unreadable one. Never throws.
|
|
286
|
+
*
|
|
287
|
+
* Prefer this over `pathExists` when the caller is about to write *into* `p` —
|
|
288
|
+
* `pathExists` also returns true for a regular file, so a stray file sitting at
|
|
289
|
+
* a record path would pass the check and fail confusingly at write time.
|
|
290
|
+
*/
|
|
291
|
+
const isExistingDirectory = async (p) => {
|
|
292
|
+
try {
|
|
293
|
+
const stats = await fs_1.promises.stat(p);
|
|
294
|
+
return stats.isDirectory();
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
exports.isExistingDirectory = isExistingDirectory;
|
|
271
301
|
const getPathsInPath = async (p) => {
|
|
272
302
|
if (!(0, exports.isUnderPath)(ConfigManager.getSourcePath(), p) &&
|
|
273
303
|
!(0, exports.isUnderPath)(ConfigManager.getBuildPath(), p)) {
|
package/dist/appUtils.js
CHANGED
|
@@ -60,24 +60,81 @@ const getUpdateSetConfig = () => (0, updateSetConfig_1.readUpdateSetConfig)();
|
|
|
60
60
|
// (/<table>.do?sys_id=...) resolves against whatever instance the reader is on.
|
|
61
61
|
// Matches any *.service-now.com host so the output is portable across instances.
|
|
62
62
|
const stripRecordLinkHost = (link) => link.replace(/^https?:\/\/[^/]+\.service-now\.com/i, "");
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
63
|
+
// A ServiceNow field is serialized as a { value, display_value } pair, where
|
|
64
|
+
// display_value is rendered live server-side at pull time. Some of those
|
|
65
|
+
// renderings are worth persisting and some are pure churn, so this is a
|
|
66
|
+
// selective drop rather than a blanket one — an earlier blanket strip threw away
|
|
67
|
+
// the readable sys_id -> name mapping that `Documentation/Dovetail-Development.md`
|
|
68
|
+
// documents grepping to walk the record graph.
|
|
69
|
+
//
|
|
70
|
+
// Measured over 43,990 pairs in the servicenow-files mirror:
|
|
71
|
+
//
|
|
72
|
+
// DROPPED
|
|
73
|
+
// identical (62.1%, ~4.5 MB) — display_value === value carries zero
|
|
74
|
+
// information by construction, at any size. The heavy ones are the large
|
|
75
|
+
// text fields (script, props, label_cache, composition, operation_script):
|
|
76
|
+
// 619 pairs, but ~3.7 MB of the total.
|
|
77
|
+
// datetime (3.9%) — `value` is UTC; `display_value` is rendered in the
|
|
78
|
+
// TIMEZONE OF WHOEVER RAN THE PULL. Measured 1,706 shifted out of 1,706,
|
|
79
|
+
// zero exceptions. Same instance, same record, a colleague's laptop ->
|
|
80
|
+
// different bytes. Machine-dependent output cannot live in a shared repo.
|
|
81
|
+
// sys_scope / sys_package — present in ~0.95 records per file, so renaming
|
|
82
|
+
// the app once rewrites the entire mirror. The scope is already obvious
|
|
83
|
+
// from the folder path (src/Work/, src/Core/), so the value is low and
|
|
84
|
+
// the blast radius is the whole tree.
|
|
85
|
+
//
|
|
86
|
+
// KEPT
|
|
87
|
+
// boolean (21.1%) "0" -> "false", choice/class labels (6.5%) e.g.
|
|
88
|
+
// sys_class_name -> "Access Control", and every other reference (5.6%)
|
|
89
|
+
// sys_id -> record name. These are stable, small (~420 KB combined), and
|
|
90
|
+
// they are the reason a human can read the file at all. A reference
|
|
91
|
+
// display_value still churns if someone renames its target, but that is
|
|
92
|
+
// real instance state changing — not the puller's machine.
|
|
93
|
+
//
|
|
94
|
+
// No consumer reads display_value: reconcile ignores metaData.json content
|
|
95
|
+
// entirely and push keys off `value` / the manifest.
|
|
96
|
+
const SYS_DATETIME_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/;
|
|
97
|
+
// References carried by nearly every record — one rename churns the whole tree.
|
|
98
|
+
const WHOLE_TREE_REFERENCE_KEYS = new Set(["sys_scope", "sys_package"]);
|
|
99
|
+
// Normalize a half of the pair for comparison. Returns null for anything that
|
|
100
|
+
// is not a primitive: String({}) collapses every object to "[object Object]",
|
|
101
|
+
// so two structurally different values would compare equal and silently lose a
|
|
102
|
+
// display_value. A non-primitive is treated as "not comparable" instead.
|
|
103
|
+
// null and "" are deliberately folded together — the server uses them
|
|
104
|
+
// interchangeably across the two halves, and String(null) would read as "null".
|
|
105
|
+
const displayNorm = (v) => {
|
|
106
|
+
if (v === null || v === undefined)
|
|
107
|
+
return "";
|
|
108
|
+
const t = typeof v;
|
|
109
|
+
if (t === "string" || t === "number" || t === "boolean")
|
|
110
|
+
return String(v);
|
|
111
|
+
return null;
|
|
112
|
+
};
|
|
113
|
+
const shouldDropDisplayValue = (key, value, displayValue) => {
|
|
114
|
+
const normValue = displayNorm(value);
|
|
115
|
+
const normDisplay = displayNorm(displayValue);
|
|
116
|
+
// Identical -> the display_value carries no information, at any size.
|
|
117
|
+
if (normValue !== null && normValue === normDisplay)
|
|
118
|
+
return true;
|
|
119
|
+
// Datetime -> display_value is rendered in the PULLER's timezone.
|
|
120
|
+
if (normValue !== null && SYS_DATETIME_RE.test(normValue))
|
|
121
|
+
return true;
|
|
122
|
+
// Present in ~every record -> one rename rewrites the whole mirror.
|
|
123
|
+
if (WHOLE_TREE_REFERENCE_KEYS.has(key))
|
|
124
|
+
return true;
|
|
125
|
+
return false;
|
|
126
|
+
};
|
|
74
127
|
const stripFieldDisplayValues = (metadata) => {
|
|
75
128
|
for (const key of Object.keys(metadata)) {
|
|
76
129
|
const field = metadata[key];
|
|
77
130
|
if (field !== null &&
|
|
78
131
|
typeof field === "object" &&
|
|
132
|
+
!Array.isArray(field) &&
|
|
79
133
|
"display_value" in field) {
|
|
80
|
-
|
|
134
|
+
const pair = field;
|
|
135
|
+
if (shouldDropDisplayValue(key, pair.value, pair.display_value)) {
|
|
136
|
+
delete pair.display_value;
|
|
137
|
+
}
|
|
81
138
|
}
|
|
82
139
|
}
|
|
83
140
|
};
|
|
@@ -704,11 +761,11 @@ const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
|
|
|
704
761
|
// metadataOnly refreshes what is already checked out. A record the
|
|
705
762
|
// instance has but this branch does not is left alone — pulling it in
|
|
706
763
|
// would be a content change, which is exactly what this mode excludes.
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
if (metadataOnly && !(await fUtils.
|
|
764
|
+
// isExistingDirectory, not isDirectory (which REJECTS on ENOENT, and
|
|
765
|
+
// processBatched propagates, so one absent record aborted the whole
|
|
766
|
+
// scope) and not pathExists (which is true for a regular file too, so a
|
|
767
|
+
// stray file at a record path would pass and fail at write time).
|
|
768
|
+
if (metadataOnly && !(await fUtils.isExistingDirectory(recPath))) {
|
|
712
769
|
skippedAbsentCount++;
|
|
713
770
|
// Drop the downloaded content for skipped records too — filesToProcess
|
|
714
771
|
// accumulates across every chunk, so holding it would grow unbounded
|
|
@@ -806,7 +863,10 @@ const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
|
|
|
806
863
|
Logger_1.logger.debug("No file changes detected from instance (" + unchangedCount + " checked)");
|
|
807
864
|
}
|
|
808
865
|
if (options.benchmarkCollector) {
|
|
809
|
-
|
|
866
|
+
// metadataOnly writes no field file, so writtenCount is always 0 — report
|
|
867
|
+
// the metaData writes instead, or `--benchmark --metadata-only` would show
|
|
868
|
+
// a scope that did thousands of writes as having done nothing.
|
|
869
|
+
options.benchmarkCollector.endScope(metadataOnly ? metadataWrittenCount : writtenCount, metadataOnly ? skippedAbsentCount : unchangedCount);
|
|
810
870
|
}
|
|
811
871
|
}
|
|
812
872
|
catch (e) {
|
|
@@ -113,6 +113,11 @@ function serverMetadata() {
|
|
|
113
113
|
value: "4e4449a5475c255085d19fd8036d43a0",
|
|
114
114
|
display_value: "Tenon Marketing Work Management",
|
|
115
115
|
},
|
|
116
|
+
// A plain reference — kept, so the sys_id -> name mapping survives.
|
|
117
|
+
action: {
|
|
118
|
+
value: "85de623c33ef2a107b18bc534d5c7b92",
|
|
119
|
+
display_value: "Parse hashes for to_addresses",
|
|
120
|
+
},
|
|
116
121
|
_table: "sys_script_include",
|
|
117
122
|
_sys_id: SYS_ID,
|
|
118
123
|
_record_link: "https://tenonworkstudio.service-now.com/sys_script_include.do?sys_id=" + SYS_ID,
|
|
@@ -152,14 +157,18 @@ describe("all-scopes writer — metaData.json is normalized, not written raw", f
|
|
|
152
157
|
expect(written._record_link).toBe("/sys_script_include.do?sys_id=" + SYS_ID);
|
|
153
158
|
expect(readRecordFile("metaData.json")).not.toContain("service-now.com");
|
|
154
159
|
});
|
|
155
|
-
test("
|
|
160
|
+
test("applies the selective display_value policy, same as the refresh path", async function () {
|
|
156
161
|
await AllScopes.processManifestForScope(manifestWith([{ name: "metaData", type: "json", content: serverMetadata() }]), tmpRoot, true);
|
|
157
|
-
var
|
|
158
|
-
|
|
159
|
-
expect(raw).not.toContain("display_value");
|
|
162
|
+
var written = JSON.parse(readRecordFile("metaData.json"));
|
|
163
|
+
// Dropped: identical, datetime (puller's timezone), sys_scope (whole-tree).
|
|
160
164
|
expect(written.name).toEqual({ value: "UIFilterApiMS" });
|
|
161
165
|
expect(written.sys_updated_on).toEqual({ value: "2026-04-29 14:38:51" });
|
|
162
166
|
expect(written.sys_scope).toEqual({ value: "4e4449a5475c255085d19fd8036d43a0" });
|
|
167
|
+
// Kept: the readable reference name.
|
|
168
|
+
expect(written.action).toEqual({
|
|
169
|
+
value: "85de623c33ef2a107b18bc534d5c7b92",
|
|
170
|
+
display_value: "Parse hashes for to_addresses",
|
|
171
|
+
});
|
|
163
172
|
});
|
|
164
173
|
test("drops _lastUpdatedOn and writes no wall-clock value", async function () {
|
|
165
174
|
await AllScopes.processManifestForScope(manifestWith([{ name: "metaData", type: "json", content: serverMetadata() }]), tmpRoot, true);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Coverage for the `dove refresh` flag guards and the filesystem predicate the
|
|
4
|
+
* `--metadata-only` skip depends on.
|
|
5
|
+
*
|
|
6
|
+
* Both were gaps found reviewing the metaData work after it shipped:
|
|
7
|
+
*
|
|
8
|
+
* 1. `--metadata-only` + `--force` is contradictory (one writes no field file,
|
|
9
|
+
* the other overwrites every field file). refreshCommand refuses the
|
|
10
|
+
* combination, but that refusal was user-facing behaviour with zero tests.
|
|
11
|
+
*
|
|
12
|
+
* 2. `isDirectory` REJECTS on a missing path rather than returning false, and
|
|
13
|
+
* `processBatched` propagates — which aborted an entire scope on the first
|
|
14
|
+
* record present on the instance but absent from the branch. That is the
|
|
15
|
+
* common case for `--metadata-only`, not an edge one. `isExistingDirectory`
|
|
16
|
+
* is the guarded predicate; these tests pin the contract of all three so
|
|
17
|
+
* the next caller picks the right one deliberately.
|
|
18
|
+
*/
|
|
19
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
22
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
23
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
24
|
+
}
|
|
25
|
+
Object.defineProperty(o, k2, desc);
|
|
26
|
+
}) : (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
o[k2] = m[k];
|
|
29
|
+
}));
|
|
30
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
31
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
32
|
+
}) : function(o, v) {
|
|
33
|
+
o["default"] = v;
|
|
34
|
+
});
|
|
35
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
36
|
+
var ownKeys = function(o) {
|
|
37
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
38
|
+
var ar = [];
|
|
39
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
40
|
+
return ar;
|
|
41
|
+
};
|
|
42
|
+
return ownKeys(o);
|
|
43
|
+
};
|
|
44
|
+
return function (mod) {
|
|
45
|
+
if (mod && mod.__esModule) return mod;
|
|
46
|
+
var result = {};
|
|
47
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
48
|
+
__setModuleDefault(result, mod);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
})();
|
|
52
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
+
const fs = __importStar(require("fs"));
|
|
54
|
+
const os = __importStar(require("os"));
|
|
55
|
+
const path = __importStar(require("path"));
|
|
56
|
+
var mockLogger = {
|
|
57
|
+
info: jest.fn(),
|
|
58
|
+
debug: jest.fn(),
|
|
59
|
+
warn: jest.fn(),
|
|
60
|
+
error: jest.fn(),
|
|
61
|
+
success: jest.fn(),
|
|
62
|
+
setLogLevel: jest.fn(),
|
|
63
|
+
getLogLevel: function () { return "warn"; },
|
|
64
|
+
};
|
|
65
|
+
jest.mock("../Logger", function () { return { logger: mockLogger }; });
|
|
66
|
+
jest.mock("../FileLogger", function () {
|
|
67
|
+
return { fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } };
|
|
68
|
+
});
|
|
69
|
+
jest.mock("../snClient", function () {
|
|
70
|
+
return {
|
|
71
|
+
defaultClient: function () { return { getManifest: jest.fn(), getMissingFiles: jest.fn() }; },
|
|
72
|
+
unwrapSNResponse: function (p) { return Promise.resolve(p); },
|
|
73
|
+
processPushResponse: jest.fn(),
|
|
74
|
+
retryOnErr: jest.fn(),
|
|
75
|
+
retryOnHttpErr: jest.fn(),
|
|
76
|
+
unwrapTableAPIFirstItem: jest.fn(),
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
jest.mock("../config", function () {
|
|
80
|
+
return {
|
|
81
|
+
getConfig: jest.fn().mockReturnValue({ scopes: { x_cadso_core: {} }, tableOptions: {} }),
|
|
82
|
+
getManifest: jest.fn().mockResolvedValue({}),
|
|
83
|
+
getSourcePath: jest.fn(),
|
|
84
|
+
getSourcePathForScope: jest.fn(),
|
|
85
|
+
getManifestPath: jest.fn().mockReturnValue("/tmp/dove.manifest.json"),
|
|
86
|
+
getScopeManifestPath: jest.fn().mockReturnValue("/tmp/dove.manifest.json"),
|
|
87
|
+
resolveConfigForScope: jest.fn().mockReturnValue({
|
|
88
|
+
tables: ["sys_script_include"],
|
|
89
|
+
fieldOverrides: {},
|
|
90
|
+
apiIncludes: {},
|
|
91
|
+
apiExcludes: {},
|
|
92
|
+
}),
|
|
93
|
+
isMultiScopeManifest: jest.fn().mockReturnValue(true),
|
|
94
|
+
updateManifest: jest.fn(),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
jest.mock("progress", function () {
|
|
98
|
+
return jest.fn().mockImplementation(function () { return { tick: jest.fn() }; });
|
|
99
|
+
});
|
|
100
|
+
const AppUtils = __importStar(require("../appUtils"));
|
|
101
|
+
const fUtils = __importStar(require("../FileUtils"));
|
|
102
|
+
const commands_1 = require("../commands");
|
|
103
|
+
describe("refresh — --metadata-only and --force are mutually exclusive", function () {
|
|
104
|
+
beforeEach(function () {
|
|
105
|
+
jest.clearAllMocks();
|
|
106
|
+
jest.spyOn(AppUtils, "syncManifest").mockResolvedValue(undefined);
|
|
107
|
+
});
|
|
108
|
+
afterEach(function () {
|
|
109
|
+
jest.restoreAllMocks();
|
|
110
|
+
});
|
|
111
|
+
test("rejects the combination instead of silently picking one", async function () {
|
|
112
|
+
// Silently honouring --force here would overwrite every field file from the
|
|
113
|
+
// instance for someone who explicitly asked to touch nothing but metaData.
|
|
114
|
+
await expect((0, commands_1.refreshCommand)({ metadataOnly: true, force: true })).rejects.toThrow(/--metadata-only cannot be combined with --force/);
|
|
115
|
+
});
|
|
116
|
+
test("does not reach syncManifest when the combination is refused", async function () {
|
|
117
|
+
await expect((0, commands_1.refreshCommand)({ metadataOnly: true, force: true })).rejects.toThrow();
|
|
118
|
+
expect(AppUtils.syncManifest).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
test("each flag on its own is accepted", async function () {
|
|
121
|
+
await (0, commands_1.refreshCommand)({ metadataOnly: true });
|
|
122
|
+
await (0, commands_1.refreshCommand)({ force: true });
|
|
123
|
+
expect(AppUtils.syncManifest).toHaveBeenCalledTimes(2);
|
|
124
|
+
expect(AppUtils.syncManifest.mock.calls[0][1]).toMatchObject({
|
|
125
|
+
metadataOnly: true,
|
|
126
|
+
force: false,
|
|
127
|
+
});
|
|
128
|
+
expect(AppUtils.syncManifest.mock.calls[1][1]).toMatchObject({
|
|
129
|
+
metadataOnly: false,
|
|
130
|
+
force: true,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe("FileUtils directory predicates — pick the right one deliberately", function () {
|
|
135
|
+
var tmpRoot;
|
|
136
|
+
var dir;
|
|
137
|
+
var file;
|
|
138
|
+
var missing;
|
|
139
|
+
beforeEach(function () {
|
|
140
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dove-dirpred-"));
|
|
141
|
+
dir = path.join(tmpRoot, "a-directory");
|
|
142
|
+
file = path.join(tmpRoot, "a-file");
|
|
143
|
+
missing = path.join(tmpRoot, "does-not-exist");
|
|
144
|
+
fs.mkdirSync(dir);
|
|
145
|
+
fs.writeFileSync(file, "x");
|
|
146
|
+
});
|
|
147
|
+
afterEach(function () {
|
|
148
|
+
try {
|
|
149
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
catch (e) { }
|
|
152
|
+
});
|
|
153
|
+
test("isExistingDirectory: true only for a real directory, never throws", async function () {
|
|
154
|
+
expect(await fUtils.isExistingDirectory(dir)).toBe(true);
|
|
155
|
+
expect(await fUtils.isExistingDirectory(file)).toBe(false);
|
|
156
|
+
expect(await fUtils.isExistingDirectory(missing)).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
test("isDirectory still REJECTS on a missing path (documented, relied on)", async function () {
|
|
159
|
+
// getPathsInPath treats a missing path as an error rather than a file, so
|
|
160
|
+
// this rejection is deliberate. Pinning it stops a well-meaning "fix" from
|
|
161
|
+
// changing that behaviour silently.
|
|
162
|
+
expect(await fUtils.isDirectory(dir)).toBe(true);
|
|
163
|
+
expect(await fUtils.isDirectory(file)).toBe(false);
|
|
164
|
+
await expect(fUtils.isDirectory(missing)).rejects.toThrow();
|
|
165
|
+
});
|
|
166
|
+
test("pathExists is true for a plain file — why it is the wrong write-target check", async function () {
|
|
167
|
+
expect(await fUtils.pathExists(file)).toBe(true);
|
|
168
|
+
expect(await fUtils.isExistingDirectory(file)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -118,30 +118,105 @@ describe("stampMetadataContent — no generation-time values", () => {
|
|
|
118
118
|
expect((0, appUtils_1.stampMetadataContent)(arr)).toEqual(arr);
|
|
119
119
|
});
|
|
120
120
|
});
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// display_value handling is SELECTIVE, not a blanket strip. The rule was chosen
|
|
122
|
+
// from a measurement of 43,990 pairs in the real servicenow-files mirror; each
|
|
123
|
+
// test below names the category and why it falls on the side it does.
|
|
124
|
+
describe("stampMetadataContent — display_value DROPPED where it is churn", () => {
|
|
125
|
+
it("drops it when it is identical to value (zero information, any size)", () => {
|
|
126
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
127
|
+
name: { value: "UIFilterApiMS", display_value: "UIFilterApiMS" },
|
|
128
|
+
}));
|
|
129
|
+
expect(parse(out).name).toEqual({ value: "UIFilterApiMS" });
|
|
130
|
+
});
|
|
131
|
+
it("drops identical pairs on large text fields too (script, composition)", () => {
|
|
132
|
+
// 619 pairs but ~3.7 MB of the corpus — the bulk of the file weight.
|
|
133
|
+
const big = "var x = 1;\n".repeat(60);
|
|
134
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ script: { value: big, display_value: big } }));
|
|
135
|
+
expect(parse(out).script).toEqual({ value: big });
|
|
136
|
+
});
|
|
137
|
+
it("drops it on datetime fields — display_value is the PULLER's timezone", () => {
|
|
138
|
+
// Measured 1,706 TZ-shifted out of 1,706. `value` is UTC; display_value is
|
|
139
|
+
// rendered in the local timezone of whoever ran the pull, so keeping it
|
|
140
|
+
// makes the bytes depend on which laptop synced.
|
|
141
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
142
|
+
sys_updated_on: { value: "2026-04-29 14:38:51", display_value: "2026-04-29 07:38:51" },
|
|
143
|
+
sys_created_on: { value: "2026-04-29 14:38:51", display_value: "2026-04-29 07:38:51" },
|
|
144
|
+
}));
|
|
145
|
+
const parsed = parse(out);
|
|
146
|
+
expect(parsed.sys_updated_on).toEqual({ value: "2026-04-29 14:38:51" });
|
|
147
|
+
expect(parsed.sys_created_on).toEqual({ value: "2026-04-29 14:38:51" });
|
|
148
|
+
});
|
|
149
|
+
it("drops it on a date-only value as well", () => {
|
|
150
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ start_date: { value: "2026-04-29", display_value: "29-04-2026" } }));
|
|
151
|
+
expect(parse(out).start_date).toEqual({ value: "2026-04-29" });
|
|
152
|
+
});
|
|
153
|
+
it("drops it on sys_scope / sys_package — one app rename churns the whole tree", () => {
|
|
154
|
+
// Present in ~0.95 records per file. The scope is already obvious from the
|
|
155
|
+
// folder path (src/Work/, src/Core/), so the value is low and the blast
|
|
156
|
+
// radius is every file in the mirror.
|
|
157
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
158
|
+
sys_scope: {
|
|
159
|
+
value: "4e4449a5475c255085d19fd8036d43a0",
|
|
160
|
+
display_value: "Tenon Marketing Work Management",
|
|
161
|
+
},
|
|
162
|
+
sys_package: {
|
|
163
|
+
value: "4e4449a5475c255085d19fd8036d43a0",
|
|
164
|
+
display_value: "Tenon Marketing Work Management",
|
|
165
|
+
},
|
|
166
|
+
}));
|
|
167
|
+
const parsed = parse(out);
|
|
168
|
+
expect(parsed.sys_scope).toEqual({ value: "4e4449a5475c255085d19fd8036d43a0" });
|
|
169
|
+
expect(parsed.sys_package).toEqual({ value: "4e4449a5475c255085d19fd8036d43a0" });
|
|
170
|
+
});
|
|
171
|
+
it("treats null and \"\" as identical rather than writing \"null\"", () => {
|
|
172
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ controlled_by_refs: { value: null, display_value: "" } }));
|
|
173
|
+
expect(parse(out).controlled_by_refs).toEqual({ value: null });
|
|
174
|
+
});
|
|
175
|
+
it("does not treat two different objects as identical", () => {
|
|
176
|
+
// String({}) collapses every object to "[object Object]", so a naive
|
|
177
|
+
// stringify-and-compare would call these equal and drop a display_value
|
|
178
|
+
// that carries real information. Non-primitives are simply not comparable.
|
|
179
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
180
|
+
weird: { value: { a: 1 }, display_value: { b: 2 } },
|
|
181
|
+
}));
|
|
182
|
+
expect(parse(out).weird.display_value).toEqual({ b: 2 });
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe("stampMetadataContent — display_value KEPT where it is information", () => {
|
|
186
|
+
it("keeps a reference's human name (the sys_id -> name mapping)", () => {
|
|
187
|
+
// This is the readable mapping Documentation/Dovetail-Development.md
|
|
188
|
+
// documents grepping to walk the record graph. A blanket strip destroyed it.
|
|
123
189
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
124
|
-
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
125
190
|
action: {
|
|
126
191
|
value: "85de623c33ef2a107b18bc534d5c7b92",
|
|
127
192
|
display_value: "Parse hashes for to_addresses",
|
|
128
193
|
},
|
|
129
194
|
}));
|
|
130
|
-
|
|
131
|
-
expect(parsed.action).toEqual({
|
|
195
|
+
expect(parse(out).action).toEqual({
|
|
132
196
|
value: "85de623c33ef2a107b18bc534d5c7b92",
|
|
197
|
+
display_value: "Parse hashes for to_addresses",
|
|
133
198
|
});
|
|
134
|
-
expect(parsed.action.display_value).toBeUndefined();
|
|
135
199
|
});
|
|
136
|
-
it("
|
|
200
|
+
it("keeps boolean renderings (0 -> false)", () => {
|
|
137
201
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
138
|
-
|
|
139
|
-
|
|
202
|
+
active: { value: "1", display_value: "true" },
|
|
203
|
+
client_callable: { value: "0", display_value: "false" },
|
|
140
204
|
}));
|
|
141
205
|
const parsed = parse(out);
|
|
142
|
-
expect(parsed.
|
|
143
|
-
expect(parsed.
|
|
206
|
+
expect(parsed.active).toEqual({ value: "1", display_value: "true" });
|
|
207
|
+
expect(parsed.client_callable).toEqual({ value: "0", display_value: "false" });
|
|
144
208
|
});
|
|
209
|
+
it("keeps choice / class labels", () => {
|
|
210
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
211
|
+
sys_class_name: { value: "sys_security_acl", display_value: "Access Control" },
|
|
212
|
+
access: { value: "package_private", display_value: "This application scope only" },
|
|
213
|
+
}));
|
|
214
|
+
const parsed = parse(out);
|
|
215
|
+
expect(parsed.sys_class_name.display_value).toBe("Access Control");
|
|
216
|
+
expect(parsed.access.display_value).toBe("This application scope only");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
describe("stampMetadataContent — display_value determinism", () => {
|
|
145
220
|
it("leaves value-only fields and string keys untouched", () => {
|
|
146
221
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
147
222
|
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
@@ -153,14 +228,30 @@ describe("stampMetadataContent — display_value stripping", () => {
|
|
|
153
228
|
expect(parsed.name).toEqual({ value: "plain" });
|
|
154
229
|
expect(parsed._record_link).toBe("/incident.do?sys_id=1");
|
|
155
230
|
});
|
|
156
|
-
it("is idempotent
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
231
|
+
it("is idempotent across a full mixed record — kept pairs survive a re-stamp", () => {
|
|
232
|
+
// The failure mode this guards: a rule that keeps a pair on pass 1 but drops
|
|
233
|
+
// it on pass 2 would churn forever. Run a record carrying every category.
|
|
234
|
+
const mixed = {
|
|
235
|
+
sys_updated_on: { value: "2026-04-29 14:38:51", display_value: "2026-04-29 07:38:51" },
|
|
236
|
+
sys_scope: { value: "4e4449a5", display_value: "Tenon Marketing Work Management" },
|
|
237
|
+
sys_class_name: { value: "sys_security_acl", display_value: "Access Control" },
|
|
238
|
+
active: { value: "1", display_value: "true" },
|
|
239
|
+
action: { value: "85de623c", display_value: "Parse hashes" },
|
|
240
|
+
name: { value: "same", display_value: "same" },
|
|
241
|
+
_record_link: "https://tenonworkstudio.service-now.com/incident.do?sys_id=1",
|
|
161
242
|
};
|
|
162
|
-
const first = (0, appUtils_1.stampMetadataContent)(metaFile(
|
|
243
|
+
const first = (0, appUtils_1.stampMetadataContent)(metaFile(mixed));
|
|
163
244
|
const second = (0, appUtils_1.stampMetadataContent)(first);
|
|
245
|
+
const third = (0, appUtils_1.stampMetadataContent)(second);
|
|
164
246
|
expect(second.content).toBe(first.content);
|
|
247
|
+
expect(third.content).toBe(first.content);
|
|
248
|
+
// And the kept set is exactly what we intend.
|
|
249
|
+
const parsed = parse(first);
|
|
250
|
+
expect(parsed.sys_updated_on.display_value).toBeUndefined();
|
|
251
|
+
expect(parsed.sys_scope.display_value).toBeUndefined();
|
|
252
|
+
expect(parsed.name.display_value).toBeUndefined();
|
|
253
|
+
expect(parsed.sys_class_name.display_value).toBe("Access Control");
|
|
254
|
+
expect(parsed.active.display_value).toBe("true");
|
|
255
|
+
expect(parsed.action.display_value).toBe("Parse hashes");
|
|
165
256
|
});
|
|
166
257
|
});
|
|
@@ -334,7 +334,8 @@ describe("syncManifest — --metadata-only rewrites metadata and nothing else",
|
|
|
334
334
|
// Normalized on the way in — the whole reason to regenerate.
|
|
335
335
|
expect(parsed._record_link).toBe("/sys_script_include.do?sys_id=1");
|
|
336
336
|
expect(parsed._lastUpdatedOn).toBeUndefined();
|
|
337
|
-
|
|
337
|
+
// datetime display_value is the puller's local timezone — dropped.
|
|
338
|
+
expect(parsed.sys_updated_on).toEqual({ value: "2026-04-29 14:38:51" });
|
|
338
339
|
});
|
|
339
340
|
test("does not touch field files, even when the instance content differs", async function () {
|
|
340
341
|
writeLocal("Rec", "script", "js", "var local = 1;");
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,7 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "dovetail-manifest/v1",
|
|
3
|
-
"generated_at": "2026-07-
|
|
3
|
+
"generated_at": "2026-07-28T06:44:03.536Z",
|
|
4
4
|
"events": [
|
|
5
|
+
{
|
|
6
|
+
"$schema": "dovetail-release-event/v1",
|
|
7
|
+
"event_id": "dovetail-core@0.0.117",
|
|
8
|
+
"package": "@tenonhq/dovetail-core",
|
|
9
|
+
"version": "0.0.117",
|
|
10
|
+
"prev_version": "0.0.116",
|
|
11
|
+
"published_at": "2026-07-28T06:44:03.528Z",
|
|
12
|
+
"git": {
|
|
13
|
+
"sha": "47199bcf436db3be5b8fd6728265444e72a96bc0",
|
|
14
|
+
"tag": "dovetail-core@0.0.117"
|
|
15
|
+
},
|
|
16
|
+
"semver_bump": "patch",
|
|
17
|
+
"commits": [
|
|
18
|
+
{
|
|
19
|
+
"sha": "47199bcf4",
|
|
20
|
+
"subject": "fix(core): keep informative display_value, drop only the churning kinds (#250)",
|
|
21
|
+
"pr": 250
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"sha": "afd387889",
|
|
25
|
+
"subject": "chore(release): @tenonhq/dovetail-core@0.0.116 [skip ci]"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"exports_delta": null,
|
|
29
|
+
"narrative": null
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"$schema": "dovetail-release-event/v1",
|
|
33
|
+
"event_id": "dovetail-core@0.0.116",
|
|
34
|
+
"package": "@tenonhq/dovetail-core",
|
|
35
|
+
"version": "0.0.116",
|
|
36
|
+
"prev_version": "0.0.115",
|
|
37
|
+
"published_at": "2026-07-28T06:17:01.130Z",
|
|
38
|
+
"git": {
|
|
39
|
+
"sha": "ab33183076fdb0eb18a8fc1c8e79b607af3893c4",
|
|
40
|
+
"tag": "dovetail-core@0.0.116"
|
|
41
|
+
},
|
|
42
|
+
"semver_bump": "patch",
|
|
43
|
+
"commits": [
|
|
44
|
+
{
|
|
45
|
+
"sha": "ab3318307",
|
|
46
|
+
"subject": "fix(core): --metadata-only must not write the scope manifest (#249)",
|
|
47
|
+
"pr": 249
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"sha": "f77cab2e5",
|
|
51
|
+
"subject": "chore(release): @tenonhq/dovetail-core@0.0.115 [skip ci]"
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"exports_delta": null,
|
|
55
|
+
"narrative": null
|
|
56
|
+
},
|
|
5
57
|
{
|
|
6
58
|
"$schema": "dovetail-release-event/v1",
|
|
7
59
|
"event_id": "dovetail-core@0.0.115",
|
|
@@ -1202,58 +1254,6 @@
|
|
|
1202
1254
|
],
|
|
1203
1255
|
"exports_delta": null,
|
|
1204
1256
|
"narrative": null
|
|
1205
|
-
},
|
|
1206
|
-
{
|
|
1207
|
-
"$schema": "dovetail-release-event/v1",
|
|
1208
|
-
"event_id": "dovetail-servicenow@0.0.21",
|
|
1209
|
-
"package": "@tenonhq/dovetail-servicenow",
|
|
1210
|
-
"version": "0.0.21",
|
|
1211
|
-
"prev_version": "0.0.20",
|
|
1212
|
-
"published_at": "2026-06-15T17:59:30.698Z",
|
|
1213
|
-
"git": {
|
|
1214
|
-
"sha": "51e4e4592d491cd337f5bc1aa93d04f582f555a1",
|
|
1215
|
-
"tag": "dovetail-servicenow@0.0.21"
|
|
1216
|
-
},
|
|
1217
|
-
"semver_bump": "patch",
|
|
1218
|
-
"commits": [
|
|
1219
|
-
{
|
|
1220
|
-
"sha": "51e4e4592",
|
|
1221
|
-
"subject": "feat(mcp): per-call instance retarget for servicenow_query_table (#180)",
|
|
1222
|
-
"pr": 180
|
|
1223
|
-
},
|
|
1224
|
-
{
|
|
1225
|
-
"sha": "c5d9106c4",
|
|
1226
|
-
"subject": "chore(release): publish 2 packages [skip ci]"
|
|
1227
|
-
}
|
|
1228
|
-
],
|
|
1229
|
-
"exports_delta": null,
|
|
1230
|
-
"narrative": null
|
|
1231
|
-
},
|
|
1232
|
-
{
|
|
1233
|
-
"$schema": "dovetail-release-event/v1",
|
|
1234
|
-
"event_id": "dovetail-mcp@0.0.16",
|
|
1235
|
-
"package": "@tenonhq/dovetail-mcp",
|
|
1236
|
-
"version": "0.0.16",
|
|
1237
|
-
"prev_version": "0.0.15",
|
|
1238
|
-
"published_at": "2026-06-15T17:59:30.698Z",
|
|
1239
|
-
"git": {
|
|
1240
|
-
"sha": "51e4e4592d491cd337f5bc1aa93d04f582f555a1",
|
|
1241
|
-
"tag": "dovetail-mcp@0.0.16"
|
|
1242
|
-
},
|
|
1243
|
-
"semver_bump": "patch",
|
|
1244
|
-
"commits": [
|
|
1245
|
-
{
|
|
1246
|
-
"sha": "51e4e4592",
|
|
1247
|
-
"subject": "feat(mcp): per-call instance retarget for servicenow_query_table (#180)",
|
|
1248
|
-
"pr": 180
|
|
1249
|
-
},
|
|
1250
|
-
{
|
|
1251
|
-
"sha": "c5d9106c4",
|
|
1252
|
-
"subject": "chore(release): publish 2 packages [skip ci]"
|
|
1253
|
-
}
|
|
1254
|
-
],
|
|
1255
|
-
"exports_delta": null,
|
|
1256
|
-
"narrative": null
|
|
1257
1257
|
}
|
|
1258
1258
|
]
|
|
1259
1259
|
}
|