Назад в Центр знаний
Сравнение

Selenium Grid: почему два узла расходятся

Сеанс попадает на узел со свободным слотом. Зафиксируйте, что задаёт профиль, а что решает хост, чтобы различие относилось к узлу, а не к коммиту.

BotBrowser Team

Документация

Нужна структурированная документация по теме Документация?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

Тест Selenium Grid выполняется не на машине, которая его отправила. Distributor назначает сеанс слоту на некотором узле, и тест исполняет браузер, установленный на этом узле. Два узла с одним тестом, одним кодом и одной версией браузера всё равно могут дать разные результаты, потому что слот является единицей планирования, а не гарантией одинаковой среды. Первый вопрос после такого отказа не в том, какое утверждение сломалось, а в том, какой узел его выполнял, и ответ должен браться из записи, а не из повторного запуска. BotBrowser закрепляет значения идентичности, которые страница читает через один профиль, и не меняет пиксели, которые растеризует хост, или часы, которые хост ведёт, поэтому именно эти поля продолжают различаться между узлами.

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

TL;DR

  • Слот Grid является единицей планирования. Distributor назначает сеанс тому слоту, который соответствует запросу, поэтому узел для теста выбирается во время запуска, а не файлом теста.
  • Разделите запись на поля управления и поля хоста. В режиме WebGL по умолчанию поле управления, такое как строка UNMASKED renderer или navigator.hardwareConcurrency, должно быть одинаковым при одном профиле; если оно различается, различаются профили или флаги запуска.
  • Поле хоста, такое как хеш пикселей или затраченное время, может различаться даже при безупречном профиле, потому что профиль не растеризует кадр и не ведёт часы.
  • Храните идентификатор сеанса при каждой попытке. Карта возможностей не содержит идентификатора узла, поэтому личность узла берётся из собственного запроса GraphQL или интерфейса Grid для этого сеанса.
  • Отказ, скапливающийся на одном узле, поддерживает версию об артефакте хоста; отказ, равномерно распределённый по узлам, указывает на код, данные или приложение.

Содержание

Что такое узел и слот

Grid делится на Router, очередь новых сеансов, Distributor, карту сеансов и один или несколько узлов. Router пересылает входящий запрос либо в очередь, либо на узел, который уже владеет сеансом, а Distributor назначает новый сеанс свободному слоту. У каждого узла есть один или несколько слотов, и каждый слот несёт стереотип: минимальный набор возможностей, которому должен соответствовать запрос, прежде чем Distributor отправит его на этот узел. Карта сеансов хранит соответствие между идентификатором сеанса и адресом узла, который его выполняет.

Эта структура объясняет первое, чему учит нестабильный тест Grid. Запрос с одинаковыми возможностями может быть обслужен любым узлом, чей стереотип слота совпадает, поэтому последовательные запуски одного теста могут выполняться на разных хостах без каких-либо изменений в тесте. При регистрации узел автоматически регистрирует драйверы браузеров, доступные в его собственном PATH, и создаёт по одному слоту на доступный процессор для браузеров на Chromium и Firefox, тогда как Safari получает один слот. Два узла с разным числом ядер поэтому объявляют разное число слотов с точки зрения одного и того же теста.

Регистрация узла также несёт UUID nodeId и объект osInfo с полями arch, name и version, которые показывают интерфейс Grid и запросы GraphQL. Именно эти два поля превращают «тест упал» в «тест упал на этом узле». Их чтение стоит одного запроса и завершает спор, который иначе длится днями.

Очередь важна по той же причине. Когда все подходящие слоты заняты, новый сеанс ждёт, а не падает, поэтому тест, запущенный в час пик, стартует позже, чем тот же тест в одиночку. Тайм-аут, отсчитанный от момента старта процесса теста, поэтому включает время в очереди, и набор, который задаёт собственные сроки без учёта этого, сообщит о задержке планирования как о тайм-ауте приложения. Distributor также хранит модель Grid, и документация архитектуры отмечает, что эта модель может расходиться с реальностью, например сразу после старта Distributor; сеанс, направленный на слот, который больше не отвечает, выглядит изнутри теста как сетевой отказ.

Ни один из этих компонентов не сравнивает два узла. Grid распределяет работу и отслеживает сеансы; он не сообщает, дают ли узел семь и узел двенадцать одинаковые пиксели. Это сравнение должно быть встроено в собственную запись теста.

