Skip to content

Commit ef6c3a9

Browse files
DavertMikDavertMikclaude
authored
feat: add dropzone support to attachFile (#5480)
When the target element is not an <input type="file">, attachFile now falls back to dispatching synthetic drag-and-drop events (dragenter, dragover, drop) with a DataTransfer containing the file. This enables file uploads to dropzone libraries (Dropzone.js, react-dropzone, etc.). Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bdc5c67 commit ef6c3a9

File tree

7 files changed

+162
-14
lines changed

7 files changed

+162
-14
lines changed

docs/webapi/attachFile.mustache

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ I.attachFile('form input[name=avatar]', 'data/avatar.jpg');
1111
I.attachFile('Avatar', 'data/avatar.jpg', '.form-container');
1212
```
1313

14+
If the locator points to a non-file-input element (e.g., a dropzone area),
15+
the file will be dropped onto that element using drag-and-drop events.
16+
17+
```js
18+
I.attachFile('#dropzone', 'data/avatar.jpg');
19+
```
20+
1421
@param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
1522
@param {string} pathToFile local file path relative to codecept.conf.ts or codecept.conf.js config file.
1623
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.

lib/helper/Playwright.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
normalizePath,
2727
resolveUrl,
2828
relativeDir,
29+
getMimeType,
30+
base64EncodeFile,
2931
} from '../utils.js'
3032
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
3133
import ElementNotFound from './errors/ElementNotFound.js'
@@ -2334,8 +2336,31 @@ class Playwright extends Helper {
23342336
throw new Error(`File at ${file} can not be found on local system`)
23352337
}
23362338
const els = await findFields.call(this, locator, context)
2337-
assertElementExists(els, locator, 'Field')
2338-
await els[0].setInputFiles(file)
2339+
if (els.length) {
2340+
const tag = await els[0].evaluate(el => el.tagName)
2341+
const type = await els[0].evaluate(el => el.type)
2342+
if (tag === 'INPUT' && type === 'file') {
2343+
await els[0].setInputFiles(file)
2344+
return this._waitForAction()
2345+
}
2346+
}
2347+
2348+
const targetEls = els.length ? els : await this._locate(locator)
2349+
assertElementExists(targetEls, locator, 'Element')
2350+
const base64Content = base64EncodeFile(file)
2351+
const fileName = path.basename(file)
2352+
const mimeType = getMimeType(fileName)
2353+
await targetEls[0].evaluate((el, { base64Content, fileName, mimeType }) => {
2354+
const binaryStr = atob(base64Content)
2355+
const bytes = new Uint8Array(binaryStr.length)
2356+
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
2357+
const fileObj = new File([bytes], fileName, { type: mimeType })
2358+
const dataTransfer = new DataTransfer()
2359+
dataTransfer.items.add(fileObj)
2360+
el.dispatchEvent(new DragEvent('dragenter', { dataTransfer, bubbles: true }))
2361+
el.dispatchEvent(new DragEvent('dragover', { dataTransfer, bubbles: true }))
2362+
el.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true }))
2363+
}, { base64Content, fileName, mimeType })
23392364
return this._waitForAction()
23402365
}
23412366

lib/helper/Puppeteer.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
normalizeSpacesInString,
2929
normalizePath,
3030
resolveUrl,
31+
getMimeType,
32+
base64EncodeFile,
3133
} from '../utils.js'
3234
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
3335
import ElementNotFound from './errors/ElementNotFound.js'
@@ -1626,8 +1628,31 @@ class Puppeteer extends Helper {
16261628
throw new Error(`File at ${file} can not be found on local system`)
16271629
}
16281630
const els = await findFields.call(this, locator, context)
1629-
assertElementExists(els, locator, 'Field')
1630-
await els[0].uploadFile(file)
1631+
if (els.length) {
1632+
const tag = await els[0].evaluate(el => el.tagName)
1633+
const type = await els[0].evaluate(el => el.type)
1634+
if (tag === 'INPUT' && type === 'file') {
1635+
await els[0].uploadFile(file)
1636+
return this._waitForAction()
1637+
}
1638+
}
1639+
1640+
const targetEls = els.length ? els : await this._locate(locator)
1641+
assertElementExists(targetEls, locator, 'Element')
1642+
const base64Content = base64EncodeFile(file)
1643+
const fileName = path.basename(file)
1644+
const mimeType = getMimeType(fileName)
1645+
await targetEls[0].evaluate((el, { base64Content, fileName, mimeType }) => {
1646+
const binaryStr = atob(base64Content)
1647+
const bytes = new Uint8Array(binaryStr.length)
1648+
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
1649+
const fileObj = new File([bytes], fileName, { type: mimeType })
1650+
const dataTransfer = new DataTransfer()
1651+
dataTransfer.items.add(fileObj)
1652+
el.dispatchEvent(new DragEvent('dragenter', { dataTransfer, bubbles: true }))
1653+
el.dispatchEvent(new DragEvent('dragover', { dataTransfer, bubbles: true }))
1654+
el.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true }))
1655+
}, { base64Content, fileName, mimeType })
16311656
return this._waitForAction()
16321657
}
16331658

lib/helper/WebDriver.js

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
let webdriverio
22

3+
import fs from 'fs'
34
import assert from 'assert'
45
import path from 'path'
56
import crypto from 'crypto'
@@ -24,6 +25,8 @@ import {
2425
modifierKeys,
2526
normalizePath,
2627
resolveUrl,
28+
getMimeType,
29+
base64EncodeFile,
2730
} from '../utils.js'
2831
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
2932
import ElementNotFound from './errors/ElementNotFound.js'
@@ -1352,20 +1355,41 @@ class WebDriver extends Helper {
13521355

13531356
const res = await findFields.call(this, locator, context)
13541357
this.debug(`Uploading ${file}`)
1355-
assertElementExists(res, locator, 'File field')
1356-
const el = usingFirstElement(res)
13571358

1358-
// Remote Upload (when running Selenium Server)
1359-
if (this.options.remoteFileUpload) {
1360-
try {
1361-
this.debugSection('File', 'Uploading file to remote server')
1362-
file = await this.browser.uploadFile(file)
1363-
} catch (err) {
1364-
throw new Error(`File can't be transferred to remote server. Set \`remoteFileUpload: false\` in config to upload file locally.\n${err.message}`)
1359+
if (res.length) {
1360+
const el = usingFirstElement(res)
1361+
const tag = await this.browser.execute(function (elem) { return elem.tagName }, el)
1362+
const type = await this.browser.execute(function (elem) { return elem.type }, el)
1363+
if (tag === 'INPUT' && type === 'file') {
1364+
if (this.options.remoteFileUpload) {
1365+
try {
1366+
this.debugSection('File', 'Uploading file to remote server')
1367+
file = await this.browser.uploadFile(file)
1368+
} catch (err) {
1369+
throw new Error(`File can't be transferred to remote server. Set \`remoteFileUpload: false\` in config to upload file locally.\n${err.message}`)
1370+
}
1371+
}
1372+
return el.addValue(file)
13651373
}
13661374
}
13671375

1368-
return el.addValue(file)
1376+
const targetRes = res.length ? res : await this._locate(locator)
1377+
assertElementExists(targetRes, locator, 'Element')
1378+
const targetEl = usingFirstElement(targetRes)
1379+
const base64Content = base64EncodeFile(file)
1380+
const fileName = path.basename(file)
1381+
const mimeType = getMimeType(fileName)
1382+
return this.browser.execute(function (el, data) {
1383+
var binaryStr = atob(data.base64Content)
1384+
var bytes = new Uint8Array(binaryStr.length)
1385+
for (var i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
1386+
var fileObj = new File([bytes], data.fileName, { type: data.mimeType })
1387+
var dataTransfer = new DataTransfer()
1388+
dataTransfer.items.add(fileObj)
1389+
el.dispatchEvent(new DragEvent('dragenter', { dataTransfer: dataTransfer, bubbles: true }))
1390+
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: dataTransfer, bubbles: true }))
1391+
el.dispatchEvent(new DragEvent('drop', { dataTransfer: dataTransfer, bubbles: true }))
1392+
}, targetEl, { base64Content, fileName, mimeType })
13691393
}
13701394

