У Cypress есть… трудности с работой с фреймами. В основном потому, что все встроенные cy команды обхода DOM делают жесткую остановку в тот момент, когда они достигают #document узла внутри iframe.

Если ваше веб-приложение использует фреймы iframe, то для работы с элементами в этих фреймах потребуется собственный код. В этом сообщении блога я покажу, как взаимодействовать с элементами DOM внутри iframe (даже если iframe обслуживается из другого домена), как отслеживать window.fetch запросы, которые делает iframe, и даже как заглушать запросы XHR от iframe.

Примечание: вы можете найти исходный код для этой записи в блоге в рецепте Работа с фреймами, расположенном в репозитории cypress-example-recipes.

Приложение с iframe

Давайте возьмем статическую HTML-страницу и встроим iframe. Вот полный исходный код.

<body> 
  <style> 
    iframe { 
      width: 90%; 
      height: 100%; 
    } 
  </style> 
  <h1>XHR in iframe</h1> 
  <iframe src="https://jsonplaceholder.cypress.io/" 
          data-cy="the-frame"></iframe> 
</body>

Совет. мы будем использовать атрибут data-cy для поиска iframe в соответствии с нашим руководством Рекомендации по выбору элементов.

Давайте напишем первый тест в файле спецификаций cypress/integration/first-spec.js, который посещает страницу.

it('gets the post', () => { 
  cy.visit('index.html').contains('XHR in iframe') 
  cy.get('iframe') 
})

Тест проходит, и мы видим загруженный iframe.

Если мы вручную нажмем кнопку «Попробовать», iframe действительно получит первый пост.

Нажатие кнопки внутри iframe

Давайте попробуем написать тестовые команды, чтобы найти кнопку «Попробовать», а затем нажать на нее. Эта кнопка находится внутри элемента body элемента document элемента iframe. Давайте напишем вспомогательную функцию для доступа к элементу body.

const getIframeDocument = () => { 
  return cy 
  .get('iframe[data-cy="the-frame"]') 
  // Cypress yields jQuery element, which has the real 
  // DOM element under property "0". 
  // From the real DOM iframe element we can get 
  // the "document" element, it is stored in "contentDocument" property 
  // Cypress "its" command can access deep properties using dot notation 
  // https://on.cypress.io/its  
  .its('0.contentDocument').should('exist') 
} 
const getIframeBody = () => { 
  // get the document 
  return getIframeDocument() 
  // automatically retries until body is loaded 
  .its('body').should('not.be.undefined') 
  // wraps "body" DOM element to allow 
  // chaining more Cypress commands, like ".find(...)" 
  .then(cy.wrap) 
} 
it('gets the post', () => { 
  cy.visit('index.html') 
  getIframeBody().find('#run-button').should('have.text', 'Try it').click() 
  getIframeBody().find('#result').should('include.text', '"delectus aut autem"') 
})

К сожалению, тест не проходит — элемент contentDocument никогда не меняется с null.

Наша проблема в том, что наш тест работает под доменом localhost (его можно увидеть в адресе браузера), а кнопка и сам iframe происходят из домена jsonplaceholder.cypress.io. Браузеры не позволяют JavaScript из одного домена получать доступ к элементам в другом домене — это было бы огромной дырой в безопасности. Таким образом, нам нужно сообщить нашему браузеру, выполняющему тесты, разрешить такой доступ — в конце концов, это наш тест, мы контролируем приложение и знаем, что сторонний iframe, который он внедряет, безопасен для использования.

Чтобы разрешить междоменный доступ к iframe, я установлю для свойства chromeWebSecurity значение false в файле cypress.json и перезапущу тест.

{ 
  "chromeWebSecurity": false 
}

Тест проходит!

Медленно загружаемые фреймы

Прежде чем мы продолжим, я хотел бы подтвердить, что наш код работает, даже если сторонний iframe загружается медленно. Я переключу Cypress, который по умолчанию использует браузер Electron для запуска тестов в браузере Chrome.

