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.

Cypress runner 7 tests verdes ui-avanzada file upload drag and drop editor texto enriquecido normal negrita normal de nuevo ProseMirror contenteditable
7 tests: file upload con respuesta stubeada, agrupación de columnas y editor de texto enriquecido con Tiptap/ProseMirror. Formato mixto visible en el editor.

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:

  1. File upload — Productos → imagen de producto (cy.selectFile())
  2. Drag and drop — agrupación de columnas en grilla SleekGrid
  3. 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-root en 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!"
}
Cypress runner error POST 400 FailedScan antivirus servidor rechaza upload Se produjo un error al escanear el archivo cargado en busca de virus Editar Producto Aniseed Syrup
POST 400: el demo de Serenity.is tiene un scanner de antivirus que rechaza uploads reales. El test pasa el archivo correctamente pero el servidor lo bloquea.

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
DevTools Editar Producto Aniseed Syrup botón eliminar delete-button no-text sin clase disabled antes de upload sin imagen cargada
Estado inicial: el botón de eliminar tiene clase 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')
Cypress runner file upload verde 16 pasos selectFile force true wait fileUpload POST 200 thumbnail a.thumb title test-image delete-button not disabled
Upload stubeado exitoso. Todas las aserciones pasan: thumbnail con title correcto, botón eliminar habilitado. El GET 404 del thumbnail es esperado.

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.

Cypress runner drag and drop fallido realMouseDown realMouseMove realMouseUp expected length 3 but got 2 slick-header-column-dragging SleekGrid
Tercer intento con cypress-real-events. El header entra en modo dragging pero el drop no se completa. SleekGrid no responde a eventos programáticos.

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')
})
Cypress runner 3 tests verdes file upload y drag and drop agrupación de columnas verify initial state remove grouping solo País de envío visible
Solución pragmática: verificar estado inicial y remover agrupaciones via click. El drag programático no funcionó, pero el state management sí es testeable.

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.

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:

  1. El botón "Bold" recibe la clase active al clickearlo con texto seleccionado
  2. 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')
})
Cypress runner 7 tests verdes ui-avanzada file upload drag and drop editor texto enriquecido normal negrita normal de nuevo ProseMirror Add Note
7 tests verdes. El editor muestra "normal negrita normal de nuevo" — el test verifica que solo la palabra correcta está dentro de <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

Terminal Run Finished 7 specs 47 tests 45 passing 2 failing intencional total 3 minutos 16 segundos ui-avanzada 7 de 7 passing
47 tests en 3:16. Los 7 nuevos de ui-avanzada pasaron todos. Los 2 failing son intencionales.
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