@democratize-quality/mcp-server 1.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.
- package/LICENSE +15 -0
- package/README.md +423 -0
- package/browserControl.js +113 -0
- package/cli.js +187 -0
- package/docs/api/tool-reference.md +317 -0
- package/docs/api_tools_usage.md +477 -0
- package/docs/development/adding-tools.md +274 -0
- package/docs/development/configuration.md +332 -0
- package/docs/examples/authentication.md +124 -0
- package/docs/examples/basic-automation.md +105 -0
- package/docs/getting-started.md +214 -0
- package/docs/index.md +61 -0
- package/mcpServer.js +280 -0
- package/package.json +83 -0
- package/run-server.js +140 -0
- package/src/config/environments/api-only.js +53 -0
- package/src/config/environments/development.js +54 -0
- package/src/config/environments/production.js +69 -0
- package/src/config/index.js +341 -0
- package/src/config/server.js +41 -0
- package/src/config/tools/api.js +67 -0
- package/src/config/tools/browser.js +90 -0
- package/src/config/tools/default.js +32 -0
- package/src/services/browserService.js +325 -0
- package/src/tools/api/api-request.js +641 -0
- package/src/tools/api/api-session-report.js +1262 -0
- package/src/tools/api/api-session-status.js +395 -0
- package/src/tools/base/ToolBase.js +230 -0
- package/src/tools/base/ToolRegistry.js +269 -0
- package/src/tools/browser/advanced/browser-console.js +384 -0
- package/src/tools/browser/advanced/browser-dialog.js +319 -0
- package/src/tools/browser/advanced/browser-evaluate.js +337 -0
- package/src/tools/browser/advanced/browser-file.js +480 -0
- package/src/tools/browser/advanced/browser-keyboard.js +343 -0
- package/src/tools/browser/advanced/browser-mouse.js +332 -0
- package/src/tools/browser/advanced/browser-network.js +421 -0
- package/src/tools/browser/advanced/browser-pdf.js +407 -0
- package/src/tools/browser/advanced/browser-tabs.js +497 -0
- package/src/tools/browser/advanced/browser-wait.js +378 -0
- package/src/tools/browser/click.js +168 -0
- package/src/tools/browser/close.js +60 -0
- package/src/tools/browser/dom.js +70 -0
- package/src/tools/browser/launch.js +67 -0
- package/src/tools/browser/navigate.js +270 -0
- package/src/tools/browser/screenshot.js +351 -0
- package/src/tools/browser/type.js +174 -0
- package/src/tools/index.js +95 -0
- package/src/utils/browserHelpers.js +83 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
const ToolBase = require('../../base/ToolBase');
|
|
2
|
+
const browserService = require('../../../services/browserService');
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Enhanced File Tool - Handle file uploads, downloads, and file system interactions
|
|
8
|
+
* Inspired by Playwright MCP file handling capabilities
|
|
9
|
+
*/
|
|
10
|
+
class BrowserFileTool extends ToolBase {
|
|
11
|
+
static definition = {
|
|
12
|
+
name: "browser_file",
|
|
13
|
+
description: "Handle file operations including uploads, downloads, and file input interactions. Supports various file formats and validation.",
|
|
14
|
+
input_schema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
browserId: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "The ID of the browser instance"
|
|
20
|
+
},
|
|
21
|
+
action: {
|
|
22
|
+
type: "string",
|
|
23
|
+
enum: ["upload", "download", "setDownloadPath", "getDownloads", "clearDownloads"],
|
|
24
|
+
description: "The file operation to perform"
|
|
25
|
+
},
|
|
26
|
+
selector: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "CSS selector for file input element (for upload action)"
|
|
29
|
+
},
|
|
30
|
+
filePath: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "Path to the file to upload or download location"
|
|
33
|
+
},
|
|
34
|
+
files: {
|
|
35
|
+
type: "array",
|
|
36
|
+
items: { type: "string" },
|
|
37
|
+
description: "Array of file paths for multiple file upload"
|
|
38
|
+
},
|
|
39
|
+
downloadPath: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Directory path for downloads (for setDownloadPath action)"
|
|
42
|
+
},
|
|
43
|
+
url: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Direct download URL (for download action without clicking)"
|
|
46
|
+
},
|
|
47
|
+
fileName: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Specific filename for download"
|
|
50
|
+
},
|
|
51
|
+
timeout: {
|
|
52
|
+
type: "number",
|
|
53
|
+
default: 30000,
|
|
54
|
+
description: "Timeout in milliseconds for file operations"
|
|
55
|
+
},
|
|
56
|
+
waitForDownload: {
|
|
57
|
+
type: "boolean",
|
|
58
|
+
default: true,
|
|
59
|
+
description: "Whether to wait for download completion"
|
|
60
|
+
},
|
|
61
|
+
overwrite: {
|
|
62
|
+
type: "boolean",
|
|
63
|
+
default: false,
|
|
64
|
+
description: "Whether to overwrite existing files"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
required: ["browserId", "action"]
|
|
68
|
+
},
|
|
69
|
+
output_schema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
success: { type: "boolean", description: "Whether the operation was successful" },
|
|
73
|
+
action: { type: "string", description: "The action that was performed" },
|
|
74
|
+
filePath: { type: "string", description: "Path of the uploaded/downloaded file" },
|
|
75
|
+
files: {
|
|
76
|
+
type: "array",
|
|
77
|
+
items: {
|
|
78
|
+
type: "object",
|
|
79
|
+
properties: {
|
|
80
|
+
name: { type: "string" },
|
|
81
|
+
path: { type: "string" },
|
|
82
|
+
size: { type: "number" },
|
|
83
|
+
type: { type: "string" },
|
|
84
|
+
url: { type: "string" }
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
description: "List of files"
|
|
88
|
+
},
|
|
89
|
+
downloadInfo: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: {
|
|
92
|
+
url: { type: "string" },
|
|
93
|
+
filename: { type: "string" },
|
|
94
|
+
state: { type: "string" },
|
|
95
|
+
totalBytes: { type: "number" },
|
|
96
|
+
receivedBytes: { type: "number" }
|
|
97
|
+
},
|
|
98
|
+
description: "Download progress information"
|
|
99
|
+
},
|
|
100
|
+
message: { type: "string", description: "Operation result message" },
|
|
101
|
+
browserId: { type: "string", description: "Browser instance ID" }
|
|
102
|
+
},
|
|
103
|
+
required: ["success", "action", "browserId"]
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
constructor() {
|
|
108
|
+
super();
|
|
109
|
+
this.downloadCallbacks = new Map(); // browserId -> callback functions
|
|
110
|
+
this.downloadStates = new Map(); // browserId -> download states
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async execute(parameters) {
|
|
114
|
+
const {
|
|
115
|
+
browserId,
|
|
116
|
+
action,
|
|
117
|
+
selector,
|
|
118
|
+
filePath,
|
|
119
|
+
files = [],
|
|
120
|
+
downloadPath,
|
|
121
|
+
url,
|
|
122
|
+
fileName,
|
|
123
|
+
timeout = 30000,
|
|
124
|
+
waitForDownload = true,
|
|
125
|
+
overwrite = false
|
|
126
|
+
} = parameters;
|
|
127
|
+
|
|
128
|
+
const browser = browserService.getBrowserInstance(browserId);
|
|
129
|
+
if (!browser) {
|
|
130
|
+
throw new Error(`Browser instance '${browserId}' not found`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const client = browser.client;
|
|
134
|
+
|
|
135
|
+
let result = {
|
|
136
|
+
success: false,
|
|
137
|
+
action: action,
|
|
138
|
+
browserId: browserId
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
switch (action) {
|
|
142
|
+
case 'upload':
|
|
143
|
+
if (!selector) {
|
|
144
|
+
throw new Error('Selector is required for upload action');
|
|
145
|
+
}
|
|
146
|
+
const uploadPaths = filePath ? [filePath] : files;
|
|
147
|
+
if (uploadPaths.length === 0) {
|
|
148
|
+
throw new Error('Either filePath or files array is required for upload');
|
|
149
|
+
}
|
|
150
|
+
await this.uploadFiles(client, selector, uploadPaths);
|
|
151
|
+
result.success = true;
|
|
152
|
+
result.files = await this.getFileInfo(uploadPaths);
|
|
153
|
+
result.message = `Uploaded ${uploadPaths.length} file(s)`;
|
|
154
|
+
break;
|
|
155
|
+
|
|
156
|
+
case 'download':
|
|
157
|
+
if (!url && !selector) {
|
|
158
|
+
throw new Error('Either URL or selector is required for download action');
|
|
159
|
+
}
|
|
160
|
+
const downloadResult = await this.downloadFile(client, url, selector, downloadPath, fileName, timeout, waitForDownload);
|
|
161
|
+
result.success = true;
|
|
162
|
+
result.downloadInfo = downloadResult;
|
|
163
|
+
result.filePath = downloadResult.path;
|
|
164
|
+
result.message = 'Download completed';
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case 'setDownloadPath':
|
|
168
|
+
if (!downloadPath) {
|
|
169
|
+
throw new Error('Download path is required for setDownloadPath action');
|
|
170
|
+
}
|
|
171
|
+
await this.setDownloadPath(client, downloadPath);
|
|
172
|
+
result.success = true;
|
|
173
|
+
result.message = `Download path set to: ${downloadPath}`;
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
case 'getDownloads':
|
|
177
|
+
const downloads = this.getDownloadHistory(browserId);
|
|
178
|
+
result.success = true;
|
|
179
|
+
result.files = downloads;
|
|
180
|
+
result.message = `Found ${downloads.length} download(s)`;
|
|
181
|
+
break;
|
|
182
|
+
|
|
183
|
+
case 'clearDownloads':
|
|
184
|
+
this.clearDownloadHistory(browserId);
|
|
185
|
+
result.success = true;
|
|
186
|
+
result.message = 'Download history cleared';
|
|
187
|
+
break;
|
|
188
|
+
|
|
189
|
+
default:
|
|
190
|
+
throw new Error(`Unsupported file action: ${action}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Upload files to a file input element
|
|
198
|
+
*/
|
|
199
|
+
async uploadFiles(client, selector, filePaths) {
|
|
200
|
+
// Validate files exist
|
|
201
|
+
for (const filePath of filePaths) {
|
|
202
|
+
try {
|
|
203
|
+
await fs.access(filePath);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw new Error(`File not found: ${filePath}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Enable DOM and Runtime domains
|
|
210
|
+
await client.DOM.enable();
|
|
211
|
+
await client.Runtime.enable();
|
|
212
|
+
|
|
213
|
+
// Find the file input element
|
|
214
|
+
const document = await client.DOM.getDocument();
|
|
215
|
+
const element = await client.DOM.querySelector({
|
|
216
|
+
nodeId: document.root.nodeId,
|
|
217
|
+
selector: selector
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!element.nodeId) {
|
|
221
|
+
throw new Error(`File input element not found with selector: ${selector}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Get element attributes to verify it's a file input
|
|
225
|
+
const attributes = await client.DOM.getAttributes({
|
|
226
|
+
nodeId: element.nodeId
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const attrMap = {};
|
|
230
|
+
for (let i = 0; i < attributes.attributes.length; i += 2) {
|
|
231
|
+
attrMap[attributes.attributes[i]] = attributes.attributes[i + 1];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (attrMap.type !== 'file') {
|
|
235
|
+
throw new Error(`Element is not a file input: ${JSON.stringify(attrMap)}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Set files on the input element
|
|
239
|
+
await client.DOM.setFileInputFiles({
|
|
240
|
+
files: filePaths,
|
|
241
|
+
nodeId: element.nodeId
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Trigger change event
|
|
245
|
+
await client.Runtime.evaluate({
|
|
246
|
+
expression: `
|
|
247
|
+
(function() {
|
|
248
|
+
const element = document.querySelector('${selector}');
|
|
249
|
+
if (element) {
|
|
250
|
+
const event = new Event('change', { bubbles: true });
|
|
251
|
+
element.dispatchEvent(event);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
})()
|
|
256
|
+
`
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Download a file either by URL or by clicking an element
|
|
262
|
+
*/
|
|
263
|
+
async downloadFile(client, url, selector, downloadPath, fileName, timeout, waitForDownload) {
|
|
264
|
+
// Enable Page domain for download events
|
|
265
|
+
await client.Page.enable();
|
|
266
|
+
|
|
267
|
+
let downloadInfo = null;
|
|
268
|
+
const downloadPromise = new Promise((resolve, reject) => {
|
|
269
|
+
const timeoutId = setTimeout(() => {
|
|
270
|
+
reject(new Error(`Download timeout after ${timeout}ms`));
|
|
271
|
+
}, timeout);
|
|
272
|
+
|
|
273
|
+
// Listen for download events
|
|
274
|
+
client.Page.downloadWillBegin((params) => {
|
|
275
|
+
clearTimeout(timeoutId);
|
|
276
|
+
downloadInfo = {
|
|
277
|
+
url: params.url,
|
|
278
|
+
filename: params.suggestedFilename,
|
|
279
|
+
guid: params.guid
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
client.Page.downloadProgress((params) => {
|
|
284
|
+
if (downloadInfo && params.guid === downloadInfo.guid) {
|
|
285
|
+
downloadInfo.state = params.state;
|
|
286
|
+
downloadInfo.totalBytes = params.totalBytes;
|
|
287
|
+
downloadInfo.receivedBytes = params.receivedBytes;
|
|
288
|
+
|
|
289
|
+
if (params.state === 'completed') {
|
|
290
|
+
resolve(downloadInfo);
|
|
291
|
+
} else if (params.state === 'canceled' || params.state === 'interrupted') {
|
|
292
|
+
reject(new Error(`Download ${params.state}: ${downloadInfo.filename}`));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Set download behavior if path is specified
|
|
299
|
+
if (downloadPath) {
|
|
300
|
+
await this.setDownloadPath(client, downloadPath);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Initiate download
|
|
304
|
+
if (url) {
|
|
305
|
+
// Direct URL download
|
|
306
|
+
await client.Page.navigate({ url: url });
|
|
307
|
+
} else if (selector) {
|
|
308
|
+
// Click element to trigger download
|
|
309
|
+
await client.Runtime.evaluate({
|
|
310
|
+
expression: `
|
|
311
|
+
(function() {
|
|
312
|
+
const element = document.querySelector('${selector}');
|
|
313
|
+
if (element) {
|
|
314
|
+
element.click();
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
318
|
+
})()
|
|
319
|
+
`
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Wait for download if requested
|
|
324
|
+
if (waitForDownload) {
|
|
325
|
+
downloadInfo = await downloadPromise;
|
|
326
|
+
|
|
327
|
+
// Store download info
|
|
328
|
+
this.addDownloadToHistory(browserService.getBrowserInstance(client.browserId)?.id || 'unknown', downloadInfo);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return downloadInfo || { state: 'initiated' };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Set the download directory
|
|
336
|
+
*/
|
|
337
|
+
async setDownloadPath(client, downloadPath) {
|
|
338
|
+
// Ensure directory exists
|
|
339
|
+
try {
|
|
340
|
+
await fs.mkdir(downloadPath, { recursive: true });
|
|
341
|
+
} catch (error) {
|
|
342
|
+
throw new Error(`Could not create download directory: ${error.message}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Set download behavior
|
|
346
|
+
await client.Page.setDownloadBehavior({
|
|
347
|
+
behavior: 'allow',
|
|
348
|
+
downloadPath: downloadPath
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Get file information for uploaded files
|
|
354
|
+
*/
|
|
355
|
+
async getFileInfo(filePaths) {
|
|
356
|
+
const fileInfos = [];
|
|
357
|
+
|
|
358
|
+
for (const filePath of filePaths) {
|
|
359
|
+
try {
|
|
360
|
+
const stats = await fs.stat(filePath);
|
|
361
|
+
const info = {
|
|
362
|
+
name: path.basename(filePath),
|
|
363
|
+
path: filePath,
|
|
364
|
+
size: stats.size,
|
|
365
|
+
type: this.getFileType(filePath),
|
|
366
|
+
lastModified: stats.mtime
|
|
367
|
+
};
|
|
368
|
+
fileInfos.push(info);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
fileInfos.push({
|
|
371
|
+
name: path.basename(filePath),
|
|
372
|
+
path: filePath,
|
|
373
|
+
error: error.message
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return fileInfos;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Get file type based on extension
|
|
383
|
+
*/
|
|
384
|
+
getFileType(filePath) {
|
|
385
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
386
|
+
const mimeTypes = {
|
|
387
|
+
'.txt': 'text/plain',
|
|
388
|
+
'.pdf': 'application/pdf',
|
|
389
|
+
'.doc': 'application/msword',
|
|
390
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
391
|
+
'.xls': 'application/vnd.ms-excel',
|
|
392
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
393
|
+
'.png': 'image/png',
|
|
394
|
+
'.jpg': 'image/jpeg',
|
|
395
|
+
'.jpeg': 'image/jpeg',
|
|
396
|
+
'.gif': 'image/gif',
|
|
397
|
+
'.mp4': 'video/mp4',
|
|
398
|
+
'.zip': 'application/zip',
|
|
399
|
+
'.json': 'application/json',
|
|
400
|
+
'.csv': 'text/csv'
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
return mimeTypes[ext] || 'application/octet-stream';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Add download to history
|
|
408
|
+
*/
|
|
409
|
+
addDownloadToHistory(browserId, downloadInfo) {
|
|
410
|
+
if (!this.downloadStates.has(browserId)) {
|
|
411
|
+
this.downloadStates.set(browserId, []);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const downloads = this.downloadStates.get(browserId);
|
|
415
|
+
downloads.push({
|
|
416
|
+
...downloadInfo,
|
|
417
|
+
timestamp: new Date().toISOString()
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Keep only last 100 downloads
|
|
421
|
+
if (downloads.length > 100) {
|
|
422
|
+
downloads.splice(0, downloads.length - 100);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Get download history for a browser
|
|
428
|
+
*/
|
|
429
|
+
getDownloadHistory(browserId) {
|
|
430
|
+
return this.downloadStates.get(browserId) || [];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Clear download history for a browser
|
|
435
|
+
*/
|
|
436
|
+
clearDownloadHistory(browserId) {
|
|
437
|
+
this.downloadStates.delete(browserId);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Create a temporary file for testing
|
|
442
|
+
*/
|
|
443
|
+
async createTempFile(content, extension = '.txt') {
|
|
444
|
+
const tempDir = process.env.TMPDIR || '/tmp';
|
|
445
|
+
const fileName = `temp-${Date.now()}${extension}`;
|
|
446
|
+
const filePath = path.join(tempDir, fileName);
|
|
447
|
+
|
|
448
|
+
await fs.writeFile(filePath, content);
|
|
449
|
+
return filePath;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Validate file upload constraints
|
|
454
|
+
*/
|
|
455
|
+
validateFileUpload(filePath, constraints = {}) {
|
|
456
|
+
const stats = require('fs').statSync(filePath);
|
|
457
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
458
|
+
|
|
459
|
+
const validation = {
|
|
460
|
+
valid: true,
|
|
461
|
+
errors: []
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// Check file size
|
|
465
|
+
if (constraints.maxSize && stats.size > constraints.maxSize) {
|
|
466
|
+
validation.valid = false;
|
|
467
|
+
validation.errors.push(`File size ${stats.size} exceeds maximum ${constraints.maxSize}`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Check file type
|
|
471
|
+
if (constraints.allowedTypes && !constraints.allowedTypes.includes(ext)) {
|
|
472
|
+
validation.valid = false;
|
|
473
|
+
validation.errors.push(`File type ${ext} not allowed. Allowed: ${constraints.allowedTypes.join(', ')}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return validation;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
module.exports = BrowserFileTool;
|