ui5-test-runner 6.0.0-beta.5 → 6.0.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 (130) hide show
  1. package/README.md +20 -29
  2. package/dist/Command.js +52 -0
  3. package/dist/Npm.js +154 -74
  4. package/dist/browsers/RecoverableBrowserError.js +6 -0
  5. package/dist/browsers/chromeArguments.js +5 -0
  6. package/dist/browsers/chromeOptions.js +29 -0
  7. package/dist/browsers/consoleMessage.js +23 -0
  8. package/dist/browsers/factory.js +168 -3
  9. package/dist/browsers/networkResponse.js +15 -0
  10. package/dist/browsers/playwright.js +104 -0
  11. package/dist/browsers/puppeteer.js +54 -88
  12. package/dist/browsers/seleniumWebdriver.js +124 -0
  13. package/dist/browsers/webdriverio.js +85 -0
  14. package/dist/cli.js +18 -12
  15. package/dist/configuration/CommandLine.js +46 -26
  16. package/dist/configuration/ConfigurationValidator.js +132 -18
  17. package/dist/configuration/OptionValidationError.js +50 -2
  18. package/dist/configuration/derivations.js +8 -0
  19. package/dist/configuration/options.js +380 -23
  20. package/dist/configuration/validations.js +135 -0
  21. package/dist/configuration/validators/boolean.js +1 -1
  22. package/dist/configuration/validators/enumeration.js +7 -0
  23. package/dist/configuration/validators/fsEntry.js +12 -12
  24. package/dist/configuration/validators/index.js +8 -2
  25. package/dist/configuration/validators/integer.js +7 -1
  26. package/dist/configuration/validators/json.js +23 -0
  27. package/dist/configuration/validators/libraryMapping.js +28 -0
  28. package/dist/configuration/validators/percent.js +2 -2
  29. package/dist/configuration/validators/regexp.js +2 -2
  30. package/dist/configuration/validators/secret.js +8 -0
  31. package/dist/configuration/validators/string.js +1 -1
  32. package/dist/configuration/validators/timeout.js +2 -2
  33. package/dist/configuration/validators/url.js +3 -3
  34. package/dist/end.js +58 -0
  35. package/dist/if.js +22 -0
  36. package/dist/modes/Modes.js +2 -1
  37. package/dist/modes/batch/BatchItem.js +1 -0
  38. package/dist/modes/batch/batchTask.js +159 -0
  39. package/dist/modes/batch/index.js +98 -0
  40. package/dist/modes/batch/output.js +22 -0
  41. package/dist/modes/batch/report.js +45 -0
  42. package/dist/modes/batch/resolve.js +105 -0
  43. package/dist/modes/dumpConfig.js +6 -0
  44. package/dist/modes/execute.js +6 -5
  45. package/dist/modes/help.js +86 -2
  46. package/dist/modes/log/LogReader.js +3 -2
  47. package/dist/modes/log/LogStorage.js +31 -27
  48. package/dist/modes/log/index.js +38 -6
  49. package/dist/modes/log/{REserve.js → reserve.js} +25 -2
  50. package/dist/modes/mcp/IDocument.js +2 -0
  51. package/dist/modes/mcp/frontmatter.js +142 -0
  52. package/dist/modes/mcp/index.js +49 -0
  53. package/dist/modes/mcp/knowledgeBase.js +104 -0
  54. package/dist/modes/mcp/remoteKnowledgeBase.js +79 -0
  55. package/dist/modes/mcp/reserve.js +175 -0
  56. package/dist/modes/mcp/tools/getTopic.js +46 -0
  57. package/dist/modes/mcp/tools/index.js +6 -0
  58. package/dist/modes/mcp/tools/listTopics.js +14 -0
  59. package/dist/modes/test/PageContext.js +1 -0
  60. package/dist/modes/test/Server.js +99 -0
  61. package/dist/modes/test/browser.js +31 -17
  62. package/dist/modes/test/browserConfig.js +17 -0
  63. package/dist/modes/test/coverage/collect.js +38 -0
  64. package/dist/modes/test/coverage/index.js +3 -0
  65. package/dist/modes/test/coverage/instrument.js +70 -0
  66. package/dist/modes/test/coverage/nyc.js +15 -0
  67. package/dist/modes/test/coverage/report.js +71 -0
  68. package/dist/modes/test/coverage/settings.js +53 -0
  69. package/dist/modes/test/index.js +94 -34
  70. package/dist/modes/test/pageTask.js +293 -98
  71. package/dist/modes/test/report.js +20 -3
  72. package/dist/modes/test/reserve.js +76 -0
  73. package/dist/modes/test/screenshot.js +49 -0
  74. package/dist/platform/Crypto.js +9 -0
  75. package/dist/platform/Exit.js +33 -24
  76. package/dist/platform/FileSystem.js +6 -2
  77. package/dist/platform/Host.js +9 -1
  78. package/dist/platform/Http.js +54 -29
  79. package/dist/platform/Module.js +2 -1
  80. package/dist/platform/Path.js +5 -1
  81. package/dist/platform/Process.js +67 -13
  82. package/dist/platform/Terminal.js +12 -8
  83. package/dist/platform/Thread.js +1 -1
  84. package/dist/platform/assert.js +13 -12
  85. package/dist/platform/environment.js +9 -18
  86. package/dist/platform/index.js +1 -0
  87. package/dist/platform/logger/CompressionContext.js +117 -0
  88. package/dist/platform/logger/allCompressed.js +8 -7
  89. package/dist/platform/logger/compress.js +29 -129
  90. package/dist/platform/logger/output/BaseLoggerOutput.js +49 -31
  91. package/dist/platform/logger/output/InteractiveLoggerOutput.js +1 -1
  92. package/dist/platform/logger/output/LlmLoggerOutput.js +29 -0
  93. package/dist/platform/logger/output/factory.js +5 -1
  94. package/dist/platform/logger/output.js +24 -6
  95. package/dist/platform/logger/toInternalLogAttributes.js +10 -11
  96. package/dist/platform/logger/traceFileName.js +1 -0
  97. package/dist/platform/logger.js +52 -21
  98. package/dist/platform/mock.js +110 -19
  99. package/dist/platform/version.js +1 -2
  100. package/dist/platform/workerBootstrap.js +1 -1
  101. package/dist/reports/initReportBuilder.js +18 -0
  102. package/dist/reports/junit.js +64 -0
  103. package/dist/reports/saveReport.js +10 -0
  104. package/dist/reports/textReport.js +92 -0
  105. package/dist/reserveLogger.js +22 -0
  106. package/dist/sendToParentProcess.js +6 -0
  107. package/dist/start.js +79 -0
  108. package/dist/types/ISecretValue.js +1 -0
  109. package/dist/types/SecretValue.js +15 -0
  110. package/dist/ui/agent.js +4 -2
  111. package/dist/ui/html-report.js +2 -2
  112. package/dist/ui/lib.js +1 -1
  113. package/dist/ui/log-viewer.js +1 -1
  114. package/dist/utils/node/Folder.js +2 -4
  115. package/dist/utils/node/FramedStreamReader.js +4 -5
  116. package/dist/utils/node/FramedStreamWriter.js +6 -5
  117. package/dist/utils/node/anonymize.js +11 -0
  118. package/dist/utils/node/timeout.js +11 -0
  119. package/dist/utils/shared/ProgressBar.js +1 -2
  120. package/dist/utils/shared/TestReportBuilder.js +11 -7
  121. package/dist/utils/shared/host.js +11 -0
  122. package/dist/utils/shared/object.js +12 -3
  123. package/dist/utils/shared/schema.js +20 -0
  124. package/dist/utils/shared/string.js +1 -0
  125. package/dist/utils/shared/toIError.js +3 -7
  126. package/dist/utils/shared/version.js +52 -0
  127. package/package.json +2 -78
  128. package/dist/configuration/validators/browser.js +0 -11
  129. package/dist/modes/test/REserve.js +0 -31
  130. package/dist/modes/test/server.js +0 -109
