Cypress: file upload, drag and drop y editor de texto enriquecido
cy.selectFile() con input oculto, tres intentos fallidos de drag and drop, y editor Tiptap con contenteditable. Errores reales y soluciones.
Qué cubre este post
Hasta ahora todos los tests del framework interactúan con elementos estándar: inputs, selects, botones, grillas. Pero las aplicaciones reales tienen componentes más complejos: uploads de archivos con inputs ocultos, drag and drop entre zonas del DOM, editores de texto enriquecido con contenteditable en vez de <textarea>.
Este post cubre tres escenarios que encontré en demo.serenity.is:
- File upload — Productos → imagen de producto (
cy.selectFile()) - Drag and drop — agrupación de columnas en grilla SleekGrid
- Editor de texto enriquecido — notas de cliente con Tiptap/ProseMirror
Los tres requieren técnicas distintas a las que venía usando. Y los tres me dieron errores que no esperaba.
Primero: qué tiene y qué no tiene el AUT
Antes de escribir código exploré demo.serenity.is buscando los escenarios clásicos de "UI compleja": iframes, Shadow DOM, file upload, drag and drop, editores ricos.
Lo que encontré:
- File upload — Northwind → Productos → Editar tiene un campo "Imagen del producto" con un
<input type="file">real. Perfecto. - Drag and drop — Muestras avanzadas → Cuadrículas → "Arrastrar y soltar agrupación" permite arrastrar headers de columna a una zona de agrupación.
- Editor de texto enriquecido — Northwind → Clientes → Editar → pestaña Notas → "Add Note" abre un editor Tiptap (basado en ProseMirror) con
<div contenteditable="true">y toolbar para negrita, cursiva y subrayado.
Lo que NO encontré:
- Iframes — El Google Maps Editor tiene iframes pero son internos de la API de Google (
aria-hidden="true", cross-origin). No son iframes de contenido donde un usuario interactúe. No sirven para un test representativo. - Shadow DOM — Busqué
#shadow-rooten DevTools: 0 resultados. Serenity.is usa jQuery, no Web Components.
Decidí trabajar con lo que el AUT realmente tiene. Forzar iframes o Shadow DOM con sitios externos rompería la consistencia de la serie — todos los posts testean contra demo.serenity.is. Y documentar que exploré y no encontré esos escenarios es parte del proceso.
File upload con cy.selectFile()
El input oculto
Al inspeccionar el campo "Imagen del producto" en DevTools, el <input type="file"> está ahí pero con opacity: 0 y position: absolute. Serenity lo oculta visualmente y muestra un botón estilizado "Seleccionar archivo" encima.
Cypress no puede interactuar con elementos invisibles por defecto. La solución: { force: true } en selectFile().
Pero primero necesité una imagen de test. Creé un PNG simple de 85×130px (2.44KB) y lo guardé en cypress/fixtures/test-image.png.
Error: el servidor rechaza el archivo
Primera corrida:
cy.get('.field.ProductImage input[type="file"]').selectFile(
'cypress/fixtures/test-image.png',
{ force: true }
)
El selectFile funcionó. Cypress envió el archivo y disparó un POST a /File/TemporaryUpload. Pero el servidor respondió 400:
{
"Code": "FailedScan",
"Message": "Se produjo un error al escanear el archivo cargado en busca de virus!"
}

El demo de Serenity.is tiene un antivirus que escanea archivos subidos. No puedo subir archivos reales.
Solución: stubear la respuesta con cy.intercept
Esto es un patrón real. En CI/CD muchas veces no tenés un servidor que acepte uploads, o el ambiente de test no tiene storage configurado. Stubear la respuesta te deja testear la mecánica completa del upload sin depender del backend.
cy.intercept('POST', '/File/TemporaryUpload', {
statusCode: 200,
body: {
TemporaryFile: 'temporary/test-image.png',
Size: 2440,
IsImage: true,
Width: 85,
Height: 130
}
}).as('fileUpload')
La estructura del body la saqué del error original — el response del 400 mostraba los campos que el endpoint espera devolver. Con el intercept, Cypress devuelve un 200 con datos válidos sin que el request llegue al servidor.
Verificación: qué cambió en la UI
Después del upload stubeado, la UI reaccionó correctamente:
- Apareció un thumbnail (
<a class="thumb" title="test-image.png">) dentro de.file-items - El botón de eliminar imagen perdió la clase
disabled

