@aifabrix/builder 2.32.3 → 2.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.cursor/rules/project-rules.mdc +8 -0
  2. package/README.md +36 -8
  3. package/bin/aifabrix.js +6 -8
  4. package/integration/hubspot/README.md +8 -7
  5. package/integration/hubspot/companies.json +2048 -0
  6. package/integration/hubspot/create-hubspot.js +665 -0
  7. package/integration/hubspot/{hubspot-deploy-company.json → hubspot-datasource-company.json} +1 -1
  8. package/integration/hubspot/{hubspot-deploy-contact.json → hubspot-datasource-contact.json} +1 -1
  9. package/integration/hubspot/{hubspot-deploy-deal.json → hubspot-datasource-deal.json} +1 -1
  10. package/integration/hubspot/hubspot-deploy.json +832 -81
  11. package/integration/hubspot/hubspot-system.json +99 -0
  12. package/integration/hubspot/test-artifacts/wizard-hubspot-credential-real.yaml +20 -0
  13. package/integration/hubspot/test-artifacts/wizard-hubspot-env-vars.yaml +9 -0
  14. package/integration/hubspot/test-artifacts/wizard-invalid-add-datasource.yaml +5 -0
  15. package/integration/hubspot/test-artifacts/wizard-invalid-app-name.yaml +5 -0
  16. package/integration/hubspot/test-artifacts/wizard-invalid-credential-create.yaml +7 -0
  17. package/integration/hubspot/test-artifacts/wizard-invalid-credential-select.yaml +7 -0
  18. package/integration/hubspot/test-artifacts/wizard-invalid-known-platform.yaml +4 -0
  19. package/integration/hubspot/test-artifacts/wizard-invalid-missing-app.yaml +4 -0
  20. package/integration/hubspot/test-artifacts/wizard-invalid-missing-source.yaml +2 -0
  21. package/integration/hubspot/test-artifacts/wizard-invalid-mode.yaml +5 -0
  22. package/integration/hubspot/test-artifacts/wizard-invalid-openapi-file.yaml +5 -0
  23. package/integration/hubspot/test-artifacts/wizard-invalid-openapi-url.yaml +4 -0
  24. package/integration/hubspot/test-artifacts/wizard-invalid-source.yaml +4 -0
  25. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-array-test.yaml +5 -0
  26. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-key-test.yaml +5 -0
  27. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-path-test.yaml +5 -0
  28. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-test.yaml +5 -0
  29. package/integration/hubspot/test-artifacts/wizard-valid-for-rbac-test.yaml +5 -0
  30. package/integration/hubspot/test-artifacts/wizard-valid-for-rbac-yaml-test.yaml +5 -0
  31. package/integration/hubspot/test-dataplane-down-helpers.js +246 -0
  32. package/integration/hubspot/test-dataplane-down-tests.js +419 -0
  33. package/integration/hubspot/test-dataplane-down.js +157 -0
  34. package/integration/hubspot/test.js +1517 -0
  35. package/integration/hubspot/variables.yaml +4 -4
  36. package/integration/hubspot/wizard-hubspot-e2e.yaml +16 -0
  37. package/integration/hubspot/wizard-hubspot-platform.yaml +8 -0
  38. package/lib/api/applications.api.js +1 -0
  39. package/lib/api/types/wizard.types.js +176 -38
  40. package/lib/api/wizard.api.js +161 -23
  41. package/lib/app/deploy.js +116 -54
  42. package/lib/app/display.js +6 -5
  43. package/lib/app/dockerfile.js +2 -1
  44. package/lib/app/list.js +17 -10
  45. package/lib/app/readme.js +41 -112
  46. package/lib/app/register.js +44 -9
  47. package/lib/app/rotate-secret.js +48 -31
  48. package/lib/cli.js +219 -70
  49. package/lib/commands/app.js +4 -9
  50. package/lib/commands/auth-config.js +125 -0
  51. package/lib/commands/auth-status.js +7 -8
  52. package/lib/commands/datasource.js +3 -6
  53. package/lib/commands/login-credentials.js +4 -4
  54. package/lib/commands/login-device.js +26 -17
  55. package/lib/commands/login.js +12 -10
  56. package/lib/commands/wizard-config-normalizer.js +92 -0
  57. package/lib/commands/wizard-core.js +515 -0
  58. package/lib/commands/wizard-dataplane.js +122 -0
  59. package/lib/commands/wizard-headless.js +115 -0
  60. package/lib/commands/wizard.js +110 -332
  61. package/lib/core/config.js +46 -0
  62. package/lib/core/secrets.js +3 -22
  63. package/lib/core/templates-env.js +1 -1
  64. package/lib/datasource/deploy.js +29 -21
  65. package/lib/datasource/list.js +8 -6
  66. package/lib/deployment/deployer.js +25 -0
  67. package/lib/deployment/environment.js +10 -13
  68. package/lib/external-system/delete.js +151 -0
  69. package/lib/external-system/deploy.js +53 -378
  70. package/lib/external-system/download-helpers.js +45 -65
  71. package/lib/external-system/download.js +33 -13
  72. package/lib/external-system/generator.js +11 -7
  73. package/lib/external-system/test-auth.js +4 -3
  74. package/lib/generator/builders.js +3 -1
  75. package/lib/generator/external-controller-manifest.js +157 -0
  76. package/lib/generator/external-schema-utils.js +236 -0
  77. package/lib/generator/external.js +55 -3
  78. package/lib/generator/index.js +22 -10
  79. package/lib/generator/wizard-prompts.js +33 -10
  80. package/lib/generator/wizard.js +69 -86
  81. package/lib/infrastructure/compose.js +100 -0
  82. package/lib/infrastructure/helpers.js +139 -0
  83. package/lib/infrastructure/index.js +52 -311
  84. package/lib/infrastructure/services.js +168 -0
  85. package/lib/schema/application-schema.json +23 -4
  86. package/lib/schema/external-datasource.schema.json +2 -2
  87. package/lib/schema/wizard-config.schema.json +234 -0
  88. package/lib/utils/api.js +32 -50
  89. package/lib/utils/app-existence.js +42 -0
  90. package/lib/utils/app-register-config.js +7 -2
  91. package/lib/utils/auth-config-validator.js +92 -0
  92. package/lib/utils/command-header.js +43 -0
  93. package/lib/utils/compose-generator.js +113 -70
  94. package/lib/utils/controller-url.js +65 -17
  95. package/lib/utils/dataplane-health.js +115 -0
  96. package/lib/utils/dataplane-resolver.js +29 -0
  97. package/lib/utils/dev-config.js +6 -2
  98. package/lib/utils/env-copy.js +2 -1
  99. package/lib/utils/env-ports.js +2 -1
  100. package/lib/utils/env-template.js +1 -1
  101. package/lib/utils/error-formatter.js +49 -0
  102. package/lib/utils/external-readme.js +125 -0
  103. package/lib/utils/help-builder.js +190 -0
  104. package/lib/utils/infra-status.js +13 -3
  105. package/lib/utils/paths.js +17 -2
  106. package/lib/utils/port-resolver.js +111 -0
  107. package/lib/utils/secrets-helpers.js +3 -15
  108. package/lib/utils/secrets-utils.js +2 -2
  109. package/lib/utils/token-manager.js +9 -4
  110. package/lib/utils/variable-transformer.js +7 -2
  111. package/lib/validation/external-manifest-validator.js +202 -0
  112. package/lib/validation/validate-display.js +406 -0
  113. package/lib/validation/validate.js +159 -123
  114. package/lib/validation/validator.js +36 -3
  115. package/lib/validation/wizard-config-validator.js +267 -0
  116. package/package.json +4 -2
  117. package/templates/applications/README.md.hbs +18 -16
  118. package/templates/applications/miso-controller/env.template +1 -1
  119. package/templates/applications/miso-controller/rbac.yaml +7 -7
  120. package/templates/external-system/README.md.hbs +99 -0
  121. package/templates/infra/compose.yaml.hbs +35 -0
  122. package/templates/python/docker-compose.hbs +26 -0
  123. package/templates/typescript/docker-compose.hbs +26 -0