Два вида полей в одной записи

Большинство сравнений узлов терпит неудачу ещё до начала, потому что смешивает два вопроса в одной таблице. Первый вопрос: прибыла ли заявленная среда. Второй: ведёт ли себя хост так же. Профиль отвечает на первый и не может ответить на второй, поэтому запись должна их разделять.

ПолеКто определяет значениеЧто означает различие
Строки vendor и renderer из UNMASKEDПрофиль или хост в режиме --bot-webgl=realРазные профили или два хоста с разными стеками WebGL
navigator.hardwareConcurrencyПрофиль по умолчанию или хост с --bot-cores=realПрофили различаются, либо один узел запущен с переопределением на реальные ядра
Размеры экрана, язык, часовой поясПрофиль, если не переопределено для сеансаРазличие конфигурации, а не рендеринга
Версия браузера и драйвераТо, что зарегистрировал узелСопоставление возможностей без учёта версии направило запрос на две сборки
Хеш пикселей фиксированного рисунка, --bot-webgl=realРастеризатор хостаНастоящее различие хостов, которое сохраняется при верном профиле и режиме WebGL real
Время фиксированного шагаПроцессор, нагрузка и сеть хостаКонкуренция или различие оборудования, а не идентичности

Первые три строки относятся к полям управления, когда запуск использует режим WebGL по умолчанию, и при одном профиле и одних флагах запуска они должны совпадать на обоих узлах, так что различие здесь является ошибкой конфигурации с дешёвым исправлением. У строки renderer есть оговорка, важная для этого измерения: в режиме --bot-webgl=real WebGL предоставляет хост без защиты, поэтому строки UNMASKED следуют за стеком хоста и строка перестаёт быть полем управления. Именно поэтому дайджесту пикселей нужен режим real, а строку renderer не следует включать в то же сравнение как поле управления. Документация BotBrowser по конфигурации профиля перечисляет значения идентичности, которые поставляет профиль, включая размеры экрана и окна, шрифты, WebGL и WebGPU, медиаустройства и голоса синтеза речи, а документация по управлению профилями относит к значениям профиля такие сигналы оборудования, как объём памяти устройства, число ядер процессора и модель GPU. Флаг --bot-cores документирован как выбор между заявленным в профиле числом и реальным числом хоста, и по умолчанию сохраняется заявленное значение профиля.

Последние две строки относятся к полям хоста, и именно здесь кроется неожиданность. Профиль управляет тем, что страница узнаёт о GPU, а не тем, что фактически вычисляет драйвер GPU. Документация BotBrowser по GPU-бэкенду Linux говорит об этом прямо: разные программные растеризаторы дают разные пиксели для одного шейдера, поэтому точные байты из readPixels или toDataURL различаются между SwiftShader и Mesa llvmpipe, тогда как в режиме WebGL по умолчанию строки vendor и renderer из UNMASKED остаются под управлением профиля и не меняются при таком переходе. В режиме --bot-webgl=real эта гарантия намеренно отключена, поэтому тот же переход меняет и байты пикселей, и сообщаемые строки. Сравнение canvas между двумя узлами поэтому остаётся сравнением хостов, даже когда значения идентичности совпадают идеально.

Сохранение обеих групп в одной записи и делает диагноз возможным. Несовпадение в группе управления означает, что два запуска не были одним экспериментом; несовпадение в группе хоста означает, что это был один эксперимент на двух разных машинах. Путаница между ними заставляет команды менять профиль, когда настоящая проблема в растеризаторе, или переписывать тест, когда настоящая проблема состоит в отсутствующем поле профиля.

Возможности, которые отправляет тест, описывают намерение; значения, которые читает страница, описывают результат. Это не одно и то же, и именно в разрыве между ними скрывается большинство расхождений узлов. Запрос может просить Chrome без версии, имя платформы без версии и размер окна, который узел тихо подгоняет под свой дисплей. То, что затем сообщает страница, и есть то, что фактически получает приложение, поэтому именно это должно попадать в запись.

Пример ниже собирает короткий фиксированный список внутри работающего сеанса: три поля управления, один дайджест пикселей и одно время. Он печатает одну строку, чего достаточно для сравнения двух узлов и мало для отчёта без утечки данных аккаунта.

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options()
options.add_argument("--bot-gpu-emulation=false")
options.add_argument("--bot-webgl=real")

