@tenonhq/dovetail-core 0.0.114 → 0.0.116
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/allScopesCommands.js +15 -8
- package/dist/appUtils.js +129 -51
- package/dist/commander.js +7 -0
- package/dist/commands.js +9 -0
- package/dist/reconcile/baseline.js +3 -3
- package/dist/reconcile/fields.js +2 -2
- package/dist/reconcile/recordSource.js +6 -3
- package/dist/tests/allScopesMetadata.test.js +193 -0
- package/dist/tests/metadataOnlyManifestGate.test.js +196 -0
- package/dist/tests/stampMetadataContent.test.js +63 -5
- package/dist/tests/syncManifestRefresh.test.js +159 -0
- package/package.json +1 -1
- package/release-manifest.json +273 -244
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Regression: `dove refresh --metadata-only` must not write the scope manifest.
|
|
4
|
+
*
|
|
5
|
+
* The mode's contract — stated in its CLI help and in CLAUDE.md — is that the
|
|
6
|
+
* resulting git diff is `metaData.json` and nothing else. The first
|
|
7
|
+
* implementation gated `ConfigManager.updateManifest` (the in-MEMORY manifest
|
|
8
|
+
* update) but not `fUtils.writeScopeManifest` (the actual FILE write), so a real
|
|
9
|
+
* run against tenonworkstudio rewrote all 8 `dove.manifest.x_cadso_*.json`
|
|
10
|
+
* files with unrelated record-name disambiguation drift.
|
|
11
|
+
*
|
|
12
|
+
* The companion tests in syncManifestRefresh.test.ts could not catch this: that
|
|
13
|
+
* suite mocks `writeScopeManifest` to a no-op, so nothing there ever observes
|
|
14
|
+
* the write. That mock is why the bug shipped.
|
|
15
|
+
*
|
|
16
|
+
* So this file deliberately leaves FileUtils REAL and asserts against the
|
|
17
|
+
* filesystem — the manifest file on disk must be byte-identical after a
|
|
18
|
+
* metadata-only run, and must still be updated by a plain refresh (proving the
|
|
19
|
+
* gate is scoped, not a blanket disable).
|
|
20
|
+
*/
|
|
21
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
24
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
25
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
26
|
+
}
|
|
27
|
+
Object.defineProperty(o, k2, desc);
|
|
28
|
+
}) : (function(o, m, k, k2) {
|
|
29
|
+
if (k2 === undefined) k2 = k;
|
|
30
|
+
o[k2] = m[k];
|
|
31
|
+
}));
|
|
32
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
33
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
34
|
+
}) : function(o, v) {
|
|
35
|
+
o["default"] = v;
|
|
36
|
+
});
|
|
37
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
38
|
+
var ownKeys = function(o) {
|
|
39
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
40
|
+
var ar = [];
|
|
41
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
42
|
+
return ar;
|
|
43
|
+
};
|
|
44
|
+
return ownKeys(o);
|
|
45
|
+
};
|
|
46
|
+
return function (mod) {
|
|
47
|
+
if (mod && mod.__esModule) return mod;
|
|
48
|
+
var result = {};
|
|
49
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
50
|
+
__setModuleDefault(result, mod);
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
})();
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
const fs = __importStar(require("fs"));
|
|
56
|
+
const os = __importStar(require("os"));
|
|
57
|
+
const path = __importStar(require("path"));
|
|
58
|
+
// ---------- mocks (must come before importing the module under test) ----------
|
|
59
|
+
var mockLogger = {
|
|
60
|
+
info: jest.fn(),
|
|
61
|
+
debug: jest.fn(),
|
|
62
|
+
warn: jest.fn(),
|
|
63
|
+
error: jest.fn(),
|
|
64
|
+
success: jest.fn(),
|
|
65
|
+
getLogLevel: function () { return "warn"; },
|
|
66
|
+
};
|
|
67
|
+
var mockFileLogger = {
|
|
68
|
+
debug: jest.fn(),
|
|
69
|
+
info: jest.fn(),
|
|
70
|
+
warn: jest.fn(),
|
|
71
|
+
error: jest.fn(),
|
|
72
|
+
};
|
|
73
|
+
var mockClient = {
|
|
74
|
+
getManifest: jest.fn(),
|
|
75
|
+
getMissingFiles: jest.fn(),
|
|
76
|
+
};
|
|
77
|
+
jest.mock("../Logger", function () { return { logger: mockLogger }; });
|
|
78
|
+
jest.mock("../FileLogger", function () { return { fileLogger: mockFileLogger }; });
|
|
79
|
+
jest.mock("../snClient", function () {
|
|
80
|
+
return {
|
|
81
|
+
defaultClient: function () { return mockClient; },
|
|
82
|
+
unwrapSNResponse: function (p) { return Promise.resolve(p); },
|
|
83
|
+
processPushResponse: jest.fn(),
|
|
84
|
+
retryOnErr: jest.fn(),
|
|
85
|
+
retryOnHttpErr: jest.fn(),
|
|
86
|
+
unwrapTableAPIFirstItem: jest.fn(),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
var manifestPath = "";
|
|
90
|
+
var mockConfig = {
|
|
91
|
+
getConfig: jest.fn().mockReturnValue({
|
|
92
|
+
scopes: { x_cadso_core: {} },
|
|
93
|
+
tableOptions: {},
|
|
94
|
+
}),
|
|
95
|
+
getManifest: jest.fn().mockResolvedValue({
|
|
96
|
+
x_cadso_core: { scope: "x_cadso_core", tables: {} },
|
|
97
|
+
}),
|
|
98
|
+
getSourcePathForScope: jest.fn(),
|
|
99
|
+
getSourcePath: jest.fn(),
|
|
100
|
+
getManifestPath: jest.fn(function () { return manifestPath; }),
|
|
101
|
+
// The real writeScopeManifest resolves its target through this.
|
|
102
|
+
getScopeManifestPath: jest.fn(function () { return manifestPath; }),
|
|
103
|
+
resolveConfigForScope: jest.fn().mockReturnValue({
|
|
104
|
+
tables: ["sys_script_include"],
|
|
105
|
+
fieldOverrides: {},
|
|
106
|
+
apiIncludes: {},
|
|
107
|
+
apiExcludes: {},
|
|
108
|
+
}),
|
|
109
|
+
isMultiScopeManifest: jest.fn().mockReturnValue(true),
|
|
110
|
+
updateManifest: jest.fn(),
|
|
111
|
+
};
|
|
112
|
+
jest.mock("../config", function () { return mockConfig; });
|
|
113
|
+
jest.mock("progress", function () {
|
|
114
|
+
return jest.fn().mockImplementation(function () {
|
|
115
|
+
return { tick: jest.fn() };
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
// NOTE: FileUtils is deliberately NOT mocked. Stubbing writeScopeManifest is
|
|
119
|
+
// exactly what hid this bug.
|
|
120
|
+
const AppUtils = __importStar(require("../appUtils"));
|
|
121
|
+
// ---------- scaffolding ----------
|
|
122
|
+
var tmpRoot;
|
|
123
|
+
var SENTINEL = JSON.stringify({ tables: { sentinel: "untouched" } }, null, 2);
|
|
124
|
+
function writeLocalRecord(record, content) {
|
|
125
|
+
var recDir = path.join(tmpRoot, "sys_script_include", record);
|
|
126
|
+
fs.mkdirSync(recDir, { recursive: true });
|
|
127
|
+
fs.writeFileSync(path.join(recDir, "script.js"), content);
|
|
128
|
+
}
|
|
129
|
+
function serverResponse(record, scriptContent) {
|
|
130
|
+
var records = {};
|
|
131
|
+
records[record] = {
|
|
132
|
+
name: record,
|
|
133
|
+
sys_id: "sysid_" + record,
|
|
134
|
+
files: [
|
|
135
|
+
{ name: "script", type: "js", content: scriptContent },
|
|
136
|
+
{
|
|
137
|
+
name: "metaData",
|
|
138
|
+
type: "json",
|
|
139
|
+
content: JSON.stringify({
|
|
140
|
+
sys_updated_on: { value: "2026-04-29 14:38:51" },
|
|
141
|
+
_record_link: "https://tenonworkstudio.service-now.com/x.do?sys_id=1",
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
return { sys_script_include: { records: records } };
|
|
147
|
+
}
|
|
148
|
+
describe("--metadata-only does not write the scope manifest (real FileUtils)", function () {
|
|
149
|
+
beforeEach(function () {
|
|
150
|
+
jest.clearAllMocks();
|
|
151
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dove-manifest-gate-"));
|
|
152
|
+
manifestPath = path.join(tmpRoot, "dove.manifest.x_cadso_core.json");
|
|
153
|
+
fs.writeFileSync(manifestPath, SENTINEL);
|
|
154
|
+
mockConfig.getSourcePathForScope.mockReturnValue(tmpRoot);
|
|
155
|
+
mockConfig.getSourcePath.mockReturnValue(tmpRoot);
|
|
156
|
+
// The manifest the "instance" returns differs from the sentinel on disk, so
|
|
157
|
+
// any write at all is detectable.
|
|
158
|
+
mockClient.getManifest.mockResolvedValue({
|
|
159
|
+
scope: "x_cadso_core",
|
|
160
|
+
tables: {
|
|
161
|
+
sys_script_include: {
|
|
162
|
+
records: {
|
|
163
|
+
Rec: { name: "Rec", sys_id: "sysid_Rec", files: [{ name: "script", type: "js" }] },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
mockClient.getMissingFiles.mockResolvedValue(serverResponse("Rec", "var x = 1;"));
|
|
169
|
+
});
|
|
170
|
+
afterEach(function () {
|
|
171
|
+
try {
|
|
172
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
catch (e) { }
|
|
175
|
+
});
|
|
176
|
+
test("leaves the manifest file byte-identical", async function () {
|
|
177
|
+
writeLocalRecord("Rec", "var x = 1;");
|
|
178
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
179
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
180
|
+
expect(fs.readFileSync(manifestPath, "utf8")).toBe(SENTINEL);
|
|
181
|
+
});
|
|
182
|
+
test("still rewrites the record's metaData.json (the gate is narrow)", async function () {
|
|
183
|
+
writeLocalRecord("Rec", "var x = 1;");
|
|
184
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
185
|
+
var meta = fs.readFileSync(path.join(tmpRoot, "sys_script_include", "Rec", "metaData.json"), "utf8");
|
|
186
|
+
expect(JSON.parse(meta)._record_link).toBe("/x.do?sys_id=1");
|
|
187
|
+
});
|
|
188
|
+
test("a plain refresh DOES write the manifest", async function () {
|
|
189
|
+
// Proves the gate is conditional, not a blanket disable — without this a
|
|
190
|
+
// hard-coded skip would pass the test above.
|
|
191
|
+
writeLocalRecord("Rec", "var x = 1;");
|
|
192
|
+
await AppUtils.syncManifest("x_cadso_core");
|
|
193
|
+
expect(fs.readFileSync(manifestPath, "utf8")).not.toBe(SENTINEL);
|
|
194
|
+
expect(JSON.parse(fs.readFileSync(manifestPath, "utf8")).tables).toHaveProperty("sys_script_include");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
// Tests for stampMetadataContent — the writer that finalizes each record's
|
|
3
3
|
// metaData.json before it lands on disk.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// The contract is determinism: the same record must produce the same bytes on
|
|
6
|
+
// any instance, from any machine, on every pull. Three on-disk-stability
|
|
7
|
+
// concerns are covered:
|
|
8
|
+
// 1. No generation-time value survives. `_lastUpdatedOn` is deleted outright —
|
|
9
|
+
// it duplicated sys_updated_on.value exactly, and its no-sys_updated_on
|
|
10
|
+
// branch stamped a wall-clock that rewrote the file on every pull.
|
|
8
11
|
// 2. _record_link is rewritten to an instance-relative path. The CADSO metadata
|
|
9
12
|
// endpoint returns an absolute URL (https://<instance>.service-now.com/...);
|
|
10
13
|
// persisting the host makes the artifact instance-bound and dirties every
|
|
11
14
|
// file on a re-pull from a different instance. Stripping any *.service-now.com
|
|
12
15
|
// host keeps the diff to genuine schema changes.
|
|
16
|
+
// 3. display_value is stripped — it is resolved live server-side, so renaming
|
|
17
|
+
// a referenced record re-churns every file pointing at it.
|
|
13
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
19
|
const appUtils_1 = require("../appUtils");
|
|
20
|
+
// Matches an ISO-8601 wall-clock stamp (what `new Date().toISOString()` emits).
|
|
21
|
+
const ISO_STAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
|
15
22
|
const metaFile = (content) => ({
|
|
16
23
|
name: "metaData",
|
|
17
24
|
type: "json",
|
|
@@ -46,13 +53,13 @@ describe("stampMetadataContent — _record_link host stripping", () => {
|
|
|
46
53
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ _record_link: null }));
|
|
47
54
|
expect(parse(out)._record_link).toBeNull();
|
|
48
55
|
});
|
|
49
|
-
it("
|
|
56
|
+
it("strips the host while leaving the record's own sys_updated_on intact", () => {
|
|
50
57
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
51
58
|
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
52
59
|
_record_link: "https://tenonworkstudio.service-now.com/incident.do?sys_id=1",
|
|
53
60
|
}));
|
|
54
61
|
const parsed = parse(out);
|
|
55
|
-
expect(parsed.
|
|
62
|
+
expect(parsed.sys_updated_on).toEqual({ value: "2025-08-21 17:09:02" });
|
|
56
63
|
expect(parsed._record_link).toBe("/incident.do?sys_id=1");
|
|
57
64
|
});
|
|
58
65
|
it("passes through non-metaData files verbatim", () => {
|
|
@@ -60,6 +67,57 @@ describe("stampMetadataContent — _record_link host stripping", () => {
|
|
|
60
67
|
expect((0, appUtils_1.stampMetadataContent)(script)).toEqual(script);
|
|
61
68
|
});
|
|
62
69
|
});
|
|
70
|
+
describe("stampMetadataContent — no generation-time values", () => {
|
|
71
|
+
it("never writes _lastUpdatedOn, even when sys_updated_on is present", () => {
|
|
72
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ sys_updated_on: { value: "2025-08-21 17:09:02" } }));
|
|
73
|
+
expect(parse(out)._lastUpdatedOn).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
it("deletes a pre-existing _lastUpdatedOn so a re-stamp settles legacy content", () => {
|
|
76
|
+
// A branch pulled before this change carries the key on disk. Re-stamping
|
|
77
|
+
// has to remove it, otherwise the stale value persists forever and the
|
|
78
|
+
// mirror never converges.
|
|
79
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
80
|
+
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
81
|
+
_lastUpdatedOn: "2025-08-21 17:09:02",
|
|
82
|
+
_record_link: "/incident.do?sys_id=1",
|
|
83
|
+
}));
|
|
84
|
+
const parsed = parse(out);
|
|
85
|
+
expect(parsed._lastUpdatedOn).toBeUndefined();
|
|
86
|
+
expect(parsed._record_link).toBe("/incident.do?sys_id=1");
|
|
87
|
+
});
|
|
88
|
+
it("emits no wall-clock stamp when sys_updated_on is absent", () => {
|
|
89
|
+
// The old fallback stamped new Date().toISOString() here, which is what
|
|
90
|
+
// made every pull dirty the record.
|
|
91
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({ name: { value: "no audit columns" } }));
|
|
92
|
+
expect(out.content).not.toMatch(ISO_STAMP);
|
|
93
|
+
expect(parse(out)._lastUpdatedOn).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
it("writes the deterministic placeholder for empty content", () => {
|
|
96
|
+
const out = (0, appUtils_1.stampMetadataContent)({ name: "metaData", type: "json", content: "" });
|
|
97
|
+
expect(out.content).toBe(appUtils_1.EMPTY_METADATA_CONTENT);
|
|
98
|
+
expect(out.content).not.toMatch(ISO_STAMP);
|
|
99
|
+
});
|
|
100
|
+
it("produces identical bytes across repeated runs (the whole point)", () => {
|
|
101
|
+
const server = {
|
|
102
|
+
sys_updated_on: { value: "2025-08-21 17:09:02", display_value: "2025-08-21 10:09:02" },
|
|
103
|
+
action: { value: "abc", display_value: "Some Record Name" },
|
|
104
|
+
_record_link: "https://tenonworkstudio.service-now.com/incident.do?sys_id=1",
|
|
105
|
+
_lastUpdatedOn: "2025-08-21 17:09:02",
|
|
106
|
+
};
|
|
107
|
+
// Two independent pulls of an unchanged record, one re-stamp of the result.
|
|
108
|
+
const first = (0, appUtils_1.stampMetadataContent)(metaFile(server));
|
|
109
|
+
const second = (0, appUtils_1.stampMetadataContent)(metaFile(server));
|
|
110
|
+
const restamped = (0, appUtils_1.stampMetadataContent)(first);
|
|
111
|
+
expect(second.content).toBe(first.content);
|
|
112
|
+
expect(restamped.content).toBe(first.content);
|
|
113
|
+
});
|
|
114
|
+
it("leaves non-object JSON alone rather than throwing", () => {
|
|
115
|
+
const bare = { name: "metaData", type: "json", content: '"just a string"' };
|
|
116
|
+
expect((0, appUtils_1.stampMetadataContent)(bare)).toEqual(bare);
|
|
117
|
+
const arr = { name: "metaData", type: "json", content: "[1,2,3]" };
|
|
118
|
+
expect((0, appUtils_1.stampMetadataContent)(arr)).toEqual(arr);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
63
121
|
describe("stampMetadataContent — display_value stripping", () => {
|
|
64
122
|
it("drops display_value from field pairs, keeping value", () => {
|
|
65
123
|
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
@@ -285,3 +285,162 @@ describe("syncManifest — refresh pulls instance edits down", function () {
|
|
|
285
285
|
expect(Object.keys(missingArg.sys_script_include).sort()).toEqual(["sysid_RecA", "sysid_RecB"]);
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
|
+
/**
|
|
289
|
+
* `--metadata-only` exists so a mirror can be settled after a change to the
|
|
290
|
+
* metadata writer without the source churn `--force` produces. The guarantee it
|
|
291
|
+
* has to make is narrow and total: the resulting diff is metaData.json and
|
|
292
|
+
* nothing else — no field file, no manifest, no new record directory.
|
|
293
|
+
*/
|
|
294
|
+
describe("syncManifest — --metadata-only rewrites metadata and nothing else", function () {
|
|
295
|
+
beforeEach(function () {
|
|
296
|
+
jest.clearAllMocks();
|
|
297
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sinc-metaonly-test-"));
|
|
298
|
+
mockConfig.getSourcePathForScope.mockReturnValue(tmpRoot);
|
|
299
|
+
mockConfig.getSourcePath.mockReturnValue(tmpRoot);
|
|
300
|
+
});
|
|
301
|
+
afterEach(function () {
|
|
302
|
+
try {
|
|
303
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
catch (e) { }
|
|
306
|
+
});
|
|
307
|
+
function respondWith(record, files) {
|
|
308
|
+
var records = {};
|
|
309
|
+
records[record] = { name: record, sys_id: "sysid_" + record, files: files };
|
|
310
|
+
mockClient.getMissingFiles.mockResolvedValue({
|
|
311
|
+
sys_script_include: { records: records },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
test("rewrites metaData.json even though no field file changed", async function () {
|
|
315
|
+
var identical = "var same = true;";
|
|
316
|
+
writeLocal("Rec", "script", "js", identical);
|
|
317
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "Rec", name: "script", type: "js", content: "" }]));
|
|
318
|
+
respondWith("Rec", [
|
|
319
|
+
{ name: "script", type: "js", content: identical },
|
|
320
|
+
{
|
|
321
|
+
name: "metaData",
|
|
322
|
+
type: "json",
|
|
323
|
+
content: JSON.stringify({
|
|
324
|
+
sys_updated_on: { value: "2026-04-29 14:38:51", display_value: "2026-04-29 07:38:51" },
|
|
325
|
+
_record_link: "https://tenonworkstudio.service-now.com/sys_script_include.do?sys_id=1",
|
|
326
|
+
_lastUpdatedOn: "2026-04-29 14:38:51",
|
|
327
|
+
}),
|
|
328
|
+
},
|
|
329
|
+
]);
|
|
330
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
331
|
+
var meta = readLocal("Rec", "metaData", "json");
|
|
332
|
+
expect(meta).not.toBeNull();
|
|
333
|
+
var parsed = JSON.parse(meta);
|
|
334
|
+
// Normalized on the way in — the whole reason to regenerate.
|
|
335
|
+
expect(parsed._record_link).toBe("/sys_script_include.do?sys_id=1");
|
|
336
|
+
expect(parsed._lastUpdatedOn).toBeUndefined();
|
|
337
|
+
expect(meta).not.toContain("display_value");
|
|
338
|
+
});
|
|
339
|
+
test("does not touch field files, even when the instance content differs", async function () {
|
|
340
|
+
writeLocal("Rec", "script", "js", "var local = 1;");
|
|
341
|
+
var beforeMtime = statMtime("Rec", "script", "js");
|
|
342
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "Rec", name: "script", type: "js", content: "" }]));
|
|
343
|
+
respondWith("Rec", [
|
|
344
|
+
{ name: "script", type: "js", content: "var fromInstance = 2;" },
|
|
345
|
+
{ name: "metaData", type: "json", content: JSON.stringify({ sys_id: { value: "1" } }) },
|
|
346
|
+
]);
|
|
347
|
+
await new Promise(function (r) { setTimeout(r, 10); });
|
|
348
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
349
|
+
// Unchanged on disk AND untouched — a rewrite with identical bytes would
|
|
350
|
+
// still be a write, and this mode promises none.
|
|
351
|
+
expect(readLocal("Rec", "script", "js")).toBe("var local = 1;");
|
|
352
|
+
expect(statMtime("Rec", "script", "js")).toBe(beforeMtime);
|
|
353
|
+
});
|
|
354
|
+
test("skips records that are not checked out locally (no orphan directories)", async function () {
|
|
355
|
+
// Writing metaData for a record with no directory would leave a folder
|
|
356
|
+
// holding a lone metaData.json and zero field files.
|
|
357
|
+
//
|
|
358
|
+
// The absent-record check MUST NOT throw. syncManifest swallows errors into
|
|
359
|
+
// logger.error, so an exception here would abort the whole scope while this
|
|
360
|
+
// test's file assertions still passed — "no directory, no metaData" is
|
|
361
|
+
// trivially true when nothing ran. The logger.error assertion is what makes
|
|
362
|
+
// this test real: it caught fUtils.isDirectory rejecting on ENOENT.
|
|
363
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "NotOnDisk", name: "script", type: "js", content: "" }]));
|
|
364
|
+
respondWith("NotOnDisk", [
|
|
365
|
+
{ name: "script", type: "js", content: "// new on the instance" },
|
|
366
|
+
{ name: "metaData", type: "json", content: JSON.stringify({ sys_id: { value: "1" } }) },
|
|
367
|
+
]);
|
|
368
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
369
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
370
|
+
expect(fs.existsSync(path.join(tmpRoot, "sys_script_include", "NotOnDisk"))).toBe(false);
|
|
371
|
+
expect(readLocal("NotOnDisk", "metaData", "json")).toBeNull();
|
|
372
|
+
});
|
|
373
|
+
test("an absent record does not stop the records that ARE checked out", async function () {
|
|
374
|
+
// The real shape of a metadata-only run: the instance holds records this
|
|
375
|
+
// branch never pulled, interleaved with ones it did. An abort on the first
|
|
376
|
+
// absent record would silently settle only part of the mirror.
|
|
377
|
+
writeLocal("OnDisk", "script", "js", "var x = 1;");
|
|
378
|
+
mockClient.getManifest.mockResolvedValue(setupScope([
|
|
379
|
+
{ record: "Absent", name: "script", type: "js", content: "" },
|
|
380
|
+
{ record: "OnDisk", name: "script", type: "js", content: "" },
|
|
381
|
+
]));
|
|
382
|
+
mockClient.getMissingFiles.mockResolvedValue({
|
|
383
|
+
sys_script_include: {
|
|
384
|
+
records: {
|
|
385
|
+
Absent: {
|
|
386
|
+
name: "Absent",
|
|
387
|
+
sys_id: "sysid_Absent",
|
|
388
|
+
files: [
|
|
389
|
+
{ name: "script", type: "js", content: "// only on the instance" },
|
|
390
|
+
{ name: "metaData", type: "json", content: JSON.stringify({ sys_id: { value: "a" } }) },
|
|
391
|
+
],
|
|
392
|
+
},
|
|
393
|
+
OnDisk: {
|
|
394
|
+
name: "OnDisk",
|
|
395
|
+
sys_id: "sysid_OnDisk",
|
|
396
|
+
files: [
|
|
397
|
+
{ name: "script", type: "js", content: "var x = 1;" },
|
|
398
|
+
{
|
|
399
|
+
name: "metaData",
|
|
400
|
+
type: "json",
|
|
401
|
+
content: JSON.stringify({
|
|
402
|
+
sys_id: { value: "b" },
|
|
403
|
+
_record_link: "https://tenonworkstudio.service-now.com/x.do?sys_id=b",
|
|
404
|
+
}),
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
412
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
413
|
+
// The absent one stayed absent...
|
|
414
|
+
expect(fs.existsSync(path.join(tmpRoot, "sys_script_include", "Absent"))).toBe(false);
|
|
415
|
+
// ...and the present one was still settled.
|
|
416
|
+
var meta = readLocal("OnDisk", "metaData", "json");
|
|
417
|
+
expect(meta).not.toBeNull();
|
|
418
|
+
expect(JSON.parse(meta)._record_link).toBe("/x.do?sys_id=b");
|
|
419
|
+
});
|
|
420
|
+
test("does not persist the manifest", async function () {
|
|
421
|
+
writeLocal("Rec", "script", "js", "var x = 1;");
|
|
422
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "Rec", name: "script", type: "js", content: "" }]));
|
|
423
|
+
respondWith("Rec", [
|
|
424
|
+
{ name: "script", type: "js", content: "var x = 1;" },
|
|
425
|
+
{ name: "metaData", type: "json", content: JSON.stringify({ sys_id: { value: "1" } }) },
|
|
426
|
+
]);
|
|
427
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
428
|
+
// A manifest write would put record adds/drops/renames into a diff that is
|
|
429
|
+
// supposed to be metaData.json only.
|
|
430
|
+
expect(mockConfig.updateManifest).not.toHaveBeenCalled();
|
|
431
|
+
});
|
|
432
|
+
test("a plain refresh still persists the manifest (the gate is scoped)", async function () {
|
|
433
|
+
writeLocal("Rec", "script", "js", "var x = 1;");
|
|
434
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "Rec", name: "script", type: "js", content: "" }]));
|
|
435
|
+
respondWith("Rec", [{ name: "script", type: "js", content: "var x = 1;" }]);
|
|
436
|
+
await AppUtils.syncManifest("x_cadso_core");
|
|
437
|
+
expect(mockConfig.updateManifest).toHaveBeenCalled();
|
|
438
|
+
});
|
|
439
|
+
test("writes the deterministic placeholder when the server sends no metadata", async function () {
|
|
440
|
+
writeLocal("Rec", "script", "js", "var x = 1;");
|
|
441
|
+
mockClient.getManifest.mockResolvedValue(setupScope([{ record: "Rec", name: "script", type: "js", content: "" }]));
|
|
442
|
+
respondWith("Rec", [{ name: "script", type: "js", content: "var x = 1;" }]);
|
|
443
|
+
await AppUtils.syncManifest("x_cadso_core", { metadataOnly: true });
|
|
444
|
+
expect(readLocal("Rec", "metaData", "json")).toBe(AppUtils.EMPTY_METADATA_CONTENT);
|
|
445
|
+
});
|
|
446
|
+
});
|