disabled y no hay imagen. Después del upload estos dos indicadores cambian.Esto me dio dos aserciones:
// Antes del upload
cy.get('.field.ProductImage .delete-button').should('have.class', 'disabled')
// Upload
cy.get('.field.ProductImage input[type="file"]').selectFile(
'cypress/fixtures/test-image.png',
{ force: true }
)
cy.wait('@fileUpload')
// Después del upload
cy.get('.field.ProductImage a.thumb').should('have.attr', 'title', 'test-image.png')
cy.get('.field.ProductImage .delete-button').should('not.have.class', 'disabled')

El GET 404 para /upload/temporary/test-image_t.jpg es esperado. La app intenta cargar el thumbnail desde el servidor pero como el upload fue stubeado, el archivo no existe. Lo importante: la UI procesó la respuesta correctamente.
Drag and drop — tres intentos fallidos y una solución pragmática
La página "Drag & Drop Grouping" en Muestras avanzadas muestra una grilla de Pedidos con agrupación por columnas. Al cargar ya tiene dos agrupaciones: "País de envío" y "Ciudad de envío". Se pueden agregar más arrastrando un header de columna a la zona de agrupación.
Lo que quería hacer
Arrastrar el header "Empleado" a la zona de agrupación y verificar que apareciera como tercer grupo. Lo hice manualmente en el browser y funcionó perfecto — los datos se reorganizaban en tres niveles.
Intento 1: API HTML5 de drag and drop
const dataTransfer = new DataTransfer()
cy.get('div.slick-header-column[data-id="EmployeeFullName"]')
.trigger('dragstart', { dataTransfer })
cy.get('.slick-grouping-panel')
.trigger('dragover', { dataTransfer })
.trigger('drop', { dataTransfer })
cy.get('div.slick-header-column[data-id="EmployeeFullName"]')
.trigger('dragend', { dataTransfer })
Resultado: nada. La zona de agrupación seguía con 2 grupos. SleekGrid no usa la API nativa de HTML5 drag and drop.
Intento 2: eventos de mouse con coordenadas
cy.get('div.slick-header-column[data-id="EmployeeFullName"]')
.trigger('mousedown', { which: 1, clientX: startX, clientY: startY })
cy.get('body')
.trigger('mousemove', { clientX: startX, clientY: startY - 10 })
.trigger('mousemove', { clientX: endX, clientY: endY })
cy.get('.slick-grouping-panel')
.trigger('mouseup', { clientX: endX, clientY: endY })
Resultado: nada. Los eventos de mouse de Cypress son sintéticos — no activan los listeners internos de SleekGrid.
Intento 3: cypress-real-events
Instalé cypress-real-events, un plugin que usa Chrome DevTools Protocol para disparar eventos reales del browser:
npm install cypress-real-events --save-dev
Agregué el import en e2e.ts:
import 'cypress-real-events'
Y usé realMouseDown/realMouseMove/realMouseUp:
cy.wrap($source)
.realMouseDown({ position: 'center' })
cy.wait(200)
cy.wrap($source)
.realMouseMove(0, -30, { position: 'center' })
cy.wait(200)
cy.wrap($target)
.realMouseMove(0, 0, { position: 'center' })
cy.wait(200)
cy.wrap($target)
.realMouseUp({ position: 'center' })
El header entró en modo dragging — vi la clase slick-header-column-dragging en el log. Pero el drop no se completó. SleekGrid seguía mostrando 2 agrupaciones.

Tres intentos, tres fallos. SleekGrid tiene un sistema de drag interno que no responde a ningún tipo de evento sintético.
Solución pragmática: testear el resultado, no la mecánica
En un proyecto real, si el drag no funciona via automation, testeo lo que puedo: verificar que el estado de agrupación es correcto y que se puede modificar por otros medios.
it('should verify initial grouping state', () => {
cy.visit('/AdvancedSamples/DragDropGrouping')
cy.get('div.slick-row').should('have.length.greaterThan', 0)
cy.get('.slick-grouping-panel .slick-dropped-grouping').should('have.length', 2)
cy.get('.slick-grouping-panel').should('contain', 'País de envío')
cy.get('.slick-grouping-panel').should('contain', 'Ciudad de envío')
cy.contains('.slick-group', 'Argentina').should('exist')
})
it('should remove a grouping by clicking the remove button', () => {
cy.visit('/AdvancedSamples/DragDropGrouping')
cy.get('div.slick-row').should('have.length.greaterThan', 0)
cy.get('.slick-grouping-panel .slick-dropped-grouping').should('have.length', 2)
cy.get('.slick-grouping-panel .slick-dropped-grouping')
.contains('Ciudad de envío')
.parent()
.find('.slick-groupby-remove')
.click()
cy.get('.slick-grouping-panel .slick-dropped-grouping').should('have.length', 1)
cy.get('.slick-grouping-panel').should('contain', 'País de envío')
cy.get('.slick-grouping-panel').should('not.contain', 'Ciudad de envío')
})

