@squiz/dxp-cli-next 5.31.0 → 5.32.0-develop.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/lib/cdp/instance/activate/activate.js +6 -7
  2. package/lib/cdp/instance/activate/activate.spec.js +2 -8
  3. package/lib/cdp/utils.d.ts +2 -1
  4. package/lib/cdp/utils.js +4 -2
  5. package/lib/migration/create/create.js +5 -8
  6. package/lib/migration/create/create.spec.js +1 -0
  7. package/lib/migration/index.js +2 -0
  8. package/lib/migration/pre/pre.d.ts +3 -0
  9. package/lib/migration/pre/pre.js +54 -0
  10. package/lib/migration/pre/pre.spec.d.ts +1 -0
  11. package/lib/migration/pre/pre.spec.js +269 -0
  12. package/lib/migration/types/common.types.d.ts +10 -0
  13. package/lib/migration/types/createMigration.types.d.ts +4 -0
  14. package/lib/migration/types/index.d.ts +1 -0
  15. package/lib/migration/types/index.js +1 -0
  16. package/lib/migration/types/preMigration.types.d.ts +10 -0
  17. package/lib/migration/types/preMigration.types.js +2 -0
  18. package/lib/migration/utils/common.d.ts +8 -0
  19. package/lib/migration/utils/common.js +19 -1
  20. package/lib/migration/utils/createMigration.d.ts +26 -2
  21. package/lib/migration/utils/createMigration.js +13 -6
  22. package/lib/migration/utils/loadCctIdsFromFile.d.ts +1 -0
  23. package/lib/migration/utils/loadCctIdsFromFile.js +32 -0
  24. package/lib/migration/utils/loadCctIdsFromFile.spec.d.ts +1 -0
  25. package/lib/migration/utils/loadCctIdsFromFile.spec.js +91 -0
  26. package/lib/migration/utils/options.d.ts +2 -1
  27. package/lib/migration/utils/options.js +8 -0
  28. package/lib/page/layouts/deploy/deploy.js +44 -9
  29. package/lib/page/layouts/deploy/deploy.spec.js +110 -19
  30. package/lib/page/layouts/dev/dev.js +18 -4
  31. package/lib/page/layouts/dev/dev.spec.js +117 -8
  32. package/lib/page/layouts/validation/index.d.ts +2 -0
  33. package/lib/page/layouts/validation/index.js +5 -1
  34. package/lib/page/layouts/validation/property-consistency.d.ts +7 -0
  35. package/lib/page/layouts/validation/property-consistency.js +92 -0
  36. package/lib/page/layouts/validation/property-consistency.spec.d.ts +1 -0
  37. package/lib/page/layouts/validation/property-consistency.spec.js +305 -0
  38. package/lib/page/layouts/validation/validateLayoutFormat.d.ts +2 -0
  39. package/lib/page/layouts/validation/validateLayoutFormat.js +27 -0
  40. package/lib/page/layouts/validation/validateLayoutFormat.spec.d.ts +1 -0
  41. package/lib/page/layouts/validation/validateLayoutFormat.spec.js +42 -0
  42. package/lib/page/layouts/validation/zone-consistency.d.ts +1 -1
  43. package/lib/page/layouts/validation/zone-consistency.js +10 -9
  44. package/lib/page/layouts/validation/zone-consistency.spec.js +32 -34
  45. package/lib/page/utils/definitions.d.ts +347 -50
  46. package/lib/page/utils/definitions.js +103 -22
  47. package/lib/page/utils/definitions.spec.js +516 -267
  48. package/lib/page/utils/normalize.d.ts +8 -0
  49. package/lib/page/utils/normalize.js +61 -0
  50. package/lib/page/utils/normalize.spec.d.ts +1 -0
  51. package/lib/page/utils/normalize.spec.js +315 -0
  52. package/lib/page/utils/parse-args.d.ts +20 -4
  53. package/lib/page/utils/parse-args.js +48 -13
  54. package/lib/page/utils/parse-args.spec.js +159 -21
  55. package/lib/page/utils/render.d.ts +27 -9
  56. package/lib/page/utils/render.js +66 -12
  57. package/lib/page/utils/render.spec.js +14 -14
  58. package/lib/page/utils/server.d.ts +1 -1
  59. package/lib/page/utils/server.js +2 -2
  60. package/lib/page/utils/server.spec.js +13 -13
  61. package/package.json +1 -1
