@messagevisor/core 0.0.1 → 0.1.0

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.
Files changed (211) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +7 -0
  4. package/jest.config.js +8 -0
  5. package/lib/benchmark/index.d.ts +2 -0
  6. package/lib/benchmark/index.js +417 -0
  7. package/lib/benchmark/index.js.map +1 -0
  8. package/lib/builder/index.d.ts +70 -0
  9. package/lib/builder/index.js +831 -0
  10. package/lib/builder/index.js.map +1 -0
  11. package/lib/cli/index.d.ts +28 -0
  12. package/lib/cli/index.js +182 -0
  13. package/lib/cli/index.js.map +1 -0
  14. package/lib/config/index.d.ts +61 -0
  15. package/lib/config/index.js +255 -0
  16. package/lib/config/index.js.map +1 -0
  17. package/lib/create/index.d.ts +2 -0
  18. package/lib/create/index.js +405 -0
  19. package/lib/create/index.js.map +1 -0
  20. package/lib/datasource/filesystemAdapter.d.ts +44 -0
  21. package/lib/datasource/filesystemAdapter.js +424 -0
  22. package/lib/datasource/filesystemAdapter.js.map +1 -0
  23. package/lib/datasource/index.d.ts +39 -0
  24. package/lib/datasource/index.js +96 -0
  25. package/lib/datasource/index.js.map +1 -0
  26. package/lib/error.d.ts +6 -0
  27. package/lib/error.js +49 -0
  28. package/lib/error.js.map +1 -0
  29. package/lib/evaluate/cli.d.ts +8 -0
  30. package/lib/evaluate/cli.js +179 -0
  31. package/lib/evaluate/cli.js.map +1 -0
  32. package/lib/evaluate/index.d.ts +10 -0
  33. package/lib/evaluate/index.js +131 -0
  34. package/lib/evaluate/index.js.map +1 -0
  35. package/lib/examples/coerceExampleIsoDates.d.ts +12 -0
  36. package/lib/examples/coerceExampleIsoDates.js +81 -0
  37. package/lib/examples/coerceExampleIsoDates.js.map +1 -0
  38. package/lib/examples/index.d.ts +63 -0
  39. package/lib/examples/index.js +713 -0
  40. package/lib/examples/index.js.map +1 -0
  41. package/lib/exporter/index.d.ts +60 -0
  42. package/lib/exporter/index.js +610 -0
  43. package/lib/exporter/index.js.map +1 -0
  44. package/lib/find-duplicates/index.d.ts +41 -0
  45. package/lib/find-duplicates/index.js +297 -0
  46. package/lib/find-duplicates/index.js.map +1 -0
  47. package/lib/generate-code/index.d.ts +11 -0
  48. package/lib/generate-code/index.js +157 -0
  49. package/lib/generate-code/index.js.map +1 -0
  50. package/lib/generate-code/typescript.d.ts +14 -0
  51. package/lib/generate-code/typescript.js +307 -0
  52. package/lib/generate-code/typescript.js.map +1 -0
  53. package/lib/importer/index.d.ts +64 -0
  54. package/lib/importer/index.js +1092 -0
  55. package/lib/importer/index.js.map +1 -0
  56. package/lib/index.d.ts +18 -0
  57. package/lib/index.js +35 -0
  58. package/lib/index.js.map +1 -0
  59. package/lib/info/index.d.ts +17 -0
  60. package/lib/info/index.js +132 -0
  61. package/lib/info/index.js.map +1 -0
  62. package/lib/init/index.d.ts +30 -0
  63. package/lib/init/index.js +348 -0
  64. package/lib/init/index.js.map +1 -0
  65. package/lib/lint/index.d.ts +1 -0
  66. package/lib/lint/index.js +6 -0
  67. package/lib/lint/index.js.map +1 -0
  68. package/lib/linter/attributeSchema.d.ts +7 -0
  69. package/lib/linter/attributeSchema.js +36 -0
  70. package/lib/linter/attributeSchema.js.map +1 -0
  71. package/lib/linter/checkLocaleCircularDependency.d.ts +7 -0
  72. package/lib/linter/checkLocaleCircularDependency.js +42 -0
  73. package/lib/linter/checkLocaleCircularDependency.js.map +1 -0
  74. package/lib/linter/conditionSchema.d.ts +3 -0
  75. package/lib/linter/conditionSchema.js +283 -0
  76. package/lib/linter/conditionSchema.js.map +1 -0
  77. package/lib/linter/formatSchema.d.ts +325 -0
  78. package/lib/linter/formatSchema.js +165 -0
  79. package/lib/linter/formatSchema.js.map +1 -0
  80. package/lib/linter/icuStyleLint.d.ts +6 -0
  81. package/lib/linter/icuStyleLint.js +226 -0
  82. package/lib/linter/icuStyleLint.js.map +1 -0
  83. package/lib/linter/index.d.ts +34 -0
  84. package/lib/linter/index.js +557 -0
  85. package/lib/linter/index.js.map +1 -0
  86. package/lib/linter/localeSchema.d.ts +672 -0
  87. package/lib/linter/localeSchema.js +50 -0
  88. package/lib/linter/localeSchema.js.map +1 -0
  89. package/lib/linter/messageSchema.d.ts +35 -0
  90. package/lib/linter/messageSchema.js +115 -0
  91. package/lib/linter/messageSchema.js.map +1 -0
  92. package/lib/linter/printError.d.ts +8 -0
  93. package/lib/linter/printError.js +41 -0
  94. package/lib/linter/printError.js.map +1 -0
  95. package/lib/linter/schema.d.ts +33 -0
  96. package/lib/linter/schema.js +192 -0
  97. package/lib/linter/schema.js.map +1 -0
  98. package/lib/linter/segmentSchema.d.ts +8 -0
  99. package/lib/linter/segmentSchema.js +18 -0
  100. package/lib/linter/segmentSchema.js.map +1 -0
  101. package/lib/linter/targetSchema.d.ts +337 -0
  102. package/lib/linter/targetSchema.js +39 -0
  103. package/lib/linter/targetSchema.js.map +1 -0
  104. package/lib/linter/testSchema.d.ts +71 -0
  105. package/lib/linter/testSchema.js +165 -0
  106. package/lib/linter/testSchema.js.map +1 -0
  107. package/lib/linter/zodHelpers.d.ts +2 -0
  108. package/lib/linter/zodHelpers.js +15 -0
  109. package/lib/linter/zodHelpers.js.map +1 -0
  110. package/lib/list/index.d.ts +8 -0
  111. package/lib/list/index.js +524 -0
  112. package/lib/list/index.js.map +1 -0
  113. package/lib/matrix.d.ts +4 -0
  114. package/lib/matrix.js +66 -0
  115. package/lib/matrix.js.map +1 -0
  116. package/lib/promoter/index.d.ts +65 -0
  117. package/lib/promoter/index.js +1208 -0
  118. package/lib/promoter/index.js.map +1 -0
  119. package/lib/prune/index.d.ts +37 -0
  120. package/lib/prune/index.js +673 -0
  121. package/lib/prune/index.js.map +1 -0
  122. package/lib/sets.d.ts +10 -0
  123. package/lib/sets.js +120 -0
  124. package/lib/sets.js.map +1 -0
  125. package/lib/tester/cliFormat.d.ts +8 -0
  126. package/lib/tester/cliFormat.js +15 -0
  127. package/lib/tester/cliFormat.js.map +1 -0
  128. package/lib/tester/index.d.ts +35 -0
  129. package/lib/tester/index.js +713 -0
  130. package/lib/tester/index.js.map +1 -0
  131. package/lib/tester/matrix.d.ts +14 -0
  132. package/lib/tester/matrix.js +76 -0
  133. package/lib/tester/matrix.js.map +1 -0
  134. package/lib/tester/prettyDuration.d.ts +1 -0
  135. package/lib/tester/prettyDuration.js +30 -0
  136. package/lib/tester/prettyDuration.js.map +1 -0
  137. package/lib/tester/printTestResult.d.ts +2 -0
  138. package/lib/tester/printTestResult.js +32 -0
  139. package/lib/tester/printTestResult.js.map +1 -0
  140. package/lib/tester/types.d.ts +29 -0
  141. package/lib/tester/types.js +3 -0
  142. package/lib/tester/types.js.map +1 -0
  143. package/package.json +41 -13
  144. package/src/benchmark/index.spec.ts +375 -0
  145. package/src/benchmark/index.ts +433 -0
  146. package/src/builder/index.spec.ts +822 -0
  147. package/src/builder/index.ts +920 -0
  148. package/src/cli/index.spec.ts +54 -0
  149. package/src/cli/index.ts +150 -0
  150. package/src/config/index.spec.ts +70 -0
  151. package/src/config/index.ts +259 -0
  152. package/src/create/index.spec.ts +272 -0
  153. package/src/create/index.ts +295 -0
  154. package/src/datasource/filesystemAdapter.ts +313 -0
  155. package/src/datasource/index.ts +135 -0
  156. package/src/error.ts +33 -0
  157. package/src/evaluate/cli.spec.ts +368 -0
  158. package/src/evaluate/cli.ts +130 -0
  159. package/src/evaluate/index.ts +161 -0
  160. package/src/examples/coerceExampleIsoDates.spec.ts +81 -0
  161. package/src/examples/coerceExampleIsoDates.ts +98 -0
  162. package/src/examples/index.spec.ts +453 -0
  163. package/src/examples/index.ts +854 -0
  164. package/src/exporter/index.spec.ts +443 -0
  165. package/src/exporter/index.ts +643 -0
  166. package/src/find-duplicates/index.spec.ts +289 -0
  167. package/src/find-duplicates/index.ts +314 -0
  168. package/src/generate-code/index.ts +92 -0
  169. package/src/generate-code/typescript.spec.ts +241 -0
  170. package/src/generate-code/typescript.ts +284 -0
  171. package/src/importer/index.spec.ts +1101 -0
  172. package/src/importer/index.ts +1190 -0
  173. package/src/index.ts +18 -0
  174. package/src/info/index.ts +67 -0
  175. package/src/init/index.spec.ts +279 -0
  176. package/src/init/index.ts +292 -0
  177. package/src/lint/index.ts +1 -0
  178. package/src/linter/attributeSchema.ts +38 -0
  179. package/src/linter/checkLocaleCircularDependency.ts +51 -0
  180. package/src/linter/conditionSchema.ts +386 -0
  181. package/src/linter/formatSchema.ts +170 -0
  182. package/src/linter/icuStyleLint.ts +312 -0
  183. package/src/linter/index.spec.ts +824 -0
  184. package/src/linter/index.ts +460 -0
  185. package/src/linter/localeSchema.ts +70 -0
  186. package/src/linter/messageSchema.ts +152 -0
  187. package/src/linter/printError.ts +52 -0
  188. package/src/linter/schema.ts +230 -0
  189. package/src/linter/segmentSchema.ts +15 -0
  190. package/src/linter/targetSchema.ts +50 -0
  191. package/src/linter/testSchema.spec.ts +405 -0
  192. package/src/linter/testSchema.ts +239 -0
  193. package/src/linter/zodHelpers.ts +16 -0
  194. package/src/list/index.spec.ts +431 -0
  195. package/src/list/index.ts +463 -0
  196. package/src/matrix.ts +69 -0
  197. package/src/promoter/index.spec.ts +584 -0
  198. package/src/promoter/index.ts +1267 -0
  199. package/src/prune/index.spec.ts +418 -0
  200. package/src/prune/index.ts +693 -0
  201. package/src/sets.ts +74 -0
  202. package/src/tester/cliFormat.ts +11 -0
  203. package/src/tester/featurevisorIntegration.spec.ts +101 -0
  204. package/src/tester/index.spec.ts +577 -0
  205. package/src/tester/index.ts +679 -0
  206. package/src/tester/matrix.ts +106 -0
  207. package/src/tester/prettyDuration.ts +34 -0
  208. package/src/tester/printTestResult.ts +40 -0
  209. package/src/tester/types.ts +32 -0
  210. package/tsconfig.cjs.json +11 -0
  211. package/tsconfig.typecheck.json +4 -0