13711395
/**

lib/utils.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,36 @@ export const base64EncodeFile = function (filePath) {
658658
return Buffer.from(fs.readFileSync(filePath)).toString('base64')
659659
}
660660

661+
export const getMimeType = function (fileName) {
662+
const ext = path.extname(fileName).toLowerCase()
663+
const mimeTypes = {
664+
'.jpg': 'image/jpeg',
665+
'.jpeg': 'image/jpeg',
666+
'.png': 'image/png',
667+
'.gif': 'image/gif',
668+
'.bmp': 'image/bmp',
669+
'.svg': 'image/svg+xml',
670+
'.webp': 'image/webp',
671+
'.pdf': 'application/pdf',
672+
'.txt': 'text/plain',
673+
'.html': 'text/html',
674+
'.css': 'text/css',
675+
'.js': 'application/javascript',
676+
'.json': 'application/json',
677+
'.xml': 'application/xml',
678+
'.zip': 'application/zip',
679+
'.csv': 'text/csv',
680+
'.doc': 'application/msword',
681+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
682+
'.xls': 'application/vnd.ms-excel',
683+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
684+
'.mp3': 'audio/mpeg',
685+
'.mp4': 'video/mp4',
686+
'.wav': 'audio/wav',
687+
}
688+
return mimeTypes[ext] || 'application/octet-stream'
689+
}
690+
661691
export const markdownToAnsi = function (markdown) {
662692
return (
663693
markdown
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<html><body>
2+
<div id="droparea" style="width:300px;height:200px;border:2px dashed #ccc;padding:20px;">
3+
<p id="status">Drop files here</p>
4+
</div>
5+
<div id="file-list"></div>
6+
<script>
7+
var droparea = document.getElementById('droparea');
8+
droparea.addEventListener('dragenter', function(e) { e.preventDefault(); });
9+
droparea.addEventListener('dragover', function(e) { e.preventDefault(); });
10+
droparea.addEventListener('drop', function(e) {
11+
e.preventDefault();
12+
var files = e.dataTransfer.files;
13+
document.getElementById('status').textContent = 'Dropped ' + files.length + ' file(s)';
14+
var list = document.getElementById('file-list');
15+
list.innerHTML = '';
16+
for (var i = 0; i < files.length; i++) {
17+
var div = document.createElement('div');
18+
div.className = 'file-info';
19+
div.textContent = files[i].name + ' - ' + files[i].type + ' - ' + files[i].size;
20+
list.appendChild(div);
21+
}
22+
});
23+
</script>
24+
</body></html>

test/helper/webapi.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,19 @@ export function tests() {
10911091
expect(formContents().files.avatar.name).to.eql('avatar.jpg')
10921092
expect(formContents().files.avatar.type).to.eql('image/jpeg')
10931093
})
1094+
1095+
it('should drop file to dropzone', async () => {
1096+
await I.amOnPage('/form/dropzone')
1097+
await I.attachFile('#droparea', 'app/avatar.jpg')
1098+
await I.see('Dropped 1 file(s)')
1099+
await I.see('avatar.jpg')
1100+
})
1101+
1102+
it('should see correct file type after drop', async () => {
1103+
await I.amOnPage('/form/dropzone')
1104+
await I.attachFile('#droparea', 'app/avatar.jpg')
1105+
await I.see('image/jpeg')
1106+
})
10941107
})
10951108

10961109
describe('#saveScreenshot', () => {

0 commit comments

Comments
 (0)