ui5-test-runner 6.0.0-beta.4 → 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 (131) hide show
  1. package/README.md +20 -29
  2. package/dist/Command.js +52 -0
  3. package/dist/Npm.js +193 -43
  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 +169 -4
  9. package/dist/browsers/networkResponse.js +15 -0
  10. package/dist/browsers/playwright.js +104 -0
  11. package/dist/browsers/puppeteer.js +55 -89
  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 +392 -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 +39 -7
  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 +5 -0
  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/Url.js +4 -0
  85. package/dist/platform/assert.js +13 -12
  86. package/dist/platform/environment.js +9 -18
  87. package/dist/platform/index.js +3 -0
  88. package/dist/platform/logger/CompressionContext.js +117 -0
  89. package/dist/platform/logger/allCompressed.js +8 -7
  90. package/dist/platform/logger/compress.js +29 -129
  91. package/dist/platform/logger/output/BaseLoggerOutput.js +49 -31
  92. package/dist/platform/logger/output/InteractiveLoggerOutput.js +1 -1
  93. package/dist/platform/logger/output/LlmLoggerOutput.js +29 -0
  94. package/dist/platform/logger/output/factory.js +5 -1
  95. package/dist/platform/logger/output.js +24 -6
  96. package/dist/platform/logger/toInternalLogAttributes.js +10 -11
  97. package/dist/platform/logger/traceFileName.js +1 -0
  98. package/dist/platform/logger.js +52 -21
  99. package/dist/platform/mock.js +124 -27
  100. package/dist/platform/version.js +1 -2
  101. package/dist/platform/workerBootstrap.js +1 -1
  102. package/dist/reports/initReportBuilder.js +18 -0
  103. package/dist/reports/junit.js +64 -0
  104. package/dist/reports/saveReport.js +10 -0
  105. package/dist/reports/textReport.js +92 -0
  106. package/dist/reserveLogger.js +22 -0
  107. package/dist/sendToParentProcess.js +6 -0
  108. package/dist/start.js +79 -0
  109. package/dist/types/ISecretValue.js +1 -0
  110. package/dist/types/SecretValue.js +15 -0
  111. package/dist/ui/agent.js +4 -2
  112. package/dist/ui/html-report.js +2 -2
  113. package/dist/ui/lib.js +1 -1
  114. package/dist/ui/log-viewer.js +1 -1
  115. package/dist/utils/node/Folder.js +2 -4
  116. package/dist/utils/node/FramedStreamReader.js +4 -5
  117. package/dist/utils/node/FramedStreamWriter.js +6 -5
  118. package/dist/utils/node/anonymize.js +11 -0
  119. package/dist/utils/node/timeout.js +11 -0
  120. package/dist/utils/shared/ProgressBar.js +1 -2
  121. package/dist/utils/shared/TestReportBuilder.js +11 -7
  122. package/dist/utils/shared/host.js +11 -0
  123. package/dist/utils/shared/object.js +12 -3
  124. package/dist/utils/shared/schema.js +20 -0
  125. package/dist/utils/shared/string.js +1 -0
  126. package/dist/utils/shared/toIError.js +3 -7
  127. package/dist/utils/shared/version.js +52 -0
  128. package/package.json +2 -78
  129. package/dist/configuration/validators/browser.js +0 -11
  130. package/dist/modes/test/REserve.js +0 -31
  131. package/dist/modes/test/server.js +0 -109
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # UI5 Test runner 6️⃣ β
1
+ # UI5 Test runner
2
2
 