@@ -0,0 +1,85 @@
1
+ import { logger, Path } from '../platform/index.js';
2
+ import { Npm } from '../Npm.js';
3
+ import { handleConsoleMessage } from './consoleMessage.js';
4
+ import { handleNetworkResponse } from './networkResponse.js';
5
+ import { buildChromeLaunchOptions } from './chromeOptions.js';
6
+ export const descriptor = {
7
+ supportedBrowsers: ['chrome', 'firefox', 'edge', 'safari'],
8
+ defaultBrowser: 'chrome',
9
+ screenshotFormat: '.png'
10
+ };
11
+ const headerValue = (value) => typeof value === 'string' ? value : (value?.value ?? '');
12
+ const headersToObject = (headers) => Object.fromEntries((headers ?? []).map(({ name, value }) => [name.toLowerCase(), headerValue(value)]));
13
+ export const factory = async (configuration) => {
14
+ const webdriverio = await Npm.import(configuration, 'webdriverio');
15
+ const { remote } = webdriverio;
16
+ let browser;
17
+ const contextToPageId = new Map();
18
+ return {
19
+ async setup(settings) {
20
+ logger.debug({ source: 'webdriverio', message: 'launching browser' });
21
+ process.env['WDIO_LOG_PATH'] = Path.join(configuration.reportDir, 'wdio.log');
22
+ browser = await remote({
23
+ capabilities: {
24
+ browserName: 'chrome',
25
+ webSocketUrl: true,
26
+ 'goog:chromeOptions': buildChromeLaunchOptions(settings.options, [
27
+ ...(settings.visible ? [] : ['--headless']),
28
+ '--start-maximized',
29
+ ...(settings.viewport ? [`--window-size=${settings.viewport.width},${settings.viewport.height}`] : [])
30
+ ])
31
+ }
32
+ });
33
+ browser.on('log.entryAdded', (entry) => {
34
+ const event = entry;
35
+ if (event.type !== 'console') {
36
+ return;
37
+ }
38
+ const pageId = (event.source?.context === undefined ? undefined : contextToPageId.get(event.source.context)) ?? -1;
39
+ handleConsoleMessage(event.method ?? 'log', event.text ?? '', pageId);
40
+ });
41
+ await browser.sessionSubscribe({ events: ['network.responseStarted'] });
42
+ browser.on('network.responseStarted', (entry) => {
43
+ const event = entry;
44
+ const pageId = (event.context === null ? undefined : contextToPageId.get(event.context)) ?? -1;
45
+ handleNetworkResponse(pageId, event.request.url, event.request.method, event.response.status, headersToObject(event.request.headers), headersToObject(event.response.headers));
46
+ });
47
+ return {
48
+ browserName: browser.capabilities.browserName ?? 'chrome',
49
+ browserVersion: browser.capabilities.browserVersion ?? ''
50
+ };
51
+ },
52
+ async newWindow(settings) {
53
+ const { pageId, scripts, url } = settings;
54
+ const { context } = await browser.browsingContextCreate({ type: 'tab', background: true });
55
+ contextToPageId.set(context, pageId);
56
+ for (const script of scripts) {
57
+ await browser.scriptAddPreloadScript({
58
+ functionDeclaration: `() => { ${script} }`,
59
+ contexts: [context]
60
+ });
61
+ }
62
+ await browser.browsingContextNavigate({ context, url, wait: 'interactive' });
63
+ const switchTo = () => browser.switchToWindow(context);
64
+ return {
65
+ async eval(script) {
66
+ await switchTo();
67
+ return browser.execute(`return (${script})`);
68
+ },
69
+ async screenshot(path) {
70
+ await switchTo();
71
+ await browser.saveScreenshot(path);
72
+ },
73
+ async close() {
74
+ contextToPageId.delete(context);
75
+ await browser.browsingContextClose({ context });
76
+ }
77
+ };
78
+ },
79
+ async shutdown() {
80
+ await new Promise((resolve) => setTimeout(resolve, 200));
81
+ await browser?.deleteSession();
82
+ delete process.env['WDIO_LOG_PATH'];
83
+ }
84
+ };
85
+ };
package/dist/cli.js CHANGED
@@ -3,18 +3,24 @@ import { Exit, Host } from './platform/index.js';
3
3
  import { version } from './platform/version.js';