@@ -0,0 +1,1517 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * HubSpot External System Wizard E2E Test Runner
4
+ *
5
+ * @fileoverview Runs end-to-end wizard tests for HubSpot integration.
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+ /* eslint-disable max-lines */
10
+ 'use strict';
11
+
12
+ const fs = require('fs').promises;
13
+ const fsSync = require('fs');
14
+ const path = require('path');
15
+ const { execFile } = require('child_process');
16
+ const { promisify } = require('util');
17
+ const yaml = require('js-yaml');
18
+ const chalk = require('chalk');
19
+ const { getDeploymentAuth } = require('../../lib/utils/token-manager');
20
+ const { discoverDataplaneUrl } = require('../../lib/commands/wizard-dataplane');
21
+
22
+ const execFileAsync = promisify(execFile);
23
+
24
+ const DEFAULT_CONTROLLER_URL = process.env.CONTROLLER_URL || 'http://localhost:3110';
25
+ const DEFAULT_ENVIRONMENT = process.env.ENVIRONMENT || 'miso';
26
+ const DEFAULT_DATAPLANE_URL = process.env.DATAPLANE_URL || '';
27
+ const DEFAULT_OPENAPI_FILE = process.env.HUBSPOT_OPENAPI_FILE ||
28
+ '/workspace/aifabrix-dataplane/data/hubspot/openapi/companies.json';
29
+ const LOCAL_ENV_PATH = path.join(process.cwd(), 'integration', 'hubspot', '.env');
30
+ const DEFAULT_ENV_PATH = process.env.HUBSPOT_ENV_PATH ||
31
+ (fsSync.existsSync(LOCAL_ENV_PATH) ? LOCAL_ENV_PATH : '/workspace/aifabrix-dataplane/data/hubspot/.env');
32
+
33
+ const HUBSPOT_DIR = path.join(process.cwd(), 'integration', 'hubspot');
34
+ const ARTIFACT_DIR = path.join(HUBSPOT_DIR, 'test-artifacts');
35
+ const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
36
+ const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
37
+
38
+ /**
39
+ * Error class for skipping tests
40
+ * @class SkipTestError
41
+ * @extends Error
42
+ */
43
+ class SkipTestError extends Error {
44
+ /**
45
+ * Creates a SkipTestError
46
+ * @param {string} message - Error message
47
+ */
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = 'SkipTestError';
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Parses command line arguments
56
+ * @function parseArgs
57
+ * @param {string[]} argv - Command line arguments
58
+ * @returns {Object} Parsed arguments object with tests, types, verbose, keepArtifacts, help
59
+ */
60
+ function parseArgs(argv) {
61
+ const args = {
62
+ tests: [],
63
+ types: [],
64
+ verbose: false,
65
+ keepArtifacts: false,
66
+ help: false
67
+ };
68
+ for (let i = 2; i < argv.length; i += 1) {
69
+ const arg = argv[i];
70
+ if (arg === '--test' && argv[i + 1]) {
71
+ args.tests = argv[i + 1].split(',').map(item => item.trim()).filter(Boolean);
72
+ i += 1;
73
+ continue;
74
+ }
75
+ if (arg === '--type' && argv[i + 1]) {
76
+ args.types = argv[i + 1].split(',').map(item => item.trim()).filter(Boolean);
77
+ i += 1;
78
+ continue;
79
+ }
80
+ if (arg === '--verbose') {
81
+ args.verbose = true;
82
+ continue;
83
+ }
84
+ if (arg === '--keep-artifacts') {
85
+ args.keepArtifacts = true;
86
+ continue;
87
+ }
88
+ if (arg === '--help' || arg === '-h') {
89
+ args.help = true;
90
+ }
91
+ }
92
+ return args;
93
+ }
94
+
95
+ /**
96
+ * Prints usage information
97
+ * @function printUsage
98
+ * @returns {void}
99
+ */
100
+ function printUsage() {
101
+ // eslint-disable-next-line no-console
102
+ console.log([
103
+ 'Usage:',
104
+ ' node integration/hubspot/test.js',
105
+ ' node integration/hubspot/test.js --test "1.1"',
106
+ ' node integration/hubspot/test.js --type positive',
107
+ ' node integration/hubspot/test.js --type negative --verbose',
108
+ '',
109
+ 'Options:',
110
+ ' --test <id[,id]> Run specific test IDs',
111
+ ' --type <type[,type]> Filter by type (positive|negative|real-data)',
112
+ ' --verbose Verbose command output',
113
+ ' --keep-artifacts Keep generated test artifacts',
114
+ ' -h, --help Show help'
115
+ ].join('\n'));
116
+ }
117
+
118
+ /**
119
+ * Logs info message
120
+ * @function logInfo
121
+ * @param {string} message - Message to log
122
+ * @returns {void}
123
+ */
124
+ function logInfo(message) {
125
+ // eslint-disable-next-line no-console
126
+ console.log(chalk.cyan(message));
127
+ }
128
+
129
+ /**
130
+ * Logs success message
131
+ * @function logSuccess
132
+ * @param {string} message - Message to log
133
+ * @returns {void}
134
+ */
135
+ function logSuccess(message) {
136
+ // eslint-disable-next-line no-console
137
+ console.log(chalk.green(message));
138
+ }
139
+
140
+ /**
141
+ * Logs warning message
142
+ * @function logWarn
143
+ * @param {string} message - Message to log
144
+ * @returns {void}
145
+ */
146
+ function logWarn(message) {
147
+ // eslint-disable-next-line no-console
148
+ console.warn(chalk.yellow(message));
149
+ }
150
+
151
+ /**
152
+ * Logs error message
153
+ * @function logError
154
+ * @param {string} message - Message to log
155
+ * @returns {void}
156
+ */
157
+ function logError(message) {
158
+ // eslint-disable-next-line no-console
159
+ console.error(chalk.red(message));
160
+ }
161
+
162
+ /**
163
+ * Ensures directory exists
164
+ * @async
165
+ * @function ensureDir
166
+ * @param {string} dirPath - Directory path
167
+ * @returns {Promise<void>} Resolves when directory is created
168
+ */
169
+ async function ensureDir(dirPath) {
170
+ await fs.mkdir(dirPath, { recursive: true });
171
+ }
172
+
173
+ /**
174
+ * Checks if file exists
175
+ * @async
176
+ * @function fileExists
177
+ * @param {string} filePath - File path to check
178
+ * @returns {Promise<boolean>} True if file exists
179
+ */
180
+ async function fileExists(filePath) {
181
+ try {
182
+ await fs.access(filePath);
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Parses environment file content
191
+ * @function parseEnvFile
192
+ * @param {string} content - Environment file content
193
+ * @returns {Object<string, string>} Parsed environment variables object
194
+ */
195
+ function parseEnvFile(content) {
196
+ const envVars = {};
197
+ const lines = content.split(/\r?\n/);
198
+ for (const line of lines) {
199
+ const trimmed = line.trim();
200
+ if (!trimmed || trimmed.startsWith('#')) {
201
+ continue;
202
+ }
203
+ const eqIndex = trimmed.indexOf('=');
204
+ if (eqIndex === -1) {
205
+ continue;
206
+ }
207
+ const key = trimmed.slice(0, eqIndex).trim();
208
+ let value = trimmed.slice(eqIndex + 1).trim();
209
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
210
+ value = value.slice(1, -1);
211
+ }
212
+ envVars[key] = value;
213
+ }
214
+ return envVars;
215
+ }
216
+
217
+ /**
218
+ * Stable stringify for comparison
219
+ * @function stableStringify
220
+ * @param {*} value - Value to stringify
221
+ * @returns {string} Stable JSON string
222
+ */
223
+ function stableStringify(value) {
224
+ if (Array.isArray(value)) {
225
+ return `[${value.map(item => stableStringify(item)).join(',')}]`;
226
+ }
227
+ if (value && typeof value === 'object') {
228
+ const keys = Object.keys(value).sort();
229
+ return `{${keys.map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
230
+ }
231
+ return JSON.stringify(value);
232
+ }
233
+
234
+ /**
235
+ * Normalizes file content for comparisons
236
+ * @function normalizeFileContent
237
+ * @param {string} filePath - File path
238
+ * @param {string} content - File content
239
+ * @returns {string} Normalized content
240
+ */
241
+ function normalizeFileContent(filePath, content) {
242
+ const ext = path.extname(filePath).toLowerCase();
243
+ if (ext === '.json') {
244
+ return stableStringify(JSON.parse(content));
245
+ }
246
+ if (ext === '.yaml' || ext === '.yml') {
247
+ return stableStringify(yaml.load(content));
248
+ }
249
+ return content.trim();
250
+ }
251
+
252
+ /**
253
+ * Loads environment file
254
+ * @async
255
+ * @function loadEnvFile
256
+ * @param {string} envPath - Path to environment file
257
+ * @param {Object} options - Options object
258
+ * @param {boolean} options.verbose - Verbose logging flag
259
+ * @returns {Promise<void>} Resolves when environment file is loaded
260
+ */
261
+ async function loadEnvFile(envPath, options) {
262
+ const exists = await fileExists(envPath);
263
+ if (!exists) {
264
+ logWarn(`Env file not found: ${envPath}`);
265
+ return;
266
+ }
267
+ const content = await fs.readFile(envPath, 'utf8');
268
+ const parsed = parseEnvFile(content);
269
+ for (const [key, value] of Object.entries(parsed)) {
270
+ if (process.env[key] === undefined) {
271
+ process.env[key] = value;
272
+ }
273
+ }
274
+ if (options.verbose) {
275
+ logInfo(`Loaded env vars from: ${envPath}`);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Ensure dataplane URL is available for tests
281
+ * @async
282
+ * @function ensureDataplaneUrl
283
+ * @param {Object} context - Test context
284
+ * @param {string} appName - Application name
285
+ * @returns {Promise<string>} Dataplane URL
286
+ */
287
+ async function ensureDataplaneUrl(context, appName) {
288
+ if (context.dataplaneUrl) {
289
+ return context.dataplaneUrl;
290
+ }
291
+ const authConfig = await getDeploymentAuth(context.controllerUrl, context.environment, appName);
292
+ const dataplaneUrl = await discoverDataplaneUrl(context.controllerUrl, context.environment, authConfig);
293
+ context.dataplaneUrl = dataplaneUrl;
294
+ return dataplaneUrl;
295
+ }
296
+
297
+ /**
298
+ * Ensures environment variable is set
299
+ * @function ensureEnvVar
300
+ * @param {string} name - Variable name
301
+ * @param {string} value - Variable value
302
+ * @returns {void}
303
+ */
304
+ function ensureEnvVar(name, value) {
305
+ if (process.env[name] === undefined && value !== undefined && value !== null && value !== '') {
306
+ process.env[name] = value;
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Requires environment variables to be set
312
+ * @function requireEnvVars
313
+ * @param {string[]} names - Array of variable names
314
+ * @returns {void}
315
+ * @throws {SkipTestError} If any required variable is missing
316
+ */
317
+ function requireEnvVars(names) {
318
+ const missing = names.filter(name => !process.env[name]);
319
+ if (missing.length > 0) {
320
+ throw new SkipTestError(`Missing required env vars: ${missing.join(', ')}`);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Runs a command
326
+ * @async
327
+ * @function runCommand
328
+ * @param {string} command - Command to run
329
+ * @param {string[]} args - Command arguments
330
+ * @param {Object} options - Options object
331
+ * @param {boolean} options.verbose - Verbose logging flag
332
+ * @returns {Promise<Object>} Command result object with success, stdout, stderr
333
+ */
334
+ async function runCommand(command, args, options) {
335
+ try {
336
+ const result = await execFileAsync(command, args, {
337
+ cwd: process.cwd(),
338
+ env: { ...process.env },
339
+ maxBuffer: MAX_OUTPUT_BYTES,
340
+ timeout: COMMAND_TIMEOUT_MS
341
+ });
342
+ if (options.verbose) {
343
+ if (result.stdout) {
344
+ logInfo(result.stdout.trim());
345
+ }
346
+ if (result.stderr) {
347
+ logWarn(result.stderr.trim());
348
+ }
349
+ }
350
+ return { success: true, stdout: result.stdout || '', stderr: result.stderr || '' };
351
+ } catch (error) {
352
+ const stdout = error.stdout || '';
353
+ const stderr = error.stderr || '';
354
+ if (options.verbose) {
355
+ if (stdout) {
356
+ logInfo(stdout.trim());
357
+ }
358
+ if (stderr) {
359
+ logWarn(stderr.trim());
360
+ }
361
+ }
362
+ return { success: false, stdout, stderr, error };
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Validates authentication status
368
+ * @async
369
+ * @function validateAuth
370
+ * @param {Object} context - Test context
371
+ * @param {string} context.controllerUrl - Controller URL
372
+ * @param {string} context.environment - Environment name
373
+ * @param {Object} options - Options object
374
+ * @returns {Promise<void>} Resolves when authentication is validated
375
+ * @throws {Error} If authentication fails
376
+ */
377
+ async function validateAuth(context, options) {
378
+ logInfo('Validating authentication status...');
379
+ const configArgs = [
380
+ 'bin/aifabrix.js',
381
+ 'auth',
382
+ 'config',
383
+ '--set-controller',
384
+ context.controllerUrl,
385
+ '--set-environment',
386
+ context.environment
387
+ ];
388
+ await runCommand('node', configArgs, options);
389
+ const args = ['bin/aifabrix.js', 'auth', 'status'];
390
+ const result = await runCommand('node', args, options);
391
+ if (!result.success) {
392
+ throw new Error('Authentication check failed. Run: node bin/aifabrix.js login --controller <url> --method device');
393
+ }
394
+ const output = `${result.stdout}\n${result.stderr}`;
395
+ if (output.includes('Not authenticated') || output.includes('✗')) {
396
+ throw new Error('Not authenticated. Run: node bin/aifabrix.js login --controller <url> --method device');
397
+ }
398
+ logSuccess('Authentication validated.');
399
+ }
400
+
401
+ /**
402
+ * Writes wizard configuration to file
403
+ * @async
404
+ * @function writeWizardConfig
405
+ * @param {string} name - Configuration name
406
+ * @param {Object} config - Configuration object
407
+ * @returns {Promise<string>} Path to written configuration file
408
+ */
409
+ async function writeWizardConfig(name, config) {
410
+ await ensureDir(ARTIFACT_DIR);
411
+ const configPath = path.join(ARTIFACT_DIR, `${name}.yaml`);
412
+ const serialized = yaml.dump(config, { lineWidth: -1, noRefs: true });
413
+ await fs.writeFile(configPath, serialized, 'utf8');
414
+ return configPath;
415
+ }
416
+
417
+ /**
418
+ * Lists files in a directory recursively
419
+ * @async
420
+ * @function listFilesRecursive
421
+ * @param {string} dirPath - Directory path
422
+ * @param {string} [prefix=''] - Prefix for file paths
423
+ * @returns {Promise<string[]>} Array of file paths
424
+ */
425
+ async function listFilesRecursive(dirPath, prefix = '') {
426
+ const files = [];
427
+ try {
428
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
429
+ for (const entry of entries) {
430
+ const fullPath = path.join(dirPath, entry.name);
431
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
432
+ if (entry.isDirectory()) {
433
+ const subFiles = await listFilesRecursive(fullPath, relativePath);
434
+ files.push(...subFiles);
435
+ } else {
436
+ files.push(relativePath);
437
+ }
438
+ }
439
+ } catch (error) {
440
+ // Ignore errors, return empty array
441
+ }
442
+ return files;
443
+ }
444
+
445
+ /**
446
+ * Checks if application directory exists
447
+ * @async
448
+ * @function checkAppDirectory
449
+ * @param {string} appPath - Application directory path
450
+ * @returns {Promise<string[]>} Directory entries
451
+ * @throws {Error} If directory doesn't exist or can't be read
452
+ */
453
+ async function checkAppDirectory(appPath) {
454
+ const dirExists = await fileExists(appPath);
455
+ if (!dirExists) {
456
+ throw new Error(`Application directory does not exist: ${appPath}. Wizard may have failed to create files.`);
457
+ }
458
+ try {
459
+ return await fs.readdir(appPath);
460
+ } catch (error) {
461
+ throw new Error(`Failed to read application directory ${appPath}: ${error.message}`);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Validates required files exist
467
+ * @async
468
+ * @function validateRequiredFiles
469
+ * @param {string} appPath - Application directory path
470
+ * @param {string[]} entries - Directory entries
471
+ * @returns {Promise<void>} Resolves when all required files are validated
472
+ * @throws {Error} If required files are missing
473
+ */
474
+ async function validateRequiredFiles(appPath, entries) {
475
+ const requiredFiles = ['variables.yaml', 'env.template', 'README.md', 'deploy.sh', 'deploy.ps1'];
476
+ const missingFiles = [];
477
+ for (const fileName of requiredFiles) {
478
+ const filePath = path.join(appPath, fileName);
479
+ const exists = await fileExists(filePath);
480
+ if (!exists) {
481
+ missingFiles.push(fileName);
482
+ }
483
+ }
484
+ if (missingFiles.length > 0) {
485
+ const allFiles = await listFilesRecursive(appPath);
486
+ throw new Error(
487
+ `Missing required files in ${appPath}: ${missingFiles.join(', ')}\n` +
488
+ 'Directory exists: true\n' +
489
+ `Files found in directory: ${entries.length > 0 ? entries.join(', ') : 'none'}\n` +
490
+ `All files (recursive): ${allFiles.length > 0 ? allFiles.join(', ') : 'none'}`
491
+ );
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Validates deploy JSON files exist
497
+ * @function validateDeployFiles
498
+ * @param {string} appPath - Application directory path
499
+ * @param {string[]} entries - Directory entries
500
+ * @returns {string[]} Deploy file names
501
+ * @throws {Error} If no deploy files found
502
+ */
503
+ function validateDeployFiles(appPath, entries) {
504
+ const deployFiles = entries.filter(file => /-deploy.*\.json$/.test(file));
505
+ if (deployFiles.length === 0) {
506
+ throw new Error(`No deploy JSON files found in: ${appPath}. Found files: ${entries.join(', ')}`);
507
+ }
508
+ return deployFiles;
509
+ }
510
+
511
+ /**
512
+ * Validates file contents syntax
513
+ * @async
514
+ * @function validateFileContents
515
+ * @param {string} appPath - Application directory path
516
+ * @param {string[]} deployFiles - Deploy file names
517
+ * @returns {Promise<void>} Resolves when all file contents are validated
518
+ * @throws {Error} If file contents are invalid
519
+ */
520
+ async function validateFileContents(appPath, deployFiles) {
521
+ try {
522
+ const variablesContent = await fs.readFile(path.join(appPath, 'variables.yaml'), 'utf8');
523
+ yaml.load(variablesContent);
524
+ } catch (error) {
525
+ throw new Error(`Invalid YAML syntax in variables.yaml: ${error.message}`);
526
+ }
527
+ for (const fileName of deployFiles) {
528
+ try {
529
+ const fileContent = await fs.readFile(path.join(appPath, fileName), 'utf8');
530
+ JSON.parse(fileContent);
531
+ } catch (error) {
532
+ throw new Error(`Invalid JSON syntax in ${fileName}: ${error.message}`);
533
+ }
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Validates generated files exist and are valid
539
+ * @async
540
+ * @function validateGeneratedFiles
541
+ * @param {string} appName - Application name
542
+ * @returns {Promise<void>} Resolves when all files are validated
543
+ * @throws {Error} If required files are missing or invalid
544
+ */
545
+ async function validateGeneratedFiles(appName) {
546
+ const appPath = path.join(process.cwd(), 'integration', appName);
547
+ const entries = await checkAppDirectory(appPath);
548
+ await validateRequiredFiles(appPath, entries);
549
+ const deployFiles = validateDeployFiles(appPath, entries);
550
+ await validateFileContents(appPath, deployFiles);
551
+ }
552
+
553
+ /**
554
+ * Captures a snapshot of external system files for comparison
555
+ * @async
556
+ * @function captureExternalSnapshot
557
+ * @param {string} appPath - Application directory path
558
+ * @returns {Promise<Object>} Snapshot of file contents keyed by path
559
+ */
560
+ async function captureExternalSnapshot(appPath) {
561
+ const variablesPath = path.join(appPath, 'variables.yaml');
562
+ const variablesContent = await fs.readFile(variablesPath, 'utf8');
563
+ const variables = yaml.load(variablesContent);
564
+
565
+ if (!variables || !variables.externalIntegration) {
566
+ throw new Error(`externalIntegration block not found in ${variablesPath}`);
567
+ }
568
+
569
+ const systemFiles = variables.externalIntegration.systems || [];
570
+ const datasourceFiles = variables.externalIntegration.dataSources || [];
571
+ const fileNames = [
572
+ 'variables.yaml',
573
+ 'env.template',
574
+ 'README.md',
575
+ ...systemFiles,
576
+ ...datasourceFiles
577
+ ];
578
+
579
+ const rbacPath = path.join(appPath, 'rbac.yml');
580
+ if (await fileExists(rbacPath)) {
581
+ fileNames.push('rbac.yml');
582
+ }
583
+
584
+ const snapshot = {};
585
+ for (const fileName of fileNames) {
586
+ const filePath = path.join(appPath, fileName);
587
+ if (!(await fileExists(filePath))) {
588
+ throw new Error(`Expected file not found: ${filePath}`);
589
+ }
590
+ const content = await fs.readFile(filePath, 'utf8');
591
+ snapshot[filePath] = normalizeFileContent(filePath, content);
592
+ }
593
+
594
+ return snapshot;
595
+ }
596
+
597
+ /**
598
+ * Compares snapshots and throws on differences
599
+ * @function compareSnapshots
600
+ * @param {Object} before - Snapshot before
601
+ * @param {Object} after - Snapshot after
602
+ * @returns {void}
603
+ */
604
+ function compareSnapshots(before, after) {
605
+ for (const [filePath, content] of Object.entries(before)) {
606
+ if (!Object.prototype.hasOwnProperty.call(after, filePath)) {
607
+ throw new Error(`File missing after split: ${filePath}`);
608
+ }
609
+ if (after[filePath] !== content) {
610
+ throw new Error(`File content mismatch after split: ${filePath}`);
611
+ }
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Checks if app name is a test app name
617
+ * @function isTestAppName
618
+ * @param {string} appName - Application name
619
+ * @returns {boolean} True if test app name
620
+ */
621
+ function isTestAppName(appName) {
622
+ return appName.startsWith('hubspot-test-');
623
+ }
624
+
625
+ /**
626
+ * Cleans up app artifacts
627
+ * @async
628
+ * @function cleanupAppArtifacts
629
+ * @param {string} appName - Application name
630
+ * @param {Object} options - Options object
631
+ * @param {boolean} options.keepArtifacts - Keep artifacts flag
632
+ * @returns {Promise<void>} Resolves when cleanup is complete
633
+ * @throws {Error} If app name is not a test app name
634
+ */
635
+ async function cleanupAppArtifacts(appName, options) {
636
+ if (options.keepArtifacts) {
637
+ return;
638
+ }
639
+ if (!isTestAppName(appName)) {
640
+ throw new Error(`Refusing to delete non-test app directory: ${appName}`);
641
+ }
642
+ const appPath = path.join(process.cwd(), 'integration', appName);
643
+ await fs.rm(appPath, { recursive: true, force: true });
644
+ }
645
+
646
+ /**
647
+ * Runs wizard command
648
+ * @async
649
+ * @function runWizard
650
+ * @param {string} configPath - Path to wizard config file
651
+ * @param {Object} context - Test context
652
+ * @param {Object} options - Options object
653
+ * @returns {Promise<Object>} Command result object
654
+ */
655
+ async function runWizard(configPath, context, options) {
656
+ const args = ['bin/aifabrix.js', 'wizard', '--config', configPath];
657
+ return await runCommand('node', args, options);
658
+ }
659
+
660
+ /**
661
+ * Builds command arguments for external system commands
662
+ * @function buildExternalCommandArgs
663
+ * @param {string} command - Command name (download, json, split-json)
664
+ * @param {string} appName - Application name
665
+ * @returns {string[]} Command arguments array
666
+ */
667
+ function buildExternalCommandArgs(command, appName) {
668
+ const args = ['bin/aifabrix.js', command, appName];
669
+ if (command !== 'download') {
670
+ args.push('--type', 'external');
671
+ }
672
+ return args;
673
+ }
674
+
675
+ /**
676
+ * Runs a command and handles errors, with special handling for "not found" cases
677
+ * @async
678
+ * @function runCommandWithErrorHandling
679
+ * @param {string} command - Command description for error messages
680
+ * @param {string[]} args - Command arguments
681
+ * @param {Object} options - Options object
682
+ * @param {string} [appName] - Application name (for skip logic)
683
+ * @returns {Promise<Object>} Command result object
684
+ * @throws {Error} If command fails (unless it's a "not found" case)
685
+ */
686
+ async function runCommandWithErrorHandling(command, args, options, appName) {
687
+ const result = await runCommand('node', args, options);
688
+ if (!result.success) {
689
+ const errorOutput = `${result.stdout}\n${result.stderr}`;
690
+ if (appName && (errorOutput.includes('not found') ||
691
+ (errorOutput.includes('External system') && errorOutput.includes('not found')))) {
692
+ logWarn(`System ${appName} not deployed, skipping download test`);
693
+ return { skipped: true };
694
+ }
695
+ throw new Error(`${command} failed: ${result.stderr || result.stdout}`);
696
+ }
697
+ return result;
698
+ }
699
+
700
+ /**
701
+ * Downloads external system and validates split workflow
702
+ * @async
703
+ * @function testDownloadAndSplit
704
+ * @param {string} appName - Application name
705
+ * @param {Object} context - Test context
706
+ * @param {Object} options - Options object
707
+ * @returns {Promise<void>} Resolves when download and split validation succeeds
708
+ */
709
+ async function testDownloadAndSplit(appName, context, options) {
710
+ logInfo(`Downloading external system ${appName}...`);
711
+ const downloadArgs = buildExternalCommandArgs('download', appName);
712
+ const downloadResult = await runCommandWithErrorHandling('Download', downloadArgs, options, appName);
713
+ if (downloadResult.skipped) {
714
+ return;
715
+ }
716
+
717
+ const appPath = path.join(process.cwd(), 'integration', appName);
718
+ const snapshotBefore = await captureExternalSnapshot(appPath);
719
+
720
+ logInfo('Generating application-schema.json...');
721
+ const jsonArgs = buildExternalCommandArgs('json', appName);
722
+ await runCommandWithErrorHandling('JSON generation', jsonArgs, options);
723
+
724
+ logInfo('Splitting application-schema.json...');
725
+ const splitArgs = buildExternalCommandArgs('split-json', appName);
726
+ await runCommandWithErrorHandling('Split', splitArgs, options);
727
+
728
+ const snapshotAfter = await captureExternalSnapshot(appPath);
729
+ compareSnapshots(snapshotBefore, snapshotAfter);
730
+ logSuccess('Download and split workflow validated.');
731
+ }
732
+
733
+ /**
734
+ * Runs wizard and validates generated files
735
+ * @async
736
+ * @function runWizardAndValidate
737
+ * @param {string} configPath - Path to wizard config file
738
+ * @param {string} appName - Application name
739
+ * @param {Object} context - Test context
740
+ * @param {Object} options - Options object
741
+ * @returns {Promise<void>} Resolves when wizard completes and files are validated
742
+ * @throws {Error} If wizard fails or validation fails
743
+ */
744
+ async function runWizardAndValidate(configPath, appName, context, options) {
745
+ const result = await runWizard(configPath, context, options);
746
+ if (!result.success) {
747
+ const errorOutput = `${result.stdout}\n${result.stderr}`;
748
+ throw new Error(`Wizard failed for ${appName}:\n${errorOutput}`);
749
+ }
750
+
751
+ // Wait a brief moment to ensure file system operations complete
752
+ await new Promise(resolve => setTimeout(resolve, 100));
753
+
754
+ // Validate files were created
755
+ if (options.verbose) {
756
+ logInfo(`Validating files for ${appName}...`);
757
+ }
758
+ await validateGeneratedFiles(appName);
759
+
760
+ if (options.verbose) {
761
+ const appPath = path.join(process.cwd(), 'integration', appName);
762
+ const entries = await fs.readdir(appPath);
763
+ logSuccess(`Files created successfully: ${entries.join(', ')}`);
764
+ }
765
+
766
+ await testDownloadAndSplit(appName, context, options);
767
+
768
+ await cleanupAppArtifacts(appName, options);
769
+ }
770
+
771
+ /**
772
+ * Runs wizard expecting failure
773
+ * @async
774
+ * @function runWizardExpectFailure
775
+ * @param {string} configPath - Path to wizard config file
776
+ * @param {Object} context - Test context
777
+ * @param {Object} options - Options object
778
+ * @param {string} [expectedMessage] - Expected error message
779
+ * @returns {Promise<void>} Resolves when wizard fails as expected
780
+ * @throws {Error} If wizard succeeds or expected message not found
781
+ */
782
+ async function runWizardExpectFailure(configPath, context, options, expectedMessage = null) {
783
+ const result = await runWizard(configPath, context, options);
784
+ if (result.success) {
785
+ throw new Error('Expected wizard to fail, but it succeeded.');
786
+ }
787
+ if (expectedMessage) {
788
+ const combined = `${result.stdout}\n${result.stderr}`;
789
+ if (!combined.includes(expectedMessage)) {
790
+ throw new Error(`Expected error message not found: ${expectedMessage}`);
791
+ }
792
+ }
793
+ }
794
+
795
+ /**
796
+ * Checks if test case matches selection criteria
797
+ * @function matchesSelection
798
+ * @param {Object} testCase - Test case object
799
+ * @param {string[]} selections - Selection criteria
800
+ * @returns {boolean} True if matches
801
+ */
802
+ function matchesSelection(testCase, selections) {
803
+ if (!selections || selections.length === 0) {
804
+ return true;
805
+ }
806
+ const lowerSelections = selections.map(item => item.toLowerCase());
807
+ const idMatch = lowerSelections.includes(testCase.id.toLowerCase());
808
+ const nameMatch = lowerSelections.some(item => testCase.name.toLowerCase().includes(item));
809
+ return idMatch || nameMatch;
810
+ }
811
+
812
+ /**
813
+ * Checks if test case matches type criteria
814
+ * @function matchesType
815
+ * @param {Object} testCase - Test case object
816
+ * @param {string[]} types - Type criteria
817
+ * @returns {boolean} True if matches
818
+ */
819
+ function matchesType(testCase, types) {
820
+ if (!types || types.length === 0) {
821
+ return true;
822
+ }
823
+ return types.map(type => type.toLowerCase()).includes(testCase.type.toLowerCase());
824
+ }
825
+
826
+ /**
827
+ * Builds positive test cases
828
+ * @function buildPositiveTestCases
829
+ * @param {Object} context - Test context
830
+ * @returns {Array<Object>} Array of positive test case objects
831
+ */
832
+ // eslint-disable-next-line max-lines-per-function, require-jsdoc
833
+ function buildPositiveTestCases(context) {
834
+ return [
835
+ {
836
+ id: '1.1',
837
+ type: 'positive',
838
+ name: 'Complete wizard flow with OpenAPI file',
839
+ run: async(options) => {
840
+ const configPath = path.join(HUBSPOT_DIR, 'wizard-hubspot-e2e.yaml');
841
+ if (!(await fileExists(configPath))) {
842
+ throw new Error(`Missing config file: ${configPath}`);
843
+ }
844
+ if (!(await fileExists(context.openapiFile))) {
845
+ throw new Error(`OpenAPI file not found: ${context.openapiFile}`);
846
+ }
847
+ ensureEnvVar('CONTROLLER_URL', context.controllerUrl);
848
+ // Try to run wizard - if dataplane discovery fails, skip the test
849
+ const result = await runWizard(configPath, context, options);
850
+ if (!result.success) {
851
+ const errorOutput = `${result.stdout}\n${result.stderr}`;
852
+ if (errorOutput.includes('Failed to discover dataplane URL') ||
853
+ errorOutput.includes('Application not found')) {
854
+ throw new SkipTestError('Dataplane service not found in environment. Deploy dataplane service to the controller.');
855
+ }
856
+ throw new Error(`Wizard failed: ${errorOutput}`);
857
+ }
858
+ await validateGeneratedFiles('hubspot-test-e2e');
859
+ await cleanupAppArtifacts('hubspot-test-e2e', options);
860
+ }
861
+ },
862
+ {
863
+ id: '1.2',
864
+ type: 'positive',
865
+ name: 'Wizard flow with known platform',
866
+ run: async(options) => {
867
+ const configPath = path.join(HUBSPOT_DIR, 'wizard-hubspot-platform.yaml');
868
+ if (!(await fileExists(configPath))) {
869
+ throw new Error(`Missing config file: ${configPath}`);
870
+ }
871
+ try {
872
+ await ensureDataplaneUrl(context, 'hubspot-test-platform');
873
+ } catch (error) {
874
+ throw new SkipTestError(`Dataplane service not found: ${error.message}`);
875
+ }
876
+ await runWizardAndValidate(configPath, 'hubspot-test-platform', context, options);
877
+ }
878
+ },
879
+ {
880
+ id: '1.6',
881
+ type: 'positive',
882
+ name: 'Wizard flow with environment variables',
883
+ run: async(options) => {
884
+ let dataplaneUrl;
885
+ try {
886
+ dataplaneUrl = await ensureDataplaneUrl(context, 'hubspot-test-env-vars');
887
+ } catch (error) {
888
+ throw new SkipTestError(`Dataplane service not found: ${error.message}`);
889
+ }
890
+ ensureEnvVar('CONTROLLER_URL', context.controllerUrl);
891
+ ensureEnvVar('DATAPLANE_URL', dataplaneUrl);
892
+ const configPath = await writeWizardConfig('wizard-hubspot-env-vars', {
893
+ appName: 'hubspot-test-env-vars',
894
+ mode: 'create-system',
895
+ source: {
896
+ type: 'openapi-file',
897
+ filePath: context.openapiFile
898
+ },
899
+ deployment: {
900
+ controller: '${CONTROLLER_URL}',
901
+ dataplane: '${DATAPLANE_URL}',
902
+ environment: context.environment
903
+ }
904
+ });
905
+ await runWizardAndValidate(configPath, 'hubspot-test-env-vars', context, options);
906
+ }
907
+ }
908
+ ];
909
+ }
910
+
911
+ /**
912
+ * Builds real-data test cases
913
+ * @function buildRealDataTestCases
914
+ * @param {Object} context - Test context
915
+ * @returns {Array<Object>} Array of real-data test cases
916
+ */
917
+ function buildRealDataTestCases(context) {
918
+ return [
919
+ {
920
+ id: '1.3',
921
+ type: 'real-data',
922
+ name: 'Wizard flow with real credential creation',
923
+ run: async(options) => {
924
+ if (!(await fileExists(context.openapiFile))) {
925
+ throw new Error(`OpenAPI file not found: ${context.openapiFile}`);
926
+ }
927
+ requireEnvVars(['HUBSPOT_CLIENT_ID', 'HUBSPOT_CLIENT_SECRET']);
928
+ ensureEnvVar('HUBSPOT_TOKEN_URL', 'https://api.hubapi.com/oauth/v1/token');
929
+ const configPath = await writeWizardConfig('wizard-hubspot-credential-real', {
930
+ appName: 'hubspot-test-credential-real',
931
+ mode: 'create-system',
932
+ source: {
933
+ type: 'openapi-file',
934
+ filePath: context.openapiFile
935
+ },
936
+ credential: {
937
+ action: 'create',
938
+ config: {
939
+ key: 'hubspot-test-cred-real',
940
+ displayName: 'HubSpot Test Credential (Real)',
941
+ type: 'OAUTH2',
942
+ config: {
943
+ tokenUrl: '${HUBSPOT_TOKEN_URL}',
944
+ clientId: '${HUBSPOT_CLIENT_ID}',
945
+ clientSecret: '${HUBSPOT_CLIENT_SECRET}',
946
+ scopes: [
947
+ 'crm.objects.companies.read',
948
+ 'crm.objects.companies.write',
949
+ 'crm.objects.contacts.read',
950
+ 'crm.objects.contacts.write'
951
+ ]
952
+ }
953
+ }
954
+ }
955
+ });
956
+ await runWizardAndValidate(configPath, 'hubspot-test-credential-real', context, options);
957
+ }
958
+ }
959
+ ];
960
+ }
961
+
962
+ /**
963
+ * Builds negative config validation test cases
964
+ * @function buildNegativeConfigTestCases
965
+ * @param {Object} context - Test context
966
+ * @returns {Array<Object>} Array of negative config test case objects
967
+ */
968
+ // eslint-disable-next-line max-lines-per-function, require-jsdoc
969
+ function buildNegativeConfigTestCases(context) {
970
+ return [
971
+ {
972
+ id: '2.1',
973
+ type: 'negative',
974
+ name: 'Invalid config missing appName',
975
+ run: async(options) => {
976
+ const configPath = await writeWizardConfig('wizard-invalid-missing-app', {
977
+ mode: 'create-system',
978
+ source: { type: 'known-platform', platform: 'hubspot' }
979
+ });
980
+ await runWizardExpectFailure(configPath, context, options, 'Missing required field: appName');
981
+ }
982
+ },
983
+ {
984
+ id: '2.2',
985
+ type: 'negative',
986
+ name: 'Invalid app name with uppercase',
987
+ run: async(options) => {
988
+ const configPath = await writeWizardConfig('wizard-invalid-app-name', {
989
+ appName: 'HubSpot-Test',
990
+ mode: 'create-system',
991
+ source: { type: 'known-platform', platform: 'hubspot' }
992
+ });
993
+ await runWizardExpectFailure(configPath, context, options, 'must match pattern');
994
+ }
995
+ },
996
+ {
997
+ id: '2.3',
998
+ type: 'negative',
999
+ name: 'Missing source block',
1000
+ run: async(options) => {
1001
+ const configPath = await writeWizardConfig('wizard-invalid-missing-source', {
1002
+ appName: 'hubspot-test-negative-missing-source',
1003
+ mode: 'create-system'
1004
+ });
1005
+ await runWizardExpectFailure(configPath, context, options, 'Missing required field: source');
1006
+ }
1007
+ },
1008
+ {
1009
+ id: '2.4',
1010
+ type: 'negative',
1011
+ name: 'Invalid source type',
1012
+ run: async(options) => {
1013
+ const configPath = await writeWizardConfig('wizard-invalid-source', {
1014
+ appName: 'hubspot-test-negative-source',
1015
+ mode: 'create-system',
1016
+ source: { type: 'invalid-type' }
1017
+ });
1018
+ await runWizardExpectFailure(configPath, context, options, 'Allowed values');
1019
+ }
1020
+ },
1021
+ {
1022
+ id: '2.5',
1023
+ type: 'negative',
1024
+ name: 'Invalid mode',
1025
+ run: async(options) => {
1026
+ const configPath = await writeWizardConfig('wizard-invalid-mode', {
1027
+ appName: 'hubspot-test-negative-mode',
1028
+ mode: 'invalid-mode',
1029
+ source: { type: 'known-platform', platform: 'hubspot' }
1030
+ });
1031
+ await runWizardExpectFailure(configPath, context, options, 'Allowed values');
1032
+ }
1033
+ },
1034
+ {
1035
+ id: '2.6',
1036
+ type: 'negative',
1037
+ name: 'Known platform missing platform',
1038
+ run: async(options) => {
1039
+ const configPath = await writeWizardConfig('wizard-invalid-known-platform', {
1040
+ appName: 'hubspot-test-negative-platform',
1041
+ mode: 'create-system',
1042
+ source: { type: 'known-platform' }
1043
+ });
1044
+ await runWizardExpectFailure(configPath, context, options, 'Missing required field: platform');
1045
+ }
1046
+ },
1047
+ {
1048
+ id: '2.7',
1049
+ type: 'negative',
1050
+ name: 'Missing OpenAPI file path',
1051
+ run: async(options) => {
1052
+ const configPath = await writeWizardConfig('wizard-invalid-openapi-file', {
1053
+ appName: 'hubspot-test-negative-openapi',
1054
+ mode: 'create-system',
1055
+ source: { type: 'openapi-file', filePath: '/tmp/does-not-exist.json' }
1056
+ });
1057
+ await runWizardExpectFailure(configPath, context, options, 'OpenAPI file not found');
1058
+ }
1059
+ },
1060
+ {
1061
+ id: '2.8',
1062
+ type: 'negative',
1063
+ name: 'OpenAPI URL missing url',
1064
+ run: async(options) => {
1065
+ const configPath = await writeWizardConfig('wizard-invalid-openapi-url', {
1066
+ appName: 'hubspot-test-negative-openapi-url',
1067
+ mode: 'create-system',
1068
+ source: { type: 'openapi-url' }
1069
+ });
1070
+ await runWizardExpectFailure(configPath, context, options, 'Missing required field: url');
1071
+ }
1072
+ }
1073
+ ];
1074
+ }
1075
+
1076
+ /**
1077
+ * Builds negative credential and datasource test cases
1078
+ * @function buildNegativeCredentialTestCases
1079
+ * @param {Object} context - Test context
1080
+ * @returns {Array<Object>} Array of negative credential test cases
1081
+ */
1082
+ function buildNegativeCredentialTestCases(context) {
1083
+ return [
1084
+ {
1085
+ id: '2.9',
1086
+ type: 'negative',
1087
+ name: 'Add datasource missing systemIdOrKey',
1088
+ run: async(options) => {
1089
+ const configPath = await writeWizardConfig('wizard-invalid-add-datasource', {
1090
+ appName: 'hubspot-test-negative-add-datasource',
1091
+ mode: 'add-datasource',
1092
+ source: { type: 'known-platform', platform: 'hubspot' }
1093
+ });
1094
+ await runWizardExpectFailure(configPath, context, options, 'systemIdOrKey');
1095
+ }
1096
+ },
1097
+ {
1098
+ id: '2.10',
1099
+ type: 'negative',
1100
+ name: 'Credential select missing credentialIdOrKey',
1101
+ run: async(options) => {
1102
+ const configPath = await writeWizardConfig('wizard-invalid-credential-select', {
1103
+ appName: 'hubspot-test-negative-credential-select',
1104
+ mode: 'create-system',
1105
+ source: { type: 'known-platform', platform: 'hubspot' },
1106
+ credential: { action: 'select' }
1107
+ });
1108
+ await runWizardExpectFailure(configPath, context, options, 'Missing required field: credentialIdOrKey');
1109
+ }
1110
+ },
1111
+ {
1112
+ id: '2.11',
1113
+ type: 'negative',
1114
+ name: 'Credential create missing config',
1115
+ run: async(options) => {
1116
+ const configPath = await writeWizardConfig('wizard-invalid-credential-create', {
1117
+ appName: 'hubspot-test-negative-credential-create',
1118
+ mode: 'create-system',
1119
+ source: { type: 'known-platform', platform: 'hubspot' },
1120
+ credential: { action: 'create' }
1121
+ });
1122
+ await runWizardExpectFailure(configPath, context, options, 'Missing required field: config');
1123
+ }
1124
+ }
1125
+ ];
1126
+ }
1127
+
1128
+ /**
1129
+ * Runs validation command and expects failure
1130
+ * @async
1131
+ * @function runValidationExpectFailure
1132
+ * @param {string} appName - Application name
1133
+ * @param {Object} context - Test context
1134
+ * @param {Object} options - Options object
1135
+ * @param {string} expectedMessage - Expected error message
1136
+ * @returns {Promise<void>} Resolves when validation fails as expected
1137
+ * @throws {Error} If validation succeeds or expected message not found
1138
+ */
1139
+ async function runValidationExpectFailure(appName, context, options, expectedMessage = null) {
1140
+ const validateArgs = [
1141
+ 'bin/aifabrix.js',
1142
+ 'validate',
1143
+ appName,
1144
+ '--type',
1145
+ 'external'
1146
+ ];
1147
+ const result = await runCommand('node', validateArgs, options);
1148
+ if (result.success) {
1149
+ throw new Error('Expected validation to fail, but it succeeded.');
1150
+ }
1151
+ if (expectedMessage) {
1152
+ const combined = `${result.stdout}\n${result.stderr}`;
1153
+ if (!combined.includes(expectedMessage)) {
1154
+ throw new Error(`Expected error message not found: ${expectedMessage}\nActual output: ${combined}`);
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Creates a system via wizard for negative testing
1161
+ * @async
1162
+ * @function createSystemForNegativeTest
1163
+ * @param {string} appName - Application name
1164
+ * @param {string} configName - Configuration name
1165
+ * @param {Object} context - Test context
1166
+ * @param {Object} options - Options object
1167
+ * @returns {Promise<string>} Application path
1168
+ * @throws {SkipTestError} If wizard fails
1169
+ */
1170
+ async function createSystemForNegativeTest(appName, configName, context, options) {
1171
+ const configPath = await writeWizardConfig(configName, {
1172
+ appName: appName,
1173
+ mode: 'create-system',
1174
+ source: { type: 'known-platform', platform: 'hubspot' }
1175
+ });
1176
+ const wizardResult = await runWizard(configPath, context, options);
1177
+ if (!wizardResult.success) {
1178
+ throw new SkipTestError(`Wizard failed to create system: ${wizardResult.stderr}`);
1179
+ }
1180
+ await new Promise(resolve => setTimeout(resolve, 200));
1181
+ return path.join(process.cwd(), 'integration', appName);
1182
+ }
1183
+
1184
+ /**
1185
+ * Corrupts system file with invalid permission referencing non-existent role
1186
+ * @async
1187
+ * @function corruptSystemFileWithInvalidRole
1188
+ * @param {string} appPath - Application path
1189
+ * @returns {Promise<void>} Resolves when file is corrupted
1190
+ */
1191
+ async function corruptSystemFileWithInvalidRole(appPath) {
1192
+ const systemFiles = await fs.readdir(appPath);
1193
+ const systemFile = systemFiles.find(f => f.includes('-system.json'));
1194
+ if (!systemFile) {
1195
+ throw new Error(`System file not found in ${appPath}`);
1196
+ }
1197
+ const systemFilePath = path.join(appPath, systemFile);
1198
+ const systemContent = await fs.readFile(systemFilePath, 'utf8');
1199
+ const systemJson = JSON.parse(systemContent);
1200
+ if (!systemJson.permissions) {
1201
+ systemJson.permissions = [];
1202
+ }
1203
+ systemJson.permissions.push({
1204
+ name: 'test:invalid',
1205
+ roles: ['non-existent-role'],
1206
+ description: 'Test permission'
1207
+ });
1208
+ await fs.writeFile(systemFilePath, JSON.stringify(systemJson, null, 2), 'utf8');
1209
+ }
1210
+
1211
+ /**
1212
+ * Corrupts rbac.yaml with invalid YAML
1213
+ * @async
1214
+ * @function corruptRbacFile
1215
+ * @param {string} appPath - Application path
1216
+ * @returns {Promise<void>} Resolves when file is corrupted
1217
+ */
1218
+ async function corruptRbacFile(appPath) {
1219
+ const rbacPath = path.join(appPath, 'rbac.yml');
1220
+ await fs.writeFile(rbacPath, 'invalid: yaml: syntax: [', 'utf8');
1221
+ }
1222
+
1223
+ /**
1224
+ * Gets first datasource file path
1225
+ * @async
1226
+ * @function getFirstDatasourcePath
1227
+ * @param {string} appPath - Application path
1228
+ * @returns {Promise<string>} Datasource file path
1229
+ * @throws {Error} If no datasource files found
1230
+ */
1231
+ async function getFirstDatasourcePath(appPath) {
1232
+ const datasourceFiles = (await fs.readdir(appPath)).filter(f => f.includes('-datasource-'));
1233
+ if (datasourceFiles.length === 0) {
1234
+ throw new Error(`No datasource files found in ${appPath}`);
1235
+ }
1236
+ return path.join(appPath, datasourceFiles[0]);
1237
+ }
1238
+
1239
+ /**
1240
+ * Corrupts datasource file by removing dimensions
1241
+ * @async
1242
+ * @function corruptDatasourceRemoveDimensions
1243
+ * @param {string} datasourcePath - Datasource file path
1244
+ * @returns {Promise<void>} Resolves when file is corrupted
1245
+ */
1246
+ async function corruptDatasourceRemoveDimensions(datasourcePath) {
1247
+ const datasourceContent = await fs.readFile(datasourcePath, 'utf8');
1248
+ const datasourceJson = JSON.parse(datasourceContent);
1249
+ if (datasourceJson.fieldMappings) {
1250
+ delete datasourceJson.fieldMappings.dimensions;
1251
+ } else {
1252
+ datasourceJson.fieldMappings = {};
1253
+ }
1254
+ await fs.writeFile(datasourcePath, JSON.stringify(datasourceJson, null, 2), 'utf8');
1255
+ }
1256
+
1257
+ /**
1258
+ * Corrupts datasource file with invalid dimension key
1259
+ * @async
1260
+ * @function corruptDatasourceInvalidDimensionKey
1261
+ * @param {string} datasourcePath - Datasource file path
1262
+ * @returns {Promise<void>} Resolves when file is corrupted
1263
+ */
1264
+ async function corruptDatasourceInvalidDimensionKey(datasourcePath) {
1265
+ const datasourceContent = await fs.readFile(datasourcePath, 'utf8');
1266
+ const datasourceJson = JSON.parse(datasourceContent);
1267
+ if (!datasourceJson.fieldMappings) {
1268
+ datasourceJson.fieldMappings = {};
1269
+ }
1270
+ if (!datasourceJson.fieldMappings.dimensions) {
1271
+ datasourceJson.fieldMappings.dimensions = {};
1272
+ }
1273
+ datasourceJson.fieldMappings.dimensions['invalid-key-with-hyphen'] = 'metadata.id';
1274
+ await fs.writeFile(datasourcePath, JSON.stringify(datasourceJson, null, 2), 'utf8');
1275
+ }
1276
+
1277
+ /**
1278
+ * Corrupts datasource file with invalid attribute path
1279
+ * @async
1280
+ * @function corruptDatasourceInvalidAttributePath
1281
+ * @param {string} datasourcePath - Datasource file path
1282
+ * @returns {Promise<void>} Resolves when file is corrupted
1283
+ */
1284
+ async function corruptDatasourceInvalidAttributePath(datasourcePath) {
1285
+ const datasourceContent = await fs.readFile(datasourcePath, 'utf8');
1286
+ const datasourceJson = JSON.parse(datasourceContent);
1287
+ if (!datasourceJson.fieldMappings) {
1288
+ datasourceJson.fieldMappings = {};
1289
+ }
1290
+ if (!datasourceJson.fieldMappings.dimensions) {
1291
+ datasourceJson.fieldMappings.dimensions = {};
1292
+ }
1293
+ datasourceJson.fieldMappings.dimensions['valid_key'] = 'metadata.id-with-invalid-chars@';
1294
+ await fs.writeFile(datasourcePath, JSON.stringify(datasourceJson, null, 2), 'utf8');
1295
+ }
1296
+
1297
+ /**
1298
+ * Corrupts datasource file with dimensions as array
1299
+ * @async
1300
+ * @function corruptDatasourceDimensionsAsArray
1301
+ * @param {string} datasourcePath - Datasource file path
1302
+ * @returns {Promise<void>} Resolves when file is corrupted
1303
+ */
1304
+ async function corruptDatasourceDimensionsAsArray(datasourcePath) {
1305
+ const datasourceContent = await fs.readFile(datasourcePath, 'utf8');
1306
+ const datasourceJson = JSON.parse(datasourceContent);
1307
+ if (!datasourceJson.fieldMappings) {
1308
+ datasourceJson.fieldMappings = {};
1309
+ }
1310
+ datasourceJson.fieldMappings.dimensions = ['invalid', 'array', 'format'];
1311
+ await fs.writeFile(datasourcePath, JSON.stringify(datasourceJson, null, 2), 'utf8');
1312
+ }
1313
+
1314
+ /**
1315
+ * Builds negative RBAC validation test cases
1316
+ * @function buildNegativeRbacTestCases
1317
+ * @param {Object} context - Test context
1318
+ * @returns {Array<Object>} Array of negative RBAC test cases
1319
+ */
1320
+ function buildNegativeRbacTestCases(context) {
1321
+ return [
1322
+ {
1323
+ id: '2.12',
1324
+ type: 'negative',
1325
+ name: 'RBAC missing role referenced in permissions',
1326
+ run: async(options) => {
1327
+ const appName = 'hubspot-test-negative-rbac-missing-role';
1328
+ const appPath = await createSystemForNegativeTest(appName, 'wizard-valid-for-rbac-test', context, options);
1329
+ await corruptSystemFileWithInvalidRole(appPath);
1330
+ await runValidationExpectFailure(appName, context, options, 'references role "non-existent-role" which does not exist');
1331
+ await cleanupAppArtifacts(appName, options);
1332
+ }
1333
+ },
1334
+ {
1335
+ id: '2.13',
1336
+ type: 'negative',
1337
+ name: 'RBAC invalid YAML syntax',
1338
+ run: async(options) => {
1339
+ const appName = 'hubspot-test-negative-rbac-invalid-yaml';
1340
+ const appPath = await createSystemForNegativeTest(appName, 'wizard-valid-for-rbac-yaml-test', context, options);
1341
+ await corruptRbacFile(appPath);
1342
+ await runValidationExpectFailure(appName, context, options, 'schema must be object or boolean');
1343
+ await cleanupAppArtifacts(appName, options);
1344
+ }
1345
+ }
1346
+ ];
1347
+ }
1348
+
1349
+ /**
1350
+ * Builds negative dimension validation test cases
1351
+ * @function buildNegativeDimensionTestCases
1352
+ * @param {Object} context - Test context
1353
+ * @returns {Array<Object>} Array of negative dimension test cases
1354
+ */
1355
+ function buildNegativeDimensionTestCases(context) {
1356
+ const createTestCase = (id, name, appName, configName, corruptFn, expectedError) => ({
1357
+ id,
1358
+ type: 'negative',
1359
+ name,
1360
+ run: async(options) => {
1361
+ const appPath = await createSystemForNegativeTest(appName, configName, context, options);
1362
+ const datasourcePath = await getFirstDatasourcePath(appPath);
1363
+ await corruptFn(datasourcePath);
1364
+ await runValidationExpectFailure(appName, context, options, expectedError);
1365
+ await cleanupAppArtifacts(appName, options);
1366
+ }
1367
+ });
1368
+
1369
+ return [
1370
+ createTestCase(
1371
+ '2.14',
1372
+ 'Datasource missing dimensions in fieldMappings',
1373
+ 'hubspot-test-negative-dimension-missing',
1374
+ 'wizard-valid-for-dimension-test',
1375
+ corruptDatasourceRemoveDimensions,
1376
+ 'Missing required property "dimensions"'
1377
+ ),
1378
+ createTestCase(
1379
+ '2.15',
1380
+ 'Datasource invalid dimension key pattern',
1381
+ 'hubspot-test-negative-dimension-invalid-key',
1382
+ 'wizard-valid-for-dimension-key-test',
1383
+ corruptDatasourceInvalidDimensionKey,
1384
+ 'Must be at most 40 characters'
1385
+ ),
1386
+ createTestCase(
1387
+ '2.16',
1388
+ 'Datasource invalid attribute path pattern',
1389
+ 'hubspot-test-negative-dimension-invalid-path',
1390
+ 'wizard-valid-for-dimension-path-test',
1391
+ corruptDatasourceInvalidAttributePath,
1392
+ 'must match pattern'
1393
+ ),
1394
+ createTestCase(
1395
+ '2.17',
1396
+ 'Datasource dimensions as array instead of object',
1397
+ 'hubspot-test-negative-dimension-array',
1398
+ 'wizard-valid-for-dimension-array-test',
1399
+ corruptDatasourceDimensionsAsArray,
1400
+ 'Expected object, got undefined'
1401
+ )
1402
+ ];
1403
+ }
1404
+
1405
+ /**
1406
+ * Builds negative test cases
1407
+ * @function buildNegativeTestCases
1408
+ * @param {Object} context - Test context
1409
+ * @returns {Array<Object>} Array of negative test cases
1410
+ */
1411
+ function buildNegativeTestCases(context) {
1412
+ return [
1413
+ ...buildNegativeConfigTestCases(context),
1414
+ ...buildNegativeCredentialTestCases(context),
1415
+ ...buildNegativeRbacTestCases(context),
1416
+ ...buildNegativeDimensionTestCases(context)
1417
+ ];
1418
+ }
1419
+
1420
+ /**
1421
+ * Builds all test cases
1422
+ * @function buildTestCases
1423
+ * @param {Object} context - Test context
1424
+ * @returns {Array<Object>} Array of all test cases
1425
+ */
1426
+ function buildTestCases(context) {
1427
+ return [
1428
+ ...buildPositiveTestCases(context),
1429
+ ...buildRealDataTestCases(context),
1430
+ ...buildNegativeTestCases(context)
1431
+ ];
1432
+ }
1433
+
1434
+ /**
1435
+ * Runs a test case
1436
+ * @async
1437
+ * @function runTestCase
1438
+ * @param {Object} testCase - Test case object
1439
+ * @param {Object} context - Test context
1440
+ * @param {Object} options - Options object
1441
+ * @returns {Promise<Object>} Test result object
1442
+ */
1443
+ async function runTestCase(testCase, context, options) {
1444
+ const start = Date.now();
1445
+ try {
1446
+ await testCase.run(options);
1447
+ const durationMs = Date.now() - start;
1448
+ logSuccess(`PASS ${testCase.id} (${durationMs}ms) - ${testCase.name}`);
1449
+ return { id: testCase.id, status: 'passed' };
1450
+ } catch (error) {
1451
+ if (error instanceof SkipTestError) {
1452
+ logWarn(`SKIP ${testCase.id} - ${error.message}`);
1453
+ return { id: testCase.id, status: 'skipped' };
1454
+ }
1455
+ logError(`FAIL ${testCase.id} - ${error.message}`);
1456
+ return { id: testCase.id, status: 'failed', error };
1457
+ }
1458
+ }
1459
+
1460
+ /**
1461
+ * Sets up test context and environment variables
1462
+ * @function setupTestContext
1463
+ * @param {Object} context - Test context object
1464
+ * @returns {void}
1465
+ */
1466
+ function setupTestContext(context) {
1467
+ ensureEnvVar('CONTROLLER_URL', context.controllerUrl);
1468
+ ensureEnvVar('ENVIRONMENT', context.environment);
1469
+ if (context.dataplaneUrl) {
1470
+ ensureEnvVar('DATAPLANE_URL', context.dataplaneUrl);
1471
+ }
1472
+ ensureEnvVar('HUBSPOT_OPENAPI_FILE', context.openapiFile);
1473
+ }
1474
+
1475
+ /**
1476
+ * Main function
1477
+ * @async
1478
+ * @function main
1479
+ * @returns {Promise<void>} Resolves when all tests complete
1480
+ */
1481
+ async function main() {
1482
+ const args = parseArgs(process.argv);
1483
+ if (args.help) {
1484
+ printUsage();
1485
+ return;
1486
+ }
1487
+ await ensureDir(ARTIFACT_DIR);
1488
+ await loadEnvFile(DEFAULT_ENV_PATH, args);
1489
+ const context = {
1490
+ controllerUrl: DEFAULT_CONTROLLER_URL,
1491
+ environment: DEFAULT_ENVIRONMENT,
1492
+ dataplaneUrl: DEFAULT_DATAPLANE_URL,
1493
+ openapiFile: DEFAULT_OPENAPI_FILE
1494
+ };
1495
+ setupTestContext(context);
1496
+ await validateAuth(context, args);
1497
+ const testCases = buildTestCases(context).filter(testCase => (
1498
+ matchesSelection(testCase, args.tests) && matchesType(testCase, args.types)
1499
+ ));
1500
+ if (testCases.length === 0) {
1501
+ logWarn('No matching test cases found.');
1502
+ return;
1503
+ }
1504
+ const results = [];
1505
+ for (const testCase of testCases) {
1506
+ results.push(await runTestCase(testCase, context, args));
1507
+ }
1508
+ const failed = results.filter(result => result.status === 'failed');
1509
+ if (failed.length > 0) {
1510
+ process.exitCode = 1;
1511
+ }
1512
+ }
1513
+
1514
+ main().catch(error => {
1515
+ logError(error.message || 'Unexpected error');
1516
+ process.exitCode = 1;
1517
+ });