driver = webdriver.Remote(command_executor="http://grid.internal:4444", options=options)
try:
    driver.get("https://app.example.test/health")
    values = driver.execute_script(
        """
        const canvas = document.createElement('canvas');
        canvas.width = 220; canvas.height = 90;
        const gl = canvas.getContext('webgl');
        const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
        const vs = [
          'attribute vec2 p;',
          'varying vec2 uv;',
          'void main() { uv = p * 0.5 + 0.5; gl_Position = vec4(p, 0.0, 1.0); }',
        ].join('\n');
        const fs = [
          'precision mediump float;',
          'varying vec2 uv;',
          'void main() {',
          '  float a = (sin(uv.x * 37.0 + 1.7) + sin(uv.y * 23.0 + 3.1) + sin((uv.x + uv.y) * 61.0)) / 3.0;',
          '  float b = fract(uv.x * 41.0) * fract(uv.y * 29.0);',
          '  gl_FragColor = vec4(0.5 + 0.45 * a, b, 0.5 - 0.45 * a, 1.0);',
          '}',
        ].join('\n');
        // Measured with --bot-gpu-emulation=false --bot-webgl=real. The defaults would answer
        // this draw from the profile GPU layer, and the digest would describe the profile.
        let digest = null;
        if (gl) {
          const compile = (type, src) => {
            const s = gl.createShader(type);
            gl.shaderSource(s, src);
            gl.compileShader(s);
            return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;
          };
          const program = gl.createProgram();
          const vertex = compile(gl.VERTEX_SHADER, vs);
          const fragment = compile(gl.FRAGMENT_SHADER, fs);
          if (!vertex || !fragment) return { pixels: null, pixels_error: 'compile_failed' };
          gl.attachShader(program, vertex);
          gl.attachShader(program, fragment);
          gl.linkProgram(program);
          if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return { pixels: null, pixels_error: 'link_failed' };
          gl.useProgram(program);
          const buffer = gl.createBuffer();
          gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
          gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
          const p = gl.getAttribLocation(program, 'p');
          gl.enableVertexAttribArray(p);
          gl.vertexAttribPointer(p, 2, gl.FLOAT, false, 0, 0);
          gl.clearColor(0.0, 0.0, 0.0, 1.0);
          gl.clear(gl.COLOR_BUFFER_BIT);
          gl.drawArrays(gl.TRIANGLES, 0, 3);
          const bytes = new Uint8Array(220 * 90 * 4);
          gl.readPixels(0, 0, 220, 90, gl.RGBA, gl.UNSIGNED_BYTE, bytes);
          let h = 2166136261;
          for (let i = 0; i < bytes.length; i += 4) h = Math.imul(h ^ bytes[i + 1], 16777619) >>> 0;
          digest = h.toString(16).padStart(8, '0');
        }
        const started = performance.now();
        for (let i = 0; i < 20000; i += 1) Math.sqrt(i);
        return {
          renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
          cores: navigator.hardwareConcurrency,
          lang: navigator.language,
          dpr: window.devicePixelRatio,
          pixels: digest,
          elapsed: Math.round((performance.now() - started) * 100) / 100,
        };
        """
    )
    print({"session": driver.session_id, "browser": driver.capabilities.get("browserVersion"), **values})
finally:
    driver.quit()

Список намеренно короткий и намеренно смешанный. cores, lang и dpr относятся к полям управления: их поставляет профиль, поэтому они должны совпадать на обоих узлах, а несовпадение становится первым, что нужно исследовать. renderer является полем управления только в режиме WebGL по умолчанию и переходит на сторону хоста при --bot-webgl=real, поэтому приведённая ниже запись держит его вне группы управления. pixels относится к полям хоста: оно рисует один фиксированный полноэкранный треугольник, чей фрагментный шейдер смешивает три синусоидальных члена с произведением fract, читает каждый пиксель обратно через readPixels и сворачивает зелёный канал в короткий дайджест FNV-1a. Интерполяция и субпиксельное покрытие как раз и являются местом, где расходятся два растеризатора, поэтому шейдер должен меняться по поверхности: постоянный цвет, и особенно одна лишь очистка, возвращает одинаковые байты на любом бэкенде и не измеряет ничего. Измерение получает заявленный смысл только при двух условиях запуска. Эмуляция GPU должна быть отключена, а WebGL должен работать в режиме real: значения по умолчанию отвечают на отрисовку WebGL из слоя профиля, который управляет и выводом шейдера, и чтением изображения, а не только строками идентичности, поэтому запуск, оставленный на значениях по умолчанию, измеряет профиль и затем сообщает его как поле хоста. Запись также несёт devicePixelRatio, потому что дайджест зависит от того, сколько пикселей у поверхности; узел, который рендерит ту же сцену в другом масштабе, даёт другой дайджест по причине, не связанной с его растеризатором. elapsed тоже относится к полям хоста и является грубым, поэтому оно отделяет конкуренцию от идентичности, не притворяясь эталонным замером.