Как только Chrome запускает тест (под тестовым профилем пользователя, созданным Cypress), я открываю Магазин расширений Chrome и устанавливаю расширение URL Throttler. Я включаю это расширение и добавляю URL-адрес https://jsonplaceholder.cypress.io/ для замедления на 2 секунды.

Обратите внимание, что тест теперь занимает больше 2 секунд — потому что iframe задерживается расширением.

Совет. вы можете включить расширение Chrome в свой репозиторий и установить его автоматически. Подробнее читайте в нашем блоге Как загрузить расширение React DevTools в Cypress.

Наш тест автоматически ожидает загрузки кадра с помощью встроенных повторных попыток.

// in getIframeDocument() 
cy 
  .get('iframe[data-cy="the-frame"]') 
  .its('0.contentDocument') 
  // above "its" command will be retried until 
  // content document property exists 
// in getIframeBody() 
getIframeDocument() 
  // automatically retries until body is loaded 
  .its('body').should('not.be.undefined')

Хотя это работает, я должен отметить, что повторяется только последняя команда its('body'), что может привести к сбою тестов. Например, веб-приложение может включать заполнитель iframe, который позже изменяет свое body, но наш код не увидит изменения, поскольку оно уже имеет свойство contentDocument и только пытается получить body. (Я видел, как это происходит при использовании виджета кредитной карты Stripe, который имеет собственный элемент iframe).

Таким образом, чтобы сделать тестовый код более надежным и повторить все, мы должны объединить все its команды в одну команду:

const getIframeBody = () => { 
  // get the iframe > document > body 
  // and retry until the body element is not empty return cy 
  .get('iframe[data-cy="the-frame"]') 
  .its('0.contentDocument.body').should('not.be.empty') 
  // wraps "body" DOM element to allow 
  // chaining more Cypress commands, like ".find(...)" 
  // https://on.cypress.io/wrap 
  .then(cy.wrap) 
} 
it('gets the post using single its', () => { 
  cy.visit('index.html') 
  getIframeBody().find('#run-button').should('have.text', 'Try it').click() 
  getIframeBody().find('#result').should('include.text', '"delectus aut autem"') 
})

Хороший.

Пользовательская команда

Мы, вероятно, будем обращаться к элементам iframe в нескольких тестах, поэтому давайте превратим приведенную выше служебную функцию в пользовательскую команду Cypress внутри файла cypress/support/index.js. Пользовательская команда будет автоматически доступна во всех файлах спецификаций, поскольку файл поддержки объединяется с каждым файлом спецификации.

// cypress/support/index.js 
Cypress.Commands.add('getIframeBody', () => { 
  // get the iframe > document > body 
  // and retry until the body element is not empty 
  return cy 
  .get('iframe[data-cy="the-frame"]') 
  .its('0.contentDocument.body').should('not.be.empty') 
  // wraps "body" DOM element to allow 
  // chaining more Cypress commands, like ".find(...)" 
  // https://on.cypress.io/wrap 
  .then(cy.wrap) 
}) 
// cypress/integration/custom-command-spec.js 
it('gets the post using custom command', () => { 
  cy.visit('index.html') 
  cy.getIframeBody() 
    .find('#run-button').should('have.text', 'Try it').click() 
  cy.getIframeBody() 
    .find('#result').should('include.text', '"delectus aut autem"') 
})

Мы можем скрыть детали каждого шага внутри cy.getIframeBody кода, отключив логирование внутренних команд.

Cypress.Commands.add('getIframeBody', () => { 
  // get the iframe > document > body 
  // and retry until the body element is not empty 
  cy.log('getIframeBody') 
  return cy 
  .get('iframe[data-cy="the-frame"]', { log: false }) 
  .its('0.contentDocument.body', { log: false }).should('not.be.empty') 
  // wraps "body" DOM element to allow 
  // chaining more Cypress commands, like ".find(...)" 
  // https://on.cypress.io/wrap 
  .then((body) => cy.wrap(body, { log: false })) 
})

Журнал команд в левой колонке теперь выглядит намного лучше.