3
3
  [![Node.js CI](https://github.com/ArnaudBuchholz/ui5-test-runner/actions/workflows/node.js.yml/badge.svg)](https://github.com/ArnaudBuchholz/ui5-test-runner/actions/workflows/node.js.yml)
4
4
  [![Package Quality](https://npm.packagequality.com/shield/ui5-test-runner.svg)](https://packagequality.com/#?package=ui5-test-runner)
@@ -6,48 +6,39 @@
6
6
  [![ui5-test-runner](https://badge.fury.io/js/ui5-test-runner.svg)](https://www.npmjs.org/package/ui5-test-runner)
7
7
  [![PackagePhobia](https://img.shields.io/badge/%F0%9F%93%A6package-phobia-lightgrey)](https://packagephobia.com/result?p=ui5-test-runner)
8
8
  [![MIT License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
- [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FArnaudBuchholz%2Fui5-test-runner.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2FArnaudBuchholz%2Fui5-test-runner?ref=badge_shield)
10
- [![Documentation](https://img.shields.io/badge/-%F0%9F%93%9Adocumentation-blueviolet)](https://arnaudbuchholz.github.io/ui5-test-runner/)
9
+ [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FArnaudBuchholz%2Fui5-test-runner.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2FArnaudBuchholz%2Fui5-test-runner?ref=badge_shield&issueType=license)
10
+ [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FArnaudBuchholz%2Fui5-test-runner.svg?type=shield&issueType=security)](https://app.fossa.com/projects/git%2Bgithub.com%2FArnaudBuchholz%2Fui5-test-runner?ref=badge_shield&issueType=security)
11
+ [![Documentation](https://img.shields.io/badge/-%F0%9F%93%9Adocumentation-blueviolet)](https://github.com/ArnaudBuchholz/ui5-test-runner/tree/main/docs#-documentation)
11
12
 
12
- A self-sufficient test runner for UI5 applications enabling parallel execution of tests.
13
13
 
14
- > To put it in a nutshell, some UI5 applications have so many tests that when you run them in a browser, it ends up **crashing**. The main reason is **memory consumption** : the browser process goes up to 2 GB and it blows up. JavaScript is based on garbage collecting but it needs time to operate and the stress caused by executing the tests as well as the use of iframes do not let enough bandwidth for the browser to free up the memory.
14
+ A self-sufficient test runner for [UI5 applications](https://ui5.sap.com/) enabling parallel execution of tests.
15
15
 
16
- > This tool is designed and built as a **substitute** of the [UI5 karma runner](https://github.com/SAP/karma-ui5). It executes all the tests in **parallel** thanks to several browser instances *(which also **reduces the total execution time**)*.
16
+ > To put it in a nutshell, some [UI5 applications](https://ui5.sap.com/) have so many tests that when you run them in a browser, it ends up **crashing**. The main reason is **memory consumption** : the browser process goes up to 2 GB and it blows up. JavaScript is based on garbage collecting but it needs time to operate and the stress caused by executing the tests as well as the use of iframes do not let enough bandwidth for the browser to free up the memory.
17
17
 
18
- ## 📚 [Documentation](https://arnaudbuchholz.github.io/ui5-test-runner/)
18
+ > This tool is designed and built as a **substitute** of the [UI5 karma runner](https://github.com/SAP/karma-ui5). It executes all the tests in **parallel** thanks to several browser tabs *(which also **reduces the total execution time**)*.
19
19
 
20
20
  ## 💿 How to install
21
21
 
22
- * Works with [Node.js](https://nodejs.org/en/download/) >= 24
22
+ * Works with [Node.js](https://nodejs.org/en/download/) >= 24.11.1
23
23
  * Local installation
24
- * `npm install --save-dev ui5-test-runner`
24
+ * `npm install --save-dev ui5-test-runner@6`
25
25
  * Trigger either with `npx ui5-test-runner` or through an npm script invoking `ui5-test-runner`
26
26
  * Global installation
27
- * `npm install --global ui5-test-runner`
27
+ * `npm install --global ui5-test-runner@6`
28
28
  * Trigger with `ui5-test-runner`
29
29
 
30
- **NOTE** : additional packages might be needed during the execution (`puppeteer`, `selenium-webdriver`, `nyc`...) . If they are found installed **locally** in the tested project, they are used. Otherwise, they are installed **globally**.
30
+ **NOTE** : additional packages might be needed during the execution (`puppeteer`, `selenium-webdriver`, `nyc`...) . If they are found installed **locally** in the tested project, they are used. Otherwise, they are installed **on demand**.
31
31
 
32
- ## ⚖️ License
33
- [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FArnaudBuchholz%2Fui5-test-runner.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2FArnaudBuchholz%2Fui5-test-runner?ref=badge_large)
32
+ ## 🤖 AI-powered documentation
34
33
 
35
- ## ⚠️ Breaking changes
34
+ Use `npx ui5-test-runner@6 --mcp` to start an MCP server exposing the latest indexed documentation to your AI client.
36
35
 
37
- | Version | Reason |
38
- |-|-|
39
- | **6**.0.0 | • Drop support of Node.js < 24 |
40
- | **5**.0.0 | • Some coverage reports now includes **all** files, leading to a potential decrease of coverage |
41
- | **4**.0.0 | • Drop support of Node.js 16 |
42
- | **3**.0.0 | • Drop support of Node.js 14 |
43
- | **2**.0.0 | • Command line **parameters** as well as configuration file **syntax** have changed |
44
- || • Dependencies are installed **on demand** |
45
- || • Browser instantiation command evolved in an **incompatible way** (see [documentation](https://arnaudbuchholz.github.io/ui5-test-runner/browser.html)) |
46
- || • Output is different (report, traces) |
36
+ ## 🧭 Links
37
+
38
+ * 📚 [Documentation](https://github.com/ArnaudBuchholz/ui5-test-runner/tree/main/docs#-documentation)
39
+
40
+ * ⚠️ [Breaking changes](https://github.com/ArnaudBuchholz/ui5-test-runner/blob/main/docs/v6_breaking_changes.md)
41
+
42
+ * ✒ [Contributors](https://github.com/ArnaudBuchholz/ui5-test-runner/graphs/contributors?all=1)
47
43
 
48
- ## ✒ Contributors
49
44
 
50
- * [Marian Zeis](https://github.com/marianfoo): Documentation page revamp [PR #54](https://github.com/ArnaudBuchholz/ui5-test-runner/pull/54)
51
- * [Raj Singh](https://github.com/rajxsingh): Basic HTTP Authentication in Puppeteer [PR #71](https://github.com/ArnaudBuchholz/ui5-test-runner/pull/71)
52
- * [Andreas Kunz](https://github.com/akudev): Improved documentation for TypeScript testing and reviewed `nyc` settings handling [PR #110](https://github.com/ArnaudBuchholz/ui5-test-runner/pull/110)
53
- * [Juri Zeisler](https://github.com/juriz889): Added screenshots to the JUnit XML Report [PR #158](https://github.com/ArnaudBuchholz/ui5-test-runner/pull/158)
@@ -0,0 +1,52 @@
1
+ import { assert } from './platform/index.js';
2
+ import { Npm } from './Npm.js';
3
+ const QUOTED_AND_UNQUOTED_TOKENS = /"[^"]*"|'[^']*'|[^ ]+/g;
4
+ export const Command = {
5
+ split(command) {
6
+ return command
7
+ .matchAll(QUOTED_AND_UNQUOTED_TOKENS)
8
+ .map(([value]) => {
9
+ return (value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))
10
+ ? value.slice(1, -1)
11
+ : value;
12
+ })
13
+ .toArray();
14
+ },
15
+ substitute(configuration, value, extras = {}) {
16
+ return value.replaceAll(/\{\{(\w+)\}\}/g, (_, optionName) => {
17
+ if (optionName in extras) {
18
+ return extras[optionName];
19
+ }
20
+ assert(optionName in configuration, `Invalid command line substitution parameter: ${optionName}`);
21
+ return configuration[optionName]?.toString() ?? '';
22
+ });
23
+ },
24
+ async parse(configuration, command, extras = {}) {
25
+ let executable = 'node';
26
+ const tokens = Command.split(command);
27
+ const environment = {};
28
+ while (tokens.length > 0 && /^\w+=/.test(tokens[0])) {
29
+ const [key, ...rest] = tokens.shift().split('=');
30
+ environment[key] = Command.substitute(configuration, rest.join('='), extras);
31
+ }
32
+ const [commandSpecifier, ...parameters] = tokens;
33
+ assert(!!commandSpecifier);
34
+ if (commandSpecifier === 'npm') {
35
+ parameters.unshift(await Npm.getCliPath());
36
+ }
37
+ else if (commandSpecifier !== 'node') {
38
+ const scripts = await Npm.listPackageScriptNames(configuration.cwd);
39
+ if (scripts.includes(commandSpecifier)) {
40
+ parameters.unshift(await Npm.getCliPath(), 'run', commandSpecifier);
41
+ }
42
+ else {
43
+ executable = commandSpecifier;
44
+ }
45
+ }
46
+ return [
47
+ executable,
48
+ parameters.map((parameter) => Command.substitute(configuration, parameter, extras)),
49
+ environment
50
+ ];
51
+ }
52
+ };
package/dist/Npm.js CHANGED
@@ -1,80 +1,230 @@
1
- import { logger, FileSystem, Http, Path, Process } from './platform/index.js';
1
+ import { assert, logger, FileSystem, Http, Module, Path, Process, Url } from './platform/index.js';
2
2
  import { memoize } from './utils/shared/memoize.js';
3
- const getNpmCliPath = memoize(async () => {
3
+ export const getNpmCliPath = async () => {
4
4
  const npmChildProcess = Process.spawn('npm', [], {
5
5
  shell: true
6
6
  });
7
7
  await npmChildProcess.closed;
8
- const error = new Error('Unable to initialize NPM');
9
8
  const match = /^npm@([^ ]+) (.*)$/gm.exec(npmChildProcess.stdout);
10
- if (!match) {
11
- logger.fatal({ source: 'npm', message: 'Unable to match NPM output', error });
12
- throw error;
13
- }
9
+ assert(match !== null, 'unable to match NPM output');
14
10
  const [, semver, path] = match;
15
- if (!semver || !path) {
16
- logger.fatal({ source: 'npm', message: 'Failed to parse NPM output', error, data: { semver, path } });
17
- throw error;
18
- }
11
+ assert(!!semver && !!path, 'failed to parse NPM output');
19
12
  logger.debug({ source: 'npm', message: `npm@${semver} ${path}` });
20
13
  return Path.join(path, 'bin/npm-cli.js');
21
- });
14
+ };
15
+ const memoizedNpmCliPath = memoize(getNpmCliPath);
22
16
  const npm = async (...arguments_) => {
23
- const npmCliPath = await getNpmCliPath();
17
+ const npmCliPath = await memoizedNpmCliPath();
24
18
  return Process.spawn('node', [npmCliPath, ...arguments_], {
25
19
  detached: true
26
20
  });
27
21
  };
22
+ const validateRoot = async (proc, label) => {
23
+ if (proc.code !== 0) {
24
+ logger.fatal({ source: 'npm', message: `npm root ${label} failed with code ${proc.code}` });
25
+ }
26
+ const value = proc.stdout.trim();
27
+ if (value === '' || !Path.isAbsolute(value)) {
28
+ logger.fatal({ source: 'npm', message: `npm root ${label} returned an invalid path: "${value}"` });
29
+ }
30
+ try {
31
+ await FileSystem.access(value, FileSystem.constants.R_OK);
32
+ }
33
+ catch {
34
+ logger.fatal({ source: 'npm', message: `npm root ${label} directory is not accessible: "${value}"` });
35
+ }
36
+ return value;
37
+ };
28
38
  const getRoots = memoize(async () => {
29
39
  const localRootProcess = await npm('root');
30
40
  const globalRootProcess = await npm('root', '--global');
31
41
  await Promise.all([localRootProcess.closed, globalRootProcess.closed]);
32
- const local = localRootProcess.stdout.trim();
33
- const global = globalRootProcess.stdout.trim();
34
- logger.debug({ source: 'npm', message: 'Roots', data: { local, global } });
35
- return {
36
- local: localRootProcess.stdout.trim(),
37
- global: globalRootProcess.stdout.trim()
38
- };
42
+ const local = await validateRoot(localRootProcess, '(local)');
43
+ const global = await validateRoot(globalRootProcess, '(global)');
44
+ logger.debug({ source: 'npm', message: 'roots', data: { local, global } });
45
+ return { local, global };
39
46
  });
40
- export const Npm = {
41
- async getLatestVersion(moduleName) {
47
+ const buildInstallPlan = (strategy, moduleName, options) => {
48
+ const { globalRoot, prefix, allowScripts, minReleaseAge } = options;
49
+ const noScripts = allowScripts ? [] : ['--ignore-scripts'];
50
+ const releaseAge = minReleaseAge > 0 ? [`--min-release-age=${minReleaseAge}`] : [];
51
+ const extraFlags = [...noScripts, ...releaseAge];
52
+ if (strategy === 'global') {
53
+ return { installArguments: ['install', '-g', ...extraFlags, moduleName], reimportPath: globalRoot };
54
+ }
55
+ return strategy === 'prefix'
56
+ ? {
57
+ installArguments: ['install', '--prefix', prefix, '--no-save', ...extraFlags, moduleName],
58
+ reimportPath: Path.join(prefix, 'node_modules')
59
+ }
60
+ : { installArguments: ['install', '--no-save', ...extraFlags, moduleName], reimportPath: undefined };
61
+ };
62
+ export class Npm {
63
+ static dynamicImport(specifier) {
64
+ return import(specifier);
65
+ }
66
+ static tryResolvePackageDir(moduleName, nodeModulesPath) {
42
67
  try {
43
- const response = await Http.get(`https://registry.npmjs.org/${moduleName}/latest`);
68
+ const packageJsonPath = Module.findPackageJSON(moduleName, `${nodeModulesPath}/`);
69
+ return packageJsonPath === undefined ? undefined : Path.dirname(packageJsonPath);
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ static async tryImportFromPath(configuration, moduleName, nodeModulesPath) {
76
+ try {
77
+ const require = Module.createRequire(`${Url.pathToFileURL(configuration.cwd).href}/`);
78
+ const resolved = require.resolve(moduleName, { paths: [nodeModulesPath] });
79
+ return await this.dynamicImport(Url.pathToFileURL(resolved).href);
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ static async tryImportFromRoots(configuration, moduleName, roots) {
86
+ for (const { path, label, checkVersion } of roots) {
87
+ const result = await this.tryImportFromPath(configuration, moduleName, path);
88
+ if (result !== undefined) {
89
+ logger.debug({ source: 'npm', message: `Module ${moduleName} ${label}` });
90
+ if (checkVersion)
91
+ void this.checkIfLatestVersion(configuration, moduleName);
92
+ return result;
93
+ }
94
+ }
95
+ return undefined;
96
+ }
97
+ static async import(configuration, moduleName) {
98
+ logger.debug({ source: 'npm', message: `Importing module: ${moduleName}` });
99
+ try {
100
+ const module = await this.dynamicImport(moduleName);
101
+ logger.debug({ source: 'npm', message: `Module ${moduleName} found locally` });
102
+ void this.checkIfLatestVersion(configuration, moduleName);
103
+ return module;
104
+ }
105
+ catch (error) {
106
+ const code = error.code;
107
+ if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') {
108
+ throw error;
109
+ }
110
+ if (moduleName === this.packageNameOf(moduleName)) {
111
+ logger.warn({ source: 'npm', message: `Module ${moduleName} not found locally` });
112
+ }
113
+ }
114
+ const { local: localRoot, global: globalRoot } = await getRoots();
115
+ const extraRoots = [
116
+ ...(configuration.alternateNpmPath
117
+ ? [{ path: configuration.alternateNpmPath, label: 'found in alternateNpmPath' }]
118
+ : []),
119
+ ...(configuration.npmInstallPrefix
120
+ ? [{ path: Path.join(configuration.npmInstallPrefix, 'node_modules'), label: 'found in npmInstallPrefix' }]
121
+ : [])
122
+ ];
123
+ const found = await this.tryImportFromRoots(configuration, moduleName, [
124
+ { path: localRoot, label: 'found in local node_modules', checkVersion: true },
125
+ { path: globalRoot, label: 'found globally', checkVersion: true },
126
+ ...extraRoots
127
+ ]);
128
+ if (found !== undefined)
129
+ return found;
130
+ if (configuration.noNpmInstall) {
131
+ const message = `Module ${moduleName} not found and noNpmInstall is set`;
132
+ logger.fatal({ source: 'npm', message });
133
+ }
134
+ const strategy = configuration.npmInstall;
135
+ logger.info({ source: 'npm', message: `Installing ${moduleName} (strategy: ${strategy})` });
136
+ const { installArguments, reimportPath } = buildInstallPlan(strategy, moduleName, {
137
+ globalRoot,
138
+ prefix: configuration.npmInstallPrefix ?? '',
139
+ allowScripts: !!configuration.npmAllowInstallScripts,
140
+ minReleaseAge: configuration.npmInstallMinReleaseAge
141
+ });
142
+ const installProcess = await npm(...installArguments);
143
+ await installProcess.closed;
144
+ if (reimportPath !== undefined) {
145
+ const result = await this.tryImportFromPath(configuration, moduleName, reimportPath);
146
+ if (result === undefined) {
147
+ const message = `Module ${moduleName} could not be loaded after install`;
148
+ logger.fatal({ source: 'npm', message });
149
+ }
150
+ return result;
151
+ }
152
+ return await this.dynamicImport(moduleName);
153
+ }
154
+ static async resolvePackageDir(configuration, moduleName) {
155
+ const { local, global: globalRoot } = await getRoots();
156
+ const fromLocal = this.tryResolvePackageDir(moduleName, local);
157
+ if (fromLocal)
158
+ return fromLocal;
159
+ const fromGlobal = this.tryResolvePackageDir(moduleName, globalRoot);
160
+ if (fromGlobal)
161
+ return fromGlobal;
162
+ if (configuration.alternateNpmPath) {
163
+ const fromAlternate = this.tryResolvePackageDir(moduleName, configuration.alternateNpmPath);
164
+ if (fromAlternate)
165
+ return fromAlternate;
166
+ }
167
+ if (configuration.npmInstallPrefix) {
168
+ const fromPrefix = this.tryResolvePackageDir(moduleName, Path.join(configuration.npmInstallPrefix, 'node_modules'));
169
+ if (fromPrefix)
170
+ return fromPrefix;
171
+ }
172
+ throw new Error(`Cannot locate package directory for ${moduleName}`);
173
+ }
174
+ static packageNameOf(moduleName) {
175
+ if (moduleName.startsWith('@')) {
176
+ const [scope, package_] = moduleName.split('/', 2);
177
+ return `${scope}/${package_}`;
178
+ }
179
+ return moduleName.split('/', 1)[0];
180
+ }
181
+ static async getLatestVersion(packageName) {
182
+ try {
183
+ const response = await Http.getAsText(`https://registry.npmjs.org/${packageName}/latest`);
44
184
  const { version } = JSON.parse(response);
45
185
  return version;
46
186
  }
47
187
  catch (error) {
48
- throw new Error(`Unable to fetch latest version of ${moduleName} from NPM registry`, {
188
+ throw new Error(`Unable to fetch latest version of ${packageName} from NPM registry`, {
49
189
  cause: error
50
190
  });
51
191
  }
52
- },
53
- async checkIfLatestVersion(moduleName, isLocal) {
192
+ }
193
+ static _checkedPackages = new Set();
194
+ static async checkIfLatestVersion(configuration, moduleName) {
195
+ const packageName = this.packageNameOf(moduleName);
196
+ if (this._checkedPackages.has(packageName)) {
197
+ return;
198
+ }
199
+ this._checkedPackages.add(packageName);
54
200
  try {
55
- const { local, global } = await getRoots();
56
- const { version: installedVersion } = JSON.parse(await FileSystem.readFile(Path.join(isLocal ? local : global, moduleName, 'package.json'), 'utf8'));
57
- logger.info({ source: 'npm', message: `Installed version of ${moduleName} is ${installedVersion}` });
58
- const latestVersion = await Npm.getLatestVersion(moduleName);
201
+ const packageDirectory = await this.resolvePackageDir(configuration, packageName);
202
+ const { version: installedVersion } = JSON.parse(await FileSystem.readFile(Path.join(packageDirectory, 'package.json'), 'utf8'));
203
+ logger.info({ source: 'npm', message: `Installed version of ${packageName} is ${installedVersion}` });
204
+ const latestVersion = await this.getLatestVersion(packageName);
59
205
  if (latestVersion !== installedVersion) {
60
- logger.warn({ source: 'npm', message: `[PKGVRS] Latest version of ${moduleName} is ${latestVersion}` });
206
+ logger.warn({ source: 'npm', message: `Latest version of ${packageName} is ${latestVersion}` });
61
207
  }
62
208
  }
63
209
  catch (error) {
64
- logger.error({ source: 'npm', message: 'Failed in checkIfLatestVersion', error });
210
+ logger.error({ source: 'npm', message: 'failed in checkIfLatestVersion', error });
65
211
  }
66
- },
67
- async import(moduleName) {
68
- logger.debug({ source: 'npm', message: `Npm.import(${moduleName})` });
212
+ }
213
+ static async getCliPath() {
214
+ return await memoizedNpmCliPath();
215
+ }
216
+ static async listPackageScriptNames(cwd) {
69
217
  try {
70
- const module = (await import(moduleName));
71
- logger.debug({ source: 'npm', message: `Module ${moduleName} found locally` });
72
- void this.checkIfLatestVersion(moduleName, true);
73
- return module;
218
+ const packageJsonPath = Module.findPackageJSON(`${Url.pathToFileURL(cwd).href}/`);
219
+ if (!packageJsonPath) {
220
+ return [];
221
+ }
222
+ const content = JSON.parse(await FileSystem.readFile(packageJsonPath, 'utf8'));
223
+ return Object.keys(content?.scripts ?? {});
74
224
  }
75
- catch {
76
- logger.warn({ source: 'npm', message: `Module ${moduleName} not found locally` });
225
+ catch (error) {
226
+ logger.error({ source: 'npm', message: 'failed to list package script names', error, data: { cwd } });
227
+ return [];
77
228
  }
78
- throw new Error('Not implemented');
79
229
  }
80
- };
230
+ }
@@ -0,0 +1,6 @@
1
+ export class RecoverableBrowserError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'RecoverableBrowserError';
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ import { Host } from '../platform/index.js';
2
+ export const getExtraChromeArguments = () => {
3
+ const raw = Host.env['UI5TR_CHROME_ARGS'];
4
+ return raw === undefined ? [] : raw.split(/\s+/).filter((argument) => argument.length > 0);
5
+ };
@@ -0,0 +1,29 @@
1
+ import { getExtraChromeArguments } from './chromeArguments.js';
2
+ export const DEFAULT_CHROME_ARGS = [
3
+ '--disable-background-timer-throttling',
4
+ '--disable-backgrounding-occluded-windows',
5
+ '--disable-renderer-backgrounding'
6
+ ];
7
+ export const buildChromeLaunchOptions = (options, extraArguments = [], shouldIncludeEnvironmentArguments = true) => {
8
+ const rawArguments = options?.['args'];
9
+ const userArguments = Array.isArray(rawArguments)
10
+ ? rawArguments.filter((a) => typeof a === 'string')
11
+ : [];
12
+ const rawIgnoreDefaultArguments = options?.['ignoreDefaultArgs'];
13
+ let ignoreDefaultArguments;
14
+ if (rawIgnoreDefaultArguments === true) {
15
+ ignoreDefaultArguments = true;
16
+ }
17
+ else if (Array.isArray(rawIgnoreDefaultArguments)) {
18
+ ignoreDefaultArguments = rawIgnoreDefaultArguments.filter((argument) => typeof argument === 'string');
19
+ }
20
+ return {
21
+ args: [
22
+ ...DEFAULT_CHROME_ARGS,
23
+ ...extraArguments,
24
+ ...userArguments,
25
+ ...(shouldIncludeEnvironmentArguments ? getExtraChromeArguments() : [])
26
+ ],
27
+ ...(ignoreDefaultArguments !== undefined && { ignoreDefaultArgs: ignoreDefaultArguments })
28
+ };
29
+ };
@@ -0,0 +1,23 @@
1
+ import { logger } from '../platform/index.js';
2
+ import { agentLogPrefix } from '../types/AgentState.js';
3
+ const LOG_TYPES = {
4
+ error: 'error',
5
+ warn: 'warn',
6
+ assert: 'warn',
7
+ debug: 'debug'
8
+ };
9
+ export const handleConsoleMessage = (type, text, pageId) => {
10
+ const normalizedType = type === 'warning' ? 'warn' : type;
11
+ const logType = LOG_TYPES[normalizedType] ?? 'info';
12
+ let source;
13
+ let message;
14
+ if (text.startsWith(agentLogPrefix)) {
15
+ source = 'browser/agent';
16
+ message = text.slice(agentLogPrefix.length);
17
+ }
18
+ else {
19
+ source = 'browser/console';
20
+ message = text;
21
+ }
22
+ logger[logType]({ source, message, pageId, data: { type: normalizedType } });
23
+ };
@@ -1,9 +1,174 @@
1
- import { factory as puppeteerFactory } from './puppeteer.js';
1
+ import { factory as puppeteerFactory, descriptor as puppeteerDescriptor } from './puppeteer.js';
2
+ import { factory as playwrightFactory, descriptor as playwrightDescriptor } from './playwright.js';
3
+ import { factory as webdriverioFactory, descriptor as webdriverioDescriptor } from './webdriverio.js';
4
+ import { factory as seleniumWebdriverFactory, descriptor as seleniumWebdriverDescriptor } from './seleniumWebdriver.js';
5
+ import { Exit, logger } from '../platform/index.js';
6
+ import { RecoverableBrowserError } from './RecoverableBrowserError.js';
7
+ const DRIVERS = ['puppeteer', 'playwright', 'webdriverio', 'selenium-webdriver'];
8
+ export const isDriver = (value) => DRIVERS.includes(value);
2
9
  const factories = {
3
- puppeteer: puppeteerFactory
10
+ puppeteer: puppeteerFactory,
11
+ playwright: playwrightFactory,
12
+ webdriverio: webdriverioFactory,
13
+ 'selenium-webdriver': seleniumWebdriverFactory
14
+ };
15
+ const descriptors = {
16
+ puppeteer: puppeteerDescriptor,
17
+ playwright: playwrightDescriptor,
18
+ webdriverio: webdriverioDescriptor,
19
+ 'selenium-webdriver': seleniumWebdriverDescriptor
20
+ };
21
+ export const getDescriptor = (driver) => descriptors[driver];
22
+ let _instanceCount = 0;
23
+ const _activeInstances = new Set();
24
+ const shutdown = async (browser, inner) => {
25
+ logger.debug({ source: browser, message: 'shutdown' });
26
+ try {
27
+ await inner.shutdown();
28
+ logger.debug({ source: browser, message: 'shutdown completed' });
29
+ }
30
+ catch (error) {
31
+ logger.warn({
32
+ source: browser,
33
+ message: 'shutdown failed',
34
+ error
35
+ });
36
+ }
4
37
  };
5
38
  export const BrowserFactory = {
6
- async build(browser) {
7
- return await factories[browser]();
39
+ async build(configuration, browser) {
40
+ if (_activeInstances.has(browser)) {
41
+ logger.fatal({ source: browser, message: 'build failed: already has an active instance' });
42
+ }
43
+ _activeInstances.add(browser);
44
+ const abortController = new AbortController();
45
+ let inner;
46
+ try {
47
+ logger.debug({ source: browser, message: 'build' });
48
+ inner = await factories[browser](configuration, abortController.signal);
49
+ logger.debug({ source: browser, message: 'build completed' });
50
+ }
51
+ catch (error) {
52
+ _activeInstances.delete(browser);
53
+ logger.fatal({ source: browser, message: 'build failed', error });
54
+ throw error;
55
+ }
56
+ const _openWindows = new Set();
57
+ const closeAllWindows = async () => {
58
+ await Promise.all([..._openWindows].map(async (w) => {
59
+ try {
60
+ await w.close();
61
+ }
62
+ catch {
63
+ }
64
+ }));
65
+ };
66
+ let task;
67
+ return {
68
+ async setup(settings) {
69
+ task = Exit.registerAsyncTask({
70
+ name: `${browser}#${++_instanceCount}`,
71
+ async stop() {
72
+ abortController.abort();
73
+ await closeAllWindows();
74
+ await shutdown(browser, inner);
75
+ }
76
+ });
77
+ logger.debug({ source: browser, message: 'setup', data: settings });
78
+ try {
79
+ const capabilities = await inner.setup(settings);
80
+ logger.debug({ source: browser, message: 'setup completed', data: capabilities });
81
+ return capabilities;
82
+ }
83
+ catch (error) {
84
+ task[Symbol.dispose]();
85
+ _activeInstances.delete(browser);
86
+ logger.fatal({
87
+ source: browser,
88
+ message: 'setup failed',
89
+ error
90
+ });
91
+ throw error;
92
+ }
93
+ },
94
+ async newWindow(settings) {
95
+ const { pageId } = settings;
96
+ logger.debug({ source: browser, message: 'newWindow', pageId, data: settings });
97
+ try {
98
+ const innerWindow = await inner.newWindow(settings);
99
+ logger.debug({ source: browser, message: 'newWindow completed', pageId });
100
+ const wrapped = wrapWindow(innerWindow, browser, pageId, () => _openWindows.delete(wrapped));
101
+ _openWindows.add(wrapped);
102
+ return wrapped;
103
+ }
104
+ catch (error) {
105
+ const logMethod = error instanceof RecoverableBrowserError ? 'error' : 'fatal';
106
+ logger[logMethod]({
107
+ source: browser,
108
+ message: 'newWindow failed',
109
+ error
110
+ });
111
+ throw error;
112
+ }
113
+ },
114
+ async shutdown() {
115
+ await closeAllWindows();
116
+ await shutdown(browser, inner);
117
+ task?.[Symbol.dispose]();
118
+ _activeInstances.delete(browser);
119
+ }
120
+ };
8
121
  }
9
122
  };
123
+ const wrapWindow = (innerWindow, source, pageId, onClosed) => {
124
+ let isClosed = false;
125
+ return {
126
+ async eval(script) {
127
+ logger.debug({ source, message: 'eval', pageId, data: { script } });
128
+ try {
129
+ if (isClosed) {
130
+ throw new Error('window closed');
131
+ }
132
+ const result = await innerWindow.eval(script);
133
+ logger.debug({ source, message: 'eval completed', pageId, data: { result } });
134
+ return result;
135
+ }
136
+ catch (error) {
137
+ logger.error({ source, message: 'eval failed', pageId, error });
138
+ throw error;
139
+ }
140
+ },
141
+ async screenshot(path) {
142
+ logger.debug({ source, message: 'screenshot', pageId, data: { path } });
143
+ try {
144
+ if (isClosed) {
145
+ throw new Error('window closed');
146
+ }
147
+ await innerWindow.screenshot(path);
148
+ logger.debug({ source, message: 'screenshot completed', pageId, data: { path } });
149
+ }
150
+ catch (error) {
151
+ logger.error({ source, message: 'screenshot failed', pageId, error, data: { path } });
152
+ throw error;
153
+ }
154
+ },
155
+ async close() {
156
+ if (isClosed) {
157
+ logger.warn({ source, message: 'closing an already closed window, ignored', pageId });
158
+ return;
159
+ }
160
+ isClosed = true;
161
+ logger.debug({ source, message: 'window close', pageId });
162
+ try {
163
+ await innerWindow.close();
164
+ logger.debug({ source, message: 'window closed', pageId });
165
+ }
166
+ catch (error) {
167
+ logger.error({ source, message: 'window close failed', error });
168
+ }
169
+ finally {
170
+ onClosed();
171
+ }
172
+ }
173
+ };
174
+ };