@@ -18,20 +18,27 @@ const path_1 = __importDefault(require("path"));
18
18
  const ApiService_1 = require("../../ApiService");
19
19
  const _1 = require(".");
20
20
  const child_process_1 = require("child_process");
21
- function createMigration(options) {
21
+ /**
22
+ * Creates a new migration or pre-migration using the AI Page Migration service.
23
+ * Pre-migrations are used to generate specs for CCTs so they can be converted and deployed in advance.
24
+ * @param {CreateMigrationInput} input - The input parameters for the migration/pre-migration creation.
25
+ * @returns {Promise<CreateMigrationApiResponse>}
26
+ */
27
+ function createMigration({ tenant, overrideUrl, assetId, previewAssetId, matrixUrl, cctIds, }) {
22
28
  return __awaiter(this, void 0, void 0, function* () {
23
29
  const apiService = new ApiService_1.ApiService({
24
30
  validateStatus: _1.validateAxiosStatus,
25
31
  });
26
- const migrationUrl = yield (0, _1.buildMigrationUrl)(options.tenant, options.overrideUrl);
32
+ const migrationUrl = yield (0, _1.buildMigrationUrl)(tenant, overrideUrl);
27
33
  try {
28
34
  const payload = {
29
- assetId: options.assetId,
30
- previewAssetId: options.previewAssetId,
31
- matrixUrl: options.matrixUrl,
35
+ assetId,
36
+ previewAssetId,
37
+ matrixUrl,
38
+ cctIds,
32
39
  };
33
40
  const response = yield apiService.client.post(`${migrationUrl}`, payload, {
34
- headers: yield (0, _1.getMigrationHeaders)(options.tenant),
41
+ headers: yield (0, _1.getMigrationHeaders)(tenant),
35
42
  });
36
43
  if (response.status !== 200 && response.status !== 201) {
37
44
  throw new Error(`Migration creation failed with status: ${response.status}`);
@@ -0,0 +1 @@
1
+ export declare const loadCctIdsFromFile: (filePath?: string) => string[] | undefined;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadCctIdsFromFile = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const loadCctIdsFromFile = (filePath) => {
9
+ if (!filePath) {
10
+ return undefined;
11
+ }
12
+ try {
13
+ if (!fs_1.default.existsSync(filePath)) {
14
+ throw new Error(`CCT IDs file not found: ${filePath}`);
15
+ }
16
+ const fileContent = fs_1.default.readFileSync(filePath, 'utf8');
17
+ const cctIds = JSON.parse(fileContent);
18
+ if (!Array.isArray(cctIds) ||
19
+ cctIds.some(id => typeof id !== 'string' || !id.trim()) ||
20
+ cctIds.length === 0) {
21
+ throw new Error('CCT IDs file must contain a valid array of strings representing CCT IDs with at least one CCT ID');
22
+ }
23
+ return cctIds;
24
+ }
25
+ catch (error) {
26
+ if (error instanceof Error) {
27
+ throw new Error(`Failed to load CCT IDs from file: ${error.message}`);
28
+ }
29
+ throw new Error(`Failed to load CCT IDs from file: ${error}`);
30
+ }
31
+ };
32
+ exports.loadCctIdsFromFile = loadCctIdsFromFile;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const loadCctIdsFromFile_1 = require("./loadCctIdsFromFile");
8
+ jest.mock('fs');
9
+ const mockFs = fs_1.default;
10
+ describe('loadCctIdsFromFile', () => {
11
+ beforeEach(() => {
12
+ jest.clearAllMocks();
13
+ });
14
+ it('should return undefined when no file path is provided', () => {
15
+ const result = (0, loadCctIdsFromFile_1.loadCctIdsFromFile)();
16
+ expect(result).toBeUndefined();
17
+ });
18
+ it('should load and parse valid JSON file with CCT IDs', () => {
19
+ const mockFilePath = '/test/path/cct-ids.json';
20
+ const mockJsonContent = '["cct-id-1", "cct-id-2", "cct-id-3"]';
21
+ const expectedResult = ['cct-id-1', 'cct-id-2', 'cct-id-3'];
22
+ mockFs.existsSync.mockReturnValue(true);
23
+ mockFs.readFileSync.mockReturnValue(mockJsonContent);
24
+ const result = (0, loadCctIdsFromFile_1.loadCctIdsFromFile)(mockFilePath);
25
+ expect(mockFs.existsSync).toHaveBeenCalledWith(mockFilePath);
26
+ expect(mockFs.readFileSync).toHaveBeenCalledWith(mockFilePath, 'utf8');
27
+ expect(result).toEqual(expectedResult);
28
+ });
29
+ it('should throw error when file does not exist', () => {
30
+ const mockFilePath = '/test/path/nonexistent.json';
31
+ mockFs.existsSync.mockReturnValue(false);
32
+ expect(() => (0, loadCctIdsFromFile_1.loadCctIdsFromFile)(mockFilePath)).toThrow(`Failed to load CCT IDs from file: CCT IDs file not found: ${mockFilePath}`);
33
+ });
34
+ it('should throw error when JSON is invalid', () => {
35
+ const mockFilePath = '/test/path/invalid.json';
36
+ const invalidJson = '["invalid": json}';
37
+ mockFs.existsSync.mockReturnValue(true);
38
+ mockFs.readFileSync.mockReturnValue(invalidJson);
39
+ expect(() => (0, loadCctIdsFromFile_1.loadCctIdsFromFile)(mockFilePath)).toThrow('Failed to load CCT IDs from file:');
40
+ });
41
+ it('should throw error when JSON is not an array', () => {
42
+ const mockFilePath = '/test/path/object.json';
43
+ const objectJson = '{"id": "cct-id-1"}';
44
+ mockFs.existsSync.mockReturnValue(true);
45
+ mockFs.readFileSync.mockReturnValue(objectJson);
46
+ expect(() => (0, loadCctIdsFromFile_1.loadCctIdsFromFile)(mockFilePath)).toThrow('CCT IDs file must contain a valid array of strings representing CCT IDs with at least one CCT ID');
47
+ });
48
+ it('should throw error when array is empty', () => {
49
+ const mockFilePath = '/test/path/empty.json';
50
+ const emptyArrayJson = '[]';
51
+ mockFs.existsSync.mockReturnValue(true);
52
+ mockFs.readFileSync.mockReturnValue(emptyArrayJson);
53
+ expect(() => (0, loadCctIdsFromFile_1.loadCctIdsFromFile)(mockFilePath)).toThrow('CCT IDs file must contain a valid array of strings representing CCT IDs with at least one CCT ID');
54
+ });
55
+ it('should throw error when array contains non-string values', () => {
56
+ const mockFilePath = '/test/path/mixed.json';
57
+ const mixedArrayJson = '["cct-id-1", 123, "cct-id-3"]';
58
+ mockFs.existsSync.mockReturnValue(true);
59
+ mockFs.readFileSync.mockReturnValue(mixedArrayJson);
60
+ expect(() => (0, loadCctIdsFromFile_1.loadCctIdsFromFile)(mockFilePath)).toThrow('CCT IDs file must contain a valid array of strings representing CCT IDs with at least one CCT ID');
61
+ });
62
+ it('should throw error when array contains empty strings', () => {
63
+ const mockFilePath = '/test/path/empty-strings.json';
64
+ const emptyStringsJson = '["cct-id-1", "", "cct-id-3"]';
65
+ mockFs.existsSync.mockReturnValue(true);
66
+ mockFs.readFileSync.mockReturnValue(emptyStringsJson);
67
+ expect(() => (0, loadCctIdsFromFile_1.loadCctIdsFromFile)(mockFilePath)).toThrow('CCT IDs file must contain a valid array of strings representing CCT IDs with at least one CCT ID');
68
+ });
69
+ it('should throw error when array contains whitespace-only strings', () => {
70
+ const mockFilePath = '/test/path/whitespace.json';
71
+ const whitespaceJson = '["cct-id-1", " ", "cct-id-3"]';
72
+ mockFs.existsSync.mockReturnValue(true);
73
+ mockFs.readFileSync.mockReturnValue(whitespaceJson);
74
+ expect(() => (0, loadCctIdsFromFile_1.loadCctIdsFromFile)(mockFilePath)).toThrow('CCT IDs file must contain a valid array of strings representing CCT IDs with at least one CCT ID');
75
+ });
76
+ it('should throw error when JSON is null', () => {
77
+ const mockFilePath = '/test/path/null.json';
78
+ const nullJson = 'null';
79
+ mockFs.existsSync.mockReturnValue(true);
80
+ mockFs.readFileSync.mockReturnValue(nullJson);
81
+ expect(() => (0, loadCctIdsFromFile_1.loadCctIdsFromFile)(mockFilePath)).toThrow('CCT IDs file must contain a valid array of strings representing CCT IDs with at least one CCT ID');
82
+ });
83
+ it('should handle single CCT ID in array', () => {
84
+ const mockFilePath = '/test/path/single.json';
85
+ const singleIdJson = '["cct-id-only"]';
86
+ mockFs.existsSync.mockReturnValue(true);
87
+ mockFs.readFileSync.mockReturnValue(singleIdJson);
88
+ const result = (0, loadCctIdsFromFile_1.loadCctIdsFromFile)(mockFilePath);
89
+ expect(result).toEqual(['cct-id-only']);
90
+ });
91
+ });
@@ -8,7 +8,8 @@ export declare enum OptionName {
8
8
  MATRIX_IDENTIFIER = "matrix-identifier",
9
9
  MATRIX_KEY = "matrix-key",
10
10
  CONTENT_API_KEY = "content-api-key",
11
- STAGE_OPTIONS = "stage-options"
11
+ STAGE_OPTIONS = "stage-options",
12
+ CCT_IDS = "cct-ids"
12
13
  }
13
14
  export declare const getParamOption: (param: OptionName, makeOptionMandatory?: boolean) => Option;
14
15
  export declare const addOverrideUrlOption: (command: Command) => void;
@@ -13,6 +13,7 @@ var OptionName;
13
13
  OptionName["MATRIX_KEY"] = "matrix-key";
14
14
  OptionName["CONTENT_API_KEY"] = "content-api-key";
15
15
  OptionName["STAGE_OPTIONS"] = "stage-options";
16
+ OptionName["CCT_IDS"] = "cct-ids";
16
17
  })(OptionName = exports.OptionName || (exports.OptionName = {}));
17
18
  const params = new Map([
18
19
  [
@@ -78,6 +79,13 @@ const params = new Map([
78
79
  description: 'Path to a JSON file containing stage options',
79
80
  },
80
81
  ],
82
+ [
83
+ OptionName.CCT_IDS,
84
+ {
85
+ flags: '--cct-ids <string>',
86
+ description: 'The IDs of the CCTs to be converted in a pre-migration, or to be skipped in a migration (already converted)',
87
+ },
88
+ ],
81
89
  ]);
82
90
  const getParamOption = (param, makeOptionMandatory = true) => {
83
91
  const paramInfo = params.get(param);
@@ -28,7 +28,7 @@ exports.logger = (0, dx_logger_lib_1.getLogger)({
28
28
  const createDeployCommand = () => {
29
29
  const deployCommand = new commander_1.Command()
30
30
  .name('deploy')
31
- .addOption(new commander_1.Option('--config <string>', 'File path to the page layout config file').default('./page-layout.yaml'))
31
+ .addOption(new commander_1.Option('--config <string>', 'File path to the page layout config file'))
32
32
  .addOption(new commander_1.Option('-cu, --content-service-url <string>', 'Override the content service url from login')
33
33
  .env('CONTENT_SERVICE_URL')
34
34
  .hideHelp(true))
@@ -47,16 +47,25 @@ const createDeployCommand = () => {
47
47
  tenantId: (_c = options.tenant) !== null && _c !== void 0 ? _c : maybeConfig === null || maybeConfig === void 0 ? void 0 : maybeConfig.tenant,
48
48
  });
49
49
  const contentServiceUrl = (_d = options.contentServiceUrl) !== null && _d !== void 0 ? _d : new URL('/__dxp/service/components-content', baseUrl).toString();
50
- const layoutFile = options.config;
50
+ let layoutFile = options.config;
51
+ (0, validation_1.validateLayoutFormat)(exports.logger, layoutFile);
52
+ if (!layoutFile) {
53
+ layoutFile = `./${definitions_1.LAYOUT_MANIFEST_FILE}`;
54
+ }
51
55
  exports.logger.info(`Loading layout data from the file ${layoutFile}`);
52
56
  try {
53
57
  const layout = yield (0, definitions_1.loadLayoutDefinition)(layoutFile);
54
58
  if (layout !== undefined) {
55
- // Pre-deployment validation: check zones match between YAML and template
59
+ // Pre-deployment validation: check zones match between manifest and template
56
60
  const zoneValidationError = (0, validation_1.validateZoneConsistency)(layout);
57
61
  if (zoneValidationError) {
58
62
  throw new Error(zoneValidationError);
59
63
  }
64
+ // Validate Handlebars template only refers to properties if manifest.json is used
65
+ const propertyValidationError = (0, validation_1.validatePropertyConsistency)(layout, layoutFile);
66
+ if (propertyValidationError) {
67
+ throw new Error(propertyValidationError);
68
+ }
60
69
  const response = yield uploadLayout(apiService.client, layout, contentServiceUrl, options.dryRun);
61
70
  if (!options.dryRun) {
62
71
  exports.logger.info(`Layout "${layout.name}" version ${response.data.version} deployed successfully.`);
@@ -90,8 +99,34 @@ const createDeployCommand = () => {
90
99
  return deployCommand;
91
100
  };
92
101
  exports.default = createDeployCommand;
102
+ /**
103
+ * Formats backend error responses into user-friendly error messages.
104
+ * Handles both validation errors (400s) and unknown server errors (500s).
105
+ *
106
+ * @param data - The error response data from the backend
107
+ * @param statusCode - The HTTP status code (optional)
108
+ * @returns Formatted error message string
109
+ */
110
+ function formatErrorResponse(data, statusCode) {
111
+ // For 500 errors or unknown errors, show a generic message
112
+ if (statusCode && statusCode >= 500) {
113
+ return data.message || data.data || 'An unknown error occurred';
114
+ }
115
+ // For validation errors (400s), format with details
116
+ let errorMessage = data.message || 'Validation failed';
117
+ // Properties/options validation errors come as an object with field keys
118
+ if (data.details &&
119
+ typeof data.details === 'object' &&
120
+ !Array.isArray(data.details)) {
121
+ const details = Object.entries(data.details)
122
+ .map(([field, error]) => ` - ${field}: ${error.message}`)
123
+ .join('\n');
124
+ errorMessage += `\n${details}`;
125
+ }
126
+ return errorMessage;
127
+ }
93
128
  function uploadLayout(client, layout, contentServiceUrl, dryRun) {
94
- var _a, _b;
129
+ var _a;
95
130
  return __awaiter(this, void 0, void 0, function* () {
96
131
  try {
97
132
  const queryParam = dryRun ? '?_dryRun=true' : '';
@@ -101,13 +136,13 @@ function uploadLayout(client, layout, contentServiceUrl, dryRun) {
101
136
  if (response.status === 200) {
102
137
  return response;
103
138
  }
104
- const error = new Error(response.data.message);
105
- if ((_b = (_a = response.data.details) === null || _a === void 0 ? void 0 : _a.input) === null || _b === void 0 ? void 0 : _b.message) {
106
- error.message += `: ${response.data.details.input.message}`;
107
- }
108
- throw error;
139
+ throw new Error(formatErrorResponse(response.data, response.status));
109
140
  }
110
141
  catch (error) {
142
+ // Extract error details from axios error response
143
+ if ((_a = error.response) === null || _a === void 0 ? void 0 : _a.data) {
144
+ throw new Error(formatErrorResponse(error.response.data, error.response.status));
145
+ }
111
146
  throw error;
112
147
  }
113
148
  });
@@ -17,10 +17,12 @@ jest.mock('@squiz/dx-logger-lib', () => ({
17
17
  getLogger: () => {
18
18
  return {
19
19
  info: mockLoggerInfoFn,
20
+ warn: mockLoggerWarnFn,
20
21
  };
21
22
  },
22
23
  }));
23
24
  const mockLoggerInfoFn = jest.fn();
25
+ const mockLoggerWarnFn = jest.fn();
24
26
  const nock_1 = __importDefault(require("nock"));
25
27
  const deploy_1 = __importDefault(require("./deploy"));
26
28
  const definitions_1 = require("../../utils/definitions");
@@ -58,7 +60,7 @@ describe('deployCommand', () => {
58
60
  const program = (0, deploy_1.default)();
59
61
  yield program.parseAsync(createMockArgs({}));
60
62
  const opts = program.opts();
61
- expect(opts.config).toEqual('./page-layout.yaml');
63
+ expect(opts.config).toBeUndefined();
62
64
  expect(opts.tenant).toEqual('myTenant');
63
65
  expect(opts.dryRun).toEqual(false);
64
66
  }));
@@ -90,13 +92,17 @@ describe('deployCommand', () => {
90
92
  expect(logSpy).toHaveBeenCalledWith(`NOTICE: CONTENT_SERVICE_URL is set and will deploy to ${contentServiceUrl}`);
91
93
  }));
92
94
  it('deploys a layout successfully', () => __awaiter(void 0, void 0, void 0, function* () {
93
- const file = './src/__tests__/layouts/page-layout.yaml';
95
+ const file = `./src/__tests__/layouts/${definitions_1.LAYOUT_MANIFEST_FILE}`;
94
96
  const dxpBaseUrl = 'http://dxp-base-url.com';
95
97
  const mockLayout = {
96
98
  name: 'test-layout',
97
- zones: {
98
- content: { displayName: 'Content', description: 'Main content' },
99
- },
99
+ zones: [
100
+ {
101
+ key: 'content',
102
+ displayName: 'Content',
103
+ description: 'Main content',
104
+ },
105
+ ],
100
106
  template: '<div>{{zones.content}}</div>',
101
107
  };
102
108
  const mockResponse = { name: 'test-layout', version: '12345' };
@@ -116,9 +122,13 @@ describe('deployCommand', () => {
116
122
  const dryRun = true;
117
123
  const mockLayout = {
118
124
  name: 'test-layout',
119
- zones: {
120
- content: { displayName: 'Content', description: 'Main content' },
121
- },
125
+ zones: [
126
+ {
127
+ key: 'content',
128
+ displayName: 'Content',
129
+ description: 'Main content',
130
+ },
131
+ ],
122
132
  template: '<div>{{zones.content}}</div>',
123
133
  };
124
134
  const mockResponse = { name: 'test-layout', version: '12345' };
@@ -170,7 +180,7 @@ describe('deployCommand', () => {
170
180
  const program = (0, deploy_1.default)();
171
181
  errorSpy = jest.spyOn(program, 'error').mockImplementation();
172
182
  yield program.parseAsync(createMockArgs({ contentServiceUrl }));
173
- expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('An unknown error occurred'));
183
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Internal server error'));
174
184
  }));
175
185
  it('should log additional details when layout validation fails', () => __awaiter(void 0, void 0, void 0, function* () {
176
186
  const dxpBaseUrl = 'http://dxp-base-url.com';
@@ -187,8 +197,53 @@ describe('deployCommand', () => {
187
197
  const program = (0, deploy_1.default)();
188
198
  errorSpy = jest.spyOn(program, 'error').mockImplementation();
189
199
  yield program.parseAsync(createMockArgs({ dxpBaseUrl }));
190
- expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Layout validation failed: ERROR: Validation failed: "version" is an excess property and therefore is not allowed'));
200
+ const errorCall = errorSpy.mock.calls[0][0];
201
+ expect(errorCall).toContain('- input: ERROR: Validation failed: "version" is an excess property and therefore is not allowed');
191
202
  }));
203
+ describe('backend property validation error reporting', () => {
204
+ it('should display clean error when single property validation fails', () => __awaiter(void 0, void 0, void 0, function* () {
205
+ const contentServiceUrl = 'http://localhost:9999';
206
+ (0, nock_1.default)(contentServiceUrl)
207
+ .post('/page-layout')
208
+ .reply(400, {
209
+ message: 'Validation failed',
210
+ details: {
211
+ 'input.properties.myBoolean': {
212
+ message: "Property 'myBoolean' with type 'boolean' can not have values specified",
213
+ },
214
+ },
215
+ });
216
+ const program = (0, deploy_1.default)();
217
+ errorSpy = jest.spyOn(program, 'error').mockImplementation();
218
+ yield program.parseAsync(createMockArgs({ contentServiceUrl }));
219
+ const errorCall = errorSpy.mock.calls[0][0];
220
+ expect(errorCall).toContain('Validation failed');
221
+ expect(errorCall).toContain("- input.properties.myBoolean: Property 'myBoolean' with type 'boolean' can not have values specified");
222
+ }));
223
+ it('should display multiple property validation errors cleanly', () => __awaiter(void 0, void 0, void 0, function* () {
224
+ const contentServiceUrl = 'http://localhost:9999';
225
+ (0, nock_1.default)(contentServiceUrl)
226
+ .post('/page-layout')
227
+ .reply(400, {
228
+ message: 'Validation failed',
229
+ details: {
230
+ 'input.properties.badBoolean': {
231
+ message: "Property 'badBoolean' with type 'boolean' cannot have enum values specified",
232
+ },
233
+ 'input.properties.badProperty.type': {
234
+ message: "Property 'badProperty' has invalid type 'INVALID'. Must be one of: string, boolean",
235
+ },
236
+ },
237
+ });
238
+ const program = (0, deploy_1.default)();
239
+ errorSpy = jest.spyOn(program, 'error').mockImplementation();
240
+ yield program.parseAsync(createMockArgs({ contentServiceUrl }));
241
+ const errorCall = errorSpy.mock.calls[0][0];
242
+ expect(errorCall).toContain('Validation failed');
243
+ expect(errorCall).toContain("- input.properties.badBoolean: Property 'badBoolean' with type 'boolean' cannot have enum values specified");
244
+ expect(errorCall).toContain("- input.properties.badProperty.type: Property 'badProperty' has invalid type 'INVALID'. Must be one of: string, boolean");
245
+ }));
246
+ });
192
247
  describe('zone consistency validation', () => {
193
248
  it('should handle zone consistency validation errors where zones are used but not defined in the layout', () => __awaiter(void 0, void 0, void 0, function* () {
194
249
  const file = './src/__tests__/layout.yaml';
@@ -196,33 +251,69 @@ describe('deployCommand', () => {
196
251
  // Mock layout with zones that don't match the template
197
252
  const mockLayout = {
198
253
  name: 'test-layout',
199
- zones: {
200
- col1: { displayName: 'Column 1', description: 'The first column' },
201
- },
254
+ zones: [
255
+ {
256
+ key: 'col1',
257
+ displayName: 'Column 1',
258
+ description: 'The first column',
259
+ },
260
+ ],
202
261
  template: '{{zones.col1}} {{zones.col2}}', // col2 is used but not defined in zones
203
262
  };
204
263
  definitions_1.loadLayoutDefinition.mockResolvedValue(mockLayout);
205
264
  const program = (0, deploy_1.default)();
206
265
  errorSpy = jest.spyOn(program, 'error').mockImplementation();
207
266
  yield program.parseAsync(createMockArgs({ config: file, dxpBaseUrl }));
208
- expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Zone consistency validation failed[\s\S]*Zones used in template but not defined in YAML: col2/));
267
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Zone consistency validation failed[\s\S]*Zones used in template but not defined in layout definition: col2/));
209
268
  }));
210
269
  it('should handle zone consistency validation errors where zones are defined but not used in the template', () => __awaiter(void 0, void 0, void 0, function* () {
211
270
  const file = './src/__tests__/layout.yaml';
212
271
  const dxpBaseUrl = 'http://dxp-base-url.com';
213
272
  const mockLayout = {
214
273
  name: 'test-layout',
215
- zones: {
216
- col1: { displayName: 'Column 1', description: 'The first column' },
217
- col2: { displayName: 'Column 2', description: 'The second column' }, // col2 is defined but not used in the template
218
- },
274
+ zones: [
275
+ {
276
+ key: 'col1',
277
+ displayName: 'Column 1',
278
+ description: 'The first column',
279
+ },
280
+ {
281
+ key: 'col2',
282
+ displayName: 'Column 2',
283
+ description: 'The second column',
284
+ }, // col2 is defined but not used in the template
285
+ ],
219
286
  template: '{{zones.col1}}',
220
287
  };
221
288
  definitions_1.loadLayoutDefinition.mockResolvedValue(mockLayout);
222
289
  const program = (0, deploy_1.default)();
223
290
  errorSpy = jest.spyOn(program, 'error').mockImplementation();
224
291
  yield program.parseAsync(createMockArgs({ config: file, dxpBaseUrl }));
225
- expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Zone consistency validation failed[\s\S]*Zones defined in YAML but not used in template: col2/));
292
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Zone consistency validation failed[\s\S]*Zones defined in layout definition but not used in template: col2/));
293
+ }));
294
+ });
295
+ describe('property consistency validation', () => {
296
+ it('should handle property consistency validation errors where properties are used but not defined in the layout', () => __awaiter(void 0, void 0, void 0, function* () {
297
+ const file = './src/__tests__/manifest.json';
298
+ const dxpBaseUrl = 'http://dxp-base-url.com';
299
+ // Mock layout with properties that don't match the template
300
+ const mockLayout = {
301
+ name: 'test-layout',
302
+ zones: [],
303
+ properties: {
304
+ title: {
305
+ type: 'string',
306
+ title: 'Title',
307
+ description: 'Page title',
308
+ },
309
+ },
310
+ template: '{{properties.title}} {{properties.undefined}}', // undefined is used but not defined in properties
311
+ };
312
+ definitions_1.loadLayoutDefinition.mockResolvedValue(mockLayout);
313
+ const program = (0, deploy_1.default)();
314
+ errorSpy = jest.spyOn(program, 'error').mockImplementation();
315
+ yield program.parseAsync(createMockArgs({ config: file, dxpBaseUrl }));
316
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/Property consistency validation failed[\s\S]*Properties used in template but not defined in layout definition: undefined/));
226
317
  }));
227
318
  });
228
319
  });
@@ -19,6 +19,7 @@ const definitions_1 = require("../../utils/definitions");
19
19
  const path_1 = __importDefault(require("path"));
20
20
  const server_1 = require("../../utils/server");
21
21
  const parse_args_1 = require("../../utils/parse-args");
22
+ const validation_1 = require("../validation");
22
23
  exports.logger = (0, dx_logger_lib_1.getLogger)({
23
24
  name: 'layout-dev',
24
25
  format: 'human',
@@ -27,13 +28,13 @@ const createDevCommand = () => {
27
28
  const devCommand = new commander_1.Command()
28
29
  .name('dev')
29
30
  .description('Start a development server for page layouts')
30
- .option('--config <string>', 'File path to the page layout config file', './page-layout.yaml')
31
+ .option('--config <string>', 'File path to the page layout config file', `./${definitions_1.LAYOUT_MANIFEST_FILE}`)
31
32
  .option('--port <number>', 'Port to run the development server on', '4040')
32
33
  .option('--no-open', 'Do not automatically open browser')
33
34
  .option('--stylesheet <path>', 'Path to CSS file to include')
34
35
  .option('--zones <items>', 'Zone content mappings', parse_args_1.parseZonesList, {})
35
- .option('--options <items>', 'Layout options', parse_args_1.parseOptionsList, {})
36
- .allowUnknownOption(true)
36
+ .option('--properties <items>', 'Layout properties', parse_args_1.parsePropertiesList, {})
37
+ .allowUnknownOption(false)
37
38
  .allowExcessArguments(true)
38
39
  .action((options) => __awaiter(void 0, void 0, void 0, function* () {
39
40
  try {
@@ -43,14 +44,27 @@ const createDevCommand = () => {
43
44
  if (!rawLayoutDefinition) {
44
45
  throw new Error(`Failed to load layout definition from ${options.config}`);
45
46
  }
47
+ (0, validation_1.validateLayoutFormat)(exports.logger, options.config);
48
+ // Check zones match between manifest and template
49
+ const zoneValidationError = (0, validation_1.validateZoneConsistency)(rawLayoutDefinition);
50
+ if (zoneValidationError) {
51
+ throw new Error(zoneValidationError);
52
+ }
53
+ // Validate Handlebars template only refers to properties if manifest.json is used
54
+ const propertyValidationError = (0, validation_1.validatePropertyConsistency)(rawLayoutDefinition, options.config);
55
+ if (propertyValidationError) {
56
+ throw new Error(propertyValidationError);
57
+ }
46
58
  // Confirm for entry property
47
59
  const layoutDefinition = Object.assign({}, rawLayoutDefinition);
60
+ // Normalize properties to convert string booleans to actual booleans
61
+ const normalizedProperties = (0, parse_args_1.normalizeLayoutProperties)(options.properties, layoutDefinition);
48
62
  exports.logger.info('Starting development server...');
49
63
  yield (0, server_1.startDevServer)({
50
64
  configPath: path_1.default.resolve(options.config),
51
65
  layoutDefinition,
52
66
  zoneContent: options.zones,
53
- layoutOptions: options.options,
67
+ layoutProperties: normalizedProperties,
54
68
  stylesheet: options.stylesheet
55
69
  ? path_1.default.resolve(options.stylesheet)
56
70
  : undefined,