Los tests verifican que el componente de agrupación funciona correctamente: muestra los grupos, muestra los datos agrupados en la grilla, y permite remover agrupaciones. Lo que no puedo testear via automation es la acción de arrastrar — eso queda para testing manual o para herramientas especializadas de UI testing.
Diferencia con Playwright
En la serie de Playwright usé page.dragTo() que funciona a nivel del browser, no con eventos sintéticos. Es posible que Playwright maneje mejor este escenario. En Cypress, la limitación es real: hay componentes de terceros cuyo drag and drop simplemente no responde a eventos programáticos.
Editor de texto enriquecido — contenteditable y Tiptap
El tercer escenario es un editor de texto enriquecido. Northwind → Clientes → Editar → pestaña "Notas" → "Add Note" abre un dialog con un editor Tiptap.
En DevTools: no es un <textarea> ni un <iframe>. Es un <div class="tiptap ProseMirror" contenteditable="true">. Esto cambia la forma de interactuar — cy.type() funciona, pero hay que verificar el HTML que genera el editor, no solo el texto visible.
Navegación hasta el editor
La ruta requiere varios pasos: visitar Clientes, abrir un cliente, ir a la pestaña Notas, clickear "Add Note". Lo moví al beforeEach del context porque los cuatro tests lo necesitan:
beforeEach(() => {
cy.visit('/Northwind/Customer')
cy.get('div.slick-row').should('have.length.greaterThan', 0)
cy.contains('a.s-EditLink', 'ANATR').click()
cy.get('.s-CustomerDialog').should('be.visible')
cy.contains('.nav-tabs a', 'Notas').click()
cy.get('.s-NotesEditor .add-button').click()
cy.get('.s-NoteDialog').should('be.visible')
})
Un error que tuve: el selector original era cy.get('div.slick-row').first().find('a.s-EditLink').click(). Falló con "cy.click() can only be called on a single element. Your subject contained 2 elements". Cada fila de clientes tiene dos a.s-EditLink — uno para el ID y otro para el nombre de la empresa. Lo resolví usando cy.contains('a.s-EditLink', 'ANATR') para ser específico.
Test 1: escribir en contenteditable
it('should type text in the contenteditable editor', () => {
cy.get('.s-NoteDialog .ProseMirror')
.should('have.attr', 'contenteditable', 'true')
.click()
.type('Nota de prueba automatizada')
cy.get('.s-NoteDialog .ProseMirror')
.should('contain', 'Nota de prueba automatizada')
})
cy.type() funciona igual que con un input normal. La diferencia es que el contenido no va a un value sino al innerHTML del div. .should('contain', ...) busca en el contenido de texto del elemento.
Test 2: negrita via botón de toolbar
it('should apply bold via toolbar button', () => {
cy.get('.s-NoteDialog .ProseMirror').click().type('texto en negrita')
cy.get('.s-NoteDialog .ProseMirror').type('{selectall}')
cy.get('.s-NoteDialog button[title="Bold"]').click()
cy.get('.s-NoteDialog button[title="Bold"]').should('have.class', 'active')
cy.get('.s-NoteDialog .ProseMirror strong')
.should('contain', 'texto en negrita')
})
Lo que verifico:
- El botón "Bold" recibe la clase
activeal clickearlo con texto seleccionado - El editor genera un
<strong>alrededor del texto
Verificar el HTML generado (<strong>, <em>) es importante porque es lo que el servidor va a recibir y almacenar. Que el texto se vea en negrita visualmente no es suficiente — necesito confirmar que el markup es correcto.
Test 3: cursiva via atajo de teclado
it('should apply italic via keyboard shortcut', () => {
cy.get('.s-NoteDialog .ProseMirror').click().type('texto en cursiva')
cy.get('.s-NoteDialog .ProseMirror').type('{selectall}')
cy.get('.s-NoteDialog .ProseMirror').type('{ctrl+i}')
cy.get('.s-NoteDialog button[title="Italic"]').should('have.class', 'active')
cy.get('.s-NoteDialog .ProseMirror em')
.should('contain', 'texto en cursiva')
})
Cypress soporta atajos con {ctrl+i}, {ctrl+b}, etc. Es otra forma de aplicar formato que un usuario usaría. El editor genera <em> para cursiva.
Test 4: formato mixto
it('should combine multiple formats', () => {
cy.get('.s-NoteDialog .ProseMirror').click().type('normal ')
cy.get('.s-NoteDialog button[title="Bold"]').click()
cy.get('.s-NoteDialog .ProseMirror').type('negrita ')
cy.get('.s-NoteDialog button[title="Bold"]').click()
cy.get('.s-NoteDialog .ProseMirror').type('normal de nuevo')
cy.get('.s-NoteDialog .ProseMirror strong')
.should('have.length', 1)
.and('contain', 'negrita')
cy.get('.s-NoteDialog .ProseMirror')
.should('contain', 'normal')
.and('contain', 'normal de nuevo')
})