Шпионить за window.fetch

Когда пользователь или Cypress нажимает кнопку «Попробовать», веб-приложение отправляет запрос на выборку в конечную точку REST API.

Мы можем проверить ответ, возвращенный сервером, нажав на запрос.

В данном случае это объект JSON, представляющий ресурс todo с определенными ключами и значениями. Подтвердим, что метод window.fetch был вызван приложением с ожидаемыми параметрами. Мы можем использовать команду cy.spy, чтобы шпионить за методами объекта.

const getIframeWindow = () => { 
  return cy 
  .get('iframe[data-cy="the-frame"]') 
  .its('0.contentWindow').should('exist') 
} 
it('spies on window.fetch method call', () => { 
  cy.visit('index.html') 
  getIframeWindow().then((win) => { 
    cy.spy(win, 'fetch').as('fetch') 
  }) 
  cy.getIframeBody().find('#run-button').should('have.text', 'Try it').click() 
  cy.getIframeBody().find('#result').should('include.text', '"delectus aut autem"') 
  // because the UI has already updated, we know the fetch has happened 
  // so we can use "cy.get" to retrieve it without waiting 
  // otherwise we would have used "cy.wait('@fetch')" 
  cy.get('@fetch').should('have.been.calledOnce') 
  // let's confirm the url argument 
  .and('have.been.calledWith', 'https://jsonplaceholder.cypress.io/todos/1') 
})

Мы получаем объект window из iframe, затем настраиваем метод-шпион, используя cy.spy(win, 'fetch'), и даем ему псевдоним as('fetch') для извлечения вызовов, которые проходят через этот метод позже. Мы видим шпионов, и когда они были вызваны в журнале команд, я отметил их зелеными стрелками на скриншоте ниже.

Совет. мы можем переместить служебную функцию getIframeWindow в пользовательскую команду, аналогично тому, как мы создали команду cy.getIframeBody().

Ajax-вызовы из iframe

