create-contractor-site 2.1.1
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/bin/create-contractor-site.mjs +293 -0
- package/package.json +42 -0
- package/scripts/smoke-test.mjs +502 -0
- package/src/copy-template.mjs +286 -0
- package/src/prompts.mjs +121 -0
- package/src/replace-data.mjs +465 -0
- package/src/run-command.mjs +399 -0
- package/src/validate-target.mjs +96 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Committed CLI smoke tests — Node built-ins only (no test framework).
|
|
4
|
+
*
|
|
5
|
+
* Covers:
|
|
6
|
+
* 1. Missing target argument
|
|
7
|
+
* 2. Existing non-empty target
|
|
8
|
+
* 3. Target equal to / inside template root
|
|
9
|
+
* 4. Duplicate service input normalization + slug uniqueness + business/services alignment
|
|
10
|
+
* 5. Service-area name parse/dedupe (Chesapeake duplicate-slug case) + areas rebuild
|
|
11
|
+
* without leaking stale template county/ZIP metadata into new areas
|
|
12
|
+
* 6. Temp-target --yes scaffold (install/validate/build) unless SKIP_CLI_E2E=1
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import {
|
|
21
|
+
buildAlignedAreas,
|
|
22
|
+
buildAlignedServices,
|
|
23
|
+
normalizePrimaryServices,
|
|
24
|
+
normalizeServiceAreaNames,
|
|
25
|
+
parseServiceAreaNames,
|
|
26
|
+
replaceTargetData,
|
|
27
|
+
slugify,
|
|
28
|
+
} from '../src/replace-data.mjs';
|
|
29
|
+
import { buildAnswers } from '../src/prompts.mjs';
|
|
30
|
+
import { isSameOrInside, validateTarget, TargetValidationError } from '../src/validate-target.mjs';
|
|
31
|
+
import { findLocalTemplateRoot } from '../src/copy-template.mjs';
|
|
32
|
+
import { isVersionAtLeast, resolveCommandPath } from '../src/run-command.mjs';
|
|
33
|
+
|
|
34
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const CLI = path.resolve(__dirname, '../bin/create-contractor-site.mjs');
|
|
36
|
+
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
37
|
+
const REPO_ROOT = findLocalTemplateRoot(PKG_ROOT);
|
|
38
|
+
|
|
39
|
+
let passed = 0;
|
|
40
|
+
let failed = 0;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} name
|
|
44
|
+
* @param {() => void | Promise<void>} fn
|
|
45
|
+
*/
|
|
46
|
+
async function test(name, fn) {
|
|
47
|
+
try {
|
|
48
|
+
await fn();
|
|
49
|
+
passed += 1;
|
|
50
|
+
console.log(` ✓ ${name}`);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
failed += 1;
|
|
53
|
+
console.error(` ✗ ${name}`);
|
|
54
|
+
console.error(` ${err instanceof Error ? err.message : err}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {boolean} condition
|
|
60
|
+
* @param {string} message
|
|
61
|
+
*/
|
|
62
|
+
function assert(condition, message) {
|
|
63
|
+
if (!condition) throw new Error(message);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string[]} args
|
|
68
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string }} [opts]
|
|
69
|
+
*/
|
|
70
|
+
function runCli(args, opts = {}) {
|
|
71
|
+
return spawnSync(process.execPath, [CLI, ...args], {
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
cwd: opts.cwd || process.cwd(),
|
|
74
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
75
|
+
shell: false,
|
|
76
|
+
windowsHide: true,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
console.log('create-contractor-site smoke tests\n');
|
|
82
|
+
|
|
83
|
+
await test('resolveCommandPath finds pnpm and git without shell', () => {
|
|
84
|
+
const pnpm = resolveCommandPath('pnpm');
|
|
85
|
+
const git = resolveCommandPath('git');
|
|
86
|
+
assert(Boolean(pnpm), 'expected pnpm on PATH');
|
|
87
|
+
assert(Boolean(git), 'expected git on PATH');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('pnpm version comparator enforces minimum version', () => {
|
|
91
|
+
assert(isVersionAtLeast('11.1.2', '11.1.2'), 'same version should pass');
|
|
92
|
+
assert(isVersionAtLeast('11.2.0', '11.1.2'), 'newer minor should pass');
|
|
93
|
+
assert(isVersionAtLeast('12.0.0', '11.1.2'), 'newer major should pass');
|
|
94
|
+
assert(!isVersionAtLeast('11.1.1', '11.1.2'), 'older patch should fail');
|
|
95
|
+
assert(!isVersionAtLeast('10.9.9', '11.1.2'), 'older major should fail');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await test('missing target argument exits 1 with usage', () => {
|
|
99
|
+
const result = runCli([]);
|
|
100
|
+
assert(result.status === 1, `expected exit 1, got ${result.status}`);
|
|
101
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
102
|
+
assert(/Usage: create-contractor-site/i.test(out), 'expected usage text');
|
|
103
|
+
assert(/Missing target directory/i.test(out), 'expected missing-target message');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await test('existing non-empty target exits 1', () => {
|
|
107
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-nonempty-'));
|
|
108
|
+
fs.writeFileSync(path.join(dir, 'keep.txt'), 'x');
|
|
109
|
+
try {
|
|
110
|
+
const result = runCli(['--yes', dir], {
|
|
111
|
+
env: REPO_ROOT
|
|
112
|
+
? { CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT }
|
|
113
|
+
: {},
|
|
114
|
+
});
|
|
115
|
+
assert(result.status === 1, `expected exit 1, got ${result.status}`);
|
|
116
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
117
|
+
assert(/not empty/i.test(out), `expected non-empty message, got:\n${out}`);
|
|
118
|
+
} finally {
|
|
119
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test('rejects target equal to or inside template root', () => {
|
|
124
|
+
assert(REPO_ROOT, 'expected local template root for this check');
|
|
125
|
+
let threw = false;
|
|
126
|
+
try {
|
|
127
|
+
validateTarget(REPO_ROOT, { templateRoot: REPO_ROOT });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
threw = err instanceof TargetValidationError;
|
|
130
|
+
assert(threw, 'expected TargetValidationError for template root');
|
|
131
|
+
assert(
|
|
132
|
+
/must not be the template root or inside it/i.test(
|
|
133
|
+
/** @type {Error} */ (err).message,
|
|
134
|
+
),
|
|
135
|
+
'expected inside-template message',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
assert(threw, 'validateTarget should throw for template root');
|
|
139
|
+
|
|
140
|
+
const inside = path.join(REPO_ROOT, 'src');
|
|
141
|
+
assert(isSameOrInside(inside, REPO_ROOT), 'src should be inside template');
|
|
142
|
+
threw = false;
|
|
143
|
+
try {
|
|
144
|
+
validateTarget(inside, { templateRoot: REPO_ROOT });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
threw = err instanceof TargetValidationError;
|
|
147
|
+
}
|
|
148
|
+
assert(threw, 'validateTarget should throw for path inside template');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('normalizePrimaryServices drops name and slug duplicates', () => {
|
|
152
|
+
const input = [
|
|
153
|
+
'Masonry',
|
|
154
|
+
'masonry',
|
|
155
|
+
'Patios',
|
|
156
|
+
'Patio Work',
|
|
157
|
+
'Patios',
|
|
158
|
+
' ',
|
|
159
|
+
'Retaining Walls',
|
|
160
|
+
'retaining-walls', // slug collision with Retaining Walls
|
|
161
|
+
];
|
|
162
|
+
const normalized = normalizePrimaryServices(input);
|
|
163
|
+
assert(
|
|
164
|
+
JSON.stringify(normalized) ===
|
|
165
|
+
JSON.stringify(['Masonry', 'Patios', 'Patio Work', 'Retaining Walls']),
|
|
166
|
+
`unexpected normalized list: ${JSON.stringify(normalized)}`,
|
|
167
|
+
);
|
|
168
|
+
const slugs = normalized.map(slugify);
|
|
169
|
+
assert(new Set(slugs).size === slugs.length, 'normalized slugs must be unique');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await test('buildAlignedServices keeps unique names/slugs and business alignment', () => {
|
|
173
|
+
const existing = [
|
|
174
|
+
{ id: 'masonry', name: 'Masonry', slug: 'masonry', short_description: 'a' },
|
|
175
|
+
{ id: 'hardscape', name: 'Hardscape', slug: 'hardscape', short_description: 'b' },
|
|
176
|
+
{ id: 'patios', name: 'Patios', slug: 'patios', short_description: 'c' },
|
|
177
|
+
{
|
|
178
|
+
id: 'retaining-walls',
|
|
179
|
+
name: 'Retaining Walls',
|
|
180
|
+
slug: 'retaining-walls',
|
|
181
|
+
short_description: 'd',
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const { services, offered } = buildAlignedServices(
|
|
186
|
+
existing,
|
|
187
|
+
['Masonry', 'Masonry', 'Patios', 'Retaining Walls', 'Custom Stone', 'custom stone'],
|
|
188
|
+
{ businessName: 'Acme', serviceArea: 'VA Beach' },
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const names = services.map((s) => String(s.name));
|
|
192
|
+
const slugs = services.map((s) => String(s.slug));
|
|
193
|
+
assert(new Set(names.map((n) => n.toLowerCase())).size === names.length, `duplicate names: ${names}`);
|
|
194
|
+
assert(new Set(slugs).size === slugs.length, `duplicate slugs: ${slugs}`);
|
|
195
|
+
|
|
196
|
+
assert(offered.length === services.length, 'offered length must match services');
|
|
197
|
+
for (let i = 0; i < services.length; i += 1) {
|
|
198
|
+
assert(offered[i].name === services[i].name, `name mismatch at ${i}`);
|
|
199
|
+
assert(offered[i].slug === services[i].slug, `slug mismatch at ${i}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Template leading slugs preserved for first slots after input dedupe
|
|
203
|
+
assert(services[0].slug === 'masonry', 'first slug should stay masonry');
|
|
204
|
+
assert(services[1].slug === 'hardscape' || services[1].name === 'Patios', 'slot mapping still shape-stable');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await test('parse/normalize service areas keep city first and drop slug dups', () => {
|
|
208
|
+
const parsed = parseServiceAreaNames(
|
|
209
|
+
'Chesapeake, Virginia Beach; Norfolk / Newport News & Hampton\nPortsmouth, Suffolk, Williamsburg and Poquoson',
|
|
210
|
+
);
|
|
211
|
+
assert(parsed.includes('Williamsburg'), 'expected Williamsburg from "and" split');
|
|
212
|
+
assert(parsed.includes('Poquoson'), 'expected Poquoson from "and" split');
|
|
213
|
+
assert(parsed.includes('Virginia Beach'), 'expected Virginia Beach');
|
|
214
|
+
assert(parsed.includes('Newport News'), 'expected Newport News from slash split');
|
|
215
|
+
assert(parsed.includes('Hampton'), 'expected Hampton from ampersand split');
|
|
216
|
+
assert(parsed.includes('Portsmouth'), 'expected Portsmouth from newline split');
|
|
217
|
+
|
|
218
|
+
const normalized = normalizeServiceAreaNames(
|
|
219
|
+
'Chesapeake',
|
|
220
|
+
'Chesapeake, Virginia Beach, Norfolk, Newport News, Hampton, Portsmouth, Suffolk, Williamsburg and Poquoson',
|
|
221
|
+
);
|
|
222
|
+
assert(normalized[0] === 'Chesapeake', `city must be first: ${normalized[0]}`);
|
|
223
|
+
assert(
|
|
224
|
+
normalized.filter((n) => n.toLowerCase() === 'chesapeake').length === 1,
|
|
225
|
+
'Chesapeake must appear once',
|
|
226
|
+
);
|
|
227
|
+
const slugs = normalized.map(slugify);
|
|
228
|
+
assert(new Set(slugs).size === slugs.length, `duplicate area slugs: ${slugs}`);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await test('buildAlignedAreas reuses rows and never emits duplicate slugs', () => {
|
|
232
|
+
const existing = [
|
|
233
|
+
{
|
|
234
|
+
name: 'Virginia Beach',
|
|
235
|
+
slug: 'virginia-beach',
|
|
236
|
+
county: 'Virginia Beach',
|
|
237
|
+
state: 'VA',
|
|
238
|
+
zip_codes: ['23451'],
|
|
239
|
+
},
|
|
240
|
+
{ name: 'Norfolk', slug: 'norfolk', county: 'Norfolk', state: 'VA', zip_codes: ['23502'] },
|
|
241
|
+
{
|
|
242
|
+
name: 'Chesapeake',
|
|
243
|
+
slug: 'chesapeake',
|
|
244
|
+
county: 'Chesapeake',
|
|
245
|
+
state: 'VA',
|
|
246
|
+
zip_codes: ['23320'],
|
|
247
|
+
},
|
|
248
|
+
{ name: 'Suffolk', slug: 'suffolk', county: 'Suffolk', state: 'VA', zip_codes: ['23434'] },
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const names = normalizeServiceAreaNames(
|
|
252
|
+
'Chesapeake',
|
|
253
|
+
'Chesapeake, Virginia Beach, Norfolk, Newport News, Hampton, Portsmouth, Suffolk, Williamsburg and Poquoson',
|
|
254
|
+
);
|
|
255
|
+
const areas = buildAlignedAreas(existing, names, {
|
|
256
|
+
city: 'Chesapeake',
|
|
257
|
+
state: 'VA',
|
|
258
|
+
zip: '23320',
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
assert(areas[0].name === 'Chesapeake', 'primary area name');
|
|
262
|
+
assert(areas[0].slug === 'chesapeake', 'primary area slug');
|
|
263
|
+
assert(
|
|
264
|
+
JSON.stringify(areas[0].zip_codes) === JSON.stringify(['23320']),
|
|
265
|
+
'primary zip from answers',
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const slugs = areas.map((a) => String(a.slug));
|
|
269
|
+
assert(new Set(slugs).size === slugs.length, `duplicate slugs in aligned areas: ${slugs}`);
|
|
270
|
+
|
|
271
|
+
const norfolk = areas.find((a) => a.slug === 'norfolk');
|
|
272
|
+
assert(norfolk, 'expected reused Norfolk row');
|
|
273
|
+
assert(
|
|
274
|
+
JSON.stringify(norfolk.zip_codes) === JSON.stringify(['23502']),
|
|
275
|
+
'matched rows keep prior zip_codes',
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
for (const slug of ['newport-news', 'hampton', 'portsmouth', 'williamsburg', 'poquoson']) {
|
|
279
|
+
const area = areas.find((a) => a.slug === slug);
|
|
280
|
+
assert(area, `expected generated ${slug} area`);
|
|
281
|
+
assert(area.county === area.name, `${slug} must not inherit stale county: ${area.county}`);
|
|
282
|
+
assert(
|
|
283
|
+
JSON.stringify(area.zip_codes) === JSON.stringify([]),
|
|
284
|
+
`${slug} must not inherit stale zip_codes: ${JSON.stringify(area.zip_codes)}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await test('replaceTargetData writes aligned services into temp data files', () => {
|
|
290
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
291
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-replace-'));
|
|
292
|
+
const dataSrc = path.join(REPO_ROOT, 'src', 'data');
|
|
293
|
+
const dataDst = path.join(tmp, 'src', 'data');
|
|
294
|
+
fs.mkdirSync(dataDst, { recursive: true });
|
|
295
|
+
for (const file of [
|
|
296
|
+
'business.json',
|
|
297
|
+
'site.json',
|
|
298
|
+
'services.json',
|
|
299
|
+
'areas.json',
|
|
300
|
+
'navigation.json',
|
|
301
|
+
'landings.json',
|
|
302
|
+
]) {
|
|
303
|
+
fs.copyFileSync(path.join(dataSrc, file), path.join(dataDst, file));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const answers = buildAnswers({
|
|
307
|
+
businessName: 'Acme Masonry',
|
|
308
|
+
primaryServices: [
|
|
309
|
+
'Masonry',
|
|
310
|
+
'Masonry',
|
|
311
|
+
'Patios',
|
|
312
|
+
'Retaining Walls',
|
|
313
|
+
'Custom Fabrication',
|
|
314
|
+
'custom-fabrication',
|
|
315
|
+
],
|
|
316
|
+
city: 'Virginia Beach',
|
|
317
|
+
state: 'VA',
|
|
318
|
+
serviceArea: 'Hampton Roads',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
replaceTargetData(tmp, answers);
|
|
322
|
+
|
|
323
|
+
const business = JSON.parse(
|
|
324
|
+
fs.readFileSync(path.join(dataDst, 'business.json'), 'utf8'),
|
|
325
|
+
);
|
|
326
|
+
const services = JSON.parse(
|
|
327
|
+
fs.readFileSync(path.join(dataDst, 'services.json'), 'utf8'),
|
|
328
|
+
);
|
|
329
|
+
const navigation = JSON.parse(
|
|
330
|
+
fs.readFileSync(path.join(dataDst, 'navigation.json'), 'utf8'),
|
|
331
|
+
);
|
|
332
|
+
const landings = JSON.parse(
|
|
333
|
+
fs.readFileSync(path.join(dataDst, 'landings.json'), 'utf8'),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const offered = business.services_offered;
|
|
337
|
+
const catalog = services.services;
|
|
338
|
+
assert(Array.isArray(offered) && Array.isArray(catalog), 'expected service arrays');
|
|
339
|
+
assert(offered.length === catalog.length, 'services_offered must align with services.json length');
|
|
340
|
+
|
|
341
|
+
const names = catalog.map((s) => s.name);
|
|
342
|
+
const slugs = catalog.map((s) => s.slug);
|
|
343
|
+
assert(new Set(names.map((n) => n.toLowerCase())).size === names.length, `dup names ${names}`);
|
|
344
|
+
assert(new Set(slugs).size === slugs.length, `dup slugs ${slugs}`);
|
|
345
|
+
|
|
346
|
+
for (let i = 0; i < catalog.length; i += 1) {
|
|
347
|
+
assert(offered[i].name === catalog[i].name, `aligned name ${i}`);
|
|
348
|
+
assert(offered[i].slug === catalog[i].slug, `aligned slug ${i}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const expectedLinks = catalog.map((service) => ({
|
|
352
|
+
label: service.name,
|
|
353
|
+
href: `/services/${service.slug}`,
|
|
354
|
+
}));
|
|
355
|
+
const serviceNav = navigation.header.find((item) => item.href === '/services');
|
|
356
|
+
assert(serviceNav, 'expected Services nav item');
|
|
357
|
+
assert(
|
|
358
|
+
JSON.stringify(serviceNav.children) === JSON.stringify(expectedLinks),
|
|
359
|
+
`navigation service links not aligned: ${JSON.stringify(serviceNav.children)}`,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const footerServices = navigation.footer.find((column) => column.title === 'Services');
|
|
363
|
+
assert(footerServices, 'expected Services footer column');
|
|
364
|
+
assert(
|
|
365
|
+
JSON.stringify(footerServices.links) === JSON.stringify(expectedLinks),
|
|
366
|
+
`footer service links not aligned: ${JSON.stringify(footerServices.links)}`,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const landingPages = landings.landing_pages;
|
|
370
|
+
assert(
|
|
371
|
+
landingPages.every((page) => catalog.some((service) => service.slug === page.slug && service.name === page.name)),
|
|
372
|
+
`landing pages must align to generated services: ${JSON.stringify(landingPages)}`,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
assert(business.name === 'Acme Masonry', 'business name replaced');
|
|
376
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await test('replaceTargetData Chesapeake case keeps unique area slugs', () => {
|
|
380
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
381
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-areas-'));
|
|
382
|
+
const dataSrc = path.join(REPO_ROOT, 'src', 'data');
|
|
383
|
+
const dataDst = path.join(tmp, 'src', 'data');
|
|
384
|
+
fs.mkdirSync(dataDst, { recursive: true });
|
|
385
|
+
for (const file of [
|
|
386
|
+
'business.json',
|
|
387
|
+
'site.json',
|
|
388
|
+
'services.json',
|
|
389
|
+
'areas.json',
|
|
390
|
+
'navigation.json',
|
|
391
|
+
'landings.json',
|
|
392
|
+
]) {
|
|
393
|
+
fs.copyFileSync(path.join(dataSrc, file), path.join(dataDst, file));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const originalAreas = JSON.parse(
|
|
397
|
+
fs.readFileSync(path.join(dataDst, 'areas.json'), 'utf8'),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const answers = buildAnswers({
|
|
401
|
+
businessName: 'Chesapeake Stoneworks',
|
|
402
|
+
city: 'Chesapeake',
|
|
403
|
+
state: 'VA',
|
|
404
|
+
zip: '23320',
|
|
405
|
+
serviceArea:
|
|
406
|
+
'Chesapeake, Virginia Beach, Norfolk, Newport News, Hampton, Portsmouth, Suffolk, Williamsburg and Poquoson',
|
|
407
|
+
primaryServices: ['Masonry', 'Patios'],
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
replaceTargetData(tmp, answers);
|
|
411
|
+
|
|
412
|
+
const areas = JSON.parse(fs.readFileSync(path.join(dataDst, 'areas.json'), 'utf8'));
|
|
413
|
+
assert(areas.primary_city === 'Chesapeake', 'primary_city must be Chesapeake');
|
|
414
|
+
assert(areas.variant === originalAreas.variant, 'variant preserved');
|
|
415
|
+
assert(areas.section_title === originalAreas.section_title, 'section_title preserved');
|
|
416
|
+
assert(areas._instructions, '_instructions preserved');
|
|
417
|
+
|
|
418
|
+
assert(Array.isArray(areas.areas) && areas.areas.length > 0, 'areas array required');
|
|
419
|
+
assert(areas.areas[0].name === 'Chesapeake', `first area name: ${areas.areas[0].name}`);
|
|
420
|
+
assert(areas.areas[0].slug === 'chesapeake', `first area slug: ${areas.areas[0].slug}`);
|
|
421
|
+
|
|
422
|
+
const slugs = areas.areas.map((a) => a.slug);
|
|
423
|
+
assert(new Set(slugs).size === slugs.length, `duplicate area slugs after replace: ${slugs}`);
|
|
424
|
+
assert(
|
|
425
|
+
slugs.filter((s) => s === 'chesapeake').length === 1,
|
|
426
|
+
'chesapeake slug must appear exactly once',
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const skipE2E = process.env.SKIP_CLI_E2E === '1';
|
|
433
|
+
if (skipE2E) {
|
|
434
|
+
console.log('\n ↷ SKIP_CLI_E2E=1 — skipping temp-target full scaffold');
|
|
435
|
+
} else {
|
|
436
|
+
await test('temp-target --yes scaffold install/validate/build/git', () => {
|
|
437
|
+
assert(REPO_ROOT, 'expected local template root for E2E');
|
|
438
|
+
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-e2e-'));
|
|
439
|
+
// mkdtemp creates the dir; CLI rejects non-empty — use a child path that does not exist.
|
|
440
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
441
|
+
const targetDir = target;
|
|
442
|
+
|
|
443
|
+
const result = runCli(['--yes', targetDir], {
|
|
444
|
+
env: {
|
|
445
|
+
CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT,
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
450
|
+
if (result.status !== 0) {
|
|
451
|
+
throw new Error(`scaffold failed (exit ${result.status}):\n${out.slice(-4000)}`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
assert(/Scaffold complete/i.test(out), 'expected success banner');
|
|
455
|
+
assert(fs.existsSync(path.join(targetDir, 'dist')), 'expected dist/ after build');
|
|
456
|
+
assert(fs.existsSync(path.join(targetDir, 'AGENTS.md')), 'expected AGENTS.md guard');
|
|
457
|
+
assert(
|
|
458
|
+
!fs.existsSync(path.join(targetDir, 'openspec')),
|
|
459
|
+
'openspec must not be copied',
|
|
460
|
+
);
|
|
461
|
+
assert(
|
|
462
|
+
!fs.existsSync(path.join(targetDir, 'packages')),
|
|
463
|
+
'packages/ must not be copied',
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const business = JSON.parse(
|
|
467
|
+
fs.readFileSync(path.join(targetDir, 'src/data/business.json'), 'utf8'),
|
|
468
|
+
);
|
|
469
|
+
assert(business.name === 'Acme Masonry', 'expected replaced business name');
|
|
470
|
+
|
|
471
|
+
const services = JSON.parse(
|
|
472
|
+
fs.readFileSync(path.join(targetDir, 'src/data/services.json'), 'utf8'),
|
|
473
|
+
);
|
|
474
|
+
const names = services.services.map((s) => s.name);
|
|
475
|
+
assert(
|
|
476
|
+
new Set(names.map((n) => n.toLowerCase())).size === names.length,
|
|
477
|
+
`duplicate service names in E2E: ${names}`,
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const offeredNames = business.services_offered.map((s) => s.name);
|
|
481
|
+
assert(
|
|
482
|
+
JSON.stringify(offeredNames) === JSON.stringify(names),
|
|
483
|
+
'business.services_offered names must match services.json',
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
// Best-effort cleanup of large scaffold output.
|
|
487
|
+
try {
|
|
488
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
489
|
+
} catch {
|
|
490
|
+
console.warn(` (could not fully remove ${targetDir})`);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
496
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
main().catch((err) => {
|
|
500
|
+
console.error(err);
|
|
501
|
+
process.exit(1);
|
|
502
|
+
});
|