23 мая

Запись видео UI автотестов, работающих в headless Chrome

Google ChromeC#Тестирование веб-сервисов
Всем привет!

В данной статье хочется рассказать о том, как была решена задача записи видео автотестов, работающих в headless Chrome (с записью в не headless режиме проблем быть не должно). Будет рассмотрено несколько разных подходов, рассказано про трудности и пути их решения.

Постановка задачи

  1. Тесты выполняются под Windows
  2. Тесты используют Selenium Web Driver + Headless Chrome
  3. Тесты запускаются в несколько потоков

Для упавших тестов нужно сохранить видео, при этом

  1. Время выполнения должно вырасти не более чем на 10%.
  2. Обойтись минимумом изменений в текущей имплементации

Если интересно решение, добро пожаловать под кат.

Наивный подход. Cкриншоты


В нашем тестовом фреймворке есть низкоуровневая обертка над Selenium. Поэтому первая имплементация была очень простой и крайне наивной: во все места, изменяющие страницу (Click, Set textbox, Navigate и т.п.) был добавлен код, сохраняющий скриншот экрана через Selenium Web Driver

Driver.TakeScreenshot().SaveAsFile(screenshotPath);

Время выполнения тестов с таким подходом выросло в разы. Причина: операция сохранения скриншота работает совсем не быстро — от 0.5 секунды до нескольких секунд.

Cкриншоты в отдельном потоке


Вместо кода, сохраняющего скриншоты во всех местах, изменяющих страницу (Click, Set textbox, Navigate) был добавлен код, постоянно сохраняющий скриншоты в отдельном потоке

Скрытый текст
...
var timeTillNextFrame = TimeSpan.Zero;
while (!_stopThread.WaitOne(timeTillNextFrame))
{
    var screenShotDriver = Driver as ITakesScreenshot;
    if (screenShotDriver == null)
    {
        continue;
    }

    var screenShot = screenShotDriver.GetScreenshot();
    ...
}


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

Cкриншоты в отдельном потоке через Puppeteer


Делать два инстанса Selenium было не очень интересно, так как я давно хотел попробовать puppeteer-sharp в деле — а тут нашлась подходящая причина. Сбоку от Selenium был создан Puppeteer, который просто приконнектился к Chrome, уже созданному через Selenium

Скрытый текст
var options = new ConnectOptions()
{
    BrowserURL = $"http://127.0.0.1:{debugPort}"
};

_puppeteerBrowser = Puppeteer.ConnectAsync(options).GetAwaiter().GetResult();


Тест пошел своей дорожкой через Selenium, а скриншотами в отдельном потоке занялся Puppeteer

Скрытый текст
...
var timeTillNextFrame = TimeSpan.Zero;
while (!_stopThread.WaitOne(timeTillNextFrame))
{
    var pages = _puppeteerBrowser.PagesAsync().GetAwaiter().GetResult();
    if (pages.Length <= 0)
    {
        continue;
    }
    
    var page = pages[0];
    
    page.SetViewportAsync(new ViewPortOptions
    {
        Width = screenWidth,
        Height = screenHeight
    }).GetAwaiter().GetResult();
    
    var screen = page.ScreenshotStreamAsync().GetAwaiter().GetResult();
    ...
}


Эта имплементация дала обнадеживающие результаты, время выполнения увеличилось на допустимые 10%.

Минусы

  1. Время сохранения скриншотов через Puppeteer не мгновенное, часть фреймов будет потеряна, а в них может оказаться, что-то интересное для разбора.
  2. Если Selenium переключает табы, нужно уведомить Puppeteer, иначе он будет скриншотить только первый таб в коде выше (возможно, есть способ найти активный таб — надо смотреть).

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

Screencast


В Chrome есть интересная фишка — Page.startScreencast. По описанию — она как раз делает то, что надо — кастит измененные фреймы, чтобы желающие могли их перехватить и что-нибудь интересное с ними сделать.

И в Selenium, и в Puppeteer можно стартовать Page.startScreencast, но добавить обработчики нельзя ни в одном, ни в другом. Хотелка такая уже озвучена — ждем имплементацию.