Две детали реализации меняют смысл записи. Карта возможностей не содержит идентификатора узла, поэтому скрипт печатает идентификатор сеанса, а личность узла выясняется из Grid позже; предположение о существовании возможности nodeId даёт None на каждом узле и молча удаляет единственное поле, делающее привязку возможной. Строка renderer доступна только при наличии расширения отладочного рендерера; браузер, который её скрывает, возвращает null, и это само по себе сигнал, что значение было замаскировано, а не сообщено.

Одна запись на каждую попытку

Запись, которая делает расхождение видимым, мала: личность узла, личность сборки и две группы полей. Конструктор ниже представляет собранный вручную пример, показывающий, как один тест выглядит на двух узлах с общим профилем.

// Constructed fixture: one test, two attempts, one shared profile, two hosts.
const attempts = [
  {
    n: 1,
    session: 'd41f-07',
    browser: 'Chrome 156.0.0.0',
    control: { cores: 8, lang: 'en-US', dpr: 1 },
    host: { pixels: 'fc0472cd', elapsed: 12.4 },
  },
  {
    n: 2,
    session: 'd41f-12',
    browser: 'Chrome 156.0.0.0',
    control: { cores: 8, lang: 'en-US', dpr: 1 },
    host: { pixels: '3b9e51a0', elapsed: 9.1 },
  },
];

const controlKeys = Object.keys(attempts[0].control);
const controlDrift = controlKeys.filter(k => attempts[0].control[k] !== attempts[1].control[k]);
const pixelsMissing = attempts.some(a => a.host.pixels == null);
const pixelDrift = !pixelsMissing && attempts[0].host.pixels !== attempts[1].host.pixels;
const elapsedDrift = attempts[0].host.elapsed !== attempts[1].host.elapsed;
const verdict = controlDrift.length
  ? 'configuration_drift'
  : pixelsMissing
    ? 'pixel_unavailable'
    : pixelDrift
      ? 'pixel_divergence'
      : elapsedDrift
        ? 'contention'
        : 'no_divergence';

const lines = [
  `attempts=${attempts.length} browser_same=${attempts[0].browser === attempts[1].browser}`,
  ...controlKeys.map(k => `control=${k} same=${attempts[0].control[k] === attempts[1].control[k]}`),
  `host=pixels same=${pixelDrift ? 'false' : 'true'}`,
  `host=elapsed same=${elapsedDrift ? 'false' : 'true'}`,
  `verdict=${verdict}`,
];

// The non-default branches are what separate the causes; print one line each so a
// reader can check the rule rather than trust it.
const cases = [
  { pixels: 'p', elapsed: 1 },
  { pixels: 'q', elapsed: 1 },
  { pixels: 'p', elapsed: 9 },
  { pixels: 'p', elapsed: 1 },
  { pixels: null, elapsed: 1 },
];
for (let i = 0; i + 1 < cases.length; i += 1) {
  const a = cases[i];
  const b = cases[i + 1];
  const missing = a.pixels == null || b.pixels == null;
  const branch = missing
    ? 'pixel_unavailable'
    : a.pixels !== b.pixels
      ? 'pixel_divergence'
      : a.elapsed !== b.elapsed
        ? 'contention'
        : 'no_divergence';
  const samePixels = a.pixels == null || b.pixels == null ? 'null' : a.pixels === b.pixels;
  lines.push(`branch=${branch} pixels_same=${samePixels} elapsed_same=${a.elapsed === b.elapsed}`);
}
console.log(lines.join('\n'));

