ui5-test-runner 6.0.0-beta.5 → 6.0.1

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 +87 -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 +3 -79
  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,49 @@
1
+ import { Exit, FileSystem, logger } from '../../platform/index.js';
2
+ import { serve } from 'reserve';
3
+ import { buildREserveConfiguration } from './reserve.js';
4
+ import { init, reindex, DOCS_DIR } from './knowledgeBase.js';
5
+ import { logReserve } from '../../reserveLogger.js';
6
+ import { Folder } from '../../utils/node/Folder.js';
7
+ export const mcp = async (configuration) => {
8
+ await Folder.create(configuration.reportDir);
9
+ await logger.start(configuration);
10
+ await init(configuration);
11
+ if (configuration.debugMcpLocalDocs && configuration.watch) {
12
+ let debounce;
13
+ const watcher = FileSystem.watch(DOCS_DIR, { recursive: true });
14
+ watcher.on('change', () => {
15
+ clearTimeout(debounce);
16
+ debounce = setTimeout(() => {
17
+ void reindex(configuration).then(() => logger.info({ source: 'mcp', message: 'knowledge base reindexed' }));
18
+ }, 300);
19
+ });
20
+ Exit.registerAsyncTask({
21
+ name: 'mcp-watcher',
22
+ stop: () => {
23
+ clearTimeout(debounce);
24
+ watcher.close();
25
+ return Promise.resolve();
26
+ }
27
+ });
28
+ }
29
+ const { promise, resolve } = Promise.withResolvers();
30
+ const server = serve(buildREserveConfiguration(configuration));
31
+ const stop = async () => {
32
+ await server?.close();
33
+ resolve();
34
+ };
35
+ Exit.registerAsyncTask({
36
+ name: 'mcp',
37
+ stop
38
+ });
39
+ logReserve(server);
40
+ server.on('ready', ({ url }) => {
41
+ logger.info({ source: 'mcp', message: 'connect your MCP client to ' + new URL('/mcp', url).href });
42
+ logger.info({ source: 'mcp', message: 'use CTRL+C to end' });
43
+ });
44
+ server.on('error', ({ reason: error }) => {
45
+ logger.error({ source: 'mcp', message: 'server error', error });
46
+ void stop();
47
+ });
48
+ await promise;
49
+ };
@@ -0,0 +1,104 @@
1
+ import { Crypto, FileSystem, Path, __sourcesRoot, assert, logger } from '../../platform/index.js';
2
+ import { parseFrontmatter } from './frontmatter.js';
3
+ import { collectRemoteDocuments } from './remoteKnowledgeBase.js';
4
+ import { isExcludedPath } from './IDocument.js';
5
+ export const DOCS_DIR = Path.join(__sourcesRoot, '../docs');
6
+ const ENTITY_TYPES = new Set(['option', 'mode', 'concept', 'task']);
7
+ const ID_RELATIONS = ['see-also', 'affects', 'requires', 'supersedes'];
8
+ const SHA_LENGTH = 16;
9
+ export const deriveId = (documentationRelativePath) => {
10
+ const withoutExtension = documentationRelativePath.replaceAll('\\', '/').replace(/\.md$/, '');
11
+ return withoutExtension.replace(/\/index$/, '');
12
+ };
13
+ const toCatalogEntry = (entity) => ({
14
+ id: entity.id,
15
+ '#type': entity['#type'],
16
+ title: entity.title,
17
+ summary: entity.summary,
18
+ relations: entity.relations
19
+ });
20
+ const parseDocumentToEntity = ({ path, content }) => {
21
+ const { frontmatter, body } = parseFrontmatter(content);
22
+ if (frontmatter === null || frontmatter['#type'] === undefined) {
23
+ return null;
24
+ }
25
+ const type = frontmatter['#type'];
26
+ if (!ENTITY_TYPES.has(type)) {
27
+ logger.debug({ source: 'mcp', message: `skipping ${path}: invalid #type '${type}'` });
28
+ return null;
29
+ }
30
+ if (frontmatter.title === undefined || frontmatter.summary === undefined) {
31
+ logger.debug({ source: 'mcp', message: `skipping ${path}: missing title or summary` });
32
+ return null;
33
+ }
34
+ return {
35
+ id: frontmatter.id ?? deriveId(path),
36
+ '#type': type,
37
+ title: frontmatter.title,
38
+ summary: frontmatter.summary,
39
+ keywords: frontmatter.keywords ?? [],
40
+ relations: frontmatter.relations ?? {},
41
+ sha: Crypto.sha256hex(content).slice(0, SHA_LENGTH),
42
+ path,
43
+ body
44
+ };
45
+ };
46
+ const buildReverseIndex = (entities) => {
47
+ const reverse = new Map();
48
+ for (const entity of entities) {
49
+ for (const relation of ID_RELATIONS) {
50
+ const targets = entity.relations[relation] ?? [];
51
+ for (const target of targets) {
52
+ const existing = reverse.get(target) ?? [];
53
+ existing.push({ id: entity.id, relation });
54
+ reverse.set(target, existing);
55
+ }
56
+ }
57
+ }
58
+ return reverse;
59
+ };
60
+ export const buildKnowledgeBaseIndex = (documents) => {
61
+ const byId = new Map();
62
+ for (const document of documents) {
63
+ const entity = parseDocumentToEntity(document);
64
+ if (entity === null) {
65
+ continue;
66
+ }
67
+ if (byId.has(entity.id)) {
68
+ logger.debug({ source: 'mcp', message: `skipping ${entity.path}: duplicate id '${entity.id}'` });
69
+ continue;
70
+ }
71
+ byId.set(entity.id, entity);
72
+ }
73
+ const entities = byId.values().toArray();
74
+ const reverse = buildReverseIndex(entities);
75
+ const catalog = entities
76
+ .map((entity) => toCatalogEntry(entity))
77
+ .toSorted((left, right) => left.id.localeCompare(right.id));
78
+ return {
79
+ catalog,
80
+ getEntity: (id) => byId.get(id),
81
+ getReverseRelations: (id) => reverse.get(id) ?? []
82
+ };
83
+ };
84
+ let _index;
85
+ const collectLocalDocuments = async () => {
86
+ const entries = await FileSystem.readdir(DOCS_DIR, { recursive: true, withFileTypes: true });
87
+ const markdown = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md'));
88
+ const documents = await Promise.all(markdown.map(async (entry) => {
89
+ const path = Path.relative(DOCS_DIR, Path.join(entry.parentPath, entry.name));
90
+ return { path, content: await FileSystem.readFile(Path.join(DOCS_DIR, path), 'utf8') };
91
+ }));
92
+ return documents.filter((document) => !isExcludedPath(document.path));
93
+ };
94
+ const collectDocuments = (configuration) => configuration.debugMcpLocalDocs ? collectLocalDocuments() : collectRemoteDocuments();
95
+ export const reindex = async (configuration) => {
96
+ _index = buildKnowledgeBaseIndex(await collectDocuments(configuration));
97
+ };
98
+ export const init = async (configuration) => {
99
+ await reindex(configuration);
100
+ };
101
+ export const getIndex = () => {
102
+ assert(_index !== undefined, 'knowledge base not initialized');
103
+ return _index;
104
+ };
@@ -0,0 +1,79 @@
1
+ import { FileSystem, Host, Http, Path, logger } from '../../platform/index.js';
2
+ import { isExcludedPath } from './IDocument.js';
3
+ const REPO = 'ArnaudBuchholz/ui5-test-runner';
4
+ const BRANCH = 'main';
5
+ const DOCS_PATH = 'docs';
6
+ const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${BRANCH}`;
7
+ const API_BASE = `https://api.github.com/repos/${REPO}`;
8
+ const CACHE_FILE_NAME = 'ui5-test-runner-mcp-kb-cache.json';
9
+ const CACHE_FILE_PATH = Path.join(Host.tmpdir(), CACHE_FILE_NAME);
10
+ const ONE_HOUR_MS = 60 * 60 * 1000;
11
+ const loadCache = async () => {
12
+ try {
13
+ const raw = await FileSystem.readFile(CACHE_FILE_PATH, 'utf8');
14
+ return JSON.parse(raw);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ };
20
+ const saveCache = (cache) => {
21
+ FileSystem.writeFileSync(CACHE_FILE_PATH, JSON.stringify(cache));
22
+ };
23
+ const fetchTreeSha = async () => {
24
+ const url = `${API_BASE}/git/trees/${BRANCH}?recursive=1`;
25
+ const text = await Http.getAsText(url);
26
+ const tree = JSON.parse(text);
27
+ const entries = tree.tree
28
+ .filter((entry) => entry.type === 'blob' &&
29
+ entry.path.startsWith(`${DOCS_PATH}/`) &&
30
+ entry.path.endsWith('.md') &&
31
+ !isExcludedPath(entry.path.slice(DOCS_PATH.length + 1)))
32
+ .map((entry) => ({ path: entry.path.slice(DOCS_PATH.length + 1), sha: entry.sha }));
33
+ return { treeSha: tree.sha, entries };
34
+ };
35
+ const fetchFileContent = async (relativePath) => Http.getAsText(`${RAW_BASE}/${DOCS_PATH}/${relativePath}`);
36
+ const buildDocuments = (contents) => Object.entries(contents).map(([path, content]) => ({ path, content }));
37
+ export const collectRemoteDocuments = async () => {
38
+ logger.info({ source: 'mcp', message: `cache path: ${CACHE_FILE_PATH}` });
39
+ const cache = await loadCache();
40
+ const now = Date.now();
41
+ if (cache !== null && now - cache.checkedAt < ONE_HOUR_MS) {
42
+ logger.info({ source: 'mcp', message: 'using cached remote KB (within 1-hour window)' });
43
+ return buildDocuments(cache.contents);
44
+ }
45
+ logger.debug({ source: 'mcp', message: 'checking remote tree SHA' });
46
+ let treeResult;
47
+ try {
48
+ treeResult = await fetchTreeSha();
49
+ }
50
+ catch (error) {
51
+ if (cache !== null) {
52
+ logger.warn({ source: 'mcp', message: 'could not reach GitHub, using cached KB', error });
53
+ return buildDocuments(cache.contents);
54
+ }
55
+ throw new Error('Failed to fetch knowledge base from GitHub and no local cache is available', { cause: error });
56
+ }
57
+ const { treeSha, entries } = treeResult;
58
+ if (cache !== null && treeSha === cache.treeSha) {
59
+ logger.info({ source: 'mcp', message: 'remote tree SHA unchanged' });
60
+ saveCache({ ...cache, checkedAt: now });
61
+ return buildDocuments(cache.contents);
62
+ }
63
+ logger.info({ source: 'mcp', message: 'remote tree SHA changed, fetching updates' });
64
+ const previousContents = cache?.contents ?? {};
65
+ const previousFiles = cache?.files ?? {};
66
+ const newFiles = {};
67
+ const newContents = {};
68
+ for (const entry of entries) {
69
+ newFiles[entry.path] = entry.sha;
70
+ }
71
+ await Promise.all(entries.map(async (entry) => {
72
+ newContents[entry.path] =
73
+ previousFiles[entry.path] === entry.sha && previousContents[entry.path] !== undefined
74
+ ? previousContents[entry.path]
75
+ : await fetchFileContent(entry.path);
76
+ }));
77
+ saveCache({ treeSha, checkedAt: now, files: newFiles, contents: newContents });
78
+ return buildDocuments(newContents);
79
+ };
@@ -0,0 +1,175 @@
1
+ import { body } from 'reserve';
2
+ import { TOOLS } from './tools/index.js';
3
+ import { FileSystem } from '../../platform/FileSystem.js';
4
+ import { Path } from '../../platform/Path.js';
5
+ import { Crypto, __sourcesRoot } from '../../platform/index.js';
6
+ const DOCS_DIR = Path.join(__sourcesRoot, '../docs');
7
+ const SERVER_INFO = {
8
+ name: 'ui5-test-runner',
9
+ version: '1.0.0'
10
+ };
11
+ const INSTRUCTIONS = [
12
+ 'This server is the authoritative, always-current documentation for ui5-test-runner (a tool that runs UI5 QUnit/OPA tests).',
13
+ '',
14
+ 'For ANY question about ui5-test-runner — its command-line options and flags, configuration keys, execution modes, coverage, reporting, migration between versions, or error messages — call `list_topics` first, then `get_topic` on the relevant entities, and ground your answer in what they return.',
15
+ '',
16
+ 'Do NOT answer such questions from prior knowledge, and never invent option names, configuration keys, or capabilities: ui5-test-runner evolves across versions and its real option names differ from what similar tools use, so an ungrounded answer is likely wrong. If the documentation does not cover something, say so rather than guessing.',
17
+ '',
18
+ "Grounding does not mean exhaustiveness: answer the actual question and tailor the response to the user's project, leaving out documented topics that do not apply to their situation. Reading an entity does not oblige you to repeat everything in it."
19
+ ].join('\n');
20
+ const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
21
+ const HTTP_OK = 200;
22
+ const HTTP_ACCEPTED = 202;
23
+ const HTTP_METHOD_NOT_ALLOWED = 405;
24
+ const HTTP_BAD_REQUEST = 400;
25
+ const JSONRPC_PARSE_ERROR = -32_700;
26
+ const JSONRPC_METHOD_NOT_FOUND = -32_601;
27
+ const JSONRPC_INVALID_PARAMS = -32_602;
28
+ const buildError = (id, code, message) => ({
29
+ jsonrpc: '2.0',
30
+ id,
31
+ error: { code, message }
32
+ });
33
+ const isToolCallParameters = (parameters) => typeof parameters === 'object' &&
34
+ parameters !== null &&
35
+ typeof parameters.name === 'string' &&
36
+ typeof parameters.arguments === 'object';
37
+ const negotiateProtocolVersion = (parameters) => {
38
+ const requested = parameters?.protocolVersion;
39
+ return typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
40
+ ? requested
41
+ : SUPPORTED_PROTOCOL_VERSIONS[0];
42
+ };
43
+ const handleRequest = async (request) => {
44
+ const { id, method, params } = request;
45
+ if (id === null || id === undefined) {
46
+ return null;
47
+ }
48
+ if (method === 'initialize') {
49
+ return {
50
+ jsonrpc: '2.0',
51
+ id,
52
+ result: {
53
+ protocolVersion: negotiateProtocolVersion(params),
54
+ serverInfo: SERVER_INFO,
55
+ capabilities: { tools: {} },
56
+ instructions: INSTRUCTIONS
57
+ }
58
+ };
59
+ }
60
+ if (method === 'ping') {
61
+ return { jsonrpc: '2.0', id, result: {} };
62
+ }
63
+ if (method === 'resources/list') {
64
+ return { jsonrpc: '2.0', id, result: { resources: [] } };
65
+ }
66
+ if (method === 'prompts/list') {
67
+ return { jsonrpc: '2.0', id, result: { prompts: [] } };
68
+ }
69
+ if (method === 'tools/list') {
70
+ return { jsonrpc: '2.0', id, result: { tools: Object.values(TOOLS).map((t) => t.definition) } };
71
+ }
72
+ if (method === 'tools/call') {
73
+ if (!isToolCallParameters(params)) {
74
+ return buildError(id, JSONRPC_INVALID_PARAMS, 'Invalid params: expected { name, arguments }');
75
+ }
76
+ const tool = TOOLS[params.name];
77
+ if (!tool) {
78
+ return buildError(id, JSONRPC_METHOD_NOT_FOUND, `Unknown tool: ${params.name}`);
79
+ }
80
+ let content;
81
+ try {
82
+ content = await tool.handler(params.arguments);
83
+ }
84
+ catch (error) {
85
+ return {
86
+ jsonrpc: '2.0',
87
+ id,
88
+ result: { isError: true, content: [{ type: 'text', text: String(error) }] }
89
+ };
90
+ }
91
+ return {
92
+ jsonrpc: '2.0',
93
+ id,
94
+ result: { content: [{ type: 'text', text: content }] }
95
+ };
96
+ }
97
+ return buildError(id, JSONRPC_METHOD_NOT_FOUND, `Method not found: ${method}`);
98
+ };
99
+ export const buildREserveConfiguration = (configuration) => ({
100
+ hostname: '127.0.0.1',
101
+ port: configuration.port ?? 3000,
102
+ mappings: [
103
+ {
104
+ method: 'GET',
105
+ match: '/mcp',
106
+ custom: (_request, response) => {
107
+ response.writeHead(HTTP_METHOD_NOT_ALLOWED, { 'Content-Type': 'application/json' });
108
+ response.end(JSON.stringify({ error: 'Method Not Allowed' }));
109
+ }
110
+ },
111
+ {
112
+ method: 'POST',
113
+ match: '/mcp',
114
+ custom: async (request, response) => {
115
+ let parsed;
116
+ try {
117
+ parsed = (await body(request).json());
118
+ }
119
+ catch {
120
+ response.writeHead(HTTP_BAD_REQUEST, { 'Content-Type': 'application/json' });
121
+ response.end(JSON.stringify(buildError(null, JSONRPC_PARSE_ERROR, 'Parse error')));
122
+ return;
123
+ }
124
+ const result = await handleRequest(parsed);
125
+ if (result === null) {
126
+ response.writeHead(HTTP_ACCEPTED);
127
+ response.end();
128
+ return;
129
+ }
130
+ const headers = { 'Content-Type': 'application/json' };
131
+ if (parsed.method === 'initialize') {
132
+ headers['Mcp-Session-Id'] = Crypto.randomUUID();
133
+ }
134
+ response.writeHead(HTTP_OK, headers);
135
+ response.end(JSON.stringify(result));
136
+ }
137
+ },
138
+ {
139
+ method: 'GET',
140
+ match: '/([^?]*)',
141
+ custom: async ({ url }, _response, path) => {
142
+ const filePath = Path.join(DOCS_DIR, path || 'index.md');
143
+ const fileName = Path.basename(path || 'index.md');
144
+ try {
145
+ await FileSystem.access(filePath, FileSystem.constants.R_OK);
146
+ }
147
+ catch {
148
+ return 404;
149
+ }
150
+ if (url?.endsWith('?raw')) {
151
+ const content = await FileSystem.readFile(filePath, 'utf8');
152
+ return [content, { headers: { 'content-type': 'text/markdown' } }];
153
+ }
154
+ return [
155
+ `<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
156
+ <script>
157
+ fetch('${fileName}?raw')
158
+ .then(response => response.text())
159
+ .then(content => {
160
+ document.body.innerHTML = marked.parse(content);
161
+ })
162
+ </script>`,
163
+ {
164
+ headers: {
165
+ 'content-type': 'text/html'
166
+ }
167
+ }
168
+ ];
169
+ }
170
+ },
171
+ {
172
+ status: 404
173
+ }
174
+ ]
175
+ });
@@ -0,0 +1,46 @@
1
+ import { getIndex } from '../knowledgeBase.js';
2
+ const buildRelatedTopics = (entity, index) => {
3
+ const seen = new Set();
4
+ const lines = [];
5
+ const add = (id, relation) => {
6
+ if (seen.has(id) || id === entity.id) {
7
+ return;
8
+ }
9
+ seen.add(id);
10
+ const related = index.getEntity(id);
11
+ const title = related === undefined ? id : `${id} — ${related.title}`;
12
+ lines.push(`- ${title} (${relation})`);
13
+ };
14
+ for (const [relation, targets] of Object.entries(entity.relations)) {
15
+ if (relation === 'breaking-in') {
16
+ continue;
17
+ }
18
+ for (const target of targets) {
19
+ add(target, relation);
20
+ }
21
+ }
22
+ for (const { id, relation } of index.getReverseRelations(entity.id)) {
23
+ add(id, `${relation} ←`);
24
+ }
25
+ return lines.length === 0 ? '' : `\n\n## Related topics\n\n${lines.join('\n')}\n`;
26
+ };
27
+ export const toolDefinitionGetTopic = {
28
+ definition: {
29
+ name: 'get_topic',
30
+ description: 'Fetch the authoritative ui5-test-runner documentation for one entity by its id (from list_topics, e.g. "options/coverage"). Use this to ground every answer about ui5-test-runner in the actual docs rather than prior knowledge. Returns the entity markdown plus a "Related topics" section listing linked entities worth retrieving next.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ id: { type: 'string', description: 'Entity id as returned by list_topics' }
35
+ },
36
+ required: ['id']
37
+ }
38
+ },
39
+ handler: (arguments_) => {
40
+ const id = arguments_['id'];
41
+ const index = getIndex();
42
+ const entity = index.getEntity(id);
43
+ const text = entity === undefined ? `No topic found for id "${id}".` : entity.body + buildRelatedTopics(entity, index);
44
+ return Promise.resolve(text);
45
+ }
46
+ };
@@ -0,0 +1,6 @@
1
+ import { toolDefinitionListTopics } from './listTopics.js';
2
+ import { toolDefinitionGetTopic } from './getTopic.js';
3
+ export const TOOLS = {
4
+ list_topics: toolDefinitionListTopics,
5
+ get_topic: toolDefinitionGetTopic
6
+ };
@@ -0,0 +1,14 @@
1
+ import { getIndex } from '../knowledgeBase.js';
2
+ const JSON_INDENT = 2;
3
+ export const toolDefinitionListTopics = {
4
+ definition: {
5
+ name: 'list_topics',
6
+ description: 'Authoritative documentation catalog for ui5-test-runner. Call this whenever the user asks anything about ui5-test-runner — its options, flags, configuration keys, modes, coverage, reporting, migration, or error messages — BEFORE answering, and do not rely on prior knowledge (it is often wrong or outdated for this tool). Returns a flat catalog (id, type, title, summary, relations); match the question against the titles and summaries, then pass the chosen id to get_topic.',
7
+ inputSchema: {
8
+ type: 'object',
9
+ properties: {},
10
+ required: []
11
+ }
12
+ },
13
+ handler: (_arguments) => Promise.resolve(JSON.stringify(getIndex().catalog, null, JSON_INDENT))
14
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,99 @@
1
+ import { assert, Exit, logger, Thread } from '../../platform/index.js';
2
+ import { serve } from 'reserve';
3
+ import { toPlainObject } from '../../utils/shared/object.js';
4
+ import { redactSecrets } from '../../utils/node/anonymize.js';
5
+ import { buildREserveConfiguration } from './reserve.js';
6
+ import { logReserve } from '../../reserveLogger.js';
7
+ let _channel;
8
+ let _serverWorker;
9
+ let _isStopping = false;
10
+ export const Server = {
11
+ async start(configuration) {
12
+ assert(_serverWorker === undefined);
13
+ _channel = Thread.createBroadcastChannel('server');
14
+ Exit.registerAsyncTask({
15
+ name: 'server',
16
+ stop: () => Server.stop()
17
+ });
18
+ _serverWorker = Thread.createWorker('modes/test/Server', redactSecrets(toPlainObject(configuration)));
19
+ const { promise, resolve, reject } = Promise.withResolvers();
20
+ _channel.onmessage = ({ data: message }) => {
21
+ if (message.command === 'ready') {
22
+ resolve(message.port);
23
+ }
24
+ else if (message.command === 'error') {
25
+ reject(new Error('failed to start'));
26
+ }
27
+ else {
28
+ assert(false, 'unexpected');
29
+ }
30
+ };
31
+ return promise;
32
+ },
33
+ async stop() {
34
+ if (_isStopping) {
35
+ return;
36
+ }
37
+ _isStopping = true;
38
+ try {
39
+ assert(_serverWorker !== undefined);
40
+ _channel.postMessage({
41
+ command: 'terminate'
42
+ });
43
+ const { promise, resolve } = Promise.withResolvers();
44
+ _channel.onmessage = ({ data: message }) => {
45
+ if (message.command !== 'terminated') {
46
+ return;
47
+ }
48
+ _channel.close();
49
+ _serverWorker?.unref();
50
+ resolve();
51
+ };
52
+ await promise;
53
+ }
54
+ finally {
55
+ _channel.close();
56
+ }
57
+ }
58
+ };
59
+ export const workerMain = async (configuration) => {
60
+ logger.debug({ source: 'server', message: 'starting server...' });
61
+ _channel = Thread.createBroadcastChannel('server');
62
+ let reserveServer;
63
+ _channel.onmessage = ({ data: message }) => {
64
+ if (message.command !== 'terminate') {
65
+ return;
66
+ }
67
+ logger.debug({ source: 'server', message: 'stopping server...' });
68
+ void reserveServer.close({ force: true }).finally(() => {
69
+ logger.debug({ source: 'server', message: 'server stopped.' });
70
+ _channel.postMessage({
71
+ command: 'terminated'
72
+ });
73
+ _channel.close();
74
+ });
75
+ };
76
+ try {
77
+ reserveServer = serve(await buildREserveConfiguration(configuration));
78
+ }
79
+ catch (error) {
80
+ logger.error({ source: 'server', message: 'an error occurred while configuring', error });
81
+ _channel.postMessage({
82
+ command: 'error'
83
+ });
84
+ return;
85
+ }
86
+ logReserve(reserveServer);
87
+ reserveServer
88
+ .on('ready', (event) => {
89
+ _channel.postMessage({
90
+ command: 'ready',
91
+ port: event.port
92
+ });
93
+ })
94
+ .on('error', () => {
95
+ _channel.postMessage({
96
+ command: 'error'
97
+ });
98
+ });
99
+ };
@@ -1,24 +1,37 @@
1
- import { BrowserFactory } from '../../browsers/factory.js';
2
- import { Exit, assert, logger } from '../../platform/index.js';
3
- let browser;
1
+ import { BrowserFactory, getDescriptor, isDriver } from '../../browsers/factory.js';
2
+ import { Exit, logger } from '../../platform/index.js';
3
+ let _browser;
4
+ let _capabilities;
4
5
  export const setupBrowser = async (configuration) => {
5
- assert(configuration.browser === 'puppeteer');
6
- browser = await BrowserFactory.build(configuration, 'puppeteer');
7
- const { debugKeepBrowserOpen } = configuration;
6
+ const { driver } = configuration;
7
+ if (!isDriver(driver)) {
8
+ return logger.fatal({ source: 'job', message: `Unknown driver: ${driver}` });
9
+ }
10
+ _browser = await BrowserFactory.build(configuration, driver);
11
+ const { debugKeepBrowserOpen, browserVisible, browserViewportWidth, browserViewportHeight, browserDevice } = configuration;
8
12
  const settings = {
9
- visible: debugKeepBrowserOpen
13
+ visible: browserVisible || debugKeepBrowserOpen,
14
+ viewport: {
15
+ width: browserViewportWidth,
16
+ height: browserViewportHeight
17
+ },
18
+ device: browserDevice,
19
+ browser: configuration.browser ?? getDescriptor(driver).defaultBrowser,
20
+ options: configuration.browserOptions,
21
+ basicAuthentication: configuration.browserBasicAuthUsername !== undefined && configuration.browserBasicAuthPassword !== undefined
22
+ ? { username: configuration.browserBasicAuthUsername, password: configuration.browserBasicAuthPassword }
23
+ : undefined
10
24
  };
11
25
  try {
12
- await browser.setup(settings);
26
+ _capabilities = await _browser.setup(settings);
13
27
  }
14
28
  catch (error) {
15
- logger.fatal({ source: 'job', message: 'Unable to setup browser', error });
16
- throw error;
29
+ logger.fatal({ source: 'job', message: 'unable to setup browser', error });
17
30
  }
18
31
  if (debugKeepBrowserOpen) {
19
- const { newWindow } = browser;
20
- browser.newWindow = async (settings) => {
21
- const window = await newWindow.call(browser, settings);
32
+ const { newWindow } = _browser;
33
+ _browser.newWindow = async (settings) => {
34
+ const window = await newWindow.call(_browser, settings);
22
35
  window.close = async () => { };
23
36
  return window;
24
37
  };
@@ -27,11 +40,12 @@ export const setupBrowser = async (configuration) => {
27
40
  name: 'debugKeepBrowserOpen',
28
41
  stop: () => resolve()
29
42
  });
30
- browser.shutdown = () => {
31
- logger.warn({ source: 'job', message: 'Browser will remain open, use CTRL+C to end command' });
43
+ _browser.shutdown = () => {
44
+ logger.warn({ source: 'job', message: 'browser will remain open, use CTRL+C to end command' });
32
45
  return promise;
33
46
  };
34
47
  }
35
- return browser;
48
+ return _capabilities;
36
49
  };
37
- export const getBrowser = () => browser;
50
+ export const getBrowser = () => _browser;
51
+ export const getBrowserCapabilities = () => _capabilities;
@@ -0,0 +1,17 @@
1
+ import { options } from '../../configuration/options.js';
2
+ import { toPlainObject, pick } from '../../utils/shared/object.js';
3
+ import { assert } from '../../platform/index.js';
4
+ const UI5_TEST_RUNNER = 'ui5-test-runner';
5
+ const browserExposedKeys = options
6
+ .filter((option) => 'browserExposed' in option)
7
+ .map(({ name }) => name);
8
+ const serializeRegExps = (_key, value) => (value instanceof RegExp ? value.source : value);
9
+ let _browserConfigScript;
10
+ export const initBrowserConfig = (configuration) => {
11
+ const payload = pick(toPlainObject(configuration), browserExposedKeys);
12
+ _browserConfigScript = `(function(){window['${UI5_TEST_RUNNER}']=window['${UI5_TEST_RUNNER}']||{};window['${UI5_TEST_RUNNER}'].config=${JSON.stringify(payload, serializeRegExps)};})();`;
13
+ };
14
+ export const getBrowserConfigScript = (pageId) => {
15
+ assert(_browserConfigScript !== undefined, 'browserConfig not initialized');
16
+ return _browserConfigScript + `(function(){window['${UI5_TEST_RUNNER}'].config.pageId=${pageId};})();`;
17
+ };