enablement-build-template-generate 1.0.19 → 1.3.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.
- package/README.md +30 -31
- package/package.json +3 -2
- package/src/index.mjs +216 -71
- package/src/lib.mjs +131 -0
- package/tests/index.test.mjs +545 -0
- package/tests/lib.test.mjs +208 -0
package/src/lib.mjs
CHANGED
|
@@ -1,4 +1,135 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import { readdirSync } from 'fs';
|
|
3
|
+
import { detectCicdType } from 'enablement-build-manifest/src/detect.mjs';
|
|
4
|
+
import { resolveWorkspaceChildren, buildPackageJsonGlob, excludeChildren } from 'enablement-build-manifest/src/workspace.mjs';
|
|
5
|
+
|
|
6
|
+
// Re-exposes the manifest package's pure package-type detector (output/language/
|
|
7
|
+
// tier/packageType from package.json shape or csproj/pyproject.toml/Dockerfile)
|
|
8
|
+
// so template-generate can classify a package without needing manifest.json.
|
|
9
|
+
export { detectCicdType };
|
|
10
|
+
|
|
11
|
+
// Re-exposes the manifest package's workspace root-directory detector
|
|
12
|
+
// (pnpm-workspace.yaml or package.json#workspaces), the exclude filter for it,
|
|
13
|
+
// and the glob it implies, so template-generate can find package roots
|
|
14
|
+
// without needing manifest.json.
|
|
15
|
+
export { resolveWorkspaceChildren, buildPackageJsonGlob, excludeChildren };
|
|
16
|
+
|
|
17
|
+
// Re-exposes the manifest package's dependency-order sort, so a package's
|
|
18
|
+
// local dependencies (per manifest.json's "dependencies") are generated
|
|
19
|
+
// before it, as much as possible.
|
|
20
|
+
export { topologicalSort } from 'enablement-build-manifest/src/dependencyOrder.mjs';
|
|
21
|
+
|
|
22
|
+
// ─── templated.json format helpers ───────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// templated.json schema versions. 1 = legacy rootPath+template shape, 2 = the
|
|
25
|
+
// advanced { templated, cicd } shape. Every write stamps ADVANCED_VERSION;
|
|
26
|
+
// LEGACY_VERSION is only ever inferred for older, unversioned files.
|
|
27
|
+
export const TEMPLATED_JSON_LEGACY_VERSION = 1;
|
|
28
|
+
export const TEMPLATED_JSON_ADVANCED_VERSION = 2;
|
|
29
|
+
|
|
30
|
+
// True when parsed templated.json content is in the advanced format. An
|
|
31
|
+
// explicit "version" property is authoritative and used exclusively — no
|
|
32
|
+
// shape-sniffing once it's present. Only when "version" is absent do we infer
|
|
33
|
+
// it from the legacy content's shape (a top-level "templated" key = advanced).
|
|
34
|
+
export function isAdvancedTemplatedFormat(data) {
|
|
35
|
+
if (!data) return false;
|
|
36
|
+
if (typeof data.version === 'number') return data.version >= TEMPLATED_JSON_ADVANCED_VERSION;
|
|
37
|
+
return !!data.templated;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pick only the per-package fields generation needs (folderName, packageFolder,
|
|
41
|
+
// metadata) out of a fuller manifest.json cicd map, dropping unrelated fields
|
|
42
|
+
// like output/language/tier/packageType/hash/version.
|
|
43
|
+
export function extractGenerationCicd(cicd = {}) {
|
|
44
|
+
const result = {};
|
|
45
|
+
Object.keys(cicd).forEach((pkgName) => {
|
|
46
|
+
const entry = cicd[pkgName] || {};
|
|
47
|
+
const picked = {};
|
|
48
|
+
if (entry.folderName) picked.folderName = entry.folderName;
|
|
49
|
+
if (entry.packageFolder) picked.packageFolder = entry.packageFolder;
|
|
50
|
+
if (entry.metadata) picked.metadata = entry.metadata;
|
|
51
|
+
result[pkgName] = picked;
|
|
52
|
+
});
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Collect every package name already assigned to a template anywhere in the
|
|
57
|
+
// advanced-format templated map, across all pipeline files and placeholders.
|
|
58
|
+
export function collectAssignedPackages(templated = {}) {
|
|
59
|
+
const names = new Set();
|
|
60
|
+
Object.values(templated).forEach((placeholders) => {
|
|
61
|
+
Object.values(placeholders || {}).forEach((pkgMap) => {
|
|
62
|
+
Object.keys(pkgMap || {}).forEach((name) => names.add(name));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
return names;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// List "build" template files: underscore-prefixed .yml files in the templates
|
|
69
|
+
// dir that aren't scan/check templates. Mirrors manifest.mjs's prior convention
|
|
70
|
+
// for choosing a template by detected package type.
|
|
71
|
+
export function listBuildTemplateFiles(templatePath) {
|
|
72
|
+
let files;
|
|
73
|
+
try {
|
|
74
|
+
files = readdirSync(templatePath);
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const withUnderscore = files.filter((f) => f.endsWith('.yml') && f.includes('_'));
|
|
79
|
+
return withUnderscore.filter((f) => !f.includes('scan') && !f.includes('check'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Map a detected cicd "output" value to the keyword used to pick a build template.
|
|
83
|
+
function templateKeywordForOutput(output) {
|
|
84
|
+
if (output === 'pypi') return 'python';
|
|
85
|
+
if (output === 'nuget') return 'dotnet';
|
|
86
|
+
if (output === 'docker') return 'docker';
|
|
87
|
+
return 'node';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Derive the best-fit build template for a detected output type.
|
|
91
|
+
export function deriveTemplateForType(output, buildTemplateFiles) {
|
|
92
|
+
const lookfor = templateKeywordForOutput(output);
|
|
93
|
+
for (const file of buildTemplateFiles) {
|
|
94
|
+
if (file.includes(lookfor)) return file;
|
|
95
|
+
}
|
|
96
|
+
return buildTemplateFiles[0] || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Choose where a newly-discovered package should be assigned, mirroring
|
|
100
|
+
// manifest.mjs's prior convention:
|
|
101
|
+
// 1. reuse the placeholder of an existing package under the same packageFolder
|
|
102
|
+
// 2. else reuse the placeholder of an existing package of the same output type
|
|
103
|
+
// 3. else fall back to the default "PACKAGES" placeholder on the first pipeline file
|
|
104
|
+
export function chooseAssignmentForNewPackage({ packageFolder, output, templated, cicd, buildTemplateFiles }) {
|
|
105
|
+
const pipelineFiles = Object.keys(templated);
|
|
106
|
+
const primaryPipelineFile = pipelineFiles[0] || 'build.yml';
|
|
107
|
+
|
|
108
|
+
for (const pipelineFile of pipelineFiles) {
|
|
109
|
+
for (const [placeholder, pkgMap] of Object.entries(templated[pipelineFile] || {})) {
|
|
110
|
+
for (const existingName of Object.keys(pkgMap || {})) {
|
|
111
|
+
if ((cicd[existingName] || {}).packageFolder === packageFolder) {
|
|
112
|
+
return { pipelineFile, placeholder, template: pkgMap[existingName] };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lookfor = templateKeywordForOutput(output);
|
|
119
|
+
for (const [placeholder, pkgMap] of Object.entries(templated[primaryPipelineFile] || {})) {
|
|
120
|
+
for (const tpl of Object.values(pkgMap || {})) {
|
|
121
|
+
if (typeof tpl === 'string' && tpl.toLowerCase().includes(lookfor)) {
|
|
122
|
+
return { pipelineFile: primaryPipelineFile, placeholder, template: tpl };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
pipelineFile: primaryPipelineFile,
|
|
129
|
+
placeholder: 'PACKAGES',
|
|
130
|
+
template: deriveTemplateForType(output, buildTemplateFiles),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
2
133
|
|
|
3
134
|
// Priority order for version manifest files (lower index = higher priority)
|
|
4
135
|
export const MANIFEST_PRIORITY = { 'package.json': 0, 'version.json': 1, 'pyproject.toml': 2 };
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
|
|
8
|
+
function writeJson(filePath, data) {
|
|
9
|
+
writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createFixtureWorkspace(mode, options = {}) {
|
|
13
|
+
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
14
|
+
|
|
15
|
+
const defaultJobTemplateLines = [
|
|
16
|
+
'- job: build_{safe}',
|
|
17
|
+
' displayName: NAME={name}',
|
|
18
|
+
' safe: SAFE={safe}',
|
|
19
|
+
' package: PKG={package}',
|
|
20
|
+
' full: FULL={full}',
|
|
21
|
+
' other: OTHER={other}',
|
|
22
|
+
" script: cd ./{path}/{name}",
|
|
23
|
+
" condition: contains(variables['changed'], '{name}')",
|
|
24
|
+
'',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const jobTemplateLines = options.jobTemplateLines || defaultJobTemplateLines;
|
|
28
|
+
|
|
29
|
+
mkdirSync(path.join(tmpRoot, '.cicd', 'templates'), { recursive: true });
|
|
30
|
+
mkdirSync(path.join(tmpRoot, 'packages', 'alpha-lib'), { recursive: true });
|
|
31
|
+
mkdirSync(path.join(tmpRoot, 'packages', 'beta-tool'), { recursive: true });
|
|
32
|
+
|
|
33
|
+
writeJson(path.join(tmpRoot, 'packages', 'alpha-lib', 'package.json'), {
|
|
34
|
+
name: '@scope/enablement-build-alpha-lib',
|
|
35
|
+
version: '1.0.0',
|
|
36
|
+
});
|
|
37
|
+
writeJson(path.join(tmpRoot, 'packages', 'beta-tool', 'package.json'), {
|
|
38
|
+
name: 'enablement-build-beta-tool',
|
|
39
|
+
version: '1.0.0',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
writeFileSync(
|
|
43
|
+
path.join(tmpRoot, '.cicd', 'templates', 'build.yml'),
|
|
44
|
+
'jobs:\n{PACKAGES}\n',
|
|
45
|
+
'utf8'
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
writeFileSync(
|
|
49
|
+
path.join(tmpRoot, '.cicd', 'templates', 'job.yml'),
|
|
50
|
+
jobTemplateLines.join('\n'),
|
|
51
|
+
'utf8'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const cicdEntries = {
|
|
55
|
+
'@scope/enablement-build-alpha-lib': {
|
|
56
|
+
packageFolder: 'packages',
|
|
57
|
+
...(options.metadataByPackage?.['@scope/enablement-build-alpha-lib'] || {}),
|
|
58
|
+
},
|
|
59
|
+
'enablement-build-beta-tool': {
|
|
60
|
+
packageFolder: 'packages',
|
|
61
|
+
...(options.metadataByPackage?.['enablement-build-beta-tool'] || {}),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (mode === 'manifest') {
|
|
66
|
+
writeJson(path.join(tmpRoot, '.cicd', 'manifest.json'), {
|
|
67
|
+
templated: {
|
|
68
|
+
'build.yml': {
|
|
69
|
+
PACKAGES: {
|
|
70
|
+
'@scope/enablement-build-alpha-lib': 'job.yml',
|
|
71
|
+
'enablement-build-beta-tool': 'job.yml',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
cicd: cicdEntries,
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
writeJson(path.join(tmpRoot, '.cicd', 'templates', 'templated.json'), {
|
|
79
|
+
'build.yml': {
|
|
80
|
+
PACKAGES: {
|
|
81
|
+
rootPath: '../../packages',
|
|
82
|
+
template: 'job.yml',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return tmpRoot;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function runGenerator(cwd, extraArgs = []) {
|
|
92
|
+
const scriptPath = fileURLToPath(new URL('../src/index.mjs', import.meta.url));
|
|
93
|
+
execFileSync(process.execPath, [scriptPath, '--platform', 'ado', ...extraArgs], {
|
|
94
|
+
cwd,
|
|
95
|
+
stdio: 'pipe',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return readFileSync(path.join(cwd, '.cicd', 'build.yml'), 'utf8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stripBanner(content) {
|
|
102
|
+
return content
|
|
103
|
+
.split('\n')
|
|
104
|
+
.filter(
|
|
105
|
+
(line) =>
|
|
106
|
+
line !== '#######################################' &&
|
|
107
|
+
line !== '### DO NOT EDIT - THIS IS GENERATED ###'
|
|
108
|
+
)
|
|
109
|
+
.join('\n')
|
|
110
|
+
.trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeOutput(content, root) {
|
|
114
|
+
return stripBanner(content).split(root).join('<ROOT>');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('template-generate mode parity', () => {
|
|
118
|
+
it('expands placeholders consistently between manifest and templated.json modes', () => {
|
|
119
|
+
const manifestRoot = createFixtureWorkspace('manifest');
|
|
120
|
+
const legacyRoot = createFixtureWorkspace('legacy');
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const manifestOut = runGenerator(manifestRoot);
|
|
124
|
+
const legacyOut = runGenerator(legacyRoot);
|
|
125
|
+
|
|
126
|
+
expect(manifestOut).toContain('NAME=alpha-lib');
|
|
127
|
+
expect(manifestOut).toContain('NAME=beta-tool');
|
|
128
|
+
expect(manifestOut).toContain('SAFE=alphalib');
|
|
129
|
+
expect(manifestOut).toContain('SAFE=betatool');
|
|
130
|
+
expect(manifestOut).toContain('PKG=enablement-build-alpha-lib');
|
|
131
|
+
expect(manifestOut).toContain('PKG=enablement-build-beta-tool');
|
|
132
|
+
expect(manifestOut).toContain('FULL=@scope/enablement-build-alpha-lib');
|
|
133
|
+
expect(manifestOut).toContain('FULL=enablement-build-beta-tool');
|
|
134
|
+
expect(manifestOut).toContain(`OTHER=${manifestRoot}/packages/beta-tool`);
|
|
135
|
+
expect(manifestOut).toContain(`OTHER=${manifestRoot}/packages/alpha-lib`);
|
|
136
|
+
expect(manifestOut).toContain('script: cd ./packages/alpha-lib');
|
|
137
|
+
expect(manifestOut).toContain('script: cd ./packages/beta-tool');
|
|
138
|
+
expect(manifestOut).toContain("condition: contains(variables['changed'], 'alpha-lib')");
|
|
139
|
+
expect(manifestOut).toContain("condition: contains(variables['changed'], 'beta-tool')");
|
|
140
|
+
|
|
141
|
+
expect(normalizeOutput(manifestOut, manifestRoot)).toBe(normalizeOutput(legacyOut, legacyRoot));
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(manifestRoot, { recursive: true, force: true });
|
|
144
|
+
rmSync(legacyRoot, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('applies custom metadata placeholders only in manifest mode', () => {
|
|
149
|
+
const options = {
|
|
150
|
+
jobTemplateLines: [
|
|
151
|
+
'- job: meta_{safe}',
|
|
152
|
+
' owner: OWNER={owner}',
|
|
153
|
+
' tier: TIER={tier}',
|
|
154
|
+
' name: NAME={name}',
|
|
155
|
+
'',
|
|
156
|
+
],
|
|
157
|
+
metadataByPackage: {
|
|
158
|
+
'@scope/enablement-build-alpha-lib': {
|
|
159
|
+
metadata: { owner: 'team-alpha', tier: 'gold' },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const manifestRoot = createFixtureWorkspace('manifest', options);
|
|
165
|
+
const legacyRoot = createFixtureWorkspace('legacy', options);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const manifestOut = runGenerator(manifestRoot);
|
|
169
|
+
const legacyOut = runGenerator(legacyRoot);
|
|
170
|
+
|
|
171
|
+
expect(manifestOut).toContain('owner: OWNER=team-alpha');
|
|
172
|
+
expect(manifestOut).toContain('tier: TIER=gold');
|
|
173
|
+
expect(manifestOut).toContain('owner: OWNER={owner}');
|
|
174
|
+
expect(manifestOut).toContain('tier: TIER={tier}');
|
|
175
|
+
|
|
176
|
+
expect(legacyOut).not.toContain('owner: OWNER=team-alpha');
|
|
177
|
+
expect(legacyOut).not.toContain('tier: TIER=gold');
|
|
178
|
+
expect(legacyOut).toContain('owner: OWNER={owner}');
|
|
179
|
+
expect(legacyOut).toContain('tier: TIER={tier}');
|
|
180
|
+
} finally {
|
|
181
|
+
rmSync(manifestRoot, { recursive: true, force: true });
|
|
182
|
+
rmSync(legacyRoot, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('templated.json migration', () => {
|
|
188
|
+
it("migrates manifest.json's templated section into templated.json and strips it from manifest.json", () => {
|
|
189
|
+
const root = createFixtureWorkspace('manifest');
|
|
190
|
+
try {
|
|
191
|
+
runGenerator(root);
|
|
192
|
+
|
|
193
|
+
const manifest = JSON.parse(readFileSync(path.join(root, '.cicd', 'manifest.json'), 'utf8'));
|
|
194
|
+
expect(manifest.templated).toBeUndefined();
|
|
195
|
+
|
|
196
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
197
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['@scope/enablement-build-alpha-lib']).toBe('job.yml');
|
|
198
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['enablement-build-beta-tool']).toBe('job.yml');
|
|
199
|
+
expect(templatedJson.cicd['@scope/enablement-build-alpha-lib'].packageFolder).toBe('packages');
|
|
200
|
+
expect(templatedJson.version).toBe(2);
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(root, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('converts a legacy templated.json to the new format on disk', () => {
|
|
207
|
+
const root = createFixtureWorkspace('legacy');
|
|
208
|
+
try {
|
|
209
|
+
runGenerator(root);
|
|
210
|
+
|
|
211
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
212
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['@scope/enablement-build-alpha-lib']).toBe('job.yml');
|
|
213
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['enablement-build-beta-tool']).toBe('job.yml');
|
|
214
|
+
expect(templatedJson.cicd['@scope/enablement-build-alpha-lib']).toEqual({ folderName: 'alpha-lib', packageFolder: 'packages' });
|
|
215
|
+
expect(templatedJson.cicd['enablement-build-beta-tool']).toEqual({ folderName: 'beta-tool', packageFolder: 'packages' });
|
|
216
|
+
expect(templatedJson.version).toBe(2);
|
|
217
|
+
} finally {
|
|
218
|
+
rmSync(root, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('backfills the version field on an unversioned advanced-format templated.json, even when nothing else changed', () => {
|
|
223
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
224
|
+
try {
|
|
225
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
226
|
+
mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
|
|
227
|
+
|
|
228
|
+
writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), {
|
|
229
|
+
name: 'existing-lib',
|
|
230
|
+
version: '1.0.0',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
234
|
+
writeFileSync(
|
|
235
|
+
path.join(root, '.cicd', 'templates', 'job.yml'),
|
|
236
|
+
'- job: build_{safe}\n displayName: NAME={name}\n',
|
|
237
|
+
'utf8'
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Advanced format already, but predates the version field.
|
|
241
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
242
|
+
templated: { 'build.yml': { PACKAGES: { 'existing-lib': 'job.yml' } } },
|
|
243
|
+
cicd: { 'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' } },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const firstOut = runGenerator(root);
|
|
247
|
+
const templatedAfter = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
248
|
+
|
|
249
|
+
expect(templatedAfter.version).toBe(2);
|
|
250
|
+
expect(templatedAfter.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
|
|
251
|
+
|
|
252
|
+
// Running again is now a true no-op: same output, same file.
|
|
253
|
+
const secondOut = runGenerator(root);
|
|
254
|
+
const templatedAfterSecond = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
255
|
+
expect(secondOut).toBe(firstOut);
|
|
256
|
+
expect(templatedAfterSecond).toEqual(templatedAfter);
|
|
257
|
+
} finally {
|
|
258
|
+
rmSync(root, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('detects a new package, assigns it a template by type, and leaves existing packages untouched', () => {
|
|
263
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
264
|
+
try {
|
|
265
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
266
|
+
mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
|
|
267
|
+
mkdirSync(path.join(root, 'apps', 'new-svc'), { recursive: true });
|
|
268
|
+
|
|
269
|
+
writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), {
|
|
270
|
+
name: 'existing-lib',
|
|
271
|
+
version: '1.0.0',
|
|
272
|
+
});
|
|
273
|
+
writeJson(path.join(root, 'apps', 'new-svc', 'package.json'), {
|
|
274
|
+
name: 'new-svc',
|
|
275
|
+
version: '1.0.0',
|
|
276
|
+
main: 'index.js',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
280
|
+
writeFileSync(
|
|
281
|
+
path.join(root, '.cicd', 'templates', 'job.yml'),
|
|
282
|
+
'- job: build_{safe}\n displayName: NAME={name}\n',
|
|
283
|
+
'utf8'
|
|
284
|
+
);
|
|
285
|
+
writeFileSync(
|
|
286
|
+
path.join(root, '.cicd', 'templates', '_build_node_package.yml'),
|
|
287
|
+
'- job: node_{safe}\n displayName: NAME={name}\n',
|
|
288
|
+
'utf8'
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
292
|
+
templated: {
|
|
293
|
+
'build.yml': {
|
|
294
|
+
PACKAGES: { 'existing-lib': 'job.yml' },
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
cicd: {
|
|
298
|
+
'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' },
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const firstOut = runGenerator(root);
|
|
303
|
+
const templatedAfterFirst = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
304
|
+
|
|
305
|
+
// Existing package untouched.
|
|
306
|
+
expect(templatedAfterFirst.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
|
|
307
|
+
expect(templatedAfterFirst.cicd['existing-lib']).toEqual({ folderName: 'existing-lib', packageFolder: 'packages' });
|
|
308
|
+
|
|
309
|
+
// New package detected, classified as a node package, and assigned the node build template.
|
|
310
|
+
expect(templatedAfterFirst.templated['build.yml'].PACKAGES['new-svc']).toBe('_build_node_package.yml');
|
|
311
|
+
expect(templatedAfterFirst.cicd['new-svc']).toEqual({ folderName: 'new-svc', packageFolder: 'apps' });
|
|
312
|
+
|
|
313
|
+
expect(firstOut).toContain('NAME=existing-lib');
|
|
314
|
+
expect(firstOut).toContain('NAME=new-svc');
|
|
315
|
+
|
|
316
|
+
// Second run: nothing new to detect, output and config are stable.
|
|
317
|
+
const secondOut = runGenerator(root);
|
|
318
|
+
const templatedAfterSecond = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
319
|
+
|
|
320
|
+
expect(secondOut).toBe(firstOut);
|
|
321
|
+
expect(templatedAfterSecond).toEqual(templatedAfterFirst);
|
|
322
|
+
} finally {
|
|
323
|
+
rmSync(root, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('template-generate new-package assignment convention', () => {
|
|
329
|
+
it('reuses the placeholder/template of an existing package under the same packageFolder (tier 1)', () => {
|
|
330
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
331
|
+
try {
|
|
332
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
333
|
+
mkdirSync(path.join(root, 'packages', 'new-a'), { recursive: true });
|
|
334
|
+
|
|
335
|
+
writeJson(path.join(root, 'packages', 'new-a', 'package.json'), { name: 'new-a', version: '1.0.0', main: 'index.js' });
|
|
336
|
+
|
|
337
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
338
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n', 'utf8');
|
|
339
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'custom.yml'), '- job: custom_{safe}\n', 'utf8');
|
|
340
|
+
// No "_build_*" template files exist at all — if this fell through to the
|
|
341
|
+
// type-derived fallback, there'd be nothing to assign and new-a would be skipped.
|
|
342
|
+
|
|
343
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
344
|
+
templated: { 'build.yml': { PACKAGES: { 'existing-a': 'custom.yml' } } },
|
|
345
|
+
cicd: { 'existing-a': { folderName: 'existing-a', packageFolder: 'packages' } },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const out = runGenerator(root, ['--children', 'packages']);
|
|
349
|
+
|
|
350
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
351
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['new-a']).toBe('custom.yml');
|
|
352
|
+
expect(templatedJson.cicd['new-a']).toEqual({ folderName: 'new-a', packageFolder: 'packages' });
|
|
353
|
+
expect(out).toContain('custom_newa');
|
|
354
|
+
} finally {
|
|
355
|
+
rmSync(root, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('reuses the placeholder/template of an existing package of the same output type when packageFolder differs (tier 2)', () => {
|
|
360
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
361
|
+
try {
|
|
362
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
363
|
+
mkdirSync(path.join(root, 'apps', 'new-node-app'), { recursive: true });
|
|
364
|
+
|
|
365
|
+
writeJson(path.join(root, 'apps', 'new-node-app', 'package.json'), { name: 'new-node-app', version: '1.0.0', main: 'index.js' });
|
|
366
|
+
|
|
367
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n{SERVICES}\n', 'utf8');
|
|
368
|
+
writeFileSync(path.join(root, '.cicd', 'templates', '_build_node_package.yml'), '- job: node_{safe}\n', 'utf8');
|
|
369
|
+
|
|
370
|
+
// existing-node-lib lives under a different packageFolder ("services"),
|
|
371
|
+
// so tier 1 (same packageFolder) cannot match new-node-app ("apps").
|
|
372
|
+
// It's assigned a template containing "node" under a non-default
|
|
373
|
+
// placeholder ("SERVICES"), which tier 2 should reuse verbatim.
|
|
374
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
375
|
+
templated: {
|
|
376
|
+
'build.yml': {
|
|
377
|
+
PACKAGES: {},
|
|
378
|
+
SERVICES: { 'existing-node-lib': '_build_node_package.yml' },
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
cicd: {
|
|
382
|
+
'existing-node-lib': { folderName: 'existing-node-lib', packageFolder: 'services' },
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
runGenerator(root, ['--children', 'apps']);
|
|
387
|
+
|
|
388
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
389
|
+
// Landed in SERVICES (existing-node-lib's placeholder), not the PACKAGES
|
|
390
|
+
// default — proving reuse-by-type (tier 2), not the type-derived fallback (tier 3).
|
|
391
|
+
expect(templatedJson.templated['build.yml'].SERVICES['new-node-app']).toBe('_build_node_package.yml');
|
|
392
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['new-node-app']).toBeUndefined();
|
|
393
|
+
} finally {
|
|
394
|
+
rmSync(root, { recursive: true, force: true });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe('template-generate --children', () => {
|
|
400
|
+
it('scopes new-package scanning to an explicit --children list, overriding the broad-scan default', () => {
|
|
401
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
402
|
+
try {
|
|
403
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
404
|
+
mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
|
|
405
|
+
mkdirSync(path.join(root, 'tools', 'skip-me'), { recursive: true });
|
|
406
|
+
|
|
407
|
+
// No pnpm-workspace.yaml / package.json#workspaces declared at all, so
|
|
408
|
+
// without --children this would broad-scan and find "tools/skip-me" too.
|
|
409
|
+
writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), { name: 'existing-lib', version: '1.0.0' });
|
|
410
|
+
writeJson(path.join(root, 'tools', 'skip-me', 'package.json'), { name: 'skip-me', version: '1.0.0', main: 'index.js' });
|
|
411
|
+
|
|
412
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
413
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n displayName: NAME={name}\n', 'utf8');
|
|
414
|
+
|
|
415
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
416
|
+
templated: { 'build.yml': { PACKAGES: { 'existing-lib': 'job.yml' } } },
|
|
417
|
+
cicd: { 'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' } },
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
runGenerator(root, ['--children', 'packages']);
|
|
421
|
+
|
|
422
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
423
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
|
|
424
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['skip-me']).toBeUndefined();
|
|
425
|
+
expect(templatedJson.cicd['skip-me']).toBeUndefined();
|
|
426
|
+
} finally {
|
|
427
|
+
rmSync(root, { recursive: true, force: true });
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe('template-generate --exclude', () => {
|
|
433
|
+
it('drops an excluded root directory from auto-detected new-package scanning', () => {
|
|
434
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
435
|
+
try {
|
|
436
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
437
|
+
mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
|
|
438
|
+
mkdirSync(path.join(root, 'tools', 'skip-me'), { recursive: true });
|
|
439
|
+
|
|
440
|
+
writeJson(path.join(root, 'package.json'), {
|
|
441
|
+
name: 'tmp-monorepo',
|
|
442
|
+
private: true,
|
|
443
|
+
workspaces: ['packages/*', 'tools/*'],
|
|
444
|
+
});
|
|
445
|
+
writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), { name: 'existing-lib', version: '1.0.0' });
|
|
446
|
+
writeJson(path.join(root, 'tools', 'skip-me', 'package.json'), { name: 'skip-me', version: '1.0.0', main: 'index.js' });
|
|
447
|
+
|
|
448
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
449
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n displayName: NAME={name}\n', 'utf8');
|
|
450
|
+
|
|
451
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
452
|
+
templated: { 'build.yml': { PACKAGES: { 'existing-lib': 'job.yml' } } },
|
|
453
|
+
cicd: { 'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' } },
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
runGenerator(root, ['--exclude', 'tools']);
|
|
457
|
+
|
|
458
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
459
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
|
|
460
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['skip-me']).toBeUndefined();
|
|
461
|
+
expect(templatedJson.cicd['skip-me']).toBeUndefined();
|
|
462
|
+
} finally {
|
|
463
|
+
rmSync(root, { recursive: true, force: true });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
describe('template-generate dependency ordering', () => {
|
|
469
|
+
it('generates a dependency before its dependent, per manifest.json dependencies', () => {
|
|
470
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
471
|
+
try {
|
|
472
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
473
|
+
|
|
474
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
475
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n', 'utf8');
|
|
476
|
+
|
|
477
|
+
// Assignment order deliberately does not match dependency order:
|
|
478
|
+
// pkg-a depends on pkg-b depends on pkg-c, but keys are alphabetical.
|
|
479
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
480
|
+
templated: {
|
|
481
|
+
'build.yml': {
|
|
482
|
+
PACKAGES: { 'pkg-a': 'job.yml', 'pkg-b': 'job.yml', 'pkg-c': 'job.yml' },
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
cicd: {
|
|
486
|
+
'pkg-a': { folderName: 'pkg-a', packageFolder: 'packages' },
|
|
487
|
+
'pkg-b': { folderName: 'pkg-b', packageFolder: 'packages' },
|
|
488
|
+
'pkg-c': { folderName: 'pkg-c', packageFolder: 'packages' },
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// manifest.json here only supplies the dependency graph (no "templated").
|
|
493
|
+
writeJson(path.join(root, '.cicd', 'manifest.json'), {
|
|
494
|
+
dependencies: {
|
|
495
|
+
'pkg-a': ['pkg-b'],
|
|
496
|
+
'pkg-b': ['pkg-c'],
|
|
497
|
+
'pkg-c': [],
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const out = runGenerator(root);
|
|
502
|
+
|
|
503
|
+
const posA = out.indexOf('build_pkga');
|
|
504
|
+
const posB = out.indexOf('build_pkgb');
|
|
505
|
+
const posC = out.indexOf('build_pkgc');
|
|
506
|
+
|
|
507
|
+
expect(posC).toBeLessThan(posB);
|
|
508
|
+
expect(posB).toBeLessThan(posA);
|
|
509
|
+
} finally {
|
|
510
|
+
rmSync(root, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
describe('template-generate --exclude (generation-side)', () => {
|
|
516
|
+
it('omits an already-assigned package from the generated output by folder name', () => {
|
|
517
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
518
|
+
try {
|
|
519
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
520
|
+
|
|
521
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
522
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n', 'utf8');
|
|
523
|
+
|
|
524
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
525
|
+
version: 2,
|
|
526
|
+
templated: {
|
|
527
|
+
'build.yml': {
|
|
528
|
+
PACKAGES: { 'keep-me': 'job.yml', 'drop-me': 'job.yml' },
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
cicd: {
|
|
532
|
+
'keep-me': { folderName: 'keep-me', packageFolder: 'packages' },
|
|
533
|
+
'drop-me': { folderName: 'drop-me', packageFolder: 'packages' },
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const out = runGenerator(root, ['--exclude', 'drop-me']);
|
|
538
|
+
|
|
539
|
+
expect(out).toContain('build_keepme');
|
|
540
|
+
expect(out).not.toContain('build_dropme');
|
|
541
|
+
} finally {
|
|
542
|
+
rmSync(root, { recursive: true, force: true });
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
});
|