Смысл именно в собранном выводе, и он состоит из двух частей. Все поля управления совпадают, потому что оба узла загрузили один профиль, а дайджест пикселей различается, поэтому вердикт: pixel_divergence, а не дефект конфигурации. Затем напечатанные ветви показывают правило, которое сохраняет вердикт честным: запись, где пиксели совпадают, а затраченное время различается, помечается как contention, а не как различие рендеринга между узлами.

attempts=2 browser_same=true
control=cores same=true
control=lang same=true
control=dpr same=true
host=pixels same=false
host=elapsed same=false
verdict=pixel_divergence
branch=pixel_divergence pixels_same=false elapsed_same=true
branch=pixel_divergence pixels_same=false elapsed_same=false
branch=contention pixels_same=true elapsed_same=false
branch=pixel_unavailable pixels_same=null elapsed_same=true

Три правила сохраняют запись полезной. Читайте значения внутри сеанса, а не из запрошенных тестом возможностей, потому что запрос описывает намерение, а страница описывает то, что пришло. Классифицируйте каждое поле как поле управления или поле хоста до любого сравнения, потому что у этих несовпадений разные владельцы. И держите список полей коротким и фиксированным, чтобы две записи сравнивались механически, а не на глаз.

Привязка отказа к узлу

Привязка является вопросом распределения. Соберите nodeId для каждой попытки падающего теста и посчитайте, как распределяются отказы. Отказ, который появляется на одном узле и никогда на других, поддерживает версию об артефакте хоста, и следующий шаг состоит в сравнении полей хоста этого узла с полями проходящего узла. Отказ, который появляется на каждом узле примерно с той же частотой, не является проблемой узла, и время, потраченное на сравнение хостов, не потрачено на код, фикстуру или приложение.

Второе распределение важно не меньше. Когда узел объявляет больше слотов, чем хост может комфортно обслужить, несколько сеансов делят одну машину, и каждый сеанс видит больше конкуренции. Тест, который падает только при полной загрузке узла, указывает на артефакт планирования, а не на различие рендеринга, и запись их разделяет, потому что конкуренция сдвигает поле elapsed, а различие растеризаторов сдвигает поле pixels.

Руководство по интеграции профиля Selenium описывает, кто владеет запуском, каталогом пользовательских данных и закрытием сеанса на узле; анализ ожиданий покрывает временную половину той же диагностики. Шаг перед обоими состоит в решении, связано ли различие вообще с хостом.

Конкуренция и расхождение

Узел, который выполняет четыре сеанса одновременно, ведёт себя не как узел с одним сеансом. Distributor считает узел заполненным при достижении maxSessionCount, и по умолчанию узел объявляет один слот на доступный процессор для браузеров на Chromium и Firefox, поэтому хост с шестнадцатью ядрами может принять шестнадцать одновременных сеансов, если конфигурация не говорит иного. Каждый из этих сеансов конкурирует за одну и ту же пропускную способность памяти, один и тот же растеризатор и один и тот же диск, и тест, измеряющий что-либо о времени, увидит конкуренцию раньше любого различия идентичности.

Различие важно, потому что две формы отказа выглядят в отчёте похоже, а исправления у них разные. Отказ из-за конкуренции появляется только когда узел занят, перемещается между тестами, а не между узлами, и исчезает при последовательном запуске. Отказ из-за расхождения появляется всякий раз, когда конкретный узел выполняет тест, занят он или свободен, и воспроизводится при последовательном запуске на этом узле. Запись числа одновременных сеансов рядом с личностью узла и разделяет их, потому что именно это число изменилось.

Конкуренция также является причиной, по которой число узлов плохо отражает ёмкость. Добавление узлов в Grid увеличивает число доступных хостов, но не меняет того, сколько сеансов принимает каждый хост, и парк, которому не хватает памяти на сеанс, будет воспроизводить те же отказы конкуренции в большем масштабе. Параметр maxSessionCount и доступная на сеанс память являются двумя числами, которые это решают, и оба должны быть в записи узла, а не в памяти команды.