<strong>.Este test es el más interesante. Activo negrita, escribo, desactivo negrita, sigo escribiendo. Después verifico que solo la parte correcta tiene <strong>. Esto confirma que el toggle del editor funciona bien — no infecta el texto siguiente.
Archivos modificados y nuevos
| Archivo | Cambio |
|---|---|
cypress/e2e/ui-avanzada/ui-avanzada.cy.ts |
Nuevo. 7 tests: file upload, agrupación, editor de texto |
cypress/support/e2e.ts |
Agregado import 'cypress-real-events' |
cypress/fixtures/test-image.png |
Nuevo. Imagen de test para upload (85×130px, 2.44KB) |
package.json |
Nueva dependencia: cypress-real-events |
Resultado: 47 tests

| Spec | Tests | Passing | Failing |
|---|---|---|---|
| auth/login.cy.ts | 8 | 8 | - |
| clientes/clientes-data.cy.ts | 2 | - | 2 (intencional) |
| clientes/clientes-interacciones.cy.ts | 7 | 7 | - |
| clientes/clientes.cy.ts | 6 | 6 | - |
| dashboard/assertions.cy.ts | 10 | 10 | - |
| intercept/intercept-stubbing.cy.ts | 7 | 7 | - |
| ui-avanzada/ui-avanzada.cy.ts | 7 | 7 | - |
| Total | 47 | 45 | 2 (intencional) |
Takeaways
cy.selectFile() funciona con inputs ocultos si usás { force: true }. Pero si el servidor no acepta uploads (como el demo con antivirus), stubear la respuesta con cy.intercept es la solución limpia. Y es un patrón real para CI/CD.
El drag and drop programático tiene límites reales. Probé tres formas (HTML5 DataTransfer, eventos de mouse, cypress-real-events) y ninguna activó el drag de SleekGrid. No es un bug de Cypress — es que ciertos componentes de terceros manejan el drag internamente de formas que no responden a eventos sintéticos. La solución pragmática: testear el estado resultante y las interacciones que sí funcionan (como remover grupos). En un proyecto real, el drag manual se cubre con testing manual o con herramientas como Playwright que operan a nivel del browser.
Los editores de texto enriquecido usan contenteditable, no <textarea>. cy.type() funciona igual, pero la verificación cambia: en vez de chequear un value, verificás el HTML generado (<strong>, <em>). Los atajos de teclado ({ctrl+b}, {ctrl+i}) funcionan directo con cy.type(). Y verificar que el botón de la toolbar refleja el estado (have.class 'active') es una aserción que un <textarea> no tiene.
Explorar el AUT antes de planificar es no negociable. El plan original incluía iframes y Shadow DOM. demo.serenity.is no tiene ninguno de los dos. Si hubiera escrito tests contra un sitio externo de práctica, habría tenido código que funciona pero que no se aplica a una aplicación real. Trabajar con lo que el AUT tiene y documentar lo que no tiene es mejor que fabricar escenarios.
Próximo post
cy.request() para testing de API directo — requests HTTP sin pasar por la UI.
🔗 Todo el código de esta serie está en: github.com/cesarbeassuarez/cypress-typescript-framework