Шпионить за вызовами методов вроде window.fetch весело, но давайте сделаем еще один шаг вперед. Cypress может напрямую отслеживать и блокировать сетевые запросы приложения, но только если веб-приложение использует объект XMLHttpRequest, а не window.fetch (мы исправим это в #95). Итак, если мы хотим напрямую отслеживать или блокировать сетевые вызовы приложения, которые делает iframe, нам необходимо:

  1. Замените window.fetch внутри iframe на XMLHttpRequest из окна приложения, потому что этот объект имеет шпионские и заглушки, добавленные Cypress Test Runner.
  2. Вызовите cy.server, а затем используйте cy.route для наблюдения за сетевыми вызовами.

Скопировать объект XMLHttpRequest

Я следую рецепту Stubbing window.fetch ​​из cypress-example-recipes, чтобы заменить window.fetch на unfetch polyfill — и скопировать объект XMLHttpRequest в iframe. Вот код утилиты, который нам нужен.

let polyfill 
// grab fetch polyfill from remote URL, could be also from a local package 
before(() => { 
  const polyfillUrl = 'https://unpkg.com/unfetch/dist/unfetch.umd.js' 
  cy.request(polyfillUrl) 
  .then((response) => { 
    polyfill = response.body 
  }) 
}) 
const getIframeWindow = () => { 
  return cy 
  .get('iframe[data-cy="the-frame"]')  
  .its('0.contentWindow').should('exist') 
} 
const replaceIFrameFetchWithXhr = () => { 
  // see recipe "Stubbing window.fetch" in 
  // https://github.com/cypress-io/cypress-example-recipes 
  getIframeWindow().then((iframeWindow) => { 
    delete iframeWindow.fetch 
    // since the application code does not ship with a polyfill 
    // load a polyfilled "fetch" from the test 
    iframeWindow.eval(polyfill) 
    iframeWindow.fetch = iframeWindow.unfetch 
  // BUT to be able to spy on XHR or stub XHR requests 
  // from the iframe we need to copy OUR window.XMLHttpRequest into the iframe 
  cy.window().then((appWindow) => { 
    iframeWindow.XMLHttpRequest = appWindow.XMLHttpRequest 
  }) 
 }) 
}

Шпионю за сетевым звонком

Вот первый тест — он шпионит за сетевым вызовом, аналогично тесту window.fetch spy выше.

it('spies on XHR request', () => { 
  cy.visit('index.html') 
  replaceIFrameFetchWithXhr() 
  // prepare to spy on XHR before clicking the button 
  cy.server() 
  cy.route('/todos/1').as('getTodo') 
  cy.getIframeBody().find('#run-button') 
    .should('have.text', 'Try it').click() 
  // let's wait for XHR request to happen 
  // for more examples, see recipe "XHR Assertions" 
  // in repository https://github.com/cypress-io/cypress-example-recipes    
  cy.wait('@getTodo').its('response.body').should('deep.equal', { 
    completed: false, 
    id: 1, 
    title: 'delectus aut autem', 
    userId: 1, 
  }) 
  // and we can confirm the UI has updated correctly 
  getIframeBody().find('#result') 
    .should('include.text', '"delectus aut autem"') 
})

Обратите внимание, как мы можем дождаться выполнения сетевого запроса и получить полный доступ к объектам запроса и ответа, которые мы можем использовать в наших утверждениях.

cy.wait('@getTodo').its('response.body').should('deep.equal', { 
  completed: false, 
  id: 1, 
  title: 'delectus aut autem', 
  userId: 1, 
})

Совет: прочтите запись в блоге Подтверждение сетевых вызовов из тестов Cypress для получения дополнительных примеров утверждений против сетевых вызовов.

Перехват сетевого вызова

Опора на сторонние API не идеальна. Давайте заменим этот вызов /todos/1 нашим собственным заглушенным ответом. Объект XMLHttpRequest был скопирован после загрузки страницы и готов iframe, давайте воспользуемся им для возврата объекта.

it('stubs XHR response', () => { 
  cy.visit('index.html') 
  replaceIFrameFetchWithXhr() 
  // prepare to stub before clicking the button 
  cy.server() 
  cy.route('/todos/1', { 
    completed: true, 
    id: 1, 
    title: 'write tests', 
    userId: 101, 
  }).as('getTodo') 
  cy.getIframeBody().find('#run-button') 
    .should('have.text', 'Try it').click() 
  // and we can confirm the UI shows our stubbed response 
  cy.getIframeBody().find('#result') 
    .should('include.text', '"write tests"') 
})

Хорошо, cy.route с аргументом объекта заглушает соответствующие сетевые запросы, и наши утверждения подтверждают, что iframe показывает текст «написать тесты».

Бонус: плагин cypress-iframe

Один из наших пользователей Keving Groat написал плагин cypress-iframe с пользовательскими командами, упрощающими работу с элементами внутри iframe. Установите плагин с помощью npm install -D cypress-iframe, затем используйте пользовательские команды.

// the next comment line loads the custom commands from the plugin 
// so that our editor understands "cy.frameLoaded" and "cy.iframe" 
/// <reference types="cypress-iframe" /> 
import 'cypress-iframe' 
describe('Recipe: blogs__iframes', () => { 
  it('fetches post using iframes plugin', () => { 
    cy.visit('index.html') 
    cy.frameLoaded('[data-cy="the-frame"]') 
    // after the frame has loaded, we can use "cy.iframe()" 
    // to retrieve it 
    cy.iframe().find('#run-button').should('have.text', 'Try it').click() 
    cy.iframe().find('#result').should('include.text', '"delectus aut autem"') 
  }) 
})

Вывод

iframe раздражают — хотелось бы, чтобы у нашей команды Cypress было достаточно времени, чтобы разобраться с ними раз и навсегда. Тем не менее, они не останавливают шоу — вам просто нужно следовать этому сообщению в блоге в качестве руководства и посмотреть код в рецепте Работа с iframes в репозитории cypress-example-recipes, чтобы обойти препятствие.

Первоначально опубликовано на https://cypress.io 12 февраля 2020 г.