emily-css 1.2.9 → 1.2.10

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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to `emily-css` are documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## v1.2.10 — May 2026
8
+
9
+ **v1.2.10 — Config validation & CI/CD**
10
+
11
+ ### Added
12
+ - feat: add config validation, E2E chaos testing, and GitHub Actions CI
13
+
14
+ ### Changed
15
+ - add randomized E2E config fuzzing suite
16
+ - add GitHub Actions test workflow and PR template
17
+
18
+ ---
7
19
  ## v1.2.9 — May 2026
8
20
 
9
21
  **Fix baseFontSize handling in emily-css init and generated CSS**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emily-css",
3
- "version": "1.2.9",
3
+ "version": "1.2.10",
4
4
  "description": "A config-driven utility CSS framework. Define your brand once, generate the CSS.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -19,7 +19,8 @@
19
19
  "dev": "nodemon src/index.js",
20
20
  "dev:full": "nodemon src/index.js -- --keep-full",
21
21
  "init": "node src/init.js",
22
- "test": "node tests/test.js",
22
+ "test": "node tests/test.js && node src/test/e2e.test.js",
23
+ "test:e2e": "node src/test/e2e.test.js",
23
24
  "emily:showcase": "node src/showcase.js",
24
25
  "commit": "node scripts/commit.js",
25
26
  "release": "node scripts/release.js",