@@ -0,0 +1,584 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+
5
+ import { getProjectConfig } from "../config";
6
+ import { Datasource } from "../datasource";
7
+ import { promotePlugin, promoteProjectSets } from "./index";
8
+
9
+ async function writeFile(root: string, relativePath: string, content: string) {
10
+ const filePath = path.join(root, relativePath);
11
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
12
+ await fs.promises.writeFile(filePath, content);
13
+ }
14
+
15
+ async function createProject(options?: { configContent?: string; sets?: string[] }) {
16
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-promote-"));
17
+ const configContent = options?.configContent ?? "module.exports = { sets: true };\n";
18
+ const sets = options?.sets ?? ["dev", "staging"];
19
+
20
+ await writeFile(root, "messagevisor.config.js", configContent);
21
+
22
+ for (const set of sets) {
23
+ await writeFile(root, `sets/${set}/locales/en.yml`, "description: English\n");
24
+ await writeFile(
25
+ root,
26
+ `sets/${set}/locales/en-US.yml`,
27
+ "description: English US\ninheritTranslationsFrom: en\n",
28
+ );
29
+ await writeFile(
30
+ root,
31
+ `sets/${set}/targets/web.yml`,
32
+ "description: Web\nincludeMessages:\n - product*\nlocales:\n - en-US\n",
33
+ );
34
+ await writeFile(root, `sets/${set}/attributes/plan.yml`, "description: Plan\ntype: string\n");
35
+ await writeFile(
36
+ root,
37
+ `sets/${set}/segments/pro.yml`,
38
+ "description: Pro\nconditions:\n - attribute: plan\n operator: equals\n value: pro\n",
39
+ );
40
+ await writeFile(
41
+ root,
42
+ `sets/${set}/messages/product/price.yml`,
43
+ "description: Product price\ntranslations:\n en: Old price\noverrides:\n - key: pro\n segments: pro\n translations:\n en: Old pro price\n",
44
+ );
45
+ await writeFile(
46
+ root,
47
+ `sets/${set}/tests/messages/product/price.spec.yml`,
48
+ "message: product.price\nassertions:\n - locale: en-US\n expectedTranslation: Old price\n",
49
+ );
50
+ }
51
+
52
+ await writeFile(
53
+ root,
54
+ "sets/dev/messages/product/price.yml",
55
+ "description: Product price\ntranslations:\n en: New price\noverrides:\n - key: pro\n segments: pro\n translations:\n en: New pro price\n",
56
+ );
57
+ await writeFile(
58
+ root,
59
+ "sets/staging/messages/product/price.yml",
60
+ "description: Product price\ntranslations:\n en: Old price\noverrides:\n - key: pro\n segments: pro\n translations:\n en: Old pro price\n - key: staging-only\n segments: pro\n translations:\n en: Staging only\n",
61
+ );
62
+
63
+ return root;
64
+ }
65
+
66
+ describe("promoteProjectSets", function () {
67
+ it("previews selected target entities by default and applies only with apply", async function () {
68
+ const root = await createProject();
69
+ const projectConfig = getProjectConfig(root);
70
+ const datasource = new Datasource(projectConfig, root);
71
+
72
+ const preview = await promoteProjectSets(projectConfig, datasource, {
73
+ from: "dev",
74
+ to: "staging",
75
+ target: "web",
76
+ locale: "en-US",
77
+ });
78
+
79
+ expect(
80
+ preview.files.updated.some((filePath) => filePath.endsWith("messages/product/price.yml")),
81
+ ).toEqual(true);
82
+ expect(preview.apply).toEqual(false);
83
+ expect(
84
+ (await datasource.forSet("staging").readMessage("product.price")).translations.en,
85
+ ).toEqual("Old price");
86
+
87
+ const result = await promoteProjectSets(projectConfig, datasource, {
88
+ from: "dev",
89
+ to: "staging",
90
+ target: "web",
91
+ locale: "en-US",
92
+ apply: true,
93
+ });
94
+ const message = await datasource.forSet("staging").readMessage("product.price");
95
+
96
+ expect(result.dependencies.locales).toEqual(2);
97
+ expect(result.dependencies.attributes).toEqual(1);
98
+ expect(result.dependencies.segments).toEqual(1);
99
+ expect(result.apply).toEqual(true);
100
+ expect(message.translations.en).toEqual("New price");
101
+ expect(message.overrides?.map((override) => override.key)).toEqual(["pro", "staging-only"]);
102
+ expect(message.overrides?.[0].translations.en).toEqual("New pro price");
103
+ expect(message.overrides?.[1].translations.en).toEqual("Staging only");
104
+ });
105
+
106
+ it("fails when promoted overrides are not keyed", async function () {
107
+ const root = await createProject();
108
+ await writeFile(
109
+ root,
110
+ "sets/dev/messages/product/price.yml",
111
+ "description: Product price\ntranslations:\n en: New price\noverrides:\n - segments: pro\n translations:\n en: New pro price\n",
112
+ );
113
+
114
+ const projectConfig = getProjectConfig(root);
115
+ const datasource = new Datasource(projectConfig, root);
116
+
117
+ await expect(
118
+ promoteProjectSets(projectConfig, datasource, {
119
+ from: "dev",
120
+ to: "staging",
121
+ includeMessages: "product*",
122
+ }),
123
+ ).rejects.toThrow('Set "dev" failed preflight lint');
124
+ });
125
+
126
+ it("can promote messages without copying or merging overrides", async function () {
127
+ const root = await createProject();
128
+ const projectConfig = getProjectConfig(root);
129
+ const datasource = new Datasource(projectConfig, root);
130
+
131
+ const result = await promoteProjectSets(projectConfig, datasource, {
132
+ from: "dev",
133
+ to: "staging",
134
+ includeMessages: "product*",
135
+ excludeOverrides: true,
136
+ apply: true,
137
+ });
138
+ const message = await datasource.forSet("staging").readMessage("product.price");
139
+
140
+ expect(result.dependencies.attributes).toEqual(0);
141
+ expect(result.dependencies.segments).toEqual(0);
142
+ expect(message.translations.en).toEqual("New price");
143
+ expect(message.overrides?.map((override) => override.key)).toEqual(["pro", "staging-only"]);
144
+ expect(message.overrides?.[0].translations.en).toEqual("Old pro price");
145
+ expect(message.overrides?.[1].translations.en).toEqual("Staging only");
146
+ });
147
+
148
+ it("fails fast for unknown requested locales", async function () {
149
+ const root = await createProject();
150
+ const projectConfig = getProjectConfig(root);
151
+ const datasource = new Datasource(projectConfig, root);
152
+
153
+ await expect(
154
+ promoteProjectSets(projectConfig, datasource, {
155
+ from: "dev",
156
+ to: "staging",
157
+ locale: "fr",
158
+ }),
159
+ ).rejects.toThrow('Unknown source locale "fr"');
160
+ });
161
+
162
+ it("fails when include message filters match nothing unless empty promotions are allowed", async function () {
163
+ const root = await createProject();
164
+ const projectConfig = getProjectConfig(root);
165
+ const datasource = new Datasource(projectConfig, root);
166
+
167
+ await expect(
168
+ promoteProjectSets(projectConfig, datasource, {
169
+ from: "dev",
170
+ to: "staging",
171
+ includeMessages: "missing*",
172
+ }),
173
+ ).rejects.toThrow("No source messages matched");
174
+
175
+ const result = await promoteProjectSets(projectConfig, datasource, {
176
+ from: "dev",
177
+ to: "staging",
178
+ includeMessages: "missing*",
179
+ allowEmpty: true,
180
+ });
181
+
182
+ expect(result.dependencies.messages).toEqual(0);
183
+ expect(result.files.updated).toEqual([]);
184
+ });
185
+
186
+ it("validates destination override keys during preflight", async function () {
187
+ const root = await createProject();
188
+ await writeFile(
189
+ root,
190
+ "sets/staging/messages/product/price.yml",
191
+ "description: Product price\ntranslations:\n en: Old price\noverrides:\n - segments: pro\n translations:\n en: Staging pro price\n",
192
+ );
193
+
194
+ const projectConfig = getProjectConfig(root);
195
+ const datasource = new Datasource(projectConfig, root);
196
+
197
+ await expect(
198
+ promoteProjectSets(projectConfig, datasource, {
199
+ from: "dev",
200
+ to: "staging",
201
+ includeMessages: "product*",
202
+ }),
203
+ ).rejects.toThrow('Set "staging" failed preflight lint');
204
+ });
205
+
206
+ it("can preserve destination conflicts instead of source values", async function () {
207
+ const root = await createProject();
208
+ const projectConfig = getProjectConfig(root);
209
+ const datasource = new Datasource(projectConfig, root);
210
+
211
+ const result = await promoteProjectSets(projectConfig, datasource, {
212
+ from: "dev",
213
+ to: "staging",
214
+ includeMessages: "product*",
215
+ conflicts: "destination",
216
+ });
217
+ const message = await datasource.forSet("staging").readMessage("product.price");
218
+
219
+ expect(result.conflicts.length).toBeGreaterThan(0);
220
+ expect(message.translations.en).toEqual("Old price");
221
+ expect(message.overrides?.[0].translations.en).toEqual("Old pro price");
222
+ expect(message.overrides?.[1].translations.en).toEqual("Staging only");
223
+ });
224
+
225
+ it("fails when conflict policy is fail and source would overwrite destination", async function () {
226
+ const root = await createProject();
227
+ const projectConfig = getProjectConfig(root);
228
+ const datasource = new Datasource(projectConfig, root);
229
+
230
+ await expect(
231
+ promoteProjectSets(projectConfig, datasource, {
232
+ from: "dev",
233
+ to: "staging",
234
+ includeMessages: "product*",
235
+ conflicts: "fail",
236
+ }),
237
+ ).rejects.toThrow("conflict(s) and --conflicts=fail was used");
238
+ });
239
+
240
+ it("hides unchanged entries by default for previews and shows them on demand", async function () {
241
+ const root = await createProject();
242
+ const projectConfig = getProjectConfig(root);
243
+ const datasource = new Datasource(projectConfig, root);
244
+ const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
245
+
246
+ try {
247
+ await promotePlugin.handler({
248
+ projectConfig,
249
+ datasource,
250
+ parsed: {
251
+ from: "dev",
252
+ to: "staging",
253
+ },
254
+ });
255
+
256
+ const defaultOutput = consoleLogSpy.mock.calls.flat().join("\n");
257
+
258
+ expect(defaultOutput).toContain("Mode: preview");
259
+ expect(defaultOutput).toContain("Unchanged: ");
260
+ expect(defaultOutput).not.toContain("Unchanged\n");
261
+ expect(defaultOutput).not.toContain("sets/staging/locales/en.yml");
262
+
263
+ consoleLogSpy.mockClear();
264
+
265
+ await promotePlugin.handler({
266
+ projectConfig,
267
+ datasource,
268
+ parsed: {
269
+ from: "dev",
270
+ to: "staging",
271
+ showUnchanged: true,
272
+ },
273
+ });
274
+
275
+ const verboseOutput = consoleLogSpy.mock.calls.flat().join("\n");
276
+
277
+ expect(verboseOutput).toContain("Unchanged\n");
278
+ expect(verboseOutput).toContain("sets/staging/locales/en.yml");
279
+ } finally {
280
+ consoleLogSpy.mockRestore();
281
+ }
282
+ });
283
+
284
+ it("applies from the CLI only when --apply is passed", async function () {
285
+ const root = await createProject();
286
+ const projectConfig = getProjectConfig(root);
287
+ const datasource = new Datasource(projectConfig, root);
288
+ const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
289
+
290
+ try {
291
+ await promotePlugin.handler({
292
+ projectConfig,
293
+ datasource,
294
+ parsed: {
295
+ from: "dev",
296
+ to: "staging",
297
+ apply: true,
298
+ },
299
+ });
300
+ const output = consoleLogSpy.mock.calls.flat().join("\n");
301
+ const message = await datasource.forSet("staging").readMessage("product.price");
302
+
303
+ expect(output).toContain("Mode: apply");
304
+ expect(output).toContain("Promotion applied");
305
+ expect(message.translations.en).toEqual("New price");
306
+ } finally {
307
+ consoleLogSpy.mockRestore();
308
+ }
309
+ });
310
+
311
+ it("prints expected workflow errors from the CLI without throwing", async function () {
312
+ const root = await createProject({ configContent: "module.exports = {};\n" });
313
+ const projectConfig = getProjectConfig(root);
314
+ const datasource = new Datasource(projectConfig, root);
315
+ const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
316
+
317
+ try {
318
+ const result = await promotePlugin.handler({
319
+ projectConfig,
320
+ datasource,
321
+ parsed: {
322
+ from: "dev",
323
+ to: "staging",
324
+ },
325
+ });
326
+
327
+ expect(result).toEqual(false);
328
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
329
+ "Promotion is only available when `sets: true` is configured.",
330
+ );
331
+ } finally {
332
+ consoleErrorSpy.mockRestore();
333
+ }
334
+ });
335
+
336
+ it("creates missing entities even when source marks them as non-promotable", async function () {
337
+ const root = await createProject();
338
+ await writeFile(root, "sets/dev/locales/nl.yml", "description: Dutch\npromotable: false\n");
339
+ await writeFile(
340
+ root,
341
+ "sets/dev/attributes/channel.yml",
342
+ "description: Channel\npromotable: false\ntype: string\n",
343
+ );
344
+ await writeFile(
345
+ root,
346
+ "sets/dev/segments/internal.yml",
347
+ "description: Internal\npromotable: false\nconditions:\n - attribute: channel\n operator: equals\n value: internal\n",
348
+ );
349
+ await writeFile(
350
+ root,
351
+ "sets/dev/targets/mobile.yml",
352
+ "description: Mobile\npromotable: false\nincludeMessages:\n - mobile*\nlocales:\n - nl\n",
353
+ );
354
+ await writeFile(
355
+ root,
356
+ "sets/dev/messages/mobile/banner.yml",
357
+ "description: Mobile banner\npromotable: false\ntranslations:\n nl: Mobiel\n",
358
+ );
359
+ await writeFile(
360
+ root,
361
+ "sets/dev/tests/messages/mobile/banner.spec.yml",
362
+ "promotable: false\nmessage: mobile.banner\nassertions:\n - locale: nl\n expectedTranslation: Mobiel\n",
363
+ );
364
+
365
+ const projectConfig = getProjectConfig(root);
366
+ const datasource = new Datasource(projectConfig, root);
367
+
368
+ const result = await promoteProjectSets(projectConfig, datasource, {
369
+ from: "dev",
370
+ to: "staging",
371
+ apply: true,
372
+ });
373
+
374
+ expect(result.files.created).toEqual(
375
+ expect.arrayContaining([
376
+ expect.stringContaining("sets/staging/locales/nl.yml"),
377
+ expect.stringContaining("sets/staging/attributes/channel.yml"),
378
+ expect.stringContaining("sets/staging/segments/internal.yml"),
379
+ expect.stringContaining("sets/staging/targets/mobile.yml"),
380
+ expect.stringContaining("sets/staging/messages/mobile/banner.yml"),
381
+ expect.stringContaining("sets/staging/tests/messages/mobile/banner.yml"),
382
+ ]),
383
+ );
384
+ expect((await datasource.forSet("staging").readLocale("nl")).promotable).toEqual(false);
385
+ expect((await datasource.forSet("staging").readAttribute("channel")).promotable).toEqual(false);
386
+ expect((await datasource.forSet("staging").readSegment("internal")).promotable).toEqual(false);
387
+ expect((await datasource.forSet("staging").readTarget("mobile")).promotable).toEqual(false);
388
+ expect((await datasource.forSet("staging").readMessage("mobile.banner")).promotable).toEqual(
389
+ false,
390
+ );
391
+ expect(await datasource.forSet("staging").listTests()).toEqual(
392
+ expect.arrayContaining(["messages.mobile.banner"]),
393
+ );
394
+ expect(
395
+ (await datasource.forSet("staging").readTest("messages.mobile.banner")).promotable,
396
+ ).toEqual(false);
397
+ });
398
+
399
+ it("skips updates when either source or destination marks an existing entity as non-promotable", async function () {
400
+ const root = await createProject();
401
+ await writeFile(
402
+ root,
403
+ "sets/dev/messages/product/price.yml",
404
+ "description: Product price\npromotable: false\ntranslations:\n en: Protected source price\noverrides:\n - key: pro\n segments: pro\n translations:\n en: Protected source pro price\n",
405
+ );
406
+ await writeFile(
407
+ root,
408
+ "sets/dev/segments/pro.yml",
409
+ "description: Source pro\nconditions:\n - attribute: plan\n operator: equals\n value: pro\n",
410
+ );
411
+ await writeFile(
412
+ root,
413
+ "sets/staging/segments/pro.yml",
414
+ "description: Destination pro\npromotable: false\nconditions:\n - attribute: plan\n operator: equals\n value: staging\n",
415
+ );
416
+
417
+ const projectConfig = getProjectConfig(root);
418
+ const datasource = new Datasource(projectConfig, root);
419
+
420
+ const result = await promoteProjectSets(projectConfig, datasource, {
421
+ from: "dev",
422
+ to: "staging",
423
+ includeMessages: "product*",
424
+ apply: true,
425
+ });
426
+ const message = await datasource.forSet("staging").readMessage("product.price");
427
+ const segment = await datasource.forSet("staging").readSegment("pro");
428
+
429
+ expect(result.files.unchanged).toEqual(
430
+ expect.arrayContaining([
431
+ expect.stringContaining("sets/staging/messages/product/price.yml"),
432
+ expect.stringContaining("sets/staging/segments/pro.yml"),
433
+ ]),
434
+ );
435
+ expect(
436
+ result.conflicts.some(
437
+ (conflict) => conflict.type === "message" && conflict.key === "product.price",
438
+ ),
439
+ ).toEqual(false);
440
+ expect(message.translations.en).toEqual("Old price");
441
+ expect(message.overrides?.[0].translations.en).toEqual("Old pro price");
442
+ expect(segment.description).toEqual("Destination pro");
443
+ expect((segment.conditions as any[])[0].value).toEqual("staging");
444
+ });
445
+
446
+ it("does not write an audit in preview mode", async function () {
447
+ const root = await createProject();
448
+ const projectConfig = getProjectConfig(root);
449
+ const datasource = new Datasource(projectConfig, root);
450
+
451
+ const result = await promoteProjectSets(projectConfig, datasource, {
452
+ from: "dev",
453
+ to: "staging",
454
+ includeMessages: "product*",
455
+ audit: "markdown",
456
+ });
457
+
458
+ expect(result.auditFilePath).toBeUndefined();
459
+ });
460
+
461
+ it("writes collision-safe UTC audit files when requested", async function () {
462
+ jest.useFakeTimers().setSystemTime(new Date("2026-04-19T10:20:30Z"));
463
+
464
+ try {
465
+ const root = await createProject();
466
+ const projectConfig = getProjectConfig(root);
467
+ const datasource = new Datasource(projectConfig, root);
468
+
469
+ const first = await promoteProjectSets(projectConfig, datasource, {
470
+ from: "dev",
471
+ to: "staging",
472
+ includeMessages: "product*",
473
+ apply: true,
474
+ audit: "markdown",
475
+ });
476
+ const second = await promoteProjectSets(projectConfig, datasource, {
477
+ from: "dev",
478
+ to: "staging",
479
+ includeMessages: "product*",
480
+ apply: true,
481
+ audit: "markdown",
482
+ });
483
+
484
+ expect(first.auditFilePath).toContain(
485
+ ".messagevisor/promotions/20260419T102030-dev-to-staging.md",
486
+ );
487
+ expect(second.auditFilePath).toContain(
488
+ ".messagevisor/promotions/20260419T102030-dev-to-staging-1.md",
489
+ );
490
+
491
+ const audit = await fs.promises.readFile(
492
+ path.resolve(process.cwd(), first.auditFilePath),
493
+ "utf8",
494
+ );
495
+ expect(audit).toContain("# Messagevisor Promotion");
496
+ expect(audit).toContain("- Mode: apply");
497
+ expect(audit).toContain("messages/product/price.yml");
498
+ } finally {
499
+ jest.useRealTimers();
500
+ }
501
+ });
502
+
503
+ it("writes apply mode in JSON audit files", async function () {
504
+ const root = await createProject();
505
+ const projectConfig = getProjectConfig(root);
506
+ const datasource = new Datasource(projectConfig, root);
507
+
508
+ const result = await promoteProjectSets(projectConfig, datasource, {
509
+ from: "dev",
510
+ to: "staging",
511
+ includeMessages: "product*",
512
+ apply: true,
513
+ audit: "json",
514
+ });
515
+
516
+ expect(result.auditFilePath).toContain(".json");
517
+
518
+ const audit = JSON.parse(
519
+ await fs.promises.readFile(path.resolve(process.cwd(), result.auditFilePath!), "utf8"),
520
+ );
521
+
522
+ expect(audit.apply).toEqual(true);
523
+ expect(audit).not.toHaveProperty("check");
524
+ expect(audit).not.toHaveProperty("dryRun");
525
+ });
526
+
527
+ it("allows configured promotion flows and blocks disallowed ones", async function () {
528
+ const root = await createProject({
529
+ sets: ["dev", "staging", "production"],
530
+ configContent: [
531
+ "module.exports = {",
532
+ " sets: true,",
533
+ " promotionFlows: [",
534
+ ' { from: "dev", to: "staging" },',
535
+ ' { from: "staging", to: "production" },',
536
+ " ],",
537
+ "};",
538
+ "",
539
+ ].join("\n"),
540
+ });
541
+ const projectConfig = getProjectConfig(root);
542
+ const datasource = new Datasource(projectConfig, root);
543
+
544
+ const allowed = await promoteProjectSets(projectConfig, datasource, {
545
+ from: "dev",
546
+ to: "staging",
547
+ });
548
+
549
+ expect(allowed.from).toEqual("dev");
550
+ expect(allowed.to).toEqual("staging");
551
+
552
+ await expect(
553
+ promoteProjectSets(projectConfig, datasource, {
554
+ from: "dev",
555
+ to: "production",
556
+ }),
557
+ ).rejects.toThrow(
558
+ 'Promotion from "dev" to "production" is not allowed by this project\'s configured promotionFlows.',
559
+ );
560
+ });
561
+
562
+ it("blocks all promotions when promotionFlows is empty", async function () {
563
+ const root = await createProject({
564
+ configContent: [
565
+ "module.exports = {",
566
+ " sets: true,",
567
+ " promotionFlows: [],",
568
+ "};",
569
+ "",
570
+ ].join("\n"),
571
+ });
572
+ const projectConfig = getProjectConfig(root);
573
+ const datasource = new Datasource(projectConfig, root);
574
+
575
+ await expect(
576
+ promoteProjectSets(projectConfig, datasource, {
577
+ from: "dev",
578
+ to: "staging",
579
+ }),
580
+ ).rejects.toThrow(
581
+ 'Promotion from "dev" to "staging" is not allowed by this project\'s configured promotionFlows.',
582
+ );
583
+ });
584
+ });