create-contractor-site 2.2.0 → 2.3.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/README.md +144 -143
- package/bin/create-contractor-site.mjs +321 -321
- package/package.json +3 -3
- package/scripts/smoke-test.mjs +1819 -1210
- package/src/copy-template.mjs +296 -286
- package/src/prompts.mjs +461 -461
- package/src/replace-data.mjs +504 -504
- package/src/run-command.mjs +399 -399
- package/src/validate-target.mjs +96 -96
package/scripts/smoke-test.mjs
CHANGED
|
@@ -1,1210 +1,1819 @@
|
|
|
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. Published DEFAULT_TEMPLATE_REF / CREATE_CONTRACTOR_TEMPLATE_REF contract (v2.
|
|
10
|
-
* 5. Duplicate service input normalization + slug uniqueness + business/services alignment
|
|
11
|
-
* 6. Service-area name parse/dedupe (Chesapeake duplicate-slug case) + areas rebuild
|
|
12
|
-
* without leaking stale template county/ZIP metadata into new areas
|
|
13
|
-
* 7. CREATE_CONTRACTOR_SITE_ANSWERS_JSON real CLI spawn (copy+replace, skip setup)
|
|
14
|
-
* 8.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
} from '../src/
|
|
55
|
-
import {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
assert(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
await test('
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
'
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
assert(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
);
|
|
308
|
-
assert(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
'
|
|
339
|
-
'Chesapeake
|
|
340
|
-
);
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
assert(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
);
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
assert(
|
|
479
|
-
assert(
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
);
|
|
507
|
-
assert(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
);
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
assert(
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
assert(
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
'
|
|
570
|
-
'
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
);
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
fs.
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const
|
|
604
|
-
fs.readFileSync(path.join(dataDst, '
|
|
605
|
-
);
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
'
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
);
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
assert(
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
);
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
);
|
|
660
|
-
assert(
|
|
661
|
-
assert(
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
assert(
|
|
689
|
-
assert(
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
assert(
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
);
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
directories: [
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
assert(
|
|
757
|
-
assert(
|
|
758
|
-
assert(
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
assert(
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
replaceTargetData(
|
|
817
|
-
|
|
818
|
-
buildAnswers({
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
fs.
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
);
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
);
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
assert(
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
assert(
|
|
1048
|
-
assert(
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
)
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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. Published DEFAULT_TEMPLATE_REF / CREATE_CONTRACTOR_TEMPLATE_REF contract (v2.3.1)
|
|
10
|
+
* 5. Duplicate service input normalization + slug uniqueness + business/services alignment
|
|
11
|
+
* 6. Service-area name parse/dedupe (Chesapeake duplicate-slug case) + areas rebuild
|
|
12
|
+
* without leaking stale template county/ZIP metadata into new areas
|
|
13
|
+
* 7. CREATE_CONTRACTOR_SITE_ANSWERS_JSON real CLI spawn (copy+replace, skip setup)
|
|
14
|
+
* 8. Root workspace isolation vs tools/smart-image capsule (scope, sharp/zod, dist/)
|
|
15
|
+
* 9. Smart-image wrapper/lifecycle (argv safety, Windows bin, setup/check, no hooks);
|
|
16
|
+
* capsule-runtime cases skip with SKIP_CAPSULE_TESTS=1, static policy checks always run
|
|
17
|
+
* 10. Scaffold deny/gitignore/required retention (isDeniedName, check-ignore, copyTemplate)
|
|
18
|
+
* 11. Temp-target --yes scaffold (install/validate/build) unless SKIP_CLI_E2E=1
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import {
|
|
27
|
+
buildAlignedAreas,
|
|
28
|
+
buildAlignedServices,
|
|
29
|
+
normalizePrimaryServices,
|
|
30
|
+
normalizeServiceAreaNames,
|
|
31
|
+
parseServiceAreaNames,
|
|
32
|
+
replaceTargetData,
|
|
33
|
+
slugify,
|
|
34
|
+
} from '../src/replace-data.mjs';
|
|
35
|
+
import {
|
|
36
|
+
buildAnswers,
|
|
37
|
+
DEFAULT_FREE_ESTIMATE,
|
|
38
|
+
DEFAULT_HOURS,
|
|
39
|
+
DEFAULT_INSURANCE,
|
|
40
|
+
DEFAULT_LICENSE,
|
|
41
|
+
DEFAULT_PAYMENT_METHODS,
|
|
42
|
+
DEFAULT_SITE_TYPE,
|
|
43
|
+
DEFAULT_YEARS_EXPERIENCE,
|
|
44
|
+
normalizeDirectories,
|
|
45
|
+
normalizeHours,
|
|
46
|
+
normalizePaymentMethods,
|
|
47
|
+
normalizeSiteType,
|
|
48
|
+
normalizeSocial,
|
|
49
|
+
optionalText,
|
|
50
|
+
requiredText,
|
|
51
|
+
SITE_TYPES,
|
|
52
|
+
SOCIAL_NETWORK_KEYS,
|
|
53
|
+
} from '../src/prompts.mjs';
|
|
54
|
+
import { isSameOrInside, validateTarget, TargetValidationError } from '../src/validate-target.mjs';
|
|
55
|
+
import {
|
|
56
|
+
DEFAULT_TEMPLATE_REF,
|
|
57
|
+
REQUIRED_AFTER_COPY,
|
|
58
|
+
assertRequiredCopied,
|
|
59
|
+
copyTemplate,
|
|
60
|
+
findLocalTemplateRoot,
|
|
61
|
+
isDeniedName,
|
|
62
|
+
} from '../src/copy-template.mjs';
|
|
63
|
+
import {
|
|
64
|
+
isVersionAtLeast,
|
|
65
|
+
resolveCommandPath,
|
|
66
|
+
runCommand,
|
|
67
|
+
} from '../src/run-command.mjs';
|
|
68
|
+
|
|
69
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
const CLI = path.resolve(__dirname, '../bin/create-contractor-site.mjs');
|
|
71
|
+
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
72
|
+
const REPO_ROOT = findLocalTemplateRoot(PKG_ROOT);
|
|
73
|
+
|
|
74
|
+
let passed = 0;
|
|
75
|
+
let failed = 0;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} name
|
|
79
|
+
* @param {() => void | Promise<void>} fn
|
|
80
|
+
*/
|
|
81
|
+
async function test(name, fn) {
|
|
82
|
+
try {
|
|
83
|
+
await fn();
|
|
84
|
+
passed += 1;
|
|
85
|
+
console.log(` ✓ ${name}`);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
failed += 1;
|
|
88
|
+
console.error(` ✗ ${name}`);
|
|
89
|
+
console.error(` ${err instanceof Error ? err.message : err}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Capsule-runtime tests need the optional `tools/smart-image` capsule install
|
|
95
|
+
* and its provider environment. They run by default and opt out with
|
|
96
|
+
* SKIP_CAPSULE_TESTS=1 for capsule-less CI runs. Static wrapper/policy checks
|
|
97
|
+
* deliberately use `test` instead, so they always run.
|
|
98
|
+
* @param {string} name
|
|
99
|
+
* @param {() => void | Promise<void>} fn
|
|
100
|
+
*/
|
|
101
|
+
async function capsuleTest(name, fn) {
|
|
102
|
+
if (process.env.SKIP_CAPSULE_TESTS === '1') {
|
|
103
|
+
console.log(` ↷ SKIP_CAPSULE_TESTS=1 — skipping capsule runtime: ${name}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await test(name, fn);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {boolean} condition
|
|
111
|
+
* @param {string} message
|
|
112
|
+
*/
|
|
113
|
+
function assert(condition, message) {
|
|
114
|
+
if (!condition) throw new Error(message);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {string} file */
|
|
118
|
+
function readJson(file) {
|
|
119
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Seed template data files into a temp target. @param {string} prefix */
|
|
123
|
+
function seedDataDir(prefix) {
|
|
124
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
125
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
126
|
+
const dataSrc = path.join(REPO_ROOT, 'src', 'data');
|
|
127
|
+
const dataDst = path.join(tmp, 'src', 'data');
|
|
128
|
+
fs.mkdirSync(dataDst, { recursive: true });
|
|
129
|
+
for (const f of [
|
|
130
|
+
'business.json',
|
|
131
|
+
'site.json',
|
|
132
|
+
'services.json',
|
|
133
|
+
'areas.json',
|
|
134
|
+
'navigation.json',
|
|
135
|
+
'landings.json',
|
|
136
|
+
'directories.json',
|
|
137
|
+
]) {
|
|
138
|
+
fs.copyFileSync(path.join(dataSrc, f), path.join(dataDst, f));
|
|
139
|
+
}
|
|
140
|
+
return { tmp, dataDst };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string[]} args
|
|
145
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string }} [opts]
|
|
146
|
+
*/
|
|
147
|
+
function runCli(args, opts = {}) {
|
|
148
|
+
return spawnSync(process.execPath, [CLI, ...args], {
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
cwd: opts.cwd || process.cwd(),
|
|
151
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
152
|
+
shell: false,
|
|
153
|
+
windowsHide: true,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function main() {
|
|
158
|
+
console.log('create-contractor-site smoke tests\n');
|
|
159
|
+
|
|
160
|
+
await test('resolveCommandPath finds pnpm and git without shell', () => {
|
|
161
|
+
const pnpm = resolveCommandPath('pnpm');
|
|
162
|
+
const git = resolveCommandPath('git');
|
|
163
|
+
assert(Boolean(pnpm), 'expected pnpm on PATH');
|
|
164
|
+
assert(Boolean(git), 'expected git on PATH');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await test('pnpm version comparator enforces minimum version', () => {
|
|
168
|
+
assert(isVersionAtLeast('11.1.2', '11.1.2'), 'same version should pass');
|
|
169
|
+
assert(isVersionAtLeast('11.2.0', '11.1.2'), 'newer minor should pass');
|
|
170
|
+
assert(isVersionAtLeast('12.0.0', '11.1.2'), 'newer major should pass');
|
|
171
|
+
assert(!isVersionAtLeast('11.1.1', '11.1.2'), 'older patch should fail');
|
|
172
|
+
assert(!isVersionAtLeast('10.9.9', '11.1.2'), 'older major should fail');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test(
|
|
176
|
+
'DEFAULT_TEMPLATE_REF / CREATE_CONTRACTOR_TEMPLATE_REF published contract is v2.3.1',
|
|
177
|
+
() => {
|
|
178
|
+
const copySrcPath = path.join(PKG_ROOT, 'src', 'copy-template.mjs');
|
|
179
|
+
const copySrc = fs.readFileSync(copySrcPath, 'utf8');
|
|
180
|
+
// Pin the source fallback so a silent ref bump cannot ship without updating this test.
|
|
181
|
+
assert(
|
|
182
|
+
/CREATE_CONTRACTOR_TEMPLATE_REF\s*\|\|\s*['"]v2\.3\.1['"]/.test(copySrc),
|
|
183
|
+
'copy-template.mjs DEFAULT_TEMPLATE_REF fallback must be v2.3.1',
|
|
184
|
+
);
|
|
185
|
+
const expected =
|
|
186
|
+
process.env.CREATE_CONTRACTOR_TEMPLATE_REF || 'v2.3.1';
|
|
187
|
+
assert(
|
|
188
|
+
DEFAULT_TEMPLATE_REF === expected,
|
|
189
|
+
`DEFAULT_TEMPLATE_REF must equal CREATE_CONTRACTOR_TEMPLATE_REF or v2.3.1 (got ${DEFAULT_TEMPLATE_REF})`,
|
|
190
|
+
);
|
|
191
|
+
if (!process.env.CREATE_CONTRACTOR_TEMPLATE_REF) {
|
|
192
|
+
assert(
|
|
193
|
+
DEFAULT_TEMPLATE_REF === 'v2.3.1',
|
|
194
|
+
`published default template ref must be v2.3.1 (got ${DEFAULT_TEMPLATE_REF})`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const help = runCli(['--help']);
|
|
199
|
+
assert(help.status === 0, `help exit 0, got ${help.status}`);
|
|
200
|
+
const helpOut = `${help.stdout}\n${help.stderr}`;
|
|
201
|
+
assert(
|
|
202
|
+
/CREATE_CONTRACTOR_TEMPLATE_REF/i.test(helpOut),
|
|
203
|
+
'help must document CREATE_CONTRACTOR_TEMPLATE_REF',
|
|
204
|
+
);
|
|
205
|
+
assert(
|
|
206
|
+
/v2\.3\.1/.test(helpOut),
|
|
207
|
+
'help must document default template ref v2.3.1',
|
|
208
|
+
);
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
await test('missing target argument exits 1 with usage', () => {
|
|
213
|
+
const result = runCli([]);
|
|
214
|
+
assert(result.status === 1, `expected exit 1, got ${result.status}`);
|
|
215
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
216
|
+
assert(/Usage: create-contractor-site/i.test(out), 'expected usage text');
|
|
217
|
+
assert(/Missing target directory/i.test(out), 'expected missing-target message');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await test('existing non-empty target exits 1', () => {
|
|
221
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-nonempty-'));
|
|
222
|
+
fs.writeFileSync(path.join(dir, 'keep.txt'), 'x');
|
|
223
|
+
try {
|
|
224
|
+
const result = runCli(['--yes', dir], {
|
|
225
|
+
env: REPO_ROOT
|
|
226
|
+
? { CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT }
|
|
227
|
+
: {},
|
|
228
|
+
});
|
|
229
|
+
assert(result.status === 1, `expected exit 1, got ${result.status}`);
|
|
230
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
231
|
+
assert(/not empty/i.test(out), `expected non-empty message, got:\n${out}`);
|
|
232
|
+
} finally {
|
|
233
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await test('rejects target equal to or inside template root', () => {
|
|
238
|
+
assert(REPO_ROOT, 'expected local template root for this check');
|
|
239
|
+
let threw = false;
|
|
240
|
+
try {
|
|
241
|
+
validateTarget(REPO_ROOT, { templateRoot: REPO_ROOT });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
threw = err instanceof TargetValidationError;
|
|
244
|
+
assert(threw, 'expected TargetValidationError for template root');
|
|
245
|
+
assert(
|
|
246
|
+
/must not be the template root or inside it/i.test(
|
|
247
|
+
/** @type {Error} */ (err).message,
|
|
248
|
+
),
|
|
249
|
+
'expected inside-template message',
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
assert(threw, 'validateTarget should throw for template root');
|
|
253
|
+
|
|
254
|
+
const inside = path.join(REPO_ROOT, 'src');
|
|
255
|
+
assert(isSameOrInside(inside, REPO_ROOT), 'src should be inside template');
|
|
256
|
+
threw = false;
|
|
257
|
+
try {
|
|
258
|
+
validateTarget(inside, { templateRoot: REPO_ROOT });
|
|
259
|
+
} catch (err) {
|
|
260
|
+
threw = err instanceof TargetValidationError;
|
|
261
|
+
}
|
|
262
|
+
assert(threw, 'validateTarget should throw for path inside template');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await test('normalizePrimaryServices drops name and slug duplicates', () => {
|
|
266
|
+
const input = [
|
|
267
|
+
'Masonry',
|
|
268
|
+
'masonry',
|
|
269
|
+
'Patios',
|
|
270
|
+
'Patio Work',
|
|
271
|
+
'Patios',
|
|
272
|
+
' ',
|
|
273
|
+
'Retaining Walls',
|
|
274
|
+
'retaining-walls', // slug collision with Retaining Walls
|
|
275
|
+
];
|
|
276
|
+
const normalized = normalizePrimaryServices(input);
|
|
277
|
+
assert(
|
|
278
|
+
JSON.stringify(normalized) ===
|
|
279
|
+
JSON.stringify(['Masonry', 'Patios', 'Patio Work', 'Retaining Walls']),
|
|
280
|
+
`unexpected normalized list: ${JSON.stringify(normalized)}`,
|
|
281
|
+
);
|
|
282
|
+
const slugs = normalized.map(slugify);
|
|
283
|
+
assert(new Set(slugs).size === slugs.length, 'normalized slugs must be unique');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await test('buildAlignedServices keeps unique names/slugs and business alignment', () => {
|
|
287
|
+
const existing = [
|
|
288
|
+
{ id: 'masonry', name: 'Masonry', slug: 'masonry', short_description: 'a' },
|
|
289
|
+
{ id: 'hardscape', name: 'Hardscape', slug: 'hardscape', short_description: 'b' },
|
|
290
|
+
{ id: 'patios', name: 'Patios', slug: 'patios', short_description: 'c' },
|
|
291
|
+
{
|
|
292
|
+
id: 'retaining-walls',
|
|
293
|
+
name: 'Retaining Walls',
|
|
294
|
+
slug: 'retaining-walls',
|
|
295
|
+
short_description: 'd',
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
|
|
299
|
+
const { services, offered } = buildAlignedServices(
|
|
300
|
+
existing,
|
|
301
|
+
['Masonry', 'Masonry', 'Patios', 'Retaining Walls', 'Custom Stone', 'custom stone'],
|
|
302
|
+
{ businessName: 'Acme', serviceArea: 'VA Beach' },
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const names = services.map((s) => String(s.name));
|
|
306
|
+
const slugs = services.map((s) => String(s.slug));
|
|
307
|
+
assert(new Set(names.map((n) => n.toLowerCase())).size === names.length, `duplicate names: ${names}`);
|
|
308
|
+
assert(new Set(slugs).size === slugs.length, `duplicate slugs: ${slugs}`);
|
|
309
|
+
|
|
310
|
+
assert(offered.length === services.length, 'offered length must match services');
|
|
311
|
+
for (let i = 0; i < services.length; i += 1) {
|
|
312
|
+
assert(offered[i].name === services[i].name, `name mismatch at ${i}`);
|
|
313
|
+
assert(offered[i].slug === services[i].slug, `slug mismatch at ${i}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Template leading slugs preserved for first slots after input dedupe
|
|
317
|
+
assert(services[0].slug === 'masonry', 'first slug should stay masonry');
|
|
318
|
+
assert(services[1].slug === 'hardscape' || services[1].name === 'Patios', 'slot mapping still shape-stable');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await test('parse/normalize service areas keep city first and drop slug dups', () => {
|
|
322
|
+
const parsed = parseServiceAreaNames(
|
|
323
|
+
'Chesapeake, Virginia Beach; Norfolk / Newport News & Hampton\nPortsmouth, Suffolk, Williamsburg and Poquoson',
|
|
324
|
+
);
|
|
325
|
+
assert(parsed.includes('Williamsburg'), 'expected Williamsburg from "and" split');
|
|
326
|
+
assert(parsed.includes('Poquoson'), 'expected Poquoson from "and" split');
|
|
327
|
+
assert(parsed.includes('Virginia Beach'), 'expected Virginia Beach');
|
|
328
|
+
assert(parsed.includes('Newport News'), 'expected Newport News from slash split');
|
|
329
|
+
assert(parsed.includes('Hampton'), 'expected Hampton from ampersand split');
|
|
330
|
+
assert(parsed.includes('Portsmouth'), 'expected Portsmouth from newline split');
|
|
331
|
+
|
|
332
|
+
const normalized = normalizeServiceAreaNames(
|
|
333
|
+
'Chesapeake',
|
|
334
|
+
'Chesapeake, Virginia Beach, Norfolk, Newport News, Hampton, Portsmouth, Suffolk, Williamsburg and Poquoson',
|
|
335
|
+
);
|
|
336
|
+
assert(normalized[0] === 'Chesapeake', `city must be first: ${normalized[0]}`);
|
|
337
|
+
assert(
|
|
338
|
+
normalized.filter((n) => n.toLowerCase() === 'chesapeake').length === 1,
|
|
339
|
+
'Chesapeake must appear once',
|
|
340
|
+
);
|
|
341
|
+
const slugs = normalized.map(slugify);
|
|
342
|
+
assert(new Set(slugs).size === slugs.length, `duplicate area slugs: ${slugs}`);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await test('buildAlignedAreas reuses rows and never emits duplicate slugs', () => {
|
|
346
|
+
const existing = [
|
|
347
|
+
{
|
|
348
|
+
name: 'Virginia Beach',
|
|
349
|
+
slug: 'virginia-beach',
|
|
350
|
+
county: 'Virginia Beach',
|
|
351
|
+
state: 'VA',
|
|
352
|
+
zip_codes: ['23451'],
|
|
353
|
+
},
|
|
354
|
+
{ name: 'Norfolk', slug: 'norfolk', county: 'Norfolk', state: 'VA', zip_codes: ['23502'] },
|
|
355
|
+
{
|
|
356
|
+
name: 'Chesapeake',
|
|
357
|
+
slug: 'chesapeake',
|
|
358
|
+
county: 'Chesapeake',
|
|
359
|
+
state: 'VA',
|
|
360
|
+
zip_codes: ['23320'],
|
|
361
|
+
},
|
|
362
|
+
{ name: 'Suffolk', slug: 'suffolk', county: 'Suffolk', state: 'VA', zip_codes: ['23434'] },
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
const names = normalizeServiceAreaNames(
|
|
366
|
+
'Chesapeake',
|
|
367
|
+
'Chesapeake, Virginia Beach, Norfolk, Newport News, Hampton, Portsmouth, Suffolk, Williamsburg and Poquoson',
|
|
368
|
+
);
|
|
369
|
+
const areas = buildAlignedAreas(existing, names, {
|
|
370
|
+
city: 'Chesapeake',
|
|
371
|
+
state: 'VA',
|
|
372
|
+
zip: '23320',
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
assert(areas[0].name === 'Chesapeake', 'primary area name');
|
|
376
|
+
assert(areas[0].slug === 'chesapeake', 'primary area slug');
|
|
377
|
+
assert(
|
|
378
|
+
JSON.stringify(areas[0].zip_codes) === JSON.stringify(['23320']),
|
|
379
|
+
'primary zip from answers',
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const slugs = areas.map((a) => String(a.slug));
|
|
383
|
+
assert(new Set(slugs).size === slugs.length, `duplicate slugs in aligned areas: ${slugs}`);
|
|
384
|
+
|
|
385
|
+
const norfolk = areas.find((a) => a.slug === 'norfolk');
|
|
386
|
+
assert(norfolk, 'expected reused Norfolk row');
|
|
387
|
+
assert(
|
|
388
|
+
JSON.stringify(norfolk.zip_codes) === JSON.stringify(['23502']),
|
|
389
|
+
'matched rows keep prior zip_codes',
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
for (const slug of ['newport-news', 'hampton', 'portsmouth', 'williamsburg', 'poquoson']) {
|
|
393
|
+
const area = areas.find((a) => a.slug === slug);
|
|
394
|
+
assert(area, `expected generated ${slug} area`);
|
|
395
|
+
assert(area.county === area.name, `${slug} must not inherit stale county: ${area.county}`);
|
|
396
|
+
assert(
|
|
397
|
+
JSON.stringify(area.zip_codes) === JSON.stringify([]),
|
|
398
|
+
`${slug} must not inherit stale zip_codes: ${JSON.stringify(area.zip_codes)}`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
await test('replaceTargetData writes aligned services into temp data files', () => {
|
|
404
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
405
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-replace-'));
|
|
406
|
+
const dataSrc = path.join(REPO_ROOT, 'src', 'data');
|
|
407
|
+
const dataDst = path.join(tmp, 'src', 'data');
|
|
408
|
+
fs.mkdirSync(dataDst, { recursive: true });
|
|
409
|
+
for (const file of [
|
|
410
|
+
'business.json',
|
|
411
|
+
'site.json',
|
|
412
|
+
'services.json',
|
|
413
|
+
'areas.json',
|
|
414
|
+
'navigation.json',
|
|
415
|
+
'landings.json',
|
|
416
|
+
'directories.json',
|
|
417
|
+
]) {
|
|
418
|
+
fs.copyFileSync(path.join(dataSrc, file), path.join(dataDst, file));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const answers = buildAnswers({
|
|
422
|
+
businessName: 'Acme Masonry',
|
|
423
|
+
primaryServices: [
|
|
424
|
+
'Masonry',
|
|
425
|
+
'Masonry',
|
|
426
|
+
'Patios',
|
|
427
|
+
'Retaining Walls',
|
|
428
|
+
'Custom Fabrication',
|
|
429
|
+
'custom-fabrication',
|
|
430
|
+
],
|
|
431
|
+
city: 'Virginia Beach',
|
|
432
|
+
state: 'VA',
|
|
433
|
+
serviceArea: 'Hampton Roads',
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
replaceTargetData(tmp, answers);
|
|
437
|
+
|
|
438
|
+
const business = JSON.parse(
|
|
439
|
+
fs.readFileSync(path.join(dataDst, 'business.json'), 'utf8'),
|
|
440
|
+
);
|
|
441
|
+
const services = JSON.parse(
|
|
442
|
+
fs.readFileSync(path.join(dataDst, 'services.json'), 'utf8'),
|
|
443
|
+
);
|
|
444
|
+
const navigation = JSON.parse(
|
|
445
|
+
fs.readFileSync(path.join(dataDst, 'navigation.json'), 'utf8'),
|
|
446
|
+
);
|
|
447
|
+
const landings = JSON.parse(
|
|
448
|
+
fs.readFileSync(path.join(dataDst, 'landings.json'), 'utf8'),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
const offered = business.services_offered;
|
|
452
|
+
const catalog = services.services;
|
|
453
|
+
assert(Array.isArray(offered) && Array.isArray(catalog), 'expected service arrays');
|
|
454
|
+
assert(offered.length === catalog.length, 'services_offered must align with services.json length');
|
|
455
|
+
|
|
456
|
+
const names = catalog.map((s) => s.name);
|
|
457
|
+
const slugs = catalog.map((s) => s.slug);
|
|
458
|
+
assert(new Set(names.map((n) => n.toLowerCase())).size === names.length, `dup names ${names}`);
|
|
459
|
+
assert(new Set(slugs).size === slugs.length, `dup slugs ${slugs}`);
|
|
460
|
+
|
|
461
|
+
for (let i = 0; i < catalog.length; i += 1) {
|
|
462
|
+
assert(offered[i].name === catalog[i].name, `aligned name ${i}`);
|
|
463
|
+
assert(offered[i].slug === catalog[i].slug, `aligned slug ${i}`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const expectedLinks = catalog.map((service) => ({
|
|
467
|
+
label: service.name,
|
|
468
|
+
href: `/services/${service.slug}`,
|
|
469
|
+
}));
|
|
470
|
+
const serviceNav = navigation.header.find((item) => item.href === '/services');
|
|
471
|
+
assert(serviceNav, 'expected Services nav item');
|
|
472
|
+
assert(
|
|
473
|
+
JSON.stringify(serviceNav.children) === JSON.stringify(expectedLinks),
|
|
474
|
+
`navigation service links not aligned: ${JSON.stringify(serviceNav.children)}`,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
const footerServices = navigation.footer.find((column) => column.title === 'Services');
|
|
478
|
+
assert(footerServices, 'expected Services footer column');
|
|
479
|
+
assert(
|
|
480
|
+
JSON.stringify(footerServices.links) === JSON.stringify(expectedLinks),
|
|
481
|
+
`footer service links not aligned: ${JSON.stringify(footerServices.links)}`,
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const landingPages = landings.landing_pages;
|
|
485
|
+
assert(
|
|
486
|
+
landingPages.every((page) => catalog.some((service) => service.slug === page.slug && service.name === page.name)),
|
|
487
|
+
`landing pages must align to generated services: ${JSON.stringify(landingPages)}`,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
assert(business.name === 'Acme Masonry', 'business name replaced');
|
|
491
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
await test('buildAnswers normalizes blank text trust fields and foundedYear', () => {
|
|
495
|
+
const answers = buildAnswers({
|
|
496
|
+
businessName: 'Trust Co',
|
|
497
|
+
primaryServices: ['Masonry'],
|
|
498
|
+
freeEstimate: ' ',
|
|
499
|
+
yearsExperience: '\t',
|
|
500
|
+
license: '',
|
|
501
|
+
insurance: null,
|
|
502
|
+
foundedYear: ' ',
|
|
503
|
+
});
|
|
504
|
+
assert(answers.freeEstimate === DEFAULT_FREE_ESTIMATE, 'freeEstimate default');
|
|
505
|
+
assert(answers.yearsExperience === DEFAULT_YEARS_EXPERIENCE, 'yearsExperience default');
|
|
506
|
+
assert(answers.license === DEFAULT_LICENSE, 'license default');
|
|
507
|
+
assert(answers.insurance === DEFAULT_INSURANCE, 'insurance default');
|
|
508
|
+
assert(answers.foundedYear === '', 'foundedYear blank → ""');
|
|
509
|
+
assert(requiredText(' x ', 'y') === 'x', 'requiredText trims');
|
|
510
|
+
assert(optionalText(' 2012 ') === '2012', 'optionalText trims');
|
|
511
|
+
assert(optionalText(undefined) === '', 'optionalText missing → ""');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
await test('normalizePaymentMethods CSV/array blank → defaults never []', () => {
|
|
515
|
+
assert(
|
|
516
|
+
JSON.stringify(normalizePaymentMethods('Cash, Check , ,Credit Card')) ===
|
|
517
|
+
JSON.stringify(['Cash', 'Check', 'Credit Card']),
|
|
518
|
+
'CSV trims and drops blanks',
|
|
519
|
+
);
|
|
520
|
+
assert(
|
|
521
|
+
JSON.stringify(normalizePaymentMethods([' Visa ', '', 'Cash'])) ===
|
|
522
|
+
JSON.stringify(['Visa', 'Cash']),
|
|
523
|
+
'array trims and drops blanks',
|
|
524
|
+
);
|
|
525
|
+
assert(
|
|
526
|
+
JSON.stringify(normalizePaymentMethods(' ')) ===
|
|
527
|
+
JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
528
|
+
'whitespace CSV → defaults',
|
|
529
|
+
);
|
|
530
|
+
assert(
|
|
531
|
+
JSON.stringify(normalizePaymentMethods([])) ===
|
|
532
|
+
JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
533
|
+
'empty array → defaults',
|
|
534
|
+
);
|
|
535
|
+
assert(
|
|
536
|
+
JSON.stringify(normalizePaymentMethods(null)) ===
|
|
537
|
+
JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
538
|
+
'null → defaults',
|
|
539
|
+
);
|
|
540
|
+
assert(normalizePaymentMethods('').length > 0, 'never empty list');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
await test('normalizeHours keeps 3-row {days,time} shape with safe defaults', () => {
|
|
544
|
+
const fromBlank = normalizeHours(undefined);
|
|
545
|
+
assert(fromBlank.length === 3, 'default length 3');
|
|
546
|
+
assert(
|
|
547
|
+
JSON.stringify(fromBlank) === JSON.stringify(DEFAULT_HOURS),
|
|
548
|
+
'missing → DEFAULT_HOURS',
|
|
549
|
+
);
|
|
550
|
+
assert(
|
|
551
|
+
JSON.stringify(normalizeHours([])) === JSON.stringify(DEFAULT_HOURS),
|
|
552
|
+
'empty array → defaults',
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
const partial = normalizeHours([
|
|
556
|
+
{ days: ' ', time: '9:00 AM - 5:00 PM' },
|
|
557
|
+
' 10:00 AM - 1:00 PM ',
|
|
558
|
+
{ days: 'Sunday', time: ' ' },
|
|
559
|
+
]);
|
|
560
|
+
assert(partial.length === 3, 'partial still length 3');
|
|
561
|
+
assert(partial[0].days === DEFAULT_HOURS[0].days, 'blank days → default days');
|
|
562
|
+
assert(partial[0].time === '9:00 AM - 5:00 PM', 'weekday time kept');
|
|
563
|
+
assert(partial[1].days === DEFAULT_HOURS[1].days, 'string row uses default days');
|
|
564
|
+
assert(partial[1].time === '10:00 AM - 1:00 PM', 'string row trims time');
|
|
565
|
+
assert(partial[2].days === 'Sunday', 'sunday days kept');
|
|
566
|
+
assert(partial[2].time === DEFAULT_HOURS[2].time, 'blank time → default');
|
|
567
|
+
|
|
568
|
+
const compact = buildAnswers({
|
|
569
|
+
businessName: 'Hours Co',
|
|
570
|
+
primaryServices: ['Masonry'],
|
|
571
|
+
hoursWeekday: '8-5',
|
|
572
|
+
hoursSaturday: ' ',
|
|
573
|
+
hoursSunday: 'Closed',
|
|
574
|
+
});
|
|
575
|
+
assert(compact.hours.length === 3, 'compact → 3 rows');
|
|
576
|
+
assert(compact.hours[0].time === '8-5', 'compact weekday');
|
|
577
|
+
assert(compact.hours[1].time === DEFAULT_HOURS[1].time, 'blank sat → default');
|
|
578
|
+
assert(compact.hours[2].time === 'Closed', 'compact sunday');
|
|
579
|
+
assert(
|
|
580
|
+
JSON.stringify(compact.paymentMethods) === JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
581
|
+
'omitted payments → defaults',
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
await test('replaceTargetData writes trust + payment + hours + empty social', () => {
|
|
586
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
587
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-trust-'));
|
|
588
|
+
const dataSrc = path.join(REPO_ROOT, 'src', 'data');
|
|
589
|
+
const dataDst = path.join(tmp, 'src', 'data');
|
|
590
|
+
fs.mkdirSync(dataDst, { recursive: true });
|
|
591
|
+
for (const file of [
|
|
592
|
+
'business.json',
|
|
593
|
+
'site.json',
|
|
594
|
+
'services.json',
|
|
595
|
+
'areas.json',
|
|
596
|
+
'navigation.json',
|
|
597
|
+
'landings.json',
|
|
598
|
+
'directories.json',
|
|
599
|
+
]) {
|
|
600
|
+
fs.copyFileSync(path.join(dataSrc, file), path.join(dataDst, file));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const original = JSON.parse(
|
|
604
|
+
fs.readFileSync(path.join(dataDst, 'business.json'), 'utf8'),
|
|
605
|
+
);
|
|
606
|
+
const originalDirs = JSON.parse(
|
|
607
|
+
fs.readFileSync(path.join(dataDst, 'directories.json'), 'utf8'),
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
const answers = buildAnswers({
|
|
611
|
+
businessName: 'Trust Builders',
|
|
612
|
+
primaryServices: ['Masonry'],
|
|
613
|
+
freeEstimate: 'Free Quote Today',
|
|
614
|
+
yearsExperience: '20+',
|
|
615
|
+
license: 'VA Class A #12345',
|
|
616
|
+
insurance: 'Bonded and insured.',
|
|
617
|
+
foundedYear: '',
|
|
618
|
+
paymentMethods: 'Cash, Zelle',
|
|
619
|
+
hours: [
|
|
620
|
+
{ days: 'Monday - Friday', time: '8:00 AM - 4:00 PM' },
|
|
621
|
+
{ days: 'Saturday', time: 'Closed' },
|
|
622
|
+
{ days: 'Sunday', time: 'Closed' },
|
|
623
|
+
],
|
|
624
|
+
});
|
|
625
|
+
replaceTargetData(tmp, answers);
|
|
626
|
+
|
|
627
|
+
const business = JSON.parse(
|
|
628
|
+
fs.readFileSync(path.join(dataDst, 'business.json'), 'utf8'),
|
|
629
|
+
);
|
|
630
|
+
const site = JSON.parse(fs.readFileSync(path.join(dataDst, 'site.json'), 'utf8'));
|
|
631
|
+
const directories = JSON.parse(
|
|
632
|
+
fs.readFileSync(path.join(dataDst, 'directories.json'), 'utf8'),
|
|
633
|
+
);
|
|
634
|
+
assert(business._instructions, '_instructions preserved');
|
|
635
|
+
assert(
|
|
636
|
+
JSON.stringify(Object.keys(business._instructions)) ===
|
|
637
|
+
JSON.stringify(Object.keys(original._instructions)),
|
|
638
|
+
'_instructions keys stable',
|
|
639
|
+
);
|
|
640
|
+
assert(business.free_estimate === 'Free Quote Today', 'free_estimate written');
|
|
641
|
+
assert(business.years_experience === '20+', 'years_experience written');
|
|
642
|
+
assert(business.license === 'VA Class A #12345', 'license written');
|
|
643
|
+
assert(business.insurance === 'Bonded and insured.', 'insurance written');
|
|
644
|
+
assert(
|
|
645
|
+
Object.prototype.hasOwnProperty.call(business, 'founded_year'),
|
|
646
|
+
'founded_year key must remain',
|
|
647
|
+
);
|
|
648
|
+
assert(business.founded_year === '', 'founded_year skip → ""');
|
|
649
|
+
assert(
|
|
650
|
+
JSON.stringify(business.payment_methods) === JSON.stringify(['Cash', 'Zelle']),
|
|
651
|
+
'payment_methods written',
|
|
652
|
+
);
|
|
653
|
+
assert(Array.isArray(business.hours) && business.hours.length === 3, 'hours length 3');
|
|
654
|
+
assert(business.hours[0].days === 'Monday - Friday', 'hours days shape');
|
|
655
|
+
assert(business.hours[0].time === '8:00 AM - 4:00 PM', 'hours time written');
|
|
656
|
+
assert(business.hours[1].time === 'Closed', 'saturday closed');
|
|
657
|
+
assert(JSON.stringify(business.social) === '{}', 'omitted social → {}');
|
|
658
|
+
assert(site.features.enable_directories === false, 'no dirs → enable false');
|
|
659
|
+
assert(directories.directories.length >= 1, 'directories min(1) when none');
|
|
660
|
+
assert(directories.directories.length === originalDirs.directories.length);
|
|
661
|
+
assert(directories._instructions && directories.variant === originalDirs.variant);
|
|
662
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
await test('normalizeSocial/directories + replace populate/none parity', () => {
|
|
666
|
+
// Social blank omission + population (object + compact CSV)
|
|
667
|
+
assert(JSON.stringify(normalizeSocial(undefined)) === '{}', 'missing social → {}');
|
|
668
|
+
assert(
|
|
669
|
+
JSON.stringify(normalizeSocial({ facebook: ' ', instagram: null, x: '' })) === '{}',
|
|
670
|
+
'blank social keys omitted',
|
|
671
|
+
);
|
|
672
|
+
const populated = normalizeSocial({
|
|
673
|
+
facebook: ' https://facebook.com/acme ',
|
|
674
|
+
instagram: '',
|
|
675
|
+
youtube: 'https://youtube.com/@acme',
|
|
676
|
+
unknown: 'https://nope.example',
|
|
677
|
+
});
|
|
678
|
+
assert(
|
|
679
|
+
JSON.stringify(populated) ===
|
|
680
|
+
JSON.stringify({
|
|
681
|
+
facebook: 'https://facebook.com/acme',
|
|
682
|
+
youtube: 'https://youtube.com/@acme',
|
|
683
|
+
}),
|
|
684
|
+
);
|
|
685
|
+
const compact = normalizeSocial(
|
|
686
|
+
'facebook=https://facebook.com/a, google-business=https://g.co/a, ,tiktok=',
|
|
687
|
+
);
|
|
688
|
+
assert(compact.facebook === 'https://facebook.com/a' && compact.google_business === 'https://g.co/a');
|
|
689
|
+
assert(!Object.prototype.hasOwnProperty.call(compact, 'tiktok'));
|
|
690
|
+
assert(SOCIAL_NETWORK_KEYS.includes('linkedin'));
|
|
691
|
+
|
|
692
|
+
// Directories provided vs none
|
|
693
|
+
assert(normalizeDirectories(undefined).length === 0);
|
|
694
|
+
assert(normalizeDirectories([]).length === 0);
|
|
695
|
+
const fromCsv = normalizeDirectories(
|
|
696
|
+
'Google Business|https://g.co/acme, BBB|https://bbb.org/acme ,badrow',
|
|
697
|
+
);
|
|
698
|
+
assert(fromCsv.length === 2 && fromCsv[0].url === 'https://g.co/acme' && fromCsv[0].initials);
|
|
699
|
+
const fromRows = normalizeDirectories([
|
|
700
|
+
{ name: 'Angi', url: 'https://angi.com/x', initials: 'A' },
|
|
701
|
+
{ name: ' ', url: 'https://skip.example' },
|
|
702
|
+
{ name: 'Houzz', url: ' ' },
|
|
703
|
+
]);
|
|
704
|
+
assert(fromRows.length === 1 && fromRows[0].initials === 'A');
|
|
705
|
+
|
|
706
|
+
const none = buildAnswers({ businessName: 'No Social', primaryServices: ['Masonry'] });
|
|
707
|
+
assert(JSON.stringify(none.social) === '{}' && none.directories.length === 0);
|
|
708
|
+
assert(none.enableDirectories === false);
|
|
709
|
+
const withDirs = buildAnswers({
|
|
710
|
+
businessName: 'With Dirs',
|
|
711
|
+
primaryServices: ['Masonry'],
|
|
712
|
+
social: { facebook: 'https://facebook.com/x', instagram: ' ' },
|
|
713
|
+
directories: 'Google|https://g.co/x',
|
|
714
|
+
});
|
|
715
|
+
assert(withDirs.social.facebook === 'https://facebook.com/x');
|
|
716
|
+
assert(!Object.prototype.hasOwnProperty.call(withDirs.social, 'instagram'));
|
|
717
|
+
assert(withDirs.directories.length === 1 && withDirs.enableDirectories === true);
|
|
718
|
+
|
|
719
|
+
// directories:[] + enableDirectories:true → feature false
|
|
720
|
+
const forcedEmpty = buildAnswers({
|
|
721
|
+
businessName: 'Forced Empty Dirs',
|
|
722
|
+
primaryServices: ['Masonry'],
|
|
723
|
+
directories: [],
|
|
724
|
+
enableDirectories: true,
|
|
725
|
+
});
|
|
726
|
+
assert(forcedEmpty.directories.length === 0 && forcedEmpty.enableDirectories === false);
|
|
727
|
+
|
|
728
|
+
// replace: populated social + directories
|
|
729
|
+
const seeded = seedDataDir('ccs-social-');
|
|
730
|
+
replaceTargetData(
|
|
731
|
+
seeded.tmp,
|
|
732
|
+
buildAnswers({
|
|
733
|
+
businessName: 'Social Co',
|
|
734
|
+
primaryServices: ['Patios'],
|
|
735
|
+
social: {
|
|
736
|
+
facebook: 'https://facebook.com/socialco',
|
|
737
|
+
instagram: '',
|
|
738
|
+
youtube: 'https://youtube.com/@socialco',
|
|
739
|
+
},
|
|
740
|
+
directories: [
|
|
741
|
+
{ name: 'Google Business', url: 'https://g.co/socialco', initials: 'G' },
|
|
742
|
+
{ name: 'Angi', url: 'https://angi.com/socialco' },
|
|
743
|
+
],
|
|
744
|
+
}),
|
|
745
|
+
);
|
|
746
|
+
const business = readJson(path.join(seeded.dataDst, 'business.json'));
|
|
747
|
+
const site = readJson(path.join(seeded.dataDst, 'site.json'));
|
|
748
|
+
const directories = readJson(path.join(seeded.dataDst, 'directories.json'));
|
|
749
|
+
assert(
|
|
750
|
+
JSON.stringify(business.social) ===
|
|
751
|
+
JSON.stringify({
|
|
752
|
+
facebook: 'https://facebook.com/socialco',
|
|
753
|
+
youtube: 'https://youtube.com/@socialco',
|
|
754
|
+
}),
|
|
755
|
+
);
|
|
756
|
+
assert(site.features.enable_directories === true);
|
|
757
|
+
assert(directories.directories.length === 2 && directories.directories[0].initials === 'G');
|
|
758
|
+
assert(directories.directories[1].initials && directories._instructions);
|
|
759
|
+
|
|
760
|
+
// replace: directories:[] + enable true → flag false + min(1) placeholder rows
|
|
761
|
+
const forced = seedDataDir('ccs-dirs-force-');
|
|
762
|
+
const origLen = readJson(path.join(forced.dataDst, 'directories.json')).directories.length;
|
|
763
|
+
replaceTargetData(
|
|
764
|
+
forced.tmp,
|
|
765
|
+
buildAnswers({
|
|
766
|
+
businessName: 'Force Enable Empty',
|
|
767
|
+
primaryServices: ['Masonry'],
|
|
768
|
+
directories: [],
|
|
769
|
+
enableDirectories: true,
|
|
770
|
+
}),
|
|
771
|
+
);
|
|
772
|
+
const siteForced = readJson(path.join(forced.dataDst, 'site.json'));
|
|
773
|
+
const dirsForced = readJson(path.join(forced.dataDst, 'directories.json'));
|
|
774
|
+
assert(siteForced.features.enable_directories === false, 'empty+force → enable false');
|
|
775
|
+
assert(dirsForced.directories.length >= 1 && dirsForced.directories.length === origLen);
|
|
776
|
+
fs.rmSync(forced.tmp, { recursive: true, force: true });
|
|
777
|
+
fs.rmSync(seeded.tmp, { recursive: true, force: true });
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
await test('normalizeSiteType canonical + human-ish aliases; invalid → multipage', () => {
|
|
781
|
+
assert(DEFAULT_SITE_TYPE === 'multipage', 'default site type is multipage');
|
|
782
|
+
assert(JSON.stringify(SITE_TYPES) === JSON.stringify(['one-page', 'multipage', 'seo']));
|
|
783
|
+
assert(normalizeSiteType(undefined) === 'multipage', 'missing → multipage');
|
|
784
|
+
assert(normalizeSiteType('') === 'multipage', 'blank → multipage');
|
|
785
|
+
assert(normalizeSiteType(' ') === 'multipage', 'whitespace → multipage');
|
|
786
|
+
assert(normalizeSiteType('one-page') === 'one-page');
|
|
787
|
+
assert(normalizeSiteType('multipage') === 'multipage');
|
|
788
|
+
assert(normalizeSiteType('seo') === 'seo');
|
|
789
|
+
assert(normalizeSiteType('one page') === 'one-page', 'one page alias');
|
|
790
|
+
assert(normalizeSiteType('onepage') === 'one-page', 'onepage alias');
|
|
791
|
+
assert(normalizeSiteType('multi page') === 'multipage', 'multi page alias');
|
|
792
|
+
assert(normalizeSiteType('multi-page') === 'multipage', 'multi-page alias');
|
|
793
|
+
assert(normalizeSiteType('SEO') === 'seo', 'case fold');
|
|
794
|
+
assert(normalizeSiteType('not-a-type') === 'multipage', 'invalid → multipage');
|
|
795
|
+
assert(normalizeSiteType('blog') === 'multipage', 'unknown → multipage');
|
|
796
|
+
|
|
797
|
+
const omitted = buildAnswers({ businessName: 'Type Co', primaryServices: ['Masonry'] });
|
|
798
|
+
assert(omitted.siteType === 'multipage', 'buildAnswers omits → multipage');
|
|
799
|
+
assert(buildAnswers({
|
|
800
|
+
businessName: 'One Page Co',
|
|
801
|
+
primaryServices: ['Masonry'],
|
|
802
|
+
siteType: 'one page',
|
|
803
|
+
}).siteType === 'one-page');
|
|
804
|
+
assert(buildAnswers({
|
|
805
|
+
businessName: 'SEO Co',
|
|
806
|
+
primaryServices: ['Masonry'],
|
|
807
|
+
siteType: 'seo',
|
|
808
|
+
}).siteType === 'seo');
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
await test('replaceTargetData writes site_type from answers (default multipage)', () => {
|
|
812
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
813
|
+
const seeded = seedDataDir('ccs-sitetype-');
|
|
814
|
+
try {
|
|
815
|
+
const originalSite = readJson(path.join(seeded.dataDst, 'site.json'));
|
|
816
|
+
replaceTargetData(
|
|
817
|
+
seeded.tmp,
|
|
818
|
+
buildAnswers({ businessName: 'Type Default Co', primaryServices: ['Masonry'] }),
|
|
819
|
+
);
|
|
820
|
+
const siteDefault = readJson(path.join(seeded.dataDst, 'site.json'));
|
|
821
|
+
assert(siteDefault.site_type === 'multipage', `default write multipage, got ${siteDefault.site_type}`);
|
|
822
|
+
assert(siteDefault._instructions, '_instructions preserved');
|
|
823
|
+
assert(
|
|
824
|
+
JSON.stringify(Object.keys(siteDefault._instructions)) ===
|
|
825
|
+
JSON.stringify(Object.keys(originalSite._instructions)),
|
|
826
|
+
'_instructions keys stable',
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
// one-page
|
|
830
|
+
const one = seedDataDir('ccs-sitetype-one-');
|
|
831
|
+
replaceTargetData(
|
|
832
|
+
one.tmp,
|
|
833
|
+
buildAnswers({
|
|
834
|
+
businessName: 'One Page Site',
|
|
835
|
+
primaryServices: ['Patios'],
|
|
836
|
+
siteType: 'one-page',
|
|
837
|
+
}),
|
|
838
|
+
);
|
|
839
|
+
assert(readJson(path.join(one.dataDst, 'site.json')).site_type === 'one-page');
|
|
840
|
+
fs.rmSync(one.tmp, { recursive: true, force: true });
|
|
841
|
+
|
|
842
|
+
// seo
|
|
843
|
+
const seo = seedDataDir('ccs-sitetype-seo-');
|
|
844
|
+
replaceTargetData(
|
|
845
|
+
seo.tmp,
|
|
846
|
+
buildAnswers({
|
|
847
|
+
businessName: 'SEO Site',
|
|
848
|
+
primaryServices: ['Patios'],
|
|
849
|
+
siteType: 'seo',
|
|
850
|
+
}),
|
|
851
|
+
);
|
|
852
|
+
assert(readJson(path.join(seo.dataDst, 'site.json')).site_type === 'seo');
|
|
853
|
+
fs.rmSync(seo.tmp, { recursive: true, force: true });
|
|
854
|
+
} finally {
|
|
855
|
+
fs.rmSync(seeded.tmp, { recursive: true, force: true });
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
await test('--help documents trust/payment/hours/social/directories/siteType defaults', () => {
|
|
860
|
+
const help = runCli(['--help']);
|
|
861
|
+
assert(help.status === 0, `help exit 0, got ${help.status}`);
|
|
862
|
+
const helpOut = `${help.stdout}\n${help.stderr}`;
|
|
863
|
+
assert(/freeEstimate/i.test(helpOut), 'help lists freeEstimate');
|
|
864
|
+
assert(/foundedYear/i.test(helpOut), 'help lists foundedYear');
|
|
865
|
+
assert(/empty string/i.test(helpOut), 'help documents foundedYear empty string');
|
|
866
|
+
assert(/paymentMethods/i.test(helpOut), 'help lists paymentMethods');
|
|
867
|
+
assert(/hoursWeekday/i.test(helpOut), 'help lists compact hours keys');
|
|
868
|
+
assert(/social/i.test(helpOut), 'help lists social');
|
|
869
|
+
assert(/directories/i.test(helpOut), 'help lists directories');
|
|
870
|
+
assert(/enable_directories/i.test(helpOut), 'help mentions enable_directories');
|
|
871
|
+
assert(/siteType/i.test(helpOut), 'help lists siteType');
|
|
872
|
+
assert(/multipage/i.test(helpOut), 'help documents multipage default');
|
|
873
|
+
assert(/one-page/i.test(helpOut), 'help lists one-page');
|
|
874
|
+
assert(/CREATE_CONTRACTOR_SITE_ANSWERS_JSON/i.test(helpOut), 'help lists JSON env');
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
await test('CREATE_CONTRACTOR_SITE_ANSWERS_JSON CLI path defaults intake and siteType', () => {
|
|
878
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
879
|
+
// Fresh non-existent target (CLI rejects existing dirs that are non-empty).
|
|
880
|
+
const targetDir = path.join(
|
|
881
|
+
fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-jsonenv-parent-')),
|
|
882
|
+
'site',
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
const payload = {
|
|
886
|
+
businessName: 'JSON Env Masonry',
|
|
887
|
+
primaryServices: ['Patios'],
|
|
888
|
+
// Blank/whitespace trust fields → buildAnswers defaults; foundedYear → ""
|
|
889
|
+
freeEstimate: ' ',
|
|
890
|
+
yearsExperience: '\t',
|
|
891
|
+
license: '',
|
|
892
|
+
insurance: null,
|
|
893
|
+
foundedYear: ' ',
|
|
894
|
+
// Blank payments + empty hours → defaults (never [])
|
|
895
|
+
paymentMethods: ' , ',
|
|
896
|
+
hours: [],
|
|
897
|
+
social: { facebook: ' ', instagram: '' }, // blank → {}
|
|
898
|
+
directories: [], // none → enable false + min(1)
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const result = runCli([targetDir], {
|
|
902
|
+
env: {
|
|
903
|
+
CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT,
|
|
904
|
+
CREATE_CONTRACTOR_SITE_ANSWERS_JSON: JSON.stringify(payload),
|
|
905
|
+
NODE_ENV: 'test',
|
|
906
|
+
CREATE_CONTRACTOR_SITE_SKIP_SETUP: '1',
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
911
|
+
try {
|
|
912
|
+
assert(result.status === 0, `CLI exit 0, got ${result.status}:\n${out.slice(-2000)}`);
|
|
913
|
+
assert(/Data replace complete/i.test(out), 'expected skip-setup success banner');
|
|
914
|
+
|
|
915
|
+
const businessPath = path.join(targetDir, 'src/data/business.json');
|
|
916
|
+
assert(fs.existsSync(businessPath), 'expected generated business.json');
|
|
917
|
+
const business = JSON.parse(fs.readFileSync(businessPath, 'utf8'));
|
|
918
|
+
const site = readJson(path.join(targetDir, 'src/data/site.json'));
|
|
919
|
+
const directories = readJson(path.join(targetDir, 'src/data/directories.json'));
|
|
920
|
+
|
|
921
|
+
assert(business.name === 'JSON Env Masonry', 'env business name via CLI');
|
|
922
|
+
assert(
|
|
923
|
+
business.free_estimate === DEFAULT_FREE_ESTIMATE,
|
|
924
|
+
`free_estimate default via CLI, got ${business.free_estimate}`,
|
|
925
|
+
);
|
|
926
|
+
assert(
|
|
927
|
+
business.years_experience === DEFAULT_YEARS_EXPERIENCE,
|
|
928
|
+
`years_experience default via CLI, got ${business.years_experience}`,
|
|
929
|
+
);
|
|
930
|
+
assert(
|
|
931
|
+
business.license === DEFAULT_LICENSE,
|
|
932
|
+
`license default via CLI, got ${business.license}`,
|
|
933
|
+
);
|
|
934
|
+
assert(
|
|
935
|
+
business.insurance === DEFAULT_INSURANCE,
|
|
936
|
+
`insurance default via CLI, got ${business.insurance}`,
|
|
937
|
+
);
|
|
938
|
+
assert(
|
|
939
|
+
Object.prototype.hasOwnProperty.call(business, 'founded_year'),
|
|
940
|
+
'founded_year key must remain',
|
|
941
|
+
);
|
|
942
|
+
assert(business.founded_year === '', 'CLI founded_year blank → ""');
|
|
943
|
+
assert(
|
|
944
|
+
JSON.stringify(business.payment_methods) === JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
945
|
+
'CLI blank payments → defaults',
|
|
946
|
+
);
|
|
947
|
+
assert(business.payment_methods.length > 0, 'payment_methods never []');
|
|
948
|
+
assert(
|
|
949
|
+
JSON.stringify(business.hours) === JSON.stringify(DEFAULT_HOURS),
|
|
950
|
+
'CLI empty hours → DEFAULT_HOURS',
|
|
951
|
+
);
|
|
952
|
+
assert(business.hours.every((h) => h.days && h.time), 'hours rows shaped');
|
|
953
|
+
assert(JSON.stringify(business.social) === '{}', 'CLI blank social → {}');
|
|
954
|
+
assert(site.features.enable_directories === false, 'CLI no dirs → enable false');
|
|
955
|
+
assert(directories.directories.length >= 1, 'CLI directories min(1)');
|
|
956
|
+
assert(site.site_type === 'multipage', `CLI omitted siteType → multipage, got ${site.site_type}`);
|
|
957
|
+
} finally {
|
|
958
|
+
const parent = path.dirname(targetDir);
|
|
959
|
+
try {
|
|
960
|
+
fs.rmSync(parent, { recursive: true, force: true });
|
|
961
|
+
} catch {
|
|
962
|
+
console.warn(` (could not fully remove ${parent})`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
await test('CREATE_CONTRACTOR_SITE_ANSWERS_JSON can set siteType one-page and seo', () => {
|
|
968
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
969
|
+
|
|
970
|
+
for (const { label, siteType, expected } of [
|
|
971
|
+
{ label: 'one-page', siteType: 'one page', expected: 'one-page' },
|
|
972
|
+
{ label: 'seo', siteType: 'seo', expected: 'seo' },
|
|
973
|
+
]) {
|
|
974
|
+
const targetDir = path.join(
|
|
975
|
+
fs.mkdtempSync(path.join(os.tmpdir(), `ccs-sitetype-${label}-`)),
|
|
976
|
+
'site',
|
|
977
|
+
);
|
|
978
|
+
const result = runCli([targetDir], {
|
|
979
|
+
env: {
|
|
980
|
+
CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT,
|
|
981
|
+
CREATE_CONTRACTOR_SITE_ANSWERS_JSON: JSON.stringify({
|
|
982
|
+
businessName: `JSON ${label}`,
|
|
983
|
+
primaryServices: ['Patios'],
|
|
984
|
+
siteType,
|
|
985
|
+
}),
|
|
986
|
+
NODE_ENV: 'test',
|
|
987
|
+
CREATE_CONTRACTOR_SITE_SKIP_SETUP: '1',
|
|
988
|
+
},
|
|
989
|
+
});
|
|
990
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
991
|
+
try {
|
|
992
|
+
assert(result.status === 0, `CLI ${label} exit 0, got ${result.status}:\n${out.slice(-1500)}`);
|
|
993
|
+
const site = readJson(path.join(targetDir, 'src/data/site.json'));
|
|
994
|
+
assert(site.site_type === expected, `CLI ${label} site_type, got ${site.site_type}`);
|
|
995
|
+
} finally {
|
|
996
|
+
try {
|
|
997
|
+
fs.rmSync(path.dirname(targetDir), { recursive: true, force: true });
|
|
998
|
+
} catch {
|
|
999
|
+
console.warn(` (could not fully remove ${path.dirname(targetDir)})`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
await test('CREATE_CONTRACTOR_SITE_ANSWERS_JSON compact payment/hours/social/dirs path', () => {
|
|
1006
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
1007
|
+
const targetDir = path.join(
|
|
1008
|
+
fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-jsoncompact-parent-')),
|
|
1009
|
+
'site',
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
const payload = {
|
|
1013
|
+
businessName: 'Compact Hours Co',
|
|
1014
|
+
primaryServices: ['Patios'],
|
|
1015
|
+
paymentMethods: ['Cash', ' ', 'ACH'],
|
|
1016
|
+
hoursWeekday: '9:00 AM - 5:00 PM',
|
|
1017
|
+
hoursSaturday: 'Closed',
|
|
1018
|
+
hoursSunday: 'Closed',
|
|
1019
|
+
social: 'facebook=https://facebook.com/compact,instagram=https://instagram.com/compact',
|
|
1020
|
+
directories: 'Google Business|https://g.co/compact,BBB|https://bbb.org/compact',
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
const result = runCli([targetDir], {
|
|
1024
|
+
env: {
|
|
1025
|
+
CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT,
|
|
1026
|
+
CREATE_CONTRACTOR_SITE_ANSWERS_JSON: JSON.stringify(payload),
|
|
1027
|
+
NODE_ENV: 'test',
|
|
1028
|
+
CREATE_CONTRACTOR_SITE_SKIP_SETUP: '1',
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
1033
|
+
try {
|
|
1034
|
+
assert(result.status === 0, `CLI exit 0, got ${result.status}:\n${out.slice(-2000)}`);
|
|
1035
|
+
const business = JSON.parse(
|
|
1036
|
+
fs.readFileSync(path.join(targetDir, 'src/data/business.json'), 'utf8'),
|
|
1037
|
+
);
|
|
1038
|
+
const site = readJson(path.join(targetDir, 'src/data/site.json'));
|
|
1039
|
+
const directories = readJson(path.join(targetDir, 'src/data/directories.json'));
|
|
1040
|
+
assert(
|
|
1041
|
+
JSON.stringify(business.payment_methods) === JSON.stringify(['Cash', 'ACH']),
|
|
1042
|
+
`compact payments, got ${JSON.stringify(business.payment_methods)}`,
|
|
1043
|
+
);
|
|
1044
|
+
assert(business.hours.length === 3, 'compact hours length');
|
|
1045
|
+
assert(business.hours[0].days === 'Monday - Friday', 'weekday days label');
|
|
1046
|
+
assert(business.hours[0].time === '9:00 AM - 5:00 PM', 'compact weekday time');
|
|
1047
|
+
assert(business.hours[1].time === 'Closed', 'compact saturday');
|
|
1048
|
+
assert(business.hours[2].time === 'Closed', 'compact sunday');
|
|
1049
|
+
// Trust defaults still apply when omitted
|
|
1050
|
+
assert(business.free_estimate === DEFAULT_FREE_ESTIMATE, 'trust default still works');
|
|
1051
|
+
assert(business.founded_year === '', 'founded_year still ""');
|
|
1052
|
+
assert(business.social.facebook === 'https://facebook.com/compact');
|
|
1053
|
+
assert(business.social.instagram === 'https://instagram.com/compact');
|
|
1054
|
+
assert(site.features.enable_directories === true, 'compact dirs → enable true');
|
|
1055
|
+
assert(directories.directories.length === 2);
|
|
1056
|
+
assert(directories.directories[0].url === 'https://g.co/compact');
|
|
1057
|
+
} finally {
|
|
1058
|
+
const parent = path.dirname(targetDir);
|
|
1059
|
+
try {
|
|
1060
|
+
fs.rmSync(parent, { recursive: true, force: true });
|
|
1061
|
+
} catch {
|
|
1062
|
+
console.warn(` (could not fully remove ${parent})`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
await test('skip setup guard requires NODE_ENV test', () => {
|
|
1068
|
+
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-skip-guard-'));
|
|
1069
|
+
const targetDir = path.join(parent, 'site');
|
|
1070
|
+
try {
|
|
1071
|
+
const result = runCli(['--yes', targetDir], {
|
|
1072
|
+
env: { CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT || '', CREATE_CONTRACTOR_SITE_SKIP_SETUP: '1', NODE_ENV: '' },
|
|
1073
|
+
});
|
|
1074
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
1075
|
+
assert(result.status === 1, `expected guard failure, got ${result.status}`);
|
|
1076
|
+
assert(/requires NODE_ENV=test/i.test(out), `expected guard message:\n${out}`);
|
|
1077
|
+
} finally {
|
|
1078
|
+
fs.rmSync(parent, { recursive: true, force: true });
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
await test('replaceTargetData Chesapeake case keeps unique area slugs', () => {
|
|
1083
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
1084
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-areas-'));
|
|
1085
|
+
const dataSrc = path.join(REPO_ROOT, 'src', 'data');
|
|
1086
|
+
const dataDst = path.join(tmp, 'src', 'data');
|
|
1087
|
+
fs.mkdirSync(dataDst, { recursive: true });
|
|
1088
|
+
for (const file of [
|
|
1089
|
+
'business.json',
|
|
1090
|
+
'site.json',
|
|
1091
|
+
'services.json',
|
|
1092
|
+
'areas.json',
|
|
1093
|
+
'navigation.json',
|
|
1094
|
+
'landings.json',
|
|
1095
|
+
'directories.json',
|
|
1096
|
+
]) {
|
|
1097
|
+
fs.copyFileSync(path.join(dataSrc, file), path.join(dataDst, file));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const originalAreas = JSON.parse(
|
|
1101
|
+
fs.readFileSync(path.join(dataDst, 'areas.json'), 'utf8'),
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
const answers = buildAnswers({
|
|
1105
|
+
businessName: 'Chesapeake Stoneworks',
|
|
1106
|
+
city: 'Chesapeake',
|
|
1107
|
+
state: 'VA',
|
|
1108
|
+
zip: '23320',
|
|
1109
|
+
serviceArea:
|
|
1110
|
+
'Chesapeake, Virginia Beach, Norfolk, Newport News, Hampton, Portsmouth, Suffolk, Williamsburg and Poquoson',
|
|
1111
|
+
primaryServices: ['Masonry', 'Patios'],
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
replaceTargetData(tmp, answers);
|
|
1115
|
+
|
|
1116
|
+
const areas = JSON.parse(fs.readFileSync(path.join(dataDst, 'areas.json'), 'utf8'));
|
|
1117
|
+
assert(areas.primary_city === 'Chesapeake', 'primary_city must be Chesapeake');
|
|
1118
|
+
assert(areas.variant === originalAreas.variant, 'variant preserved');
|
|
1119
|
+
assert(areas.section_title === originalAreas.section_title, 'section_title preserved');
|
|
1120
|
+
assert(areas._instructions, '_instructions preserved');
|
|
1121
|
+
|
|
1122
|
+
assert(Array.isArray(areas.areas) && areas.areas.length > 0, 'areas array required');
|
|
1123
|
+
assert(areas.areas[0].name === 'Chesapeake', `first area name: ${areas.areas[0].name}`);
|
|
1124
|
+
assert(areas.areas[0].slug === 'chesapeake', `first area slug: ${areas.areas[0].slug}`);
|
|
1125
|
+
|
|
1126
|
+
const slugs = areas.areas.map((a) => a.slug);
|
|
1127
|
+
assert(new Set(slugs).size === slugs.length, `duplicate area slugs after replace: ${slugs}`);
|
|
1128
|
+
assert(
|
|
1129
|
+
slugs.filter((s) => s === 'chesapeake').length === 1,
|
|
1130
|
+
'chesapeake slug must appear exactly once',
|
|
1131
|
+
);
|
|
1132
|
+
|
|
1133
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
// --- image-tooling capsule isolation (Unit 1) ---
|
|
1137
|
+
await test('root workspace stays two projects and excludes tools/*', () => {
|
|
1138
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
1139
|
+
const wsPath = path.join(REPO_ROOT, 'pnpm-workspace.yaml');
|
|
1140
|
+
const wsText = fs.readFileSync(wsPath, 'utf8');
|
|
1141
|
+
assert(/\n\s*-\s*\.\s*$/m.test(wsText) || /packages:\s*\n\s*-\s*\./.test(wsText),
|
|
1142
|
+
'root workspace must include "."');
|
|
1143
|
+
assert(/-\s*packages\/\*/.test(wsText), 'root workspace must include packages/*');
|
|
1144
|
+
assert(!/tools\//.test(wsText), 'root workspace must not absorb tools/*');
|
|
1145
|
+
assert(
|
|
1146
|
+
!/better-sqlite3/.test(wsText),
|
|
1147
|
+
'root pnpm-workspace.yaml must not declare allowBuilds.better-sqlite3',
|
|
1148
|
+
);
|
|
1149
|
+
|
|
1150
|
+
const list = runCommand('pnpm', ['list', '-r', '--depth', '-1'], {
|
|
1151
|
+
cwd: REPO_ROOT,
|
|
1152
|
+
stdio: 'pipe',
|
|
1153
|
+
});
|
|
1154
|
+
// pnpm prints one name@version line per workspace package
|
|
1155
|
+
const names = (list.stdout || '').match(/^[^\s].*@\d/gm) || [];
|
|
1156
|
+
assert(
|
|
1157
|
+
names.length === 2,
|
|
1158
|
+
`expected exactly 2 root workspace projects, got ${names.length}: ${names.join(', ')}`,
|
|
1159
|
+
);
|
|
1160
|
+
assert(
|
|
1161
|
+
!/smart-image/i.test(list.stdout || ''),
|
|
1162
|
+
'root workspace listing must not include smart-image capsule',
|
|
1163
|
+
);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
await test('root sharp/zod ranges stay unchanged with no overrides', () => {
|
|
1167
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
1168
|
+
const pkg = readJson(path.join(REPO_ROOT, 'package.json'));
|
|
1169
|
+
const sharp = pkg.devDependencies?.sharp ?? pkg.dependencies?.sharp;
|
|
1170
|
+
const zod = pkg.devDependencies?.zod ?? pkg.dependencies?.zod;
|
|
1171
|
+
assert(sharp === '^0.35.4', `root sharp range must stay ^0.35.4, got ${sharp}`);
|
|
1172
|
+
assert(zod === '^4.4.3', `root zod range must stay ^4.4.3, got ${zod}`);
|
|
1173
|
+
assert(pkg.overrides == null, 'root package.json must not declare overrides');
|
|
1174
|
+
assert(
|
|
1175
|
+
pkg.pnpm?.overrides == null,
|
|
1176
|
+
'root package.json must not declare pnpm.overrides',
|
|
1177
|
+
);
|
|
1178
|
+
assert(
|
|
1179
|
+
!pkg.dependencies?.['smart-image-cli'] && !pkg.devDependencies?.['smart-image-cli'],
|
|
1180
|
+
'root package.json must not depend on smart-image-cli',
|
|
1181
|
+
);
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
await test('dist holds zero capsule or smart-image-cli references', () => {
|
|
1185
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
1186
|
+
const distDir = path.join(REPO_ROOT, 'dist');
|
|
1187
|
+
if (!fs.existsSync(distDir)) {
|
|
1188
|
+
console.log(' (dist/ absent — skip content scan; build harness covers post-build)');
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
/** @type {string[]} */
|
|
1192
|
+
const hits = [];
|
|
1193
|
+
const needles = ['smart-image-cli', 'better-sqlite3', 'tools/smart-image', 'tools\\smart-image'];
|
|
1194
|
+
/**
|
|
1195
|
+
* @param {string} dir
|
|
1196
|
+
*/
|
|
1197
|
+
function walk(dir) {
|
|
1198
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1199
|
+
const full = path.join(dir, ent.name);
|
|
1200
|
+
if (ent.isDirectory()) {
|
|
1201
|
+
walk(full);
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
// Skip large binary assets; scan text-ish build outputs only.
|
|
1205
|
+
if (!/\.(html|js|mjs|cjs|css|json|txt|xml|svg|map)$/i.test(ent.name)) continue;
|
|
1206
|
+
let text;
|
|
1207
|
+
try {
|
|
1208
|
+
text = fs.readFileSync(full, 'utf8');
|
|
1209
|
+
} catch {
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
for (const n of needles) {
|
|
1213
|
+
if (text.includes(n)) hits.push(`${path.relative(distDir, full)}:${n}`);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
walk(distDir);
|
|
1218
|
+
assert(hits.length === 0, `dist/ must not reference capsule/CLI: ${hits.slice(0, 10).join(', ')}`);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
// --- image-tooling wrapper + lifecycle (Unit 2) ---
|
|
1222
|
+
const capDir = REPO_ROOT ? path.join(REPO_ROOT, 'tools', 'smart-image') : '';
|
|
1223
|
+
const runJs = capDir ? path.join(capDir, 'run.mjs') : '';
|
|
1224
|
+
const checkJs = capDir ? path.join(capDir, 'check.mjs') : '';
|
|
1225
|
+
|
|
1226
|
+
/** @param {string} v */
|
|
1227
|
+
const qCmd = (v) =>
|
|
1228
|
+
v === '' ? '""' : /[\s"&<>|^%!]/.test(v) ? `"${String(v).replace(/"/g, '""')}"` : v;
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* @param {string} cmd
|
|
1232
|
+
* @param {string[]} args
|
|
1233
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string }} [o]
|
|
1234
|
+
*/
|
|
1235
|
+
function spawnArgv(cmd, args, o = {}) {
|
|
1236
|
+
let c = cmd;
|
|
1237
|
+
let a = [...args];
|
|
1238
|
+
let verbatim = false;
|
|
1239
|
+
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd)) {
|
|
1240
|
+
c = process.env.ComSpec || 'cmd.exe';
|
|
1241
|
+
a = ['/d', '/s', '/c', `"${[cmd, ...args].map(qCmd).join(' ')}"`];
|
|
1242
|
+
verbatim = true;
|
|
1243
|
+
}
|
|
1244
|
+
return spawnSync(c, a, {
|
|
1245
|
+
encoding: 'utf8',
|
|
1246
|
+
cwd: o.cwd || REPO_ROOT || process.cwd(),
|
|
1247
|
+
env: { ...process.env, ...(o.env || {}) },
|
|
1248
|
+
shell: false,
|
|
1249
|
+
windowsHide: true,
|
|
1250
|
+
windowsVerbatimArguments: verbatim,
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** @param {string[]} args @param {{ env?: NodeJS.ProcessEnv, cwd?: string }} [o] */
|
|
1255
|
+
function runPnpm(args, o = {}) {
|
|
1256
|
+
const bin = resolveCommandPath('pnpm');
|
|
1257
|
+
assert(Boolean(bin), 'pnpm required');
|
|
1258
|
+
return spawnArgv(/** @type {string} */ (bin), args, o);
|
|
1259
|
+
}
|
|
1260
|
+
/** @param {string[]} args @param {{ env?: NodeJS.ProcessEnv }} [o] */
|
|
1261
|
+
const imgRun = (args, o = {}) =>
|
|
1262
|
+
spawnSync(process.execPath, [runJs, ...args], {
|
|
1263
|
+
encoding: 'utf8',
|
|
1264
|
+
cwd: REPO_ROOT,
|
|
1265
|
+
env: { ...process.env, ...(o.env || {}) },
|
|
1266
|
+
shell: false,
|
|
1267
|
+
windowsHide: true,
|
|
1268
|
+
});
|
|
1269
|
+
/** @param {{ env?: NodeJS.ProcessEnv }} [o] */
|
|
1270
|
+
const imgCheck = (o = {}) =>
|
|
1271
|
+
spawnSync(process.execPath, [checkJs], {
|
|
1272
|
+
encoding: 'utf8',
|
|
1273
|
+
cwd: REPO_ROOT,
|
|
1274
|
+
env: { ...process.env, ...(o.env || {}) },
|
|
1275
|
+
shell: false,
|
|
1276
|
+
windowsHide: true,
|
|
1277
|
+
});
|
|
1278
|
+
/** @param {import('node:child_process').SpawnSyncReturns<string>} r */
|
|
1279
|
+
const out = (r) => `${r.stdout || ''}\n${r.stderr || ''}`;
|
|
1280
|
+
|
|
1281
|
+
await capsuleTest('images:run passes shell metacharacters as literal argv (exit 3, no side effects)', () => {
|
|
1282
|
+
assert(REPO_ROOT && fs.existsSync(runJs), 'run.mjs required');
|
|
1283
|
+
const marker = path.join(os.tmpdir(), `si-side-${process.pid}.txt`);
|
|
1284
|
+
if (fs.existsSync(marker)) fs.rmSync(marker, { force: true });
|
|
1285
|
+
for (const payload of [
|
|
1286
|
+
'--json; rm -rf /',
|
|
1287
|
+
`$(echo pwned > "${marker}")`,
|
|
1288
|
+
`&& echo pwned > "${marker}"`,
|
|
1289
|
+
]) {
|
|
1290
|
+
for (const r of [runPnpm(['run', 'images:run', '--', payload]), imgRun([payload])]) {
|
|
1291
|
+
assert(r.status === 3, `exit 3 expected, got ${r.status}: ${out(r).slice(0, 300)}`);
|
|
1292
|
+
assert(/invalid_input|unknown|error/i.test(out(r)), `invalid_input for ${payload}`);
|
|
1293
|
+
}
|
|
1294
|
+
assert(!fs.existsSync(marker), `no side-effect file for ${payload}`);
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
await capsuleTest('Windows smart-img bin is silent while wrapper emits parseable doctor JSON', () => {
|
|
1299
|
+
assert(REPO_ROOT && fs.existsSync(runJs), 'run.mjs required');
|
|
1300
|
+
const binCmd = path.join(capDir, 'node_modules', '.bin', 'smart-img.CMD');
|
|
1301
|
+
assert(
|
|
1302
|
+
fs.existsSync(binCmd) || fs.existsSync(path.join(capDir, 'node_modules', '.bin', 'smart-img')),
|
|
1303
|
+
'smart-img bin required',
|
|
1304
|
+
);
|
|
1305
|
+
if (process.platform === 'win32' && fs.existsSync(binCmd)) {
|
|
1306
|
+
const bin = spawnArgv(binCmd, ['doctor', '--json']);
|
|
1307
|
+
assert(
|
|
1308
|
+
`${bin.stdout || ''}${bin.stderr || ''}`.trim().length === 0,
|
|
1309
|
+
`bin must be silent, got ${out(bin).slice(0, 200)}`,
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
// `doctor` exits 0 on a fully healthy config, or 5 when the only failing
|
|
1313
|
+
// check is optional provider credentials being absent. Any other failure
|
|
1314
|
+
// (corrupt config, unreachable endpoint, missing native dependency) must
|
|
1315
|
+
// still fail this test: only the missing-credentials shape is tolerated.
|
|
1316
|
+
for (const wrap of [runPnpm(['run', 'images:run', '--', 'doctor', '--json']), imgRun(['doctor', '--json'])]) {
|
|
1317
|
+
// pnpm appends its own `[ELIFECYCLE] ...` line to stdout on a non-zero
|
|
1318
|
+
// exit, so extract the single doctor JSON object instead of parsing all
|
|
1319
|
+
// of stdout.
|
|
1320
|
+
const stdout = (wrap.stdout || '').trim();
|
|
1321
|
+
const jsonStart = stdout.indexOf('{');
|
|
1322
|
+
const jsonEnd = stdout.lastIndexOf('}');
|
|
1323
|
+
assert(jsonStart !== -1 && jsonEnd > jsonStart, `doctor must emit JSON, got: ${out(wrap).slice(0, 200)}`);
|
|
1324
|
+
const jsonText = stdout.slice(jsonStart, jsonEnd + 1);
|
|
1325
|
+
let parsed;
|
|
1326
|
+
try {
|
|
1327
|
+
parsed = JSON.parse(jsonText);
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
throw new Error(
|
|
1330
|
+
`doctor JSON must parse, got: ${jsonText.slice(0, 200)} (${err instanceof Error ? err.message : err})`,
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
assert(parsed.command === 'doctor' || parsed.ok === true, 'doctor JSON');
|
|
1334
|
+
const checks = parsed.details?.checks;
|
|
1335
|
+
assert(Array.isArray(checks), 'doctor checks');
|
|
1336
|
+
const failing = checks.filter((c) => c.ok !== true);
|
|
1337
|
+
if (wrap.status === 0) {
|
|
1338
|
+
assert(parsed.ok === true && parsed.status === 'success', `healthy doctor JSON, got ${parsed.status}`);
|
|
1339
|
+
assert(failing.length === 0, `healthy doctor has failing checks: ${failing.map((c) => c.name).join(', ')}`);
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
assert(
|
|
1343
|
+
wrap.status === 5,
|
|
1344
|
+
`wrapper must exit 0 (healthy) or 5 (provider credentials absent), got ${wrap.status}: ${wrap.stderr}`,
|
|
1345
|
+
);
|
|
1346
|
+
assert(
|
|
1347
|
+
parsed.ok === false && parsed.status === 'failed' && parsed.reason === 'doctor_failed',
|
|
1348
|
+
'failed doctor JSON',
|
|
1349
|
+
);
|
|
1350
|
+
assert(
|
|
1351
|
+
failing.length === 1 && failing[0].name === 'provider-config' && failing[0].message == null,
|
|
1352
|
+
`exit 5 must mean only optional provider credentials are absent, got failing checks: ${failing
|
|
1353
|
+
.map((c) => c.name)
|
|
1354
|
+
.join(', ')}`,
|
|
1355
|
+
);
|
|
1356
|
+
assert(failing[0].details != null, 'provider-config failure must expose non-secret config details');
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
await test('images:* never hooked from install/build/validate/CI; scripts exist', () => {
|
|
1361
|
+
assert(REPO_ROOT, 'repo root');
|
|
1362
|
+
const pkg = readJson(path.join(REPO_ROOT, 'package.json'));
|
|
1363
|
+
const s = pkg.scripts || {};
|
|
1364
|
+
assert(/frozen-lockfile/.test(s['images:setup'] || '') && /tools\/smart-image/.test(s['images:setup'] || ''));
|
|
1365
|
+
assert(/tools\/smart-image\/run\.mjs/.test(s['images:run'] || ''));
|
|
1366
|
+
assert(/tools\/smart-image\/check\.mjs/.test(s['images:check'] || ''));
|
|
1367
|
+
for (const k of ['preinstall', 'postinstall', 'prepare', 'prepublishOnly', 'build', 'validate:data', 'ci:install']) {
|
|
1368
|
+
assert(!/images:/.test(s[k] || ''), `${k} must not reference images:`);
|
|
1369
|
+
}
|
|
1370
|
+
const ci = path.join(REPO_ROOT, '.github');
|
|
1371
|
+
if (fs.existsSync(ci)) {
|
|
1372
|
+
/** @param {string} d */
|
|
1373
|
+
const walk = (d) => {
|
|
1374
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
1375
|
+
const f = path.join(d, e.name);
|
|
1376
|
+
if (e.isDirectory()) walk(f);
|
|
1377
|
+
else if (/\.ya?ml$/i.test(e.name)) {
|
|
1378
|
+
assert(!/images:(check|setup|run)/.test(fs.readFileSync(f, 'utf8')), `CI ${f}`);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
walk(ci);
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
await capsuleTest('missing capsule / corrupt lock / offline setup remediate; provider is warning', () => {
|
|
1387
|
+
assert(REPO_ROOT && fs.existsSync(checkJs) && fs.existsSync(capDir), 'capsule required');
|
|
1388
|
+
for (const p of [
|
|
1389
|
+
path.join(REPO_ROOT, 'tools', 'smart-image-MISSING', 'check.mjs'),
|
|
1390
|
+
path.join(REPO_ROOT, 'tools', 'smart-image-MISSING', 'run.mjs'),
|
|
1391
|
+
]) {
|
|
1392
|
+
const r = spawnSync(process.execPath, [p], { encoding: 'utf8', shell: false, windowsHide: true });
|
|
1393
|
+
assert(r.error || r.status !== 0, `missing path must fail: ${p}`);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const tmpCap = fs.mkdtempSync(path.join(os.tmpdir(), 'si-cap-'));
|
|
1397
|
+
const emptyStore = fs.mkdtempSync(path.join(os.tmpdir(), 'si-store-'));
|
|
1398
|
+
try {
|
|
1399
|
+
for (const f of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', 'check.mjs', 'run.mjs']) {
|
|
1400
|
+
fs.copyFileSync(path.join(capDir, f), path.join(tmpCap, f));
|
|
1401
|
+
}
|
|
1402
|
+
const noNm = spawnSync(process.execPath, [path.join(tmpCap, 'check.mjs')], {
|
|
1403
|
+
encoding: 'utf8',
|
|
1404
|
+
shell: false,
|
|
1405
|
+
windowsHide: true,
|
|
1406
|
+
});
|
|
1407
|
+
assert(noNm.status === 1 && /images:setup|not ready|not installed/i.test(out(noNm)));
|
|
1408
|
+
const noRun = spawnSync(process.execPath, [path.join(tmpCap, 'run.mjs'), 'doctor', '--json'], {
|
|
1409
|
+
encoding: 'utf8',
|
|
1410
|
+
shell: false,
|
|
1411
|
+
windowsHide: true,
|
|
1412
|
+
});
|
|
1413
|
+
assert(noRun.status === 1 && /images:setup/i.test(out(noRun)));
|
|
1414
|
+
const offline = runPnpm(
|
|
1415
|
+
['install', '--frozen-lockfile', '--dir', tmpCap, '--offline', '--store-dir', emptyStore],
|
|
1416
|
+
);
|
|
1417
|
+
assert(offline.status !== 0 && /ERR_|error|failed|offline|not found|ENOENT/i.test(out(offline)));
|
|
1418
|
+
} finally {
|
|
1419
|
+
fs.rmSync(tmpCap, { recursive: true, force: true });
|
|
1420
|
+
fs.rmSync(emptyStore, { recursive: true, force: true });
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const lockPath = path.join(capDir, 'pnpm-lock.yaml');
|
|
1424
|
+
const lockOrig = fs.readFileSync(lockPath);
|
|
1425
|
+
try {
|
|
1426
|
+
fs.writeFileSync(lockPath, 'this: is: not: valid: lockfile\n');
|
|
1427
|
+
const setup = runPnpm(['run', 'images:setup']);
|
|
1428
|
+
assert(setup.status !== 0 && /ERR_|error|failed|lock/i.test(out(setup)));
|
|
1429
|
+
const bad = imgCheck();
|
|
1430
|
+
assert(bad.status === 1 && /corrupt|lockfile|not ready|Remediation|images:setup/i.test(out(bad)));
|
|
1431
|
+
} finally {
|
|
1432
|
+
fs.writeFileSync(lockPath, lockOrig);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const healthy = imgCheck();
|
|
1436
|
+
assert(healthy.status === 0 && /ready/i.test(healthy.stdout || ''));
|
|
1437
|
+
|
|
1438
|
+
const emptyCfg = fs.mkdtempSync(path.join(os.tmpdir(), 'si-cfg-'));
|
|
1439
|
+
try {
|
|
1440
|
+
const env = {
|
|
1441
|
+
...process.env,
|
|
1442
|
+
APPDATA: emptyCfg,
|
|
1443
|
+
XDG_CONFIG_HOME: emptyCfg,
|
|
1444
|
+
HOME: emptyCfg,
|
|
1445
|
+
USERPROFILE: emptyCfg,
|
|
1446
|
+
};
|
|
1447
|
+
const warn = imgCheck({ env });
|
|
1448
|
+
assert(warn.status === 0 && /ready/i.test(warn.stdout || ''));
|
|
1449
|
+
assert(/warning:.*provider/i.test(warn.stderr || ''), warn.stderr);
|
|
1450
|
+
const pick = imgRun(
|
|
1451
|
+
[
|
|
1452
|
+
'pick',
|
|
1453
|
+
REPO_ROOT,
|
|
1454
|
+
'--source',
|
|
1455
|
+
'pixabay',
|
|
1456
|
+
'--category',
|
|
1457
|
+
'exterior',
|
|
1458
|
+
'--orientation',
|
|
1459
|
+
'landscape',
|
|
1460
|
+
'--width',
|
|
1461
|
+
'800',
|
|
1462
|
+
'--height',
|
|
1463
|
+
'600',
|
|
1464
|
+
'--json',
|
|
1465
|
+
],
|
|
1466
|
+
{ env },
|
|
1467
|
+
);
|
|
1468
|
+
assert(pick.status !== 0, `provider pick must fail, got ${pick.status}`);
|
|
1469
|
+
assert(
|
|
1470
|
+
pick.status === 4 || /provider|api.?key|pixabay|failed|error/i.test(out(pick)),
|
|
1471
|
+
out(pick).slice(0, 300),
|
|
1472
|
+
);
|
|
1473
|
+
} finally {
|
|
1474
|
+
fs.rmSync(emptyCfg, { recursive: true, force: true });
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
// --- image-tooling scaffold / git isolation (Unit 3) ---
|
|
1479
|
+
await test('isDeniedName covers every deny entry, rules, and near-miss negatives', () => {
|
|
1480
|
+
const denyDirs = [
|
|
1481
|
+
'node_modules',
|
|
1482
|
+
'dist',
|
|
1483
|
+
'.astro',
|
|
1484
|
+
'.git',
|
|
1485
|
+
'.codegraph',
|
|
1486
|
+
'docs_trash',
|
|
1487
|
+
'openspec',
|
|
1488
|
+
'logs',
|
|
1489
|
+
'packages',
|
|
1490
|
+
'.atl',
|
|
1491
|
+
'CUSTOMER-IMAGES',
|
|
1492
|
+
'.img-ia',
|
|
1493
|
+
];
|
|
1494
|
+
const denyFiles = ['package-lock.json'];
|
|
1495
|
+
for (const name of denyDirs) {
|
|
1496
|
+
assert(isDeniedName(name) === true, `deny dir ${name}`);
|
|
1497
|
+
}
|
|
1498
|
+
for (const name of denyFiles) {
|
|
1499
|
+
assert(isDeniedName(name) === true, `deny file ${name}`);
|
|
1500
|
+
}
|
|
1501
|
+
assert(isDeniedName('debug.log') === true, '*.log rule');
|
|
1502
|
+
assert(isDeniedName('app.log') === true, '*.log rule (app.log)');
|
|
1503
|
+
assert(isDeniedName('.env') === true, '.env* rule');
|
|
1504
|
+
assert(isDeniedName('.env.local') === true, '.env* rule (.env.local)');
|
|
1505
|
+
assert(isDeniedName('.env.production') === true, '.env* rule (.env.production)');
|
|
1506
|
+
// Near-miss / unrelated basenames must stay allowed
|
|
1507
|
+
assert(isDeniedName('CUSTOMER-IMAGES.md') === false, 'CUSTOMER-IMAGES.md near-miss');
|
|
1508
|
+
assert(isDeniedName('my-CUSTOMER-IMAGES') === false, 'my-CUSTOMER-IMAGES near-miss');
|
|
1509
|
+
assert(isDeniedName('tools') === false, 'tools must never be denied');
|
|
1510
|
+
assert(isDeniedName('smart-image') === false, 'smart-image basename allowed');
|
|
1511
|
+
assert(isDeniedName('_out') === false, 'bare _out must never be denied');
|
|
1512
|
+
assert(isDeniedName('README.sh') === false, 'README.sh alone is not denied');
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
await test('git check-ignore is root-relative for state and capsule node_modules only', () => {
|
|
1516
|
+
assert(REPO_ROOT, 'expected local template root');
|
|
1517
|
+
/**
|
|
1518
|
+
* @param {string} rel
|
|
1519
|
+
* @returns {boolean} true when git treats path as ignored
|
|
1520
|
+
*/
|
|
1521
|
+
function isIgnored(rel) {
|
|
1522
|
+
const r = spawnSync('git', ['check-ignore', '-q', '--', rel], {
|
|
1523
|
+
cwd: REPO_ROOT,
|
|
1524
|
+
encoding: 'utf8',
|
|
1525
|
+
shell: false,
|
|
1526
|
+
windowsHide: true,
|
|
1527
|
+
});
|
|
1528
|
+
if (r.error) throw r.error;
|
|
1529
|
+
return r.status === 0;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// Directory patterns use trailing slash; also prove bare dir names when they exist.
|
|
1533
|
+
assert(isIgnored('CUSTOMER-IMAGES/') === true, 'root CUSTOMER-IMAGES/ ignored');
|
|
1534
|
+
assert(isIgnored('.img-ia/') === true, 'root .img-ia/ ignored');
|
|
1535
|
+
// Bare (no trailing slash) matching is directory-only, so Git can only treat the
|
|
1536
|
+
// path as ignored when it actually exists. The capsule's optional node_modules is
|
|
1537
|
+
// deliberately absent in fresh CI; the trailing-slash assertion below stays
|
|
1538
|
+
// unconditional because Git honors the explicit directory marker without a stat.
|
|
1539
|
+
if (fs.existsSync(path.join(REPO_ROOT, 'tools', 'smart-image', 'node_modules'))) {
|
|
1540
|
+
assert(isIgnored('tools/smart-image/node_modules') === true, 'capsule node_modules ignored');
|
|
1541
|
+
}
|
|
1542
|
+
assert(isIgnored('tools/smart-image/node_modules/') === true, 'capsule node_modules/ ignored');
|
|
1543
|
+
|
|
1544
|
+
const probeCi = path.join(REPO_ROOT, 'CUSTOMER-IMAGES');
|
|
1545
|
+
const probeImg = path.join(REPO_ROOT, '.img-ia');
|
|
1546
|
+
/** @type {string[]} */
|
|
1547
|
+
const probes = [];
|
|
1548
|
+
try {
|
|
1549
|
+
if (!fs.existsSync(probeCi)) {
|
|
1550
|
+
fs.mkdirSync(probeCi, { recursive: true });
|
|
1551
|
+
probes.push(probeCi);
|
|
1552
|
+
}
|
|
1553
|
+
if (!fs.existsSync(probeImg)) {
|
|
1554
|
+
fs.mkdirSync(probeImg, { recursive: true });
|
|
1555
|
+
probes.push(probeImg);
|
|
1556
|
+
}
|
|
1557
|
+
assert(isIgnored('CUSTOMER-IMAGES') === true, 'root CUSTOMER-IMAGES dir ignored');
|
|
1558
|
+
assert(isIgnored('.img-ia') === true, 'root .img-ia dir ignored');
|
|
1559
|
+
assert(isIgnored('CUSTOMER-IMAGES/secret.jpg') === true, 'root CUSTOMER-IMAGES child ignored');
|
|
1560
|
+
assert(isIgnored('.img-ia/_out/x.png') === true, 'root .img-ia child ignored');
|
|
1561
|
+
} finally {
|
|
1562
|
+
for (const p of probes) {
|
|
1563
|
+
try {
|
|
1564
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
1565
|
+
} catch {
|
|
1566
|
+
// ignore
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Nested lookalikes remain trackable (root-anchored / patterns only).
|
|
1572
|
+
// Avoid bare trailing-slash probes here: CRLF blank gitignore lines can
|
|
1573
|
+
// confuse check-ignore's directory-only matching on some Windows git builds.
|
|
1574
|
+
assert(isIgnored('src/CUSTOMER-IMAGES') === false, 'nested CUSTOMER-IMAGES trackable');
|
|
1575
|
+
assert(isIgnored('src/CUSTOMER-IMAGES/photo.jpg') === false, 'nested CUSTOMER-IMAGES file trackable');
|
|
1576
|
+
assert(isIgnored('docs/.img-ia') === false, 'nested .img-ia trackable');
|
|
1577
|
+
assert(isIgnored('docs/.img-ia/readme') === false, 'nested .img-ia child trackable');
|
|
1578
|
+
assert(isIgnored('packages/create-contractor-site/.img-ia') === false, 'nested package .img-ia trackable');
|
|
1579
|
+
|
|
1580
|
+
// Capsule source / manifest / lock / wrapper remain trackable
|
|
1581
|
+
for (const rel of [
|
|
1582
|
+
'tools/smart-image/package.json',
|
|
1583
|
+
'tools/smart-image/pnpm-workspace.yaml',
|
|
1584
|
+
'tools/smart-image/pnpm-lock.yaml',
|
|
1585
|
+
'tools/smart-image/run.mjs',
|
|
1586
|
+
'tools/smart-image/check.mjs',
|
|
1587
|
+
]) {
|
|
1588
|
+
assert(isIgnored(rel) === false, `${rel} must remain trackable`);
|
|
1589
|
+
}
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
await test('copyTemplate excludes denied parents, keeps unrelated _out, required misses loud', () => {
|
|
1593
|
+
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-deny-src-'));
|
|
1594
|
+
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-deny-dst-'));
|
|
1595
|
+
try {
|
|
1596
|
+
// Minimal template-shaped tree
|
|
1597
|
+
fs.writeFileSync(path.join(src, 'keep.txt'), 'safe');
|
|
1598
|
+
fs.mkdirSync(path.join(src, 'CUSTOMER-IMAGES'), { recursive: true });
|
|
1599
|
+
fs.writeFileSync(path.join(src, 'CUSTOMER-IMAGES', 'secret.jpg'), 'NOCOPY');
|
|
1600
|
+
fs.mkdirSync(path.join(src, '.img-ia', '_out'), { recursive: true });
|
|
1601
|
+
fs.writeFileSync(path.join(src, '.img-ia', 'README.sh'), '#!/bin/sh\necho leak\n');
|
|
1602
|
+
fs.writeFileSync(path.join(src, '.img-ia', '_out', 'candidate.png'), 'NOCOPY');
|
|
1603
|
+
fs.mkdirSync(path.join(src, '_out'), { recursive: true });
|
|
1604
|
+
fs.writeFileSync(path.join(src, '_out', 'client-keep.txt'), 'KEEP');
|
|
1605
|
+
fs.mkdirSync(path.join(src, 'tools', 'smart-image'), { recursive: true });
|
|
1606
|
+
fs.writeFileSync(path.join(src, 'tools', 'smart-image', 'run.mjs'), '// wrapper');
|
|
1607
|
+
|
|
1608
|
+
const { skipped } = copyTemplate(src, dest);
|
|
1609
|
+
const skippedNorm = skipped.map((s) => s.replaceAll('\\', '/'));
|
|
1610
|
+
|
|
1611
|
+
assert(fs.existsSync(path.join(dest, 'keep.txt')), 'unrelated file copies');
|
|
1612
|
+
assert(fs.existsSync(path.join(dest, '_out', 'client-keep.txt')), 'unrelated _out copies');
|
|
1613
|
+
assert(fs.existsSync(path.join(dest, 'tools', 'smart-image', 'run.mjs')), 'tools/smart-image retained');
|
|
1614
|
+
assert(!fs.existsSync(path.join(dest, 'CUSTOMER-IMAGES')), 'CUSTOMER-IMAGES excluded');
|
|
1615
|
+
assert(!fs.existsSync(path.join(dest, '.img-ia')), '.img-ia excluded (parent deny)');
|
|
1616
|
+
assert(
|
|
1617
|
+
!fs.existsSync(path.join(dest, '.img-ia', 'README.sh')),
|
|
1618
|
+
'.img-ia/README.sh absent — no recurse into denied parent',
|
|
1619
|
+
);
|
|
1620
|
+
assert(
|
|
1621
|
+
!fs.existsSync(path.join(dest, '.img-ia', '_out', 'candidate.png')),
|
|
1622
|
+
'.img-ia/_out bytes never copied',
|
|
1623
|
+
);
|
|
1624
|
+
assert(skippedNorm.includes('CUSTOMER-IMAGES'), 'skipped lists CUSTOMER-IMAGES');
|
|
1625
|
+
assert(skippedNorm.includes('.img-ia'), 'skipped lists .img-ia');
|
|
1626
|
+
assert(!skippedNorm.some((s) => s === '_out' || s.startsWith('_out/')), '_out not skipped');
|
|
1627
|
+
|
|
1628
|
+
// assertRequiredCopied: every required capsule/wrapper/skill path missing is reported
|
|
1629
|
+
const stubRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-req-'));
|
|
1630
|
+
try {
|
|
1631
|
+
for (const rel of REQUIRED_AFTER_COPY) {
|
|
1632
|
+
const full = path.join(stubRoot, ...rel.split('/'));
|
|
1633
|
+
const looksFile = path.extname(rel) !== '' || rel.endsWith('package.json');
|
|
1634
|
+
if (looksFile) {
|
|
1635
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
1636
|
+
fs.writeFileSync(full, 'x');
|
|
1637
|
+
} else {
|
|
1638
|
+
fs.mkdirSync(full, { recursive: true });
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
assert(assertRequiredCopied(stubRoot).length === 0, 'full stub must pass required check');
|
|
1642
|
+
|
|
1643
|
+
const requiredMustExist = [
|
|
1644
|
+
'tools/smart-image/package.json',
|
|
1645
|
+
'tools/smart-image/pnpm-workspace.yaml',
|
|
1646
|
+
'tools/smart-image/pnpm-lock.yaml',
|
|
1647
|
+
'tools/smart-image/run.mjs',
|
|
1648
|
+
'tools/smart-image/check.mjs',
|
|
1649
|
+
'.agents/skills/smart-image-cli/SKILL.md',
|
|
1650
|
+
];
|
|
1651
|
+
for (const rel of requiredMustExist) {
|
|
1652
|
+
assert(
|
|
1653
|
+
REQUIRED_AFTER_COPY.includes(rel),
|
|
1654
|
+
`REQUIRED_AFTER_COPY must list ${rel}`,
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
for (const rel of requiredMustExist) {
|
|
1659
|
+
const full = path.join(stubRoot, ...rel.split('/'));
|
|
1660
|
+
fs.rmSync(full, { force: true });
|
|
1661
|
+
const missing = assertRequiredCopied(stubRoot);
|
|
1662
|
+
assert(missing.includes(rel), `missing must report ${rel}, got ${missing.join(',')}`);
|
|
1663
|
+
// restore for next iteration
|
|
1664
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
1665
|
+
fs.writeFileSync(full, 'x');
|
|
1666
|
+
}
|
|
1667
|
+
} finally {
|
|
1668
|
+
fs.rmSync(stubRoot, { recursive: true, force: true });
|
|
1669
|
+
}
|
|
1670
|
+
} finally {
|
|
1671
|
+
fs.rmSync(src, { recursive: true, force: true });
|
|
1672
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
const skipE2E = process.env.SKIP_CLI_E2E === '1';
|
|
1677
|
+
if (skipE2E) {
|
|
1678
|
+
console.log('\n ↷ SKIP_CLI_E2E=1 — skipping temp-target full scaffold');
|
|
1679
|
+
} else {
|
|
1680
|
+
/** @type {string[]} */
|
|
1681
|
+
const e2eSeeds = [];
|
|
1682
|
+
if (REPO_ROOT) {
|
|
1683
|
+
const seedCi = path.join(REPO_ROOT, 'CUSTOMER-IMAGES');
|
|
1684
|
+
const seedImgIaRoot = path.join(REPO_ROOT, '.img-ia');
|
|
1685
|
+
if (!fs.existsSync(seedCi)) {
|
|
1686
|
+
fs.mkdirSync(seedCi, { recursive: true });
|
|
1687
|
+
fs.writeFileSync(path.join(seedCi, 'e2e-secret.jpg'), 'NOCOPY');
|
|
1688
|
+
e2eSeeds.push(seedCi);
|
|
1689
|
+
}
|
|
1690
|
+
if (!fs.existsSync(seedImgIaRoot)) {
|
|
1691
|
+
fs.mkdirSync(path.join(seedImgIaRoot, '_out'), { recursive: true });
|
|
1692
|
+
fs.writeFileSync(path.join(seedImgIaRoot, '_out', 'e2e-candidate.png'), 'NOCOPY');
|
|
1693
|
+
e2eSeeds.push(seedImgIaRoot);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
try {
|
|
1697
|
+
await test('temp-target --yes scaffold install/validate/build/git', () => {
|
|
1698
|
+
assert(REPO_ROOT, 'expected local template root for E2E');
|
|
1699
|
+
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-e2e-'));
|
|
1700
|
+
// mkdtemp creates the dir; CLI rejects non-empty — use a child path that does not exist.
|
|
1701
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
1702
|
+
const targetDir = target;
|
|
1703
|
+
|
|
1704
|
+
const result = runCli(['--yes', targetDir], {
|
|
1705
|
+
env: {
|
|
1706
|
+
CREATE_CONTRACTOR_TEMPLATE_ROOT: REPO_ROOT,
|
|
1707
|
+
},
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
const out = `${result.stdout}\n${result.stderr}`;
|
|
1711
|
+
if (result.status !== 0) {
|
|
1712
|
+
throw new Error(`scaffold failed (exit ${result.status}):\n${out.slice(-4000)}`);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
assert(/Scaffold complete/i.test(out), 'expected success banner');
|
|
1716
|
+
assert(fs.existsSync(path.join(targetDir, 'dist')), 'expected dist/ after build');
|
|
1717
|
+
assert(fs.existsSync(path.join(targetDir, 'AGENTS.md')), 'expected AGENTS.md guard');
|
|
1718
|
+
assert(
|
|
1719
|
+
!fs.existsSync(path.join(targetDir, 'openspec')),
|
|
1720
|
+
'openspec must not be copied',
|
|
1721
|
+
);
|
|
1722
|
+
assert(
|
|
1723
|
+
!fs.existsSync(path.join(targetDir, 'packages')),
|
|
1724
|
+
'packages/ must not be copied',
|
|
1725
|
+
);
|
|
1726
|
+
assert(!fs.existsSync(path.join(targetDir, 'CUSTOMER-IMAGES')), 'CUSTOMER-IMAGES excluded');
|
|
1727
|
+
assert(!fs.existsSync(path.join(targetDir, '.img-ia')), '.img-ia excluded');
|
|
1728
|
+
assert(fs.existsSync(path.join(targetDir, 'tools', 'smart-image', 'package.json')), 'capsule manifest');
|
|
1729
|
+
assert(fs.existsSync(path.join(targetDir, 'tools', 'smart-image', 'pnpm-workspace.yaml')), 'capsule workspace');
|
|
1730
|
+
assert(fs.existsSync(path.join(targetDir, 'tools', 'smart-image', 'pnpm-lock.yaml')), 'capsule lock');
|
|
1731
|
+
assert(fs.existsSync(path.join(targetDir, 'tools', 'smart-image', 'run.mjs')), 'wrapper run.mjs');
|
|
1732
|
+
assert(fs.existsSync(path.join(targetDir, 'tools', 'smart-image', 'check.mjs')), 'wrapper check.mjs');
|
|
1733
|
+
assert(
|
|
1734
|
+
fs.existsSync(path.join(targetDir, '.agents', 'skills', 'smart-image-cli', 'SKILL.md')),
|
|
1735
|
+
'skill retained',
|
|
1736
|
+
);
|
|
1737
|
+
const tgtPkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf8'));
|
|
1738
|
+
for (const k of ['images:check', 'images:setup', 'images:run']) {
|
|
1739
|
+
assert(typeof tgtPkg.scripts?.[k] === 'string', `scaffold scripts must include ${k}`);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
const business = JSON.parse(
|
|
1743
|
+
fs.readFileSync(path.join(targetDir, 'src/data/business.json'), 'utf8'),
|
|
1744
|
+
);
|
|
1745
|
+
assert(business.name === 'Acme Masonry', 'expected replaced business name');
|
|
1746
|
+
// --yes omits trust/payment/hours → buildAnswers defaults (not sample-specific facts)
|
|
1747
|
+
assert(business.free_estimate === DEFAULT_FREE_ESTIMATE, 'E2E free_estimate default');
|
|
1748
|
+
assert(
|
|
1749
|
+
business.years_experience === DEFAULT_YEARS_EXPERIENCE,
|
|
1750
|
+
'E2E years_experience default',
|
|
1751
|
+
);
|
|
1752
|
+
assert(business.license === DEFAULT_LICENSE, 'E2E license default');
|
|
1753
|
+
assert(business.insurance === DEFAULT_INSURANCE, 'E2E insurance default');
|
|
1754
|
+
assert(business.founded_year === '', 'E2E founded_year omitted → ""');
|
|
1755
|
+
assert(
|
|
1756
|
+
JSON.stringify(business.payment_methods) === JSON.stringify(DEFAULT_PAYMENT_METHODS),
|
|
1757
|
+
'E2E payment_methods defaults',
|
|
1758
|
+
);
|
|
1759
|
+
assert(business.payment_methods.length > 0, 'E2E payment_methods never []');
|
|
1760
|
+
assert(
|
|
1761
|
+
JSON.stringify(business.hours) === JSON.stringify(DEFAULT_HOURS),
|
|
1762
|
+
'E2E hours defaults',
|
|
1763
|
+
);
|
|
1764
|
+
assert(
|
|
1765
|
+
business.hours.every((h) => typeof h.days === 'string' && typeof h.time === 'string'),
|
|
1766
|
+
'E2E hours shape',
|
|
1767
|
+
);
|
|
1768
|
+
assert(JSON.stringify(business.social) === '{}', 'E2E omitted social → {}');
|
|
1769
|
+
const siteE2E = readJson(path.join(targetDir, 'src/data/site.json'));
|
|
1770
|
+
const dirsE2E = readJson(path.join(targetDir, 'src/data/directories.json'));
|
|
1771
|
+
assert(siteE2E.features.enable_directories === false, 'E2E no dirs → enable false');
|
|
1772
|
+
assert(dirsE2E.directories.length >= 1, 'E2E directories min(1)');
|
|
1773
|
+
assert(
|
|
1774
|
+
siteE2E.site_type === 'multipage',
|
|
1775
|
+
`E2E --yes site_type multipage, got ${siteE2E.site_type}`,
|
|
1776
|
+
);
|
|
1777
|
+
// Full scaffold already ran validate:data + build (includes route gate) successfully.
|
|
1778
|
+
|
|
1779
|
+
const services = JSON.parse(
|
|
1780
|
+
fs.readFileSync(path.join(targetDir, 'src/data/services.json'), 'utf8'),
|
|
1781
|
+
);
|
|
1782
|
+
const names = services.services.map((s) => s.name);
|
|
1783
|
+
assert(
|
|
1784
|
+
new Set(names.map((n) => n.toLowerCase())).size === names.length,
|
|
1785
|
+
`duplicate service names in E2E: ${names}`,
|
|
1786
|
+
);
|
|
1787
|
+
|
|
1788
|
+
const offeredNames = business.services_offered.map((s) => s.name);
|
|
1789
|
+
assert(
|
|
1790
|
+
JSON.stringify(offeredNames) === JSON.stringify(names),
|
|
1791
|
+
'business.services_offered names must match services.json',
|
|
1792
|
+
);
|
|
1793
|
+
|
|
1794
|
+
// Best-effort cleanup of large scaffold output.
|
|
1795
|
+
try {
|
|
1796
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
1797
|
+
} catch {
|
|
1798
|
+
console.warn(` (could not fully remove ${targetDir})`);
|
|
1799
|
+
}
|
|
1800
|
+
});
|
|
1801
|
+
} finally {
|
|
1802
|
+
for (const p of e2eSeeds) {
|
|
1803
|
+
try {
|
|
1804
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
1805
|
+
} catch {
|
|
1806
|
+
// ignore seed cleanup failures
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
1813
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
main().catch((err) => {
|
|
1817
|
+
console.error(err);
|
|
1818
|
+
process.exit(1);
|
|
1819
|
+
});
|