package/src/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
- const path = require('path');
5
- const { generateManifest } = require('./manifest');
6
- const { generateIntellisense } = require('./intellisense');
4
+ const path = require('path');
5
+ const { generateManifest } = require('./manifest');
6
+ const { generateIntellisense } = require('./intellisense');
7
+ const { validateConfigOrExit } = require('./validateConfig');
7
8
  const {
8
9
  getConfigPath,
9
10
  getConfig,
@@ -1508,8 +1509,9 @@ function generatePatternComponents() {
1508
1509
  // BUILD FUNCTION
1509
1510
  // ============================================================================
1510
1511
 
1511
- function buildFullFramework() {
1512
- const config = getConfig();
1512
+ function buildFullFramework() {
1513
+ validateConfigOrExit();
1514
+ const config = getConfig();
1513
1515
 
1514
1516
  console.log('Building EmilyCSS full framework...');
1515
1517
 
package/src/init.js CHANGED
@@ -3,9 +3,16 @@ const path = require("path");
3
3
  const crossSpawn = require("cross-spawn");
4
4
  const { Select, Input, Confirm } = require("enquirer");
5
5
  const chalk = require("chalk");
6
- const ora = require("ora");
7
- const boxen = require("boxen");
8
- const { DEFAULT_PURGE_IGNORE, PURGE_EXTENSIONS } = require("./constants.js");
6
+ const ora = require("ora");
7
+ const boxen = require("boxen");
8
+ const { DEFAULT_PURGE_IGNORE, PURGE_EXTENSIONS } = require("./constants.js");
9
+ const {
10
+ ALLOWED_FONT_FAMILIES,
11
+ validateHexColour,
12
+ validateSpacingValue,
13
+ validateFontFamily,
14
+ validateConfigShape,
15
+ } = require("./validate.js");
9
16
 
10
17
  // ============================================================================
11
18
  // CONSTANTS
@@ -48,15 +55,7 @@ const COLOUR_PRESETS = {
48
55
  ],
49
56
  };
50
57
 
51
- const FONT_OPTIONS = [
52
- { name: "lexend", message: "Lexend (clear, accessible - recommended)" },
53
- { name: "inter", message: "Inter (clean, widely used)" },
54
- { name: "dm-sans", message: "DM Sans (modern, geometric)" },
55
- { name: "nunito", message: "Nunito (friendly, rounded)" },
56
- { name: "atkinson", message: "Atkinson Hyperlegible (maximum legibility)" },
57
- { name: "system", message: "System sans-serif (no download required)" },
58
- ];
59
- const CORE_COLOUR_KEYS = new Set([
58
+ const CORE_COLOUR_KEYS = new Set([
60
59
  "brand",
61
60
  "accent",
62
61
  "btn-primary",
@@ -71,13 +70,9 @@ const CORE_COLOUR_KEYS = new Set([
71
70
  // HELPERS
72
71
  // ============================================================================
73
72
 
74
- function isValidHex(hex) {
75
- return /^#[0-9A-F]{6}$/i.test(hex);
76
- }
77
-
78
- function isPlainObject(value) {
79
- return (
80
- value !== null &&
73
+ function isPlainObject(value) {
74
+ return (
75
+ value !== null &&
81
76
  typeof value === "object" &&
82
77
  !Array.isArray(value)
83
78
  );
@@ -110,26 +105,63 @@ function colourSwatch(hex) {
110
105
  return chalk.hex(hex)("■");
111
106
  }
112
107
 
113
- function normaliseHex(value) {
114
- return typeof value === "string" && isValidHex(value)
115
- ? value.toUpperCase()
116
- : null;
117
- }
118
-
119
- async function askHex(promptName, message, initial) {
120
- const value = await new Input({
121
- name: promptName,
122
- message,
123
- initial: initial || "#000000",
124
- validate(value) {
125
- return isValidHex(value)
126
- ? true
127
- : "Enter a valid hex colour, e.g. #0077B6";
128
- },
129
- }).run();
130
-
131
- return value.toUpperCase();
132
- }
108
+ function normaliseHex(value) {
109
+ if (typeof value !== "string") return null;
110
+ const trimmed = value.trim();
111
+ const result = validateHexColour(trimmed);
112
+ return result.valid ? trimmed.toUpperCase() : null;
113
+ }
114
+
115
+ function formatValueForMessage(value) {
116
+ if (value === null) return "null";
117
+ if (value === undefined) return "undefined";
118
+ return "'" + String(value) + "'";
119
+ }
120
+
121
+ async function askValidatedInput({
122
+ promptName,
123
+ message,
124
+ initial,
125
+ validator,
126
+ normalise,
127
+ }) {
128
+ let nextInitial = initial;
129
+
130
+ while (true) {
131
+ const raw = await new Input({
132
+ name: promptName,
133
+ message,
134
+ initial: nextInitial,
135
+ }).run();
136
+
137
+ const value = typeof raw === "string" ? raw.trim() : raw;
138
+ const result = validator(value);
139
+
140
+ if (result.valid) {
141
+ console.log(chalk.green("✓ Valid"));
142
+ return typeof normalise === "function" ? normalise(value) : value;
143
+ }
144
+
145
+ console.log(
146
+ chalk.red(
147
+ "✗ Invalid: " + result.reason + " (got " + formatValueForMessage(raw) + ")",
148
+ ),
149
+ );
150
+ nextInitial = value || initial;
151
+ }
152
+ }
153
+
154
+ async function askHex(promptName, message, initial) {
155
+ return askValidatedInput({
156
+ promptName,
157
+ message,
158
+ initial: initial || "#000000",
159
+ validator: validateHexColour,
160
+ normalise: function (value) {
161
+ return String(value).toUpperCase();
162
+ },
163
+ });
164
+ }
133
165
 
134
166
  async function askColourFromPresets(label, presets, defaultHex, currentHex) {
135
167
  const defaultHexValue = normaliseHex(defaultHex);
@@ -224,14 +256,7 @@ function readExistingConfig() {
224
256
  }
225
257
  }
226
258
 
227
- function getFontInitialIndex(fontKey, fallbackIndex) {
228
- if (!fontKey || typeof fontKey !== "string") return fallbackIndex;
229
- const normalised = fontKey.toLowerCase();
230
- const index = FONT_OPTIONS.findIndex((option) => option.name === normalised);
231
- return index === -1 ? fallbackIndex : index;
232
- }
233
-
234
- function getExistingAdditionalColours(existingColours) {
259
+ function getExistingAdditionalColours(existingColours) {
235
260
  if (!isPlainObject(existingColours)) return {};
236
261
 
237
262
  const additional = {};
@@ -247,14 +272,13 @@ function getExistingAdditionalColours(existingColours) {
247
272
  return additional;
248
273
  }
249
274
 
250
- function getBaseUnitInitial(config) {
251
- const rawBaseUnit = config && typeof config.baseUnit === "string"
252
- ? config.baseUnit
253
- : "";
254
- const parsed = Number.parseInt(rawBaseUnit, 10);
255
- if (Number.isNaN(parsed) || parsed <= 0) return "18";
256
- return String(parsed);
257
- }
275
+ function getBaseUnitInitial(config) {
276
+ const rawBaseUnit = config && typeof config.baseUnit === "string"
277
+ ? config.baseUnit.trim()
278
+ : "";
279
+ if (validateSpacingValue(rawBaseUnit).valid) return rawBaseUnit;
280
+ return "18px";
281
+ }
258
282
 
259
283
  function hasDependency(packageJson, dependencyName) {
260
284
  if (!packageJson) return false;
@@ -435,11 +459,11 @@ function detectProject() {
435
459
  // CONFIG BUILDER
436
460
  // ============================================================================
437
461
 
438
- function createDefaultConfig({
439
- name,
440
- colours,
441
- headingFont,
442
- bodyFont,
462
+ function createDefaultConfig({
463
+ name,
464
+ colours,
465
+ headingFont,
466
+ bodyFont,
443
467
  baseUnit,
444
468
  baseFontSize,
445
469
  detectedProject,
@@ -448,7 +472,7 @@ function createDefaultConfig({
448
472
  name,
449
473
  description: name + " design system",
450
474
 
451
- baseUnit: baseUnit + "px",
475
+ baseUnit,
452
476
  baseFontSize: baseFontSize || "16px",
453
477
 
454
478
  fontFamily: {
@@ -656,14 +680,17 @@ async function init() {
656
680
  ? titleCasePackageName(packageJsonData.name)
657
681
  : "My Design System";
658
682
 
659
- const projectName = await new Input({
660
- name: "projectName",
661
- message: "Project name",
662
- initial: pkgName,
663
- validate: function (value) {
664
- return value.trim() ? true : "Project name is required";
665
- },
666
- }).run();
683
+ const projectName = await askValidatedInput({
684
+ promptName: "projectName",
685
+ message: "Project name",
686
+ initial: pkgName,
687
+ validator: function (value) {
688
+ if (typeof value !== "string" || !value.trim()) {
689
+ return { valid: false, reason: "project name is required" };
690
+ }
691
+ return { valid: true };
692
+ },
693
+ });
667
694
 
668
695
  if (!projectName || !projectName.trim()) {
669
696
  console.log(chalk.red("\nProject name is required.\n"));
@@ -778,29 +805,45 @@ async function init() {
778
805
 
779
806
  console.log(chalk.bold("\n" + chalk.magenta("→") + " Typography"));
780
807
 
781
- const headingFont = await new Select({
782
- name: "headingFont",
783
- message: "Heading font",
784
- choices: FONT_OPTIONS,
785
- initial: getFontInitialIndex(
786
- isPlainObject(existingConfig && existingConfig.fontFamily)
787
- ? existingConfig.fontFamily.heading
788
- : existingConfig && existingConfig.fontFamily,
789
- 0,
790
- ),
791
- }).run();
792
-
793
- const bodyFont = await new Select({
794
- name: "bodyFont",
795
- message: "Body font",
796
- choices: FONT_OPTIONS,
797
- initial: getFontInitialIndex(
798
- isPlainObject(existingConfig && existingConfig.fontFamily)
799
- ? existingConfig.fontFamily.body
800
- : existingConfig && existingConfig.fontFamily,
801
- 1,
802
- ),
803
- }).run();
808
+ console.log(
809
+ chalk.gray(
810
+ " Allowed font families: " + ALLOWED_FONT_FAMILIES.join(", "),
811
+ ),
812
+ );
813
+
814
+ const headingFont = await askValidatedInput({
815
+ promptName: "headingFont",
816
+ message: "Heading font family",
817
+ initial: (function () {
818
+ const existingHeading = isPlainObject(existingConfig && existingConfig.fontFamily)
819
+ ? existingConfig.fontFamily.heading
820
+ : existingConfig && existingConfig.fontFamily;
821
+ if (typeof existingHeading !== "string") return "lexend";
822
+ const candidate = existingHeading.trim().toLowerCase();
823
+ return validateFontFamily(candidate).valid ? candidate : "lexend";
824
+ })(),
825
+ validator: validateFontFamily,
826
+ normalise: function (value) {
827
+ return String(value).trim().toLowerCase();
828
+ },
829
+ });
830
+
831
+ const bodyFont = await askValidatedInput({
832
+ promptName: "bodyFont",
833
+ message: "Body font family",
834
+ initial: (function () {
835
+ const existingBody = isPlainObject(existingConfig && existingConfig.fontFamily)
836
+ ? existingConfig.fontFamily.body
837
+ : existingConfig && existingConfig.fontFamily;
838
+ if (typeof existingBody !== "string") return "inter";
839
+ const candidate = existingBody.trim().toLowerCase();
840
+ return validateFontFamily(candidate).valid ? candidate : "inter";
841
+ })(),
842
+ validator: validateFontFamily,
843
+ normalise: function (value) {
844
+ return String(value).trim().toLowerCase();
845
+ },
846
+ });
804
847
 
805
848
  const baseFontSize = await new Select({
806
849
  name: "baseFontSize",
@@ -817,22 +860,15 @@ async function init() {
817
860
  // SPACING
818
861
  // =========================================================================
819
862
 
820
- const baseUnitRaw = await new Input({
821
- name: "baseUnit",
822
- message: "Base spacing unit in px (label/documentation only)",
823
- initial: getBaseUnitInitial(existingConfig),
824
- validate: function (value) {
825
- const parsed = Number.parseInt(value, 10);
826
-
827
- if (Number.isNaN(parsed) || parsed <= 0) {
828
- return "Must be a positive number.";
829
- }
830
-
831
- return true;
832
- },
833
- }).run();
834
-
835
- const baseUnit = Number.parseInt(baseUnitRaw, 10);
863
+ const baseUnit = await askValidatedInput({
864
+ promptName: "baseUnit",
865
+ message: "Base spacing unit in px (label/documentation only) e.g. 1rem or 10px",
866
+ initial: getBaseUnitInitial(existingConfig),
867
+ validator: validateSpacingValue,
868
+ normalise: function (value) {
869
+ return String(value).trim().toLowerCase();
870
+ },
871
+ });
836
872
 
837
873
  // =========================================================================
838
874
  // PURGE / OUTPUT
@@ -875,15 +911,24 @@ async function init() {
875
911
  config.description = config.name + " design system";
876
912
  }
877
913
 
878
- config.baseUnit = baseUnit + "px";
879
- config.baseFontSize = baseFontSize || "16px";
880
- config.fontFamily = {
881
- heading: headingFont,
882
- body: bodyFont,
883
- };
884
- config.colours = colours;
885
-
886
- const configPath = path.join(process.cwd(), "emily.config.json");
914
+ config.baseUnit = baseUnit;
915
+ config.baseFontSize = baseFontSize || "16px";
916
+ config.fontFamily = {
917
+ heading: headingFont,
918
+ body: bodyFont,
919
+ };
920
+ config.colours = colours;
921
+
922
+ const finalValidation = validateConfigShape(config);
923
+ if (!finalValidation.valid) {
924
+ console.log(chalk.red("\n✗ Config validation failed. emily.config.json was not written.\n"));
925
+ finalValidation.errors.forEach(function (error) {
926
+ console.log(chalk.red(" - " + error));
927
+ });
928
+ process.exit(1);
929
+ }
930
+
931
+ const configPath = path.join(process.cwd(), "emily.config.json");
887
932
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
888
933
 
889
934
  console.log("");
@@ -0,0 +1,332 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawnSync } = require('child_process');
8
+
9
+ const TOTAL_RUNS = 60;
10
+ const FONT_KEYS = ['system', 'inter', 'lexend', 'georgia', 'dm-sans', 'nunito', 'atkinson', 'mono'];
11
+ const BUILD_MODULE_PATH = path.resolve(__dirname, '../index.js');
12
+
13
+ function readBaseConfig() {
14
+ const configPath = path.join(__dirname, '../../emily.config.json');
15
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
16
+ }
17
+
18
+ function clone(value) {
19
+ return JSON.parse(JSON.stringify(value));
20
+ }
21
+
22
+ function randomInt(min, max) {
23
+ return Math.floor(Math.random() * (max - min + 1)) + min;
24
+ }
25
+
26
+ function randomChoice(values) {
27
+ return values[randomInt(0, values.length - 1)];
28
+ }
29
+
30
+ function createDeepObject(depth) {
31
+ let root = {};
32
+ let cursor = root;
33
+ for (let i = 0; i < depth; i += 1) {
34
+ cursor['layer' + i] = {};
35
+ cursor = cursor['layer' + i];
36
+ }
37
+ cursor.value = 'deep-value';
38
+ return root;
39
+ }
40
+
41
+ function createCircularObject(baseConfig) {
42
+ const config = clone(baseConfig);
43
+ const circular = { label: 'circular' };
44
+ circular.self = circular;
45
+ config.attackVector = circular;
46
+ return config;
47
+ }
48
+
49
+ function ensureBaselineShape(config) {
50
+ if (!config || typeof config !== 'object') {
51
+ return config;
52
+ }
53
+
54
+ config.output = {
55
+ css: 'dist/emily.min.css',
56
+ fullCss: 'dist/emily.css',
57
+ };
58
+ return config;
59
+ }
60
+
61
+ function withConfigMutation(name, mutate) {
62
+ return {
63
+ name,
64
+ create(baseConfig) {
65
+ const config = ensureBaselineShape(clone(baseConfig));
66
+ mutate(config);
67
+ return { type: 'config', payload: config };
68
+ },
69
+ };
70
+ }
71
+
72
+ function withRawJson(name, payload) {
73
+ return {
74
+ name,
75
+ create() {
76
+ return { type: 'raw', payload };
77
+ },
78
+ };
79
+ }
80
+
81
+ const ABUSE_CASES = [
82
+ withConfigMutation('colour-8-digit-hex', (config) => { config.colours.brand = '#FF0000FF'; }),
83
+ withConfigMutation('colour-3-digit-hex', (config) => { config.colours.brand = '#F00'; }),
84
+ withConfigMutation('colour-invalid-hex', (config) => { config.colours.brand = '#GGGGGG'; }),
85
+ withConfigMutation('colour-named', (config) => { config.colours.brand = 'red'; }),
86
+ withConfigMutation('colour-null', (config) => { config.colours.brand = null; }),
87
+ withConfigMutation('colour-empty', (config) => { config.colours.brand = ''; }),
88
+ withConfigMutation('colour-number', (config) => { config.colours.brand = 123456; }),
89
+ withConfigMutation('colour-xss-payload', (config) => { config.colours.brand = '<script>alert("hi")</script>'; }),
90
+
91
+ withConfigMutation('spacing-negative-px', (config) => { config.spacing.scale['4'] = '-10px'; }),
92
+ withConfigMutation('spacing-negative-rem', (config) => { config.spacing.scale['4'] = '-1rem'; }),
93
+ withConfigMutation('spacing-huge-rem', (config) => { config.spacing.scale['4'] = '99999rem'; }),
94
+ withConfigMutation('spacing-huge-px', (config) => { config.spacing.scale['4'] = '1000000px'; }),
95
+ withConfigMutation('spacing-invalid-no-unit', (config) => { config.spacing.scale['4'] = '10'; }),
96
+ withConfigMutation('spacing-invalid-unit', (config) => { config.spacing.scale['4'] = '10xx'; }),
97
+ withConfigMutation('spacing-invalid-whitespace-unit', (config) => { config.spacing.scale['4'] = '10 rem'; }),
98
+ withConfigMutation('spacing-null-value', (config) => { config.spacing.scale['4'] = null; }),
99
+ withConfigMutation('spacing-nonnumeric', (config) => { config.spacing.scale['4'] = 'abc'; }),
100
+ withConfigMutation('spacing-infinity', (config) => { config.spacing.scale['4'] = 'infinity'; }),
101
+ withConfigMutation('spacing-descending-scale', (config) => {
102
+ config.spacing.scale = {
103
+ '10': '2.5rem',
104
+ '8': '2rem',
105
+ '6': '1.5rem',
106
+ '4': '1rem',
107
+ '2': '0.5rem',
108
+ '0': '0px',
109
+ };
110
+ }),
111
+
112
+ withConfigMutation('font-nonexistent', (config) => { config.fontFamily = { heading: 'banana-sans', body: 'foo' }; }),
113
+ withConfigMutation('font-null', (config) => { config.fontFamily = null; }),
114
+ withConfigMutation('font-empty-string', (config) => { config.fontFamily = ''; }),
115
+ withConfigMutation('font-number', (config) => { config.fontFamily = 42; }),
116
+ withConfigMutation('font-array', (config) => { config.fontFamily = ['inter', 'lexend']; }),
117
+ withConfigMutation('font-object-weird-types', (config) => { config.fontFamily = { heading: {}, body: [] }; }),
118
+ withConfigMutation('font-unicode-edge', (config) => { config.fontFamily = { heading: '\uD83D\uDCA5\u200D\uFE0F', body: '\u2066\u2067\u2069' }; }),
119
+ withConfigMutation('font-very-long-string', (config) => {
120
+ const long = 'x'.repeat(10000);
121
+ config.fontFamily = { heading: long, body: long };
122
+ }),
123
+
124
+ withConfigMutation('missing-colours', (config) => { delete config.colours; }),
125
+ withConfigMutation('missing-spacing', (config) => { delete config.spacing; }),
126
+ withConfigMutation('wrong-type-colours-string', (config) => { config.colours = 'not-an-object'; }),
127
+ withConfigMutation('wrong-type-spacing-string', (config) => { config.spacing = 'not-an-object'; }),
128
+ withConfigMutation('wrong-type-transitions-string', (config) => { config.transitions = 'not-an-object'; }),
129
+ withConfigMutation('unknown-extra-fields', (config) => {
130
+ config.__unknown = true;
131
+ config.notExpected = { nested: [1, 2, 3] };
132
+ }),
133
+ withConfigMutation('deeply-nested-object', (config) => {
134
+ config.veryDeep = createDeepObject(300);
135
+ }),
136
+ {
137
+ name: 'circular-reference',
138
+ create(baseConfig) {
139
+ return { type: 'config', payload: createCircularObject(baseConfig) };
140
+ },
141
+ },
142
+
143
+ withConfigMutation('transition-negative-fast', (config) => { config.transitions.fast = '-100ms'; }),
144
+ withConfigMutation('transition-negative-base', (config) => { config.transitions.base = '-200ms'; }),
145
+ withConfigMutation('transition-negative-slow', (config) => { config.transitions.slow = '-300ms'; }),
146
+ withConfigMutation('transition-nonnumeric-fast', (config) => { config.transitions.fast = 'abc'; }),
147
+ withConfigMutation('transition-null-base', (config) => { config.transitions.base = null; }),
148
+ withConfigMutation('transition-order-invalid', (config) => {
149
+ config.transitions.fast = '300ms';
150
+ config.transitions.base = '200ms';
151
+ config.transitions.slow = '100ms';
152
+ }),
153
+ withConfigMutation('transition-number-types', (config) => {
154
+ config.transitions.fast = 100;
155
+ config.transitions.base = 200;
156
+ config.transitions.slow = 300;
157
+ }),
158
+ withConfigMutation('transition-timing-malicious', (config) => {
159
+ config.transitions.timing = 'cubic-bezier(0.4, 0, 0.2, 1));background:url(javascript:alert(1))/*';
160
+ }),
161
+
162
+ withRawJson('top-level-null', 'null'),
163
+ withRawJson('top-level-string', '"oops"'),
164
+ withRawJson('top-level-array', '["bad", "config"]'),
165
+ withRawJson('invalid-json-syntax', '{"colours": {"brand": "#FF0000",}}'),
166
+
167
+ withConfigMutation('spacing-scale-missing', (config) => { delete config.spacing.scale; }),
168
+ withConfigMutation('spacing-scale-null', (config) => { config.spacing.scale = null; }),
169
+ withConfigMutation('spacing-scale-array', (config) => { config.spacing.scale = ['1rem', '2rem']; }),
170
+ withConfigMutation('manifest-wrong-type', (config) => { config.manifest = 'yes'; }),
171
+ withConfigMutation('output-wrong-type', (config) => { config.output = 'dist/emily.css'; }),
172
+ withConfigMutation('breakpoints-wrong-type', (config) => { config.breakpoints = 'sm,md,lg'; }),
173
+ withConfigMutation('typography-null', (config) => { config.typography = null; }),
174
+ withConfigMutation('typography-fontsizes-string', (config) => { config.typography.fontSizes = '16px'; }),
175
+ withConfigMutation('typography-fontweights-number', (config) => { config.typography.fontWeights = 700; }),
176
+ withConfigMutation('colours-empty-object', (config) => { config.colours = {}; }),
177
+ withConfigMutation('colours-undefined-via-delete', (config) => { delete config.colours.brand; }),
178
+ withConfigMutation('spacing-proto-pollution-shape', (config) => {
179
+ config.spacing.scale = {
180
+ '__proto__': { polluted: true },
181
+ '4': '1rem',
182
+ '8': '2rem',
183
+ '0': '0px',
184
+ };
185
+ }),
186
+ ];
187
+
188
+ function writeConfigFile(tempDir, caseDef, baseConfig) {
189
+ const generated = caseDef.create(baseConfig);
190
+ const configPath = path.join(tempDir, 'emily.config.json');
191
+
192
+ if (generated.type === 'raw') {
193
+ fs.writeFileSync(configPath, generated.payload);
194
+ return;
195
+ }
196
+
197
+ assert.strictEqual(generated.type, 'config', `Unknown generated config type: ${generated.type}`);
198
+ fs.writeFileSync(configPath, JSON.stringify(generated.payload, null, 2));
199
+ }
200
+
201
+ function runBuildInSubprocess(tempDir) {
202
+ const runner = [
203
+ 'const { buildFullFramework } = require(' + JSON.stringify(BUILD_MODULE_PATH) + ');',
204
+ 'try {',
205
+ ' buildFullFramework();',
206
+ ' process.exit(0);',
207
+ '} catch (error) {',
208
+ ' const message = error && error.message ? error.message : String(error);',
209
+ ' console.error(message);',
210
+ ' process.exit(2);',
211
+ '}',
212
+ ].join('\n');
213
+
214
+ return spawnSync(process.execPath, ['-e', runner], {
215
+ cwd: tempDir,
216
+ encoding: 'utf8',
217
+ });
218
+ }
219
+
220
+ function assertFailureLooksClear(output, caseName) {
221
+ const trimmed = output.trim();
222
+ assert.ok(trimmed.length >= 8, `Case "${caseName}" failed but gave no clear error message`);
223
+ assert.ok(trimmed !== '[object Object]', `Case "${caseName}" returned an unhelpful object error`);
224
+ }
225
+
226
+ function formatErrorSnippet(message) {
227
+ return String(message || '')
228
+ .replace(/\s+/g, ' ')
229
+ .trim()
230
+ .slice(0, 100);
231
+ }
232
+
233
+ function assertSuccessLooksSensible(tempDir, caseName) {
234
+ const cssPath = path.join(tempDir, 'dist', 'emily.css');
235
+ assert.ok(fs.existsSync(cssPath), `Case "${caseName}" succeeded but did not output dist/emily.css`);
236
+
237
+ const cssContent = fs.readFileSync(cssPath, 'utf8');
238
+ assert.ok(cssContent.trim().length > 0, `Case "${caseName}" succeeded but CSS output was empty`);
239
+
240
+ const manifestPath = path.join(tempDir, 'dist', 'emily.manifest.json');
241
+ if (fs.existsSync(manifestPath)) {
242
+ const raw = fs.readFileSync(manifestPath, 'utf8');
243
+ assert.doesNotThrow(() => JSON.parse(raw), `Case "${caseName}" wrote invalid manifest JSON`);
244
+ }
245
+ }
246
+
247
+ function run() {
248
+ const baseConfig = readBaseConfig();
249
+ const initialCwd = process.cwd();
250
+ const createdTempDirs = [];
251
+ let handledFailures = 0;
252
+ let gracefulSuccesses = 0;
253
+ let crashes = 0;
254
+ const gracefulSuccessCases = [];
255
+ const handledFailureCases = [];
256
+
257
+ for (let i = 0; i < TOTAL_RUNS; i += 1) {
258
+ const runLabel = `[${i + 1}/${TOTAL_RUNS}]`;
259
+ const caseDef = randomChoice(ABUSE_CASES);
260
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emily-chaos-'));
261
+ createdTempDirs.push(tempDir);
262
+
263
+ try {
264
+ try {
265
+ writeConfigFile(tempDir, caseDef, baseConfig);
266
+ } catch (error) {
267
+ const message = error && error.message ? error.message : String(error);
268
+ assertFailureLooksClear(message, caseDef.name);
269
+ const snippet = formatErrorSnippet(message);
270
+ console.log(`${runLabel} ${caseDef.name} ... FAIL (${snippet})`);
271
+ handledFailures += 1;
272
+ handledFailureCases.push({ name: caseDef.name, error: snippet });
273
+ continue;
274
+ }
275
+
276
+ const result = runBuildInSubprocess(tempDir);
277
+ const output = (result.stdout || '') + '\n' + (result.stderr || '');
278
+
279
+ if (result.status === 0) {
280
+ assertSuccessLooksSensible(tempDir, caseDef.name);
281
+ console.log(`${runLabel} ${caseDef.name} ... OK (graceful success)`);
282
+ gracefulSuccesses += 1;
283
+ gracefulSuccessCases.push(caseDef.name);
284
+ } else if (result.status === 1 || result.status === 2) {
285
+ assertFailureLooksClear(output, caseDef.name);
286
+ const snippet = formatErrorSnippet(output);
287
+ console.log(`${runLabel} ${caseDef.name} ... FAIL (${snippet})`);
288
+ handledFailures += 1;
289
+ handledFailureCases.push({ name: caseDef.name, error: snippet });
290
+ } else {
291
+ crashes += 1;
292
+ const snippet = formatErrorSnippet(output || `exit ${result.status}`);
293
+ console.log(`${runLabel} ${caseDef.name} ... CRASH (${snippet})`);
294
+ throw new Error(
295
+ `Case "${caseDef.name}" crashed (exit ${result.status}). Output:\n${output.trim() || '(no output)'}`,
296
+ );
297
+ }
298
+ } catch (error) {
299
+ throw new Error(`Chaos run ${i + 1}/${TOTAL_RUNS} failed for case "${caseDef.name}": ${error.message}`);
300
+ } finally {
301
+ process.chdir(initialCwd);
302
+ fs.rmSync(tempDir, { recursive: true, force: true });
303
+ }
304
+ }
305
+
306
+ const leftoverTempDirs = createdTempDirs.filter((tempDir) => fs.existsSync(tempDir));
307
+ assert.strictEqual(leftoverTempDirs.length, 0, `Temp directory cleanup failed for ${leftoverTempDirs.length} case(s)`);
308
+ assert.ok(handledFailures + gracefulSuccesses >= 50, 'Expected at least 50 chaos runs');
309
+ assert.strictEqual(crashes, 0, `Detected ${crashes} unhandled crash(es) during chaos testing`);
310
+
311
+ console.log('\nResults:');
312
+ console.log(` Graceful successes (${gracefulSuccesses}): ${gracefulSuccessCases.join(', ') || '(none)'}`);
313
+ console.log(` Handled failures (${handledFailures}):`);
314
+ if (handledFailureCases.length === 0) {
315
+ console.log(' - (none)');
316
+ } else {
317
+ handledFailureCases.forEach((entry) => {
318
+ console.log(` - ${entry.name}: ${entry.error}`);
319
+ });
320
+ }
321
+
322
+ console.log(
323
+ `✓ Chaos testing passed (50+ abuse cases, 0 crashes) [runs=${TOTAL_RUNS}, handled-failures=${handledFailures}, graceful-successes=${gracefulSuccesses}]`,
324
+ );
325
+ }
326
+
327
+ try {
328
+ run();
329
+ } catch (error) {
330
+ console.error(error.message);
331
+ process.exit(1);
332
+ }
@@ -0,0 +1,227 @@
1
+ 'use strict';
2
+
3
+ const ALLOWED_FONT_FAMILIES = [
4
+ 'system',
5
+ 'inter',
6
+ 'lexend',
7
+ 'georgia',
8
+ 'dm-sans',
9
+ 'nunito',
10
+ 'atkinson',
11
+ 'mono',
12
+ ];
13
+
14
+ const ALLOWED_FONT_FAMILY_SET = new Set(ALLOWED_FONT_FAMILIES);
15
+
16
+ function isPlainObject(value) {
17
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
18
+ }
19
+
20
+ function validateHexColour(value) {
21
+ if (typeof value !== 'string') {
22
+ return { valid: false, reason: 'must be a string in #RRGGBB format' };
23
+ }
24
+
25
+ const trimmed = value.trim();
26
+ if (!trimmed) {
27
+ return { valid: false, reason: 'must be a 6-digit hex like #FF0000 (value is empty)' };
28
+ }
29
+
30
+ if (!trimmed.startsWith('#')) {
31
+ return { valid: false, reason: 'must include # symbol (example: #FF0000)' };
32
+ }
33
+
34
+ if (!/^#[0-9A-Fa-f]{6}$/.test(trimmed)) {
35
+ return { valid: false, reason: 'must be #RRGGBB format' };
36
+ }
37
+
38
+ return { valid: true };
39
+ }
40
+
41
+ function validateSpacingValue(value) {
42
+ if (typeof value !== 'string') {
43
+ return { valid: false, reason: 'must be a string CSS length like 1rem or 10px' };
44
+ }
45
+
46
+ const trimmed = value.trim();
47
+ if (!trimmed) {
48
+ return { valid: false, reason: 'must be a CSS length like 1rem or 10px (value is empty)' };
49
+ }
50
+
51
+ if (/\s/.test(trimmed)) {
52
+ return { valid: false, reason: 'must not contain spaces (use 10px, not 10 px)' };
53
+ }
54
+
55
+ if (trimmed.startsWith('-')) {
56
+ return { valid: false, reason: 'must not be negative (e.g. -1rem is not allowed)' };
57
+ }
58
+
59
+ const match = /^(\d+(?:\.\d+)?)(rem|px)$/i.exec(trimmed);
60
+ if (!match) {
61
+ return { valid: false, reason: 'must be numeric and use rem or px units (e.g. 1rem, 10px)' };
62
+ }
63
+
64
+ const numericPart = Number.parseFloat(match[1]);
65
+ const unit = match[2].toLowerCase();
66
+
67
+ if (!Number.isFinite(numericPart)) {
68
+ return { valid: false, reason: 'must be a finite numeric value' };
69
+ }
70
+
71
+ if (unit === 'rem' && numericPart > 9999) {
72
+ return { valid: false, reason: 'rem value is too large (max 9999rem)' };
73
+ }
74
+
75
+ if (unit === 'px' && numericPart > 99999) {
76
+ return { valid: false, reason: 'px value is too large (max 99999px)' };
77
+ }
78
+
79
+ return { valid: true };
80
+ }
81
+
82
+ function validateFontFamily(value) {
83
+ if (typeof value !== 'string') {
84
+ return { valid: false, reason: 'must be a string font key' };
85
+ }
86
+
87
+ const trimmed = value.trim();
88
+ if (!trimmed) {
89
+ return { valid: false, reason: 'must not be empty' };
90
+ }
91
+
92
+ if (!ALLOWED_FONT_FAMILY_SET.has(trimmed)) {
93
+ return {
94
+ valid: false,
95
+ reason: `must be one of: ${ALLOWED_FONT_FAMILIES.join(', ')}`,
96
+ };
97
+ }
98
+
99
+ return { valid: true };
100
+ }
101
+
102
+ function validateConfigShape(config) {
103
+ const errors = [];
104
+
105
+ if (!isPlainObject(config)) {
106
+ return {
107
+ valid: false,
108
+ errors: ['config must be an object (not null, array, or string)'],
109
+ };
110
+ }
111
+
112
+ const requiredFields = ['colours', 'spacing', 'fontFamily', 'output'];
113
+ requiredFields.forEach((field) => {
114
+ if (!(field in config)) {
115
+ errors.push(`missing required field: ${field}`);
116
+ }
117
+ });
118
+
119
+ if ('colours' in config) {
120
+ if (!isPlainObject(config.colours)) {
121
+ errors.push('colours must be an object of #RRGGBB values');
122
+ } else {
123
+ Object.entries(config.colours).forEach(([name, value]) => {
124
+ const result = validateHexColour(value);
125
+ if (!result.valid) {
126
+ errors.push(`colours.${name} ${result.reason}`);
127
+ }
128
+ });
129
+ }
130
+ }
131
+
132
+ if ('spacing' in config) {
133
+ if (!isPlainObject(config.spacing)) {
134
+ errors.push('spacing must be an object');
135
+ } else if (!('scale' in config.spacing)) {
136
+ errors.push('spacing must include a scale key');
137
+ } else if (!isPlainObject(config.spacing.scale)) {
138
+ errors.push('spacing.scale must be an object of spacing values');
139
+ } else {
140
+ const spacingKeys = Object.keys(config.spacing.scale);
141
+ if (spacingKeys.length === 0) {
142
+ errors.push('spacing.scale must not be empty');
143
+ }
144
+
145
+ spacingKeys.forEach((key) => {
146
+ if (!/^(\d+(\.\d+)?|px)$/.test(key)) {
147
+ errors.push(`spacing.scale key "${key}" must be numeric (or "px" for legacy support)`);
148
+ }
149
+
150
+ const result = validateSpacingValue(config.spacing.scale[key]);
151
+ if (!result.valid) {
152
+ errors.push(`spacing.scale.${key} ${result.reason}`);
153
+ }
154
+ });
155
+ }
156
+ }
157
+
158
+ if ('fontFamily' in config) {
159
+ if (!isPlainObject(config.fontFamily)) {
160
+ errors.push('fontFamily must be an object with heading and body');
161
+ } else {
162
+ if (!('heading' in config.fontFamily)) {
163
+ errors.push('fontFamily.heading is required');
164
+ } else {
165
+ const headingValidation = validateFontFamily(config.fontFamily.heading);
166
+ if (!headingValidation.valid) {
167
+ errors.push(`fontFamily.heading ${headingValidation.reason}`);
168
+ }
169
+ }
170
+
171
+ if (!('body' in config.fontFamily)) {
172
+ errors.push('fontFamily.body is required');
173
+ } else {
174
+ const bodyValidation = validateFontFamily(config.fontFamily.body);
175
+ if (!bodyValidation.valid) {
176
+ errors.push(`fontFamily.body ${bodyValidation.reason}`);
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ if ('output' in config) {
183
+ if (!isPlainObject(config.output)) {
184
+ errors.push('output must be an object');
185
+ } else {
186
+ if (typeof config.output.css !== 'string' || !config.output.css.trim()) {
187
+ errors.push('output.css must be a non-empty string');
188
+ }
189
+
190
+ if (typeof config.output.fullCss !== 'string' || !config.output.fullCss.trim()) {
191
+ errors.push('output.fullCss must be a non-empty string');
192
+ }
193
+ }
194
+ }
195
+
196
+ if ('manifest' in config) {
197
+ if (typeof config.manifest !== 'boolean' && !isPlainObject(config.manifest)) {
198
+ errors.push('manifest must be a boolean or an object');
199
+ }
200
+
201
+ if (isPlainObject(config.manifest)) {
202
+ if ('enabled' in config.manifest && typeof config.manifest.enabled !== 'boolean') {
203
+ errors.push('manifest.enabled must be a boolean');
204
+ }
205
+
206
+ if ('output' in config.manifest) {
207
+ if (typeof config.manifest.output !== 'string' || !config.manifest.output.trim()) {
208
+ errors.push('manifest.output must be a non-empty string when provided');
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ if (errors.length > 0) {
215
+ return { valid: false, errors };
216
+ }
217
+
218
+ return { valid: true, errors: [] };
219
+ }
220
+
221
+ module.exports = {
222
+ ALLOWED_FONT_FAMILIES,
223
+ validateHexColour,
224
+ validateSpacingValue,
225
+ validateFontFamily,
226
+ validateConfigShape,
227
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { validateConfigShape } = require('./validate');
6
+
7
+ function validateConfigOrExit() {
8
+ const configPath = path.join(process.cwd(), 'emily.config.json');
9
+
10
+ if (!fs.existsSync(configPath)) {
11
+ console.error('Invalid EmilyCSS config: emily.config.json not found.');
12
+ process.exit(1);
13
+ }
14
+
15
+ let config;
16
+ try {
17
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
18
+ } catch (error) {
19
+ console.error('Invalid EmilyCSS config: emily.config.json is not valid JSON.');
20
+ console.error(error.message);
21
+ process.exit(1);
22
+ }
23
+
24
+ const result = validateConfigShape(config);
25
+ if (!result.valid) {
26
+ console.error('Invalid EmilyCSS config:');
27
+ result.errors.forEach((error) => {
28
+ console.error('- ' + error);
29
+ });
30
+ process.exit(1);
31
+ }
32
+ }
33
+
34
+ module.exports = {
35
+ validateConfigOrExit,
36
+ };