Я попробовал подружиться с библиотечкой ChromeDevTools. К сожалению, быстро наладить хорошие отношения у меня с ней не получилось. После дальнейших поисков было найдено решение для ScreenCast в mafredri/cdp Из исходного примера была убрана ненужная навигация и были добавлены необходимые входные параметры:

Скрытый текст
package main

import (
    "os"
    "context"
    "fmt"
    "io/ioutil"
    "log"
    "time"
    "flag"

    "github.com/mafredri/cdp"
    "github.com/mafredri/cdp/devtool"
    "github.com/mafredri/cdp/protocol/page"
    "github.com/mafredri/cdp/rpcc"
)

func main() {

    folderPtr := flag.String("folder", "", "folder path for screenshots: example c:\\temp\\screens\\")
    chromePtr := flag.String("chrome", "http://localhost:9222", "chrome connection - example: http://localhost:9222")
    
    widthPtr := flag.Int("width", 1280, "screencast width")
    heightPtr := flag.Int("height", 720, "screencast height")
    qualityPtr := flag.Int("quality", 100, "screencast quality")
    
    flag.Parse()

    if err := run(*folderPtr, *chromePtr, *widthPtr, *heightPtr, *qualityPtr); err != nil {
        panic(err)
    }
}

func run(folder string, chromeConnection string, width int, height int, quality int) error {
    ctx, cancel := context.WithCancel(context.TODO())
    defer cancel()
    
    chromePath := chromeConnection
    folderPath := folder

    devt := devtool.New(chromePath)

    pageTarget, err := devt.Get(ctx, devtool.Page)
    if err != nil {
        return err
    }

    conn, err := rpcc.DialContext(ctx, pageTarget.WebSocketDebuggerURL)
    if err != nil {
        return err
    }
    defer conn.Close()

    c := cdp.NewClient(conn)

    err = c.Page.Enable(ctx)
    if err != nil {
        return err
    }

    // Start listening to ScreencastFrame events.
    screencastFrame, err := c.Page.ScreencastFrame(ctx)
    if err != nil {
        return err
    }

    go func() {
        defer screencastFrame.Close()

        for {
            ev, err := screencastFrame.Recv()
            if err != nil {
                log.Printf("Failed to receive ScreencastFrame: %v", err)
                os.Exit(0)
            }
            log.Printf("Got frame with sessionID: %d: %+v", ev.SessionID, ev.Metadata)

            err = c.Page.ScreencastFrameAck(ctx, page.NewScreencastFrameAckArgs(ev.SessionID))
            if err != nil {
                log.Printf("Failed to ack ScreencastFrame: %v", err)
                os.Exit(0)
            }

            // Write to screencast_frame-[timestamp].png.
            name := fmt.Sprintf("screencast_frame-%d.png", ev.Metadata.Timestamp.Time().Unix())
            
            filePath := folderPath + name

            // Write the frame to file (without blocking).
            go func() {
                err = ioutil.WriteFile(filePath, ev.Data, 0644)
                if err != nil {
                    log.Printf("Failed to write ScreencastFrame to %q: %v", name, err)
                }
            }()
        }
    }()

    screencastArgs := page.NewStartScreencastArgs().
        SetQuality(quality).
        SetMaxWidth(width).
        SetMaxHeight(height).
        SetEveryNthFrame(1).
        SetFormat("png")
    err = c.Page.StartScreencast(ctx, screencastArgs)
    if err != nil {
        return err
    }

    // Random delay for our screencast.
    time.Sleep(600 * time.Second)

    err = c.Page.StopScreencast(ctx)
    if err != nil {
        return err
    }

    return nil
}


Далее данный файлик был собран командой:

go build -o screencast.exe main.go

И я смог использовать его в C# solution с тестами:

Скрытый текст
var startInfo = new ProcessStartInfo(screenCastPath)
{
    WindowStyle = ProcessWindowStyle.Minimized,

    Arguments = $"-folder={_framesFolderPath} " +
                $"-chrome=http://localhost:{_debugPort} " +
                "-width=1024 " +
                "-height=576 " +
                "-quality=0"
};

Process.Start(startInfo);