4
4
  import { CommandLine } from './configuration/CommandLine.js';
5
5
  import { execute } from './modes/execute.js';
6
- const main = async () => {
7
- const cliVersion = await version();
8
- const cliName = cliVersion.split('@')[0] ?? 'ui5-test-runner';
9
- const indexOfCli = process.argv.findIndex((value) => /[\\/]cli(\.[tj]s)?$/.exec(value) || value.endsWith(cliName));
10
- const configuration = await CommandLine.buildConfigurationFrom(Host.cwd(), process.argv.slice(indexOfCli + 1));
11
- await execute(configuration);
12
- };
13
- main()
14
- .catch((error) => {
6
+ import { isIfEvaluatedAsTrue } from './if.js';
7
+ import { sendToParentProcess } from './sendToParentProcess.js';
8
+ try {
9
+ const { name: cliName } = await version();
10
+ const indexOfCli = Host.argv.findIndex((value) => /[\\/]cli(\.[tj]s)?$/.exec(value) || value.endsWith(cliName));
11
+ const configuration = await CommandLine.buildConfigurationFrom(Host.cwd(), indexOfCli === -1 ? [] : Host.argv.slice(indexOfCli + 1));
12
+ if (await isIfEvaluatedAsTrue(configuration)) {
13
+ await execute(configuration);
14
+ }
15
+ else {
16
+ console.log(String.raw `/!\ Skipping execution (--if)`);
17
+ sendToParentProcess({ type: 'skip' });
18
+ }
19
+ }
20
+ catch (error) {
15
21
  console.error(error);
16
22
  Exit.code = -1;
17
- })
18
- .finally(async () => {
23
+ }
24
+ finally {
19
25
  await Exit.shutdown();
20
- });
26
+ }
@@ -1,7 +1,8 @@
1
1
  import { indexedOptions } from './indexedOptions.js';