Есть третья форма, которая не является ни конкуренцией, ни расхождением: тест, зависящий от данных, меняющихся со временем. Цена, дата или длина списка, которую фикстура считала фиксированной, упадёт по расписанию, не связанному с тем, какой узел его выполнял, и запись это покажет, потому что неудачные попытки распределяются равномерно по узлам и уровням нагрузки. Модель привязки по четырём причинам рассматривает этот случай как отдельную причину именно поэтому.

Что закрепляет профиль и что ему недоступно

Идентификационная половина записи является тем, для чего нужен профиль. BotBrowser документирует значения идентичности, которые даёт профиль, и применяет их до запуска любого скрипта страницы, и он не контролирует напрямую растеризатор или часы, которые предоставляет хост, поэтому в режиме WebGL по умолчанию строки vendor и renderer из UNMASKED, сообщаемое число ядер, размеры экрана, список шрифтов и языковой стек одинаковы на каждом хосте, который загружает один профиль с одними флагами запуска. В режиме --bot-webgl=real строки вместо этого следуют за хостом, единственная строка, которая выходит из группы управления в таком режиме. Это делает сравнение полей управления осмысленным и объясняет, почему несовпадение управления указывает на конфигурацию, а не на оборудование.

Другая половина недоступна по замыслу. Профиль не растеризует кадр, поэтому байты, которые программный бэкенд пишет для одного шейдера, различаются между бэкендами даже при идентичном профиле. Он также не планирует работу, поэтому фиксированный шаг занимает больше времени на более загруженном или медленном хосте. Руководство BotBrowser по кросс-платформенности утверждает, что переносимость требует одной версии BotBrowser, одного файла профиля и одних флагов запуска, а руководство по серверу без графического интерфейса документирует, что сервер без GPU рендерит через один из нескольких программных бэкендов с разными компромиссами. Поэтому набор тестов, сравнивающий пиксели между узлами, всё равно должен записывать бэкенд и узел, а набор, измеряющий задержку, должен записывать нагрузку.

FAQ

Почему один и тот же тест проходит на одном узле и падает на другом?

Distributor назначил два запуска разным слотам. Сравните записи двух узлов до того, как трогать тест: сначала поля управления, потому что несовпадение там означает разные профили или флаги запуска, затем поля хоста, потому что несовпадение там означает, что две машины отрендерили или отмерили одну работу по-разному.

Может ли профиль сделать два узла Grid одинаковыми?

Нет. Профиль закрепляет значения идентичности, которые читает страница, и это устраняет большой класс расхождений, но хост по-прежнему поставляет растеризатор, планирование процессора и часы. Одинаковые пиксели требуют того же бэкенда рендеринга, а не только того же профиля, а одинаковые времена требуют той же нагрузки.

Почему строка renderer может совпадать, а пиксели различаться?

Потому что их определяют разные слои и разные режимы запуска. В режиме WebGL по умолчанию строка renderer является значением идентичности, которое поставляет профиль, а пиксели представляют то, что вычисляет растеризатор хоста, поэтому совпадающая строка и различающийся дайджест пикселей являются ожидаемой парой. Документация BotBrowser по GPU-бэкенду утверждает, что в этом режиме строки UNMASKED остаются под управлением профиля при переходе между бэкендами, тогда как байты пикселей меняются. В режиме --bot-webgl=real строки следуют за хостом, поэтому на двух узлах с разными стеками WebGL и строка, и дайджест меняются вместе.

Сколько попыток нужно, прежде чем называть отказ связанным с узлом?

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

Источники

Практический вывод

Узел, расходящийся с другим, может также запускать другую сборку браузера, и это отдельная причина с отдельным исправлением; Chrome for Testing: фиксация сборки, а не отпечатка разбирает, как фиксировать сборку и что остаётся за пределами.

Записывайте идентификатор сеанса, версию браузера и драйвера, три поля управления, один дайджест пикселей и одно время при каждой попытке каждого теста Grid. Сначала сравнивайте поля управления: несовпадение там указывает на ошибку конфигурации, а не на загадку оборудования. Затем поля хоста: несовпадение там означает различие, которое профиль устранить не может, поэтому записывайте бэкенд, нагрузку и узел вместо смены профиля. Считайте отказ, скапливающийся на одном узле, поддержкой версии об артефакте хоста, а распределённый отказ считайте проблемой кода или данных.

#Selenium Grid#Автоматизация Тестов#Отпечаток Браузера#Расхождение Узлов

Выполните ту же проверку в BotBrowser

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