Отдельный поток для записи скриншотов был выкинут за ненадобностью. Алогритм работы получился таким:

  1. Стартуем Chrome через Selenium
  2. Стартуем Screencast через собранный бинарник — он коннектится к Chrome и начинает сохранять поток фреймов в указанную нами папку
  3. По окончании теста закрываем Chrome — автоматом закрывается бинарник
  4. Если тест упал — создаем видео
  5. Зачищаем папку с фреймами

Данный подход дал лучший результат по времени выполнения (задержек практически нет). Плюс он обеспечил максимум информации по тесту (потерянных фреймов практически нет).

Минусы

1. Невысокое разрешение для screencast. Если запустить тесты в пару потоков и выставить разрешение 2560*1440 для Chrome — то произойдет переполнение буфера, отведенного под передачу данных.

2. C ростом разрешения, возрастает нагрузка на CPU.

В итоге, под screencast я выбрал разрешение 1024*576 — на таком разрешение тесты отработали нормально в 6 потоков, процессор работал в комфортном режиме (6-ти ядерник i7-5820).

Собираем видео


Осталось собрать фреймы в видео. Для этого я использовал библиотечку SharpAvi

Скрытый текст
private void GenerateVideoFromScreens(string videoPath)
{
    try
    {
        var videoWriter = new AviWriter(videoPath) { FramesPerSecond = 1, EmitIndex1 = true };

        var videoStream = videoWriter.AddMotionJpegVideoStream(1024, 576);

        var screens = new DirectoryInfo(_framesFolderPath).GetFiles().OrderBy(f => f.CreationTimeUtc.Ticks).ToList();
        foreach (var screen in screens)
        {
            try
            {
                using (var bmp = new Bitmap(screen.FullName))
                {
                    var bits = bmp.LockBits(new Rectangle(0, 0, videoStream.Width, videoStream.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppRgb);
                    var videoFrame = new byte[videoStream.Width * videoStream.Height * 4];
                    Marshal.Copy(bits.Scan0, videoFrame, 0, videoFrame.Length);
                    bmp.UnlockBits(bits);

                    videoStream.WriteFrameAsync(
                        true,
                        videoFrame,
                        0,
                        videoFrame.Length).GetAwaiter().GetResult();
                }
            }
            catch(Exception ex)
            {
                // ignore all video related errors per frame
            }
        }

        videoWriter.Close();
    }
    catch
    {
        // ignore all video related errors per streams
    }
}


Upscale картинок


Так как разрешение screencast совсем небольшое 1024*576, нужно выставить и небольшое разрешение самому Chrome, иначе будут проблемы с мелким текстом.

Chrome 2560*1440 -> screencast в 1024*576 = мелкий текст практически нечитаем
Chrome 1920*1080 -> screencast в 1024*576 = мелкий текст читается с трудом
Chrome 1408*792 -> screencast в 1024*576 = мелкий текст читается без проблем

Полученное видео 1024*576 можно улучшить — если фреймы заапскейлить до 1920*1080 с помощью библиотечки PhotoSauce

Скрытый текст
public Bitmap ResizeImage(Bitmap bitmap, int width)
{
	using (var inStream = new MemoryStream())
	{
		bitmap.Save(inStream, ImageFormat.Png);
		inStream.Position = 0;
		using (MemoryStream outStream = new MemoryStream())
		{
			var settings = new ProcessImageSettings { Width = width };
			MagicImageProcessor.ProcessImage(inStream, outStream, settings);
			return new Bitmap(outStream);
		}
	}
}


В итоге получились такие параметры: Chrome работает в 1408*792, ScreenCast в 1024*576, итоговое видео для просмотра апскейлится до 1920*1080. По ссылке можно посмотреть пример финального результата.

Спасибо


Спасибо, всем кто дочитал — если есть более простое решение исходной задачки, напишите, пожалуйста, в комментарии. Так же принимается любая критика, в том числе злобная по написанному выше.

Всем здоровья и скорейшего окончания ковидных ограничений!
Теги:qa automationseleniumpuppeteerheadless chromevideo capturescreencast
Хабы: Google Chrome C# Тестирование веб-сервисов
+17
3,1k 54
Комментарии 10
Лучшие публикации за сутки