2
2
  import { ConfigurationValidator } from './ConfigurationValidator.js';
3
3
  import { OptionValidationError } from './OptionValidationError.js';
4
- import { looksLikeAnUrl } from './validators/url.js';
4
+ import { isLikeAnUrl } from './validators/url.js';
5
+ import { assert, Host } from '../platform/index.js';
5
6
  const setOption = (configuration, option, value) => {
6
7
  const name = option.name;
7
8
  if (value === undefined) {
@@ -11,12 +12,12 @@ const setOption = (configuration, option, value) => {
11
12
  });
12
13
  }
13
14
  else if (option.multiple !== true) {
14
- configuration.errors.push(new OptionValidationError(option, 'Missing value'));
15
+ configuration.errors.push(OptionValidationError.createMissingValue(option));
15
16
  }
16
17
  }
17
18
  else {
18
19
  if (option.multiple) {
19
- if (!(option.name in configuration)) {
20
+ if (!Object.hasOwn(configuration, option.name)) {
20
21
  Object.assign(configuration, {
21
22
  [name]: []
22
23
  });
@@ -35,10 +36,13 @@ const switchOption = (configuration, currentOption, name) => {
35
36
  setOption(configuration, currentOption);
36
37
  }
37
38
  const option = indexedOptions[name];
38
- if (!option) {
39
- throw OptionValidationError.createUnknown(name);
39
+ if (option) {
40
+ return option;
40
41
  }
41
- return option;
42
+ if (Host.env['IGNORE_UNKNOWN_OPTION']) {
43
+ return undefined;
44
+ }
45
+ throw OptionValidationError.createUnknown(name);
42
46
  };
43
47
  const positionalOption = {
44
48
  name: 'positional',
@@ -46,29 +50,38 @@ const positionalOption = {
46
50
  type: 'string'
47
51
  };
48
52
  const handlePositional = (configuration, value) => {
49
- if (looksLikeAnUrl(value)) {
53
+ if (isLikeAnUrl(value)) {
50
54
  setOption(configuration, indexedOptions.url, value);
51
55
  return;
52
56
  }
53
- const shortcuts = ['capabilities', 'version', 'help'];
57
+ const shortcuts = ['version', 'help'];
54
58
  for (const shortcut of shortcuts) {
55
59
  if (value === shortcut) {
56
60
  setOption(configuration, indexedOptions[shortcut]);
57
61
  return;
58
62
  }
59
63
  }
60
- configuration.errors.push(new OptionValidationError(positionalOption, `Unable to process: ${value}`));
64
+ configuration.errors.push(OptionValidationError.createUnprocessable(positionalOption, value));
65
+ };
66
+ const NEGATIVE_NUMBER = /^-\d+$|^-\d*\.\d+$/;
67
+ const switchOptionFromToken = (configuration, currentOption, argument) => {
68
+ if (argument.startsWith('--')) {
69
+ return { option: switchOption(configuration, currentOption, argument.slice(2)) };
70
+ }
71
+ return argument.startsWith('-')
72
+ ? { option: switchOption(configuration, currentOption, argument.slice(1)) }
73
+ : undefined;
61
74
  };
62
75
  const traverseArguments = (configuration, argv) => {
63
76
  let currentOption;
64
77
  for (const argument of argv) {
65
- if (argument.startsWith('--')) {
66
- currentOption = switchOption(configuration, currentOption, argument.slice(2));
67
- continue;
68
- }
69
- if (argument.startsWith('-')) {
70
- currentOption = switchOption(configuration, currentOption, argument.slice(1));
71
- continue;
78
+ const isValueForCurrentOption = currentOption !== undefined && NEGATIVE_NUMBER.test(argument);
79
+ if (!isValueForCurrentOption) {
80
+ const switched = switchOptionFromToken(configuration, currentOption, argument);
81
+ if (switched) {
82
+ currentOption = switched.option;
83
+ continue;
84
+ }
72
85
  }
73
86
  if (currentOption === undefined) {
74
87
  handlePositional(configuration, argument);
@@ -86,26 +99,33 @@ const traverseArguments = (configuration, argv) => {
86
99
  };
87
100
  export const CommandLine = {
88
101
  async buildConfigurationFrom(cwd, argv) {
89
- const configuration = { cwd, errors: [] };
102
+ const configuration = Object.create({ cwd, errors: [] });
90
103
  traverseArguments(configuration, argv);
91
- const { errors, ...configWithoutErrors } = configuration;
104
+ const cliKeys = new Set(Object.keys(configuration));
92
105
  let validatedConfiguration;
93
106
  try {
94
- validatedConfiguration = await ConfigurationValidator.validate(configWithoutErrors);
107
+ validatedConfiguration = await ConfigurationValidator.validate(configuration);
95
108
  }
96
109
  catch (error) {
97
- if (errors.length > 0 && error instanceof AggregateError) {
98
- errors.push(...error.errors);
110
+ if (configuration.errors.length > 0 && error instanceof AggregateError) {
111
+ configuration.errors.push(...error.errors);
99
112
  }
100
113
  else {
101
- errors.push(error);
114
+ configuration.errors.push(error);
102
115
  }
103
116
  }
104
- if (errors.length === 1) {
105
- throw errors[0];
117
+ if (configuration.errors.length === 1) {
118
+ throw configuration.errors[0];
119
+ }
120
+ if (configuration.errors.length > 0) {
121
+ throw new AggregateError(configuration.errors, 'Multiple errors occurred');
106
122
  }
107
- else if (errors.length > 0) {
108
- throw new AggregateError(errors, 'Multiple errors occurred');
123
+ assert(validatedConfiguration !== undefined);
124
+ validatedConfiguration.sources = {
125
+ ...validatedConfiguration.sources
126
+ };
127
+ for (const key of cliKeys) {
128
+ validatedConfiguration.sources[key] = 'cli';
109
129
  }
110
130
  return validatedConfiguration;
111
131
  }
@@ -2,20 +2,24 @@ import { options, defaults } from './options.js';
2
2
  import { indexedOptions } from './indexedOptions.js';
3
3
  import { validators } from './validators/index.js';
4
4
  import { OptionValidationError } from './OptionValidationError.js';
5
+ import { validations } from './validations.js';
6
+ import { derivations } from './derivations.js';
5
7
  import { Modes } from '../modes/Modes.js';
8
+ import { assert, FileSystem, Host, Path } from '../platform/index.js';
9
+ const MAX_CONFIG_DEPTH = 10;
6
10
  const assertIfConfiguration = (value) => {
7
11
  const errors = [];
8
12
  for (const key of Object.keys(value)) {
9
13
  const option = indexedOptions[key];
10
14
  if (option) {
11
15
  if (key === option.short) {
12
- errors.push(new OptionValidationError(option, 'Do not use short name'));
16
+ errors.push(OptionValidationError.createShortName(option));
13
17
  }
14
18
  else if (key !== option.name) {
15
- errors.push(new OptionValidationError(option, 'Do not use kebab-case'));
19
+ errors.push(OptionValidationError.createKebabCase(option));
16
20
  }
17
21
  }
18
- else {
22
+ else if (!Host.env['IGNORE_UNKNOWN_OPTION']) {
19
23
  errors.push(OptionValidationError.createUnknown(key));
20
24
  }
21
25
  }
@@ -27,25 +31,123 @@ const assertIfConfiguration = (value) => {
27
31
  throw error;
28
32
  }
29
33
  };
34
+ const callValidator = (option, value, configuration) => validators[option.type](option, value, configuration);
30
35
  const validateValue = async (option, configuration) => {
31
36
  const value = configuration[option.name];
32
37
  if ('multiple' in option) {
33
38
  const validatedValues = [];
34
39
  if (Array.isArray(value)) {
35
40
  for (const valueItem of value) {
36
- validatedValues.push(await validators[option.type](option, valueItem, configuration));
41
+ validatedValues.push(await callValidator(option, valueItem, configuration));
37
42
  }
38
43
  }
39
44
  else {
40
- validatedValues.push(await validators[option.type](option, value, configuration));
45
+ validatedValues.push(await callValidator(option, value, configuration));
41
46
  }
42
47
  return validatedValues;
43
48
  }
44
- return await validators[option.type](option, value, configuration);
49
+ return await callValidator(option, value, configuration);
50
+ };
51
+ const loadConfigFile = async (configPath) => {
52
+ let content;
53
+ try {
54
+ content = await FileSystem.readFile(configPath, 'utf8');
55
+ }
56
+ catch (error) {
57
+ throw OptionValidationError.createConfigReadError(indexedOptions.config, configPath, error);
58
+ }
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(content);
62
+ }
63
+ catch (error) {
64
+ throw OptionValidationError.createConfigInvalidJson(indexedOptions.config, configPath, error);
65
+ }
66
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
67
+ throw OptionValidationError.createConfigNotObject(indexedOptions.config, configPath);
68
+ }
69
+ return parsed;
70
+ };
71
+ const extractConfigEntries = (parsed, configDirectory) => {
72
+ const forcedKeys = new Set();
73
+ const configFileKeys = new Set();
74
+ const configFileObject = {};
75
+ for (const [rawKey, value] of Object.entries(parsed)) {
76
+ const key = rawKey.startsWith('!') ? rawKey.slice(1) : rawKey;
77
+ if (rawKey.startsWith('!')) {
78
+ forcedKeys.add(key);
79
+ }
80
+ configFileObject[key] = value;
81
+ configFileKeys.add(key);
82
+ }
83
+ if (configFileKeys.has('cwd') && typeof configFileObject['cwd'] === 'string') {
84
+ const rawCwd = configFileObject['cwd'];
85
+ if (!Path.isAbsolute(rawCwd)) {
86
+ configFileObject['cwd'] = Path.join(configDirectory, rawCwd);
87
+ }
88
+ }
89
+ else {
90
+ configFileObject['cwd'] = configDirectory;
91
+ }
92
+ return { forcedKeys, configFileKeys, configFileObject };
93
+ };
94
+ const finalizeConfiguration = async (merged) => {
95
+ const hasExplicitReportDirectory = Object.hasOwn(merged, 'reportDir');
96
+ for (const option of options) {
97
+ if (!Object.hasOwn(merged, option.name) && merged[option.name] && option.type === 'fs-entry') {
98
+ Object.assign(merged, {
99
+ [option.name]: await validateValue(option, merged)
100
+ });
101
+ }
102
+ }
103
+ const batchReportDirectory = Host.env['UI5TR_BATCH_REPORT_DIR'];
104
+ if (batchReportDirectory && !hasExplicitReportDirectory) {
105
+ const reportDirectoryOption = options.find((option) => option.name === 'reportDir');
106
+ assert(reportDirectoryOption !== undefined);
107
+ Object.assign(merged, {
108
+ reportDir: await validateValue(reportDirectoryOption, { ...merged, reportDir: batchReportDirectory })
109
+ });
110
+ }
111
+ for (const option of options) {
112
+ if (!(option.type === 'fs-entry' &&
113
+ typeof merged[option.name] === 'string' &&
114
+ merged[option.name].includes('{{reportDir}}'))) {
115
+ continue;
116
+ }
117
+ const substituted = Path.join(merged.reportDir, merged[option.name].split('{{reportDir}}', 2)[1]);
118
+ Object.assign(merged, { [option.name]: await validateValue(option, { ...merged, [option.name]: substituted }) });
119
+ }
120
+ for (const derive of derivations) {
121
+ derive(merged);
122
+ }
123
+ merged.mode = ConfigurationValidator.computeMode(merged);
124
+ for (const validate of validations) {
125
+ validate(merged);
126
+ }
45
127
  };
46
128
  export const ConfigurationValidator = {
47
- merge(configuration) {
48
- return Promise.resolve(configuration);
129
+ async merge(configuration, explicitKeys, depth) {
130
+ if (!configuration.config || !Path.isAbsolute(configuration.config)) {
131
+ return configuration;
132
+ }
133
+ if (depth >= MAX_CONFIG_DEPTH) {
134
+ throw OptionValidationError.createConfigNestingDepth(indexedOptions.config, MAX_CONFIG_DEPTH);
135
+ }
136
+ const parsed = await loadConfigFile(configuration.config);
137
+ const configDirectory = Path.dirname(configuration.config);
138
+ const { forcedKeys, configFileKeys, configFileObject } = extractConfigEntries(parsed, configDirectory);
139
+ const fileConfig = await this.validate(configFileObject, depth + 1);
140
+ for (const [key, value] of Object.entries(fileConfig)) {
141
+ if (key === 'mode' ||
142
+ key === 'config' ||
143
+ (key === 'cwd' && !configFileKeys.has('cwd')) ||
144
+ (!forcedKeys.has(key) && explicitKeys.has(key))) {
145
+ continue;
146
+ }
147
+ Object.assign(configuration, { [key]: value });
148
+ configuration.sources[key] = 'config';
149
+ }
150
+ return configuration;
49
151
  },
50
152
  computeMode(configuration) {
51
153
  if (configuration.help) {
@@ -54,26 +156,38 @@ export const ConfigurationValidator = {
54
156
  if (configuration.version) {
55
157
  return Modes.version;
56
158
  }
159
+ if (configuration.dumpConfig) {
160
+ return Modes.dumpConfig;
161
+ }
162
+ if (configuration.mcp) {
163
+ return Modes.mcp;
164
+ }
165
+ if (configuration.batch) {
166
+ return Modes.batch;
167
+ }
57
168
  if (configuration.log) {
58
169
  return Modes.log;
59
170
  }
60
- if (configuration.url) {
61
- return Modes.remote;
62
- }
63
- return Modes.legacy;
171
+ return configuration.url ? Modes.remote : Modes.legacy;
64
172
  },
65
- async validate(configuration) {
173
+ async validate(configuration, depth = 0) {
66
174
  const withDefaults = Object.assign(Object.create(defaults), configuration);
67
175
  assertIfConfiguration(withDefaults);
68
- const merged = await this.merge(withDefaults);
69
- merged.mode = this.computeMode(merged);
176
+ const explicitKeys = new Set(Object.keys(withDefaults));
70
177
  for (const option of options) {
71
- if (Object.hasOwnProperty.call(merged, option.name) || (merged[option.name] && option.type === 'fs-entry')) {
72
- Object.assign(merged, {
73
- [option.name]: await validateValue(option, merged)
178
+ if (Object.hasOwn(withDefaults, option.name)) {
179
+ Object.assign(withDefaults, {
180
+ [option.name]: await validateValue(option, withDefaults)
74
181
  });
75
182
  }
76
183
  }
184
+ if (!Object.hasOwn(withDefaults, 'sources')) {
185
+ Object.assign(withDefaults, { sources: {} });
186
+ }
187
+ const merged = await this.merge(withDefaults, explicitKeys, depth);
188
+ if (depth === 0) {
189
+ await finalizeConfiguration(merged);
190
+ }
77
191
  return merged;
78
192
  }
79
193
  };
@@ -1,9 +1,57 @@
1
1
  export class OptionValidationError extends Error {
2
+ static createInvalidValue(option) {
3
+ return new OptionValidationError(option, `Invalid value for ${option.name}`);
4
+ }
2
5
  static createUnknown(name) {
3
- return new OptionValidationError({ name, type: 'string', description: 'unknown' }, 'Unknown option');
6
+ return new OptionValidationError({ name, type: 'string', description: 'unknown' }, `Unknown option ${name}`);
7
+ }
8
+ static createShortName(option) {
9
+ return new OptionValidationError(option, 'Do not use short name');
10
+ }
11
+ static createKebabCase(option) {
12
+ return new OptionValidationError(option, 'Do not use kebab-case');
13
+ }
14
+ static createMissingValue(option) {
15
+ return new OptionValidationError(option, `Missing value for ${option.name}`);
16
+ }
17
+ static createUnprocessable(option, value) {
18
+ return new OptionValidationError(option, `Unable to process: ${value}`);
19
+ }
20
+ static createInvalidRegexp(option, cause) {
21
+ return new OptionValidationError(option, `Invalid regexp set to ${option.name}`, cause);
22
+ }
23
+ static createFsAccessError(option, cause) {
24
+ return new OptionValidationError(option, 'Unable to access file system entry', cause);
25
+ }
26
+ static createFsNotFolder(option) {
27
+ return new OptionValidationError(option, 'Not a folder');
28
+ }
29
+ static createFsNotFile(option) {
30
+ return new OptionValidationError(option, 'Not a file');
31
+ }
32
+ static createFsCheckTypeError(option, cause) {
33
+ return new OptionValidationError(option, 'Unable to check type', cause);
34
+ }
35
+ static createFsExpectedString(option) {
36
+ return new OptionValidationError(option, 'Expected string');
37
+ }
38
+ static createConfigNestingDepth(option, maxDepth) {
39
+ return new OptionValidationError(option, `config file nesting exceeded maximum depth of ${maxDepth}`);
40
+ }
41
+ static createConfigReadError(option, path, cause) {
42
+ return new OptionValidationError(option, `cannot read config file ${path}`, cause);
43
+ }
44
+ static createConfigInvalidJson(option, path, cause) {
45
+ return new OptionValidationError(option, `config file ${path} is not valid JSON`, cause);
46
+ }
47
+ static createConfigNotObject(option, path) {
48
+ return new OptionValidationError(option, `config file ${path} must be a JSON object`);
49
+ }
50
+ static createValidationError(option, message) {
51
+ return new OptionValidationError(option, `invalid use of ${option.name}: ${message}`);
4
52
  }
5
53
  _option;
6
- constructor(option, message = 'Invalid value', cause) {
54
+ constructor(option, message, cause) {
7
55
  super(message);
8
56
  this._option = option;
9
57
  this.name = 'OptionValidationError';
@@ -0,0 +1,8 @@
1
+ import { punyexpr } from 'punyexpr';
2
+ export const derivations = [
3
+ (configuration) => {
4
+ if (punyexpr('llm')(configuration)) {
5
+ Object.assign(configuration, { ci: true, noBanner: true });
6
+ }
7
+ }
8
+ ];