[{"data":1,"prerenderedAt":2572},["ShallowReactive",2],{"/blog/moving-to-headless-chrome":3,"navigation-en-us":1788,"banner-en-us":2214,"footer-en-us":2224,"blog-post-authors-en-us-Mike Greiling":2468,"blog-related-posts-en-us-moving-to-headless-chrome":2482,"blog-promotions-en-us":2508,"next-steps-en-us":2562},{"id":4,"title":5,"authors":6,"body":8,"category":1763,"date":1764,"description":1765,"extension":1766,"externalUrl":1767,"faq":1767,"featured":1768,"heroImage":1769,"meta":1770,"navigation":161,"path":1775,"seo":1776,"slug":1780,"stem":1781,"tags":1782,"template":1786,"updatedDate":1767,"__hash__":1787},"blogPosts/en-us/blog/moving-to-headless-chrome.md","How GitLab switched to Headless Chrome for testing",[7],"Mike Greiling",{"type":9,"value":10,"toc":1752},"minimark",[11,15,18,23,38,47,73,76,83,89,92,95,99,120,129,164,173,178,211,215,230,248,414,440,451,486,492,496,499,739,747,753,1676,1680,1692,1702,1705,1712,1720,1724,1727,1735,1739,1742,1745,1749],[12,13,14],"p",{},"GitLab recently switched from PhantomJS to headless Chrome for both our\nfrontend tests and our RSpec feature tests. In this post we will detail the\nreasons we made this transition, the challenges we faced, and the solutions we\ndeveloped. We hope this will benefit others making the switch.",[12,16,17],{},"We now have a truly accurate way to test GitLab within a real, modern browser.\nThe switch has improved our ability to write tests and debug them while running\nthem directly in Chrome. Plus the change forced us to confront and clean up a\nnumber of hacks we had been using in our tests.",[19,20,22],"h2",{"id":21},"switching-to-headless-chrome-from-phantomjs-background","Switching to headless Chrome from PhantomJS: background",[12,24,25,31,32,37],{},[26,27,30],"a",{"href":28,"rel":29},"http://phantomjs.org",[],"PhantomJS"," has been a part of GitLab's test framework\n",[26,33,36],{"href":34,"rel":35},"https://gitlab.com/gitlab-org/gitlab-ce/commit/ba25b2dc84cc25e66d6fa1450fee39c9bac002c5",[],"for almost five years",".\nIt has been an immensely useful tool for running browser integration tests in a\nheadless environment at a time when few options were available. However, it\nhad some shortcomings:",[12,39,40,41,46],{},"The most recent version of PhantomJS (v2.1.1) is compiled with a three-year-old\nversion of ",[26,42,45],{"href":43,"rel":44},"https://trac.webkit.org/wiki/QtWebKit",[],"QtWebKit"," (a fork of WebKit\nv538.1 according to the user-agent string). This puts it on par with something\nlike Safari 7 on macOS 10.9. It resembles a real modern browser, but it's not\nquite there. It has a different JavaScript engine, an older rendering engine,\nand a host of missing features and quirks.",[12,48,49,50,55,56,61,62,67,68,72],{},"At this time, GitLab supports ",[26,51,54],{"href":52,"rel":53},"https://docs.gitlab.com/install/requirements/#supported-web-browsers",[],"the current and previous major\nrelease"," of\nFirefox, Chrome, Safari, and Microsoft Edge/IE. This puts PhantomJS and its\ncapabilities somewhere near or below our lowest common denominator. Many modern\nbrowser features either ",[26,57,60],{"href":58,"rel":59},"http://phantomjs.org/supported-web-standards.html",[],"do not work",",\nor ",[26,63,66],{"href":64,"rel":65},"http://phantomjs.org/tips-and-tricks.html",[],"require vendor prefixes"," and\npolyfills that none of our supported browsers require. We could selectively\nadd these polyfills, prefixes, and other workarounds just within our test\nenvironment, but doing so would increase technical debt, cause confusion, and\nmake the tests less representative of a true production environment. In most\ncases we had opted to simply omit them or hack around them (more on this\n",[26,69,71],{"href":70},"#trigger-method","later",").",[12,74,75],{},"Here's a screenshot of the way PhantomJS renders a page from GitLab, followed\nby the same page rendered in Google Chrome:",[12,77,78],{},[79,80],"img",{"alt":81,"src":82},"Page Rendered by PhantomJS","https://res.cloudinary.com/about-gitlab-com/image/upload/v1782398737/blog/Content%20Images/moving-to-headless-chrome/render-phantomjs.png",[12,84,85],{},[79,86],{"alt":87,"src":88},"Page Rendered by Google Chrome","https://res.cloudinary.com/about-gitlab-com/image/upload/v1782398736/blog/Content%20Images/moving-to-headless-chrome/render-chrome.png",[12,90,91],{},"You can see in PhantomJS the filter tabs are rendered horizontally, the icons\nin the sidebar render on their own lines, the global search field is\noverflowing off the navbar, etc.",[12,93,94],{},"While it looks ugly, in most cases we could still use this to run functional\ntests, so long as elements of the page remain visible and clickable, but this\ndisparity with the way GitLab rendered in a real browser did introduce several\nedge cases.",[19,96,98],{"id":97},"what-is-headless-chrome","What is headless Chrome",[12,100,101,102,107,108,113,114,119],{},"In April of this year, ",[26,103,106],{"href":104,"rel":105},"https://news.ycombinator.com/item?id=14101233",[],"news spread","\nthat Chrome 59 would support a ",[26,109,112],{"href":110,"rel":111},"https://www.chromestatus.com/features/5678767817097216",[],"native, cross-platform headless\nmode",". It was\npreviously possible to simulate a headless Chrome browser in CI/CD ",[26,115,118],{"href":116,"rel":117},"https://gist.github.com/addyosmani/5336747",[],"using\nvirtual frame buffer",", but this\nrequired a lot of memory and extra complexities. A native headless mode is a\ngame changer. It is now possible to run integration tests in a headless\nenvironment on a real, modern web browser that our users actually use!",[12,121,122,123,128],{},"Soon after this was revealed, Vitaly Slobodin, PhantomJS's chief developer,\nannounced that the project ",[26,124,127],{"href":125,"rel":126},"https://github.com/ariya/phantomjs/issues/15105#issuecomment-322850178",[],"would no longer be\nmaintained",":",[130,131,134,158],"div",{"className":132},[133],"center",[135,136,141,153,154],"blockquote",{"className":137,"dataCards":139,"dataLang":140},[138],"twitter-tweet","hidden","en",[12,142,144,145,148,152],{"lang":140,"dir":143},"ltr","This is the end - ",[26,146,147],{"href":147},"https://t.co/GVmimAyRB5",[26,149,151],{"href":150},"https://twitter.com/hashtag/phantomjs?src=hash&ref_src=twsrc%5Etfw","#phantomjs"," 2.5 will not be released. Sorry, guys!","— Vitaly Slobodin (@Vitalliumm) ",[26,155,157],{"href":156},"https://twitter.com/Vitalliumm/status/852450027318464513?ref_src=twsrc%5Etfw","April 13, 2017",[159,160],"script",{"async":161,"src":162,"charSet":163},true,"https://platform.twitter.com/widgets.js","utf-8",[12,165,166,167,172],{},"It became clear that we would need to make the transition away from PhantomJS at\nsome point, so we ",[26,168,171],{"href":169,"rel":170},"https://gitlab.com/gitlab-org/gitlab-ce/issues/30876",[],"opened up an issue",",\ndownloaded the Chrome 59 beta, and started looking at options.",[174,175,177],"h3",{"id":176},"frontend-tests-karma","Frontend tests (Karma)",[12,179,180,181,186,187,192,193,198,199,204,205,210],{},"Our frontend test suite utilizes the ",[26,182,185],{"href":183,"rel":184},"http://karma-runner.github.io/",[],"Karma","\ntest runner, and updating this to work with Google Chrome was surprisingly\nsimple (",[26,188,191],{"href":189,"rel":190},"https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12036",[],"here's the merge request",").\nThe ",[26,194,197],{"href":195,"rel":196},"https://github.com/karma-runner/karma-chrome-launcher",[],"karma-chrome-launcher","\nplugin was very quickly updated to support headless mode starting from\n",[26,200,203],{"href":201,"rel":202},"https://github.com/karma-runner/karma-chrome-launcher/releases/tag/v2.1.0",[],"version 2.1.0",",\nand it was essentially a drop-in replacement for the PhantomJS launcher. Once\nwe ",[26,206,209],{"href":207,"rel":208},"https://gitlab.com/gitlab-org/gitlab-build-images/merge_requests/41",[],"re-built our CI/CD build images","\nto include Google Chrome 59 (and fiddled around with some pesky timeout\nsettings), it worked!  We were also able to remove some rather ugly\nPhantomJS-specific hacks that Jasmine required to spy on some built-in browser\nfunctions.",[174,212,214],{"id":213},"backend-feature-tests-rspec-capybara","Backend feature tests (RSpec + Capybara)",[12,216,217,218,223,224,229],{},"Our feature tests use RSpec and ",[26,219,222],{"href":220,"rel":221},"https://github.com/teamcapybara/capybara",[],"Capybara","\nto perform full end-to-end integration testing of database, backend, and\nfrontend interactions. Before switching to headless Chrome, we had used\n",[26,225,228],{"href":226,"rel":227},"https://github.com/teampoltergeist/poltergeist",[],"Poltergeist"," which is a\nPhantomJS driver for Capybara. It would spin up a PhantomJS browser instance\nand direct it to browse, fill out forms, and click around on pages to verify\nthat everything behaved as it should.",[12,231,232,233,238,239,243,244,247],{},"Switching from PhantomJS to Google Chrome required a change in drivers from\nPoltergeist to Selenium and ",[26,234,237],{"href":235,"rel":236},"https://sites.google.com/a/chromium.org/chromedriver/",[],"ChromeDriver",".\nSetting this up was pretty straightforward. You can install ChromeDriver on\nmacOS with ",[240,241,242],"code",{},"brew install chromedriver"," and the process is similar on any given\npackage manager in Linux. After this we added the ",[240,245,246],{},"selenium-webdriver"," gem to\nour test dependencies and configured Capybara like so:",[249,250,255],"pre",{"className":251,"code":252,"language":253,"meta":254,"style":254},"language-ruby shiki shiki-themes github-light","require 'selenium-webdriver'\n\nCapybara.register_driver :chrome do |app|\n  options = Selenium::WebDriver::Chrome::Options.new(\n    args: %w[headless disable-gpu no-sandbox]\n  )\n  Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)\nend\n\nCapybara.javascript_driver = :chrome\n","ruby","",[240,256,257,270,276,299,336,345,351,388,394,399],{"__ignoreMap":254},[258,259,262,266],"span",{"class":260,"line":261},"line",1,[258,263,265],{"class":264},"sD7c4","require",[258,267,269],{"class":268},"sYBdl"," 'selenium-webdriver'\n",[258,271,273],{"class":260,"line":272},2,[258,274,275],{"emptyLinePlaceholder":161},"\n",[258,277,279,282,286,290,293,296],{"class":260,"line":278},3,[258,280,222],{"class":281},"sYu0t",[258,283,285],{"class":284},"sgsFI",".",[258,287,289],{"class":288},"s7eDp","register_driver",[258,291,292],{"class":281}," :chrome",[258,294,295],{"class":264}," do",[258,297,298],{"class":284}," |app|\n",[258,300,302,306,309,312,315,318,320,323,325,328,330,333],{"class":260,"line":301},4,[258,303,305],{"class":304},"sqxcx","  options",[258,307,308],{"class":264}," =",[258,310,311],{"class":281}," Selenium",[258,313,314],{"class":284},"::",[258,316,317],{"class":281},"WebDriver",[258,319,314],{"class":284},[258,321,322],{"class":281},"Chrome",[258,324,314],{"class":284},[258,326,327],{"class":281},"Options",[258,329,285],{"class":284},[258,331,332],{"class":264},"new",[258,334,335],{"class":284},"(\n",[258,337,339,342],{"class":260,"line":338},5,[258,340,341],{"class":281},"    args:",[258,343,344],{"class":268}," %w[headless disable-gpu no-sandbox]\n",[258,346,348],{"class":260,"line":347},6,[258,349,350],{"class":284},"  )\n",[258,352,354,357,359,362,364,367,369,371,374,377,379,382,385],{"class":260,"line":353},7,[258,355,356],{"class":281},"  Capybara",[258,358,314],{"class":284},[258,360,361],{"class":281},"Selenium",[258,363,314],{"class":284},[258,365,366],{"class":281},"Driver",[258,368,285],{"class":284},[258,370,332],{"class":264},[258,372,373],{"class":284},"(app, ",[258,375,376],{"class":281},"browser:",[258,378,292],{"class":281},[258,380,381],{"class":284},", ",[258,383,384],{"class":281},"options:",[258,386,387],{"class":284}," options)\n",[258,389,391],{"class":260,"line":390},8,[258,392,393],{"class":264},"end\n",[258,395,397],{"class":260,"line":396},9,[258,398,275],{"emptyLinePlaceholder":161},[258,400,402,404,406,409,411],{"class":260,"line":401},10,[258,403,222],{"class":281},[258,405,285],{"class":284},[258,407,408],{"class":288},"javascript_driver",[258,410,308],{"class":264},[258,412,413],{"class":281}," :chrome\n",[12,415,416,417,425,426,429,430,434,435,285],{},"Google says the ",[26,418,421,424],{"href":419,"rel":420},"https://developers.google.com/web/updates/2017/04/headless-chrome#cli",[],[240,422,423],{},"disable-gpu"," option is necessary for the time\nbeing","\nuntil some bugs are resolved. The ",[240,427,428],{},"no-sandbox"," option also appears to be\nnecessary to get Chrome running inside a Docker container for ",[26,431,433],{"href":432},"/topics/ci-cd/","GitLab's CI/CD\nenvironment",". Google provides a ",[26,436,439],{"href":437,"rel":438},"https://developers.google.com/web/updates/2017/04/headless-chrome",[],"useful guide for working with headless Chrome\nand Selenium",[12,441,442,443,446,447,450],{},"In our final implementation we changed this to conditionally add the ",[240,444,445],{},"headless","\noption unless you have ",[240,448,449],{},"CHROME_HEADLESS=false"," in your environment. This makes\nit easy to disable headless mode while debugging or writing tests. It's also\npretty fun to watch tests execute on the browser window in real time:",[249,452,456],{"className":453,"code":454,"language":455,"meta":254,"style":254},"language-shell shiki shiki-themes github-light","export CHROME_HEADLESS=false\nbundle exec rspec spec/features/merge_requests/filter_merge_requests_spec.rb\n","shell",[240,457,458,472],{"__ignoreMap":254},[258,459,460,463,466,469],{"class":260,"line":261},[258,461,462],{"class":264},"export",[258,464,465],{"class":284}," CHROME_HEADLESS",[258,467,468],{"class":264},"=",[258,470,471],{"class":284},"false\n",[258,473,474,477,480,483],{"class":260,"line":272},[258,475,476],{"class":288},"bundle",[258,478,479],{"class":268}," exec",[258,481,482],{"class":268}," rspec",[258,484,485],{"class":268}," spec/features/merge_requests/filter_merge_requests_spec.rb\n",[12,487,488],{},[79,489],{"alt":490,"src":491},"Tests Executing in Chrome","https://res.cloudinary.com/about-gitlab-com/image/upload/v1782398736/blog/Content%20Images/moving-to-headless-chrome/headlessless-chrome-tests.gif",[174,493,495],{"id":494},"what-is-the-differences-between-poltergeist-and-selenium","What is the differences between Poltergeist and Selenium?",[12,497,498],{},"The process of switching drivers here was not nearly as straightforward as\nit was with the frontend test suite. Dozens of tests started failing as soon\nas we changed our Capybara configuration, and this was due to some major\ndifferences in the way Selenium/ChromeDriver implemented Capybara's driver API\ncompared to Poltergeist/PhantomJS. Here are some of the challenges we ran into:",[500,501,502,622,730],"ol",{},[503,504,505,509,512,513,516,517,520,521,524,525,528,529,532,533,536,537,540,541],"li",{},[506,507,508],"strong",{},"JavaScript modals are no longer accepted automatically",[510,511],"br",{},"We often use JavaScript ",[240,514,515],{},"confirm(\"Are you sure you want to do X?\");"," click\nevents when performing a destructive action such as deleting a branch or\nremoving a user from a group. Under Poltergeist a ",[240,518,519],{},".click"," action would\nautomatically accept modals like ",[240,522,523],{},"alert()"," and ",[240,526,527],{},"confirm()",", but under\nSelenium, you now need to wrap these with one of ",[240,530,531],{},"accept_alert",",\n",[240,534,535],{},"accept_confirm",", or ",[240,538,539],{},"dismiss_confirm",". e.g.:",[249,542,544],{"className":251,"code":543,"language":253,"meta":254,"style":254},"# Before\npage.within('.some-selector') do\n  click_link 'Delete'\nend\n\n# After\npage.within('.some-selector') do\n  accept_confirm { click_link 'Delete' }\nend\n",[240,545,546,552,572,580,584,588,593,607,618],{"__ignoreMap":254},[258,547,548],{"class":260,"line":261},[258,549,551],{"class":550},"sAwPA","# Before\n",[258,553,554,557,560,563,566,569],{"class":260,"line":272},[258,555,556],{"class":284},"page.",[258,558,559],{"class":288},"within",[258,561,562],{"class":284},"(",[258,564,565],{"class":268},"'.some-selector'",[258,567,568],{"class":284},") ",[258,570,571],{"class":264},"do\n",[258,573,574,577],{"class":260,"line":278},[258,575,576],{"class":284},"  click_link ",[258,578,579],{"class":268},"'Delete'\n",[258,581,582],{"class":260,"line":301},[258,583,393],{"class":264},[258,585,586],{"class":260,"line":338},[258,587,275],{"emptyLinePlaceholder":161},[258,589,590],{"class":260,"line":347},[258,591,592],{"class":550},"# After\n",[258,594,595,597,599,601,603,605],{"class":260,"line":353},[258,596,556],{"class":284},[258,598,559],{"class":288},[258,600,562],{"class":284},[258,602,565],{"class":268},[258,604,568],{"class":284},[258,606,571],{"class":264},[258,608,609,612,615],{"class":260,"line":390},[258,610,611],{"class":284},"  accept_confirm { click_link ",[258,613,614],{"class":268},"'Delete'",[258,616,617],{"class":284}," }\n",[258,619,620],{"class":260,"line":396},[258,621,393],{"class":264},[503,623,624,631,633,634,636,637,639,640,643,644,647,648,651,652,722,724,725,285],{},[506,625,626,627,630],{},"Selenium ",[240,628,629],{},"Element.visible?"," returns false for empty elements",[510,632],{},"If you have an empty ",[240,635,130],{}," or ",[240,638,258],{}," that you want to access in your test,\nSelenium does not consider these \"visible.\" This is not much of an issue\nunless you set ",[240,641,642],{},"Capybara.ignore_hidden_elements = true"," as we do in our\nfeature tests. Where ",[240,645,646],{},"find('.empty-div')"," would have worked fine in\nPoltergeist, we now need to use ",[240,649,650],{},"visible: :any"," to\nselect such elements.",[249,653,655],{"className":251,"code":654,"language":253,"meta":254,"style":254},"# Before\nfind('.empty-div')\n\n# After\nfind('.empty-div', visible: :any)\n# or\nfind('.empty-div', visible: false)\n",[240,656,657,661,674,678,682,700,705],{"__ignoreMap":254},[258,658,659],{"class":260,"line":261},[258,660,551],{"class":550},[258,662,663,666,668,671],{"class":260,"line":272},[258,664,665],{"class":288},"find",[258,667,562],{"class":284},[258,669,670],{"class":268},"'.empty-div'",[258,672,673],{"class":284},")\n",[258,675,676],{"class":260,"line":278},[258,677,275],{"emptyLinePlaceholder":161},[258,679,680],{"class":260,"line":301},[258,681,592],{"class":550},[258,683,684,686,688,690,692,695,698],{"class":260,"line":338},[258,685,665],{"class":288},[258,687,562],{"class":284},[258,689,670],{"class":268},[258,691,381],{"class":284},[258,693,694],{"class":281},"visible:",[258,696,697],{"class":281}," :any",[258,699,673],{"class":284},[258,701,702],{"class":260,"line":347},[258,703,704],{"class":550},"# or\n",[258,706,707,709,711,713,715,717,720],{"class":260,"line":353},[258,708,665],{"class":288},[258,710,562],{"class":284},[258,712,670],{"class":268},[258,714,381],{"class":284},[258,716,694],{"class":281},[258,718,719],{"class":281}," false",[258,721,673],{"class":284},[510,723],{},"More on ",[26,726,729],{"href":727,"rel":728},"https://makandracards.com/makandra/7617-change-how-capybara-sees-or-ignores-hidden-elements",[],"Capybara and hidden elements",[503,731,732],{},[506,733,734,735,738],{},"Poltergeist's ",[240,736,737],{},"Element.trigger('click')"," method does not exist in Selenium",[249,740,745],{"className":741,"code":743,"language":744},[742],"language-text","In Capybara, when you use `find('.some-selector').click`, the element you\n","text",[240,746,743],{"__ignoreMap":254},[249,748,751],{"className":749,"code":750,"language":744},[742],"are clicking must be both visible and unobscured by any overlapping\nelement. Situations where links could not be clicked would sometimes occur\nwith Poltergeist/PhantomJS due to its poor CSS support sans-prefixes.\nHere's one example:\n\n![Overlapping elements](https://res.cloudinary.com/about-gitlab-com/image/upload/v1782398736/blog/Content%20Images/moving-to-headless-chrome/overlapping-element.png)\n\nThe broken layout of the search form here was actually placing an invisible\nelement over the top of the \"Update all\" button, making it unclickable.\nPoltergeist offers a `.trigger('click')` method to work around this.\nRather than actually clicking the element, this method would trigger a DOM\nevent to simulate a click. Utilizing this method was a bad practice, but\nwe ran into similar issues so often that many developers formed a habit\nof using it everywhere. This began to lead to some lazy and sloppy test\nwriting. For instance, someone might use `.trigger` as a shortcut to click\non an link that was obscured behind an open dropdown menu, when a properly\nwritten test should `.click` somewhere to close the dropdown, and _then_\n`.click` on the item behind it.\n\nSelenium does not support the `.trigger` method. Now that we were using a\nmore accurate rendering engine that won't break our layouts, many of these\ninstances could be resolved by simply replacing `.trigger('click')` with\n`.click`, but due to some of the bad practice uses mentioned above, this\ndidn't always work.\n\nThere are of course some ways to hack a `.trigger` replacement. You could\nsimulate a click by focusing on an element and hitting the \"return\" key,\nor use JavaScript to trigger a click event, but in most cases we decided to\ntake the time and actually correct these poorly implemented tests so that a\nnormal `.click` could again be used. After all, if our tests are meant to\nsimulate a real user interacting with the page, we should limit ourselves\nto the actions a real user would be expected to use.\n\n```ruby\n# Before\nfind('.obscured-link').trigger('click')\n\n# After\n\n# bad\nfind('.obscured-link').send_keys(:return)\n\n# bad\nexecute_script(\"document.querySelector('.obscured-link').click();\")\n\n# good\n# do something to make link accessible, then\nfind('.link').click\n```\n",[240,752,750],{"__ignoreMap":254},[500,754,755,845,949,1122,1459],{},[503,756,757,763,765,766,769,770,773,774,776,777,779,780],{},[506,758,759,762],{},[240,760,761],{},"Element.send_keys"," only works on focus-able elements",[510,764],{},"We had a few places in our code where we would test out our keyboard\nshortcuts using something like ",[240,767,768],{},"find('.boards-list').native.send_keys('i')",".\nIt turns out Chrome will not allow you to ",[240,771,772],{},"send_keys"," to any element that\ncannot be \"focused\", e.g. links, form elements, the document body, or\npresumably anything with a tab index.",[510,775],{},"In all of the cases where we were doing this, triggering ",[240,778,772],{}," on the\nbody element would work since that's ultimately where our event handler was\nlistening anyway:",[249,781,783],{"className":251,"code":782,"language":253,"meta":254,"style":254},"# Before\nfind('.some-div').native.send_keys('i')\n\n# After\nfind('body').native.send_keys('i')\n",[240,784,785,789,814,818,822],{"__ignoreMap":254},[258,786,787],{"class":260,"line":261},[258,788,551],{"class":550},[258,790,791,793,795,798,800,803,805,807,809,812],{"class":260,"line":272},[258,792,665],{"class":288},[258,794,562],{"class":284},[258,796,797],{"class":268},"'.some-div'",[258,799,72],{"class":284},[258,801,802],{"class":288},"native",[258,804,285],{"class":284},[258,806,772],{"class":288},[258,808,562],{"class":284},[258,810,811],{"class":268},"'i'",[258,813,673],{"class":284},[258,815,816],{"class":260,"line":278},[258,817,275],{"emptyLinePlaceholder":161},[258,819,820],{"class":260,"line":301},[258,821,592],{"class":550},[258,823,824,826,828,831,833,835,837,839,841,843],{"class":260,"line":338},[258,825,665],{"class":288},[258,827,562],{"class":284},[258,829,830],{"class":268},"'body'",[258,832,72],{"class":284},[258,834,802],{"class":288},[258,836,285],{"class":284},[258,838,772],{"class":288},[258,840,562],{"class":284},[258,842,811],{"class":268},[258,844,673],{"class":284},[503,846,847,852,854,855,888,890,891,897,899,900,905,906,911,912],{},[506,848,849,851],{},[240,850,761],{}," does not support non-BMP characters (like emoji)",[510,853],{},"In a few tests, we needed to fill out forms with emoji characters. With\nPoltergeist we would do this like so:",[249,856,858],{"className":251,"code":857,"language":253,"meta":254,"style":254},"# Before\nfind('#note-body').native.send_keys('@💃username💃')\n",[240,859,860,864],{"__ignoreMap":254},[258,861,862],{"class":260,"line":261},[258,863,551],{"class":550},[258,865,866,868,870,873,875,877,879,881,883,886],{"class":260,"line":272},[258,867,665],{"class":288},[258,869,562],{"class":284},[258,871,872],{"class":268},"'#note-body'",[258,874,72],{"class":284},[258,876,802],{"class":288},[258,878,285],{"class":284},[258,880,772],{"class":288},[258,882,562],{"class":284},[258,884,885],{"class":268},"'@💃username💃'",[258,887,673],{"class":284},[510,889],{},"In Selenium we would get the following error message:",[249,892,895],{"className":893,"code":894,"language":744,"meta":254},[742],"Selenium::WebDriver::Error::UnknownError:\n    unknown error: ChromeDriver only supports characters in the BMP\n",[240,896,894],{"__ignoreMap":254},[510,898],{},"To work around this, we added ",[26,901,904],{"href":902,"rel":903},"https://gitlab.com/gitlab-org/gitlab-ce/blob/a8b9852837/app/assets/javascripts/test_utils/simulate_input.js",[],"a JavaScript method to our test bundle that\nwould simulate input and fire off the same DOM events","\nthat an actual keyboard input would generate on every keystroke, then\nwrapped this with a ",[26,907,910],{"href":908,"rel":909},"https://gitlab.com/gitlab-org/gitlab-ce/blob/a8b9852837/spec/support/input_helper.rb",[],"ruby helper","\nmethod that could be called like so:",[249,913,915],{"className":251,"code":914,"language":253,"meta":254,"style":254},"# After\ninclude InputHelper\n\nsimulate_input('#note-body', \"@💃username💃\")\n",[240,916,917,921,929,933],{"__ignoreMap":254},[258,918,919],{"class":260,"line":261},[258,920,592],{"class":550},[258,922,923,926],{"class":260,"line":272},[258,924,925],{"class":264},"include",[258,927,928],{"class":281}," InputHelper\n",[258,930,931],{"class":260,"line":278},[258,932,275],{"emptyLinePlaceholder":161},[258,934,935,938,940,942,944,947],{"class":260,"line":301},[258,936,937],{"class":288},"simulate_input",[258,939,562],{"class":284},[258,941,872],{"class":268},[258,943,381],{"class":284},[258,945,946],{"class":268},"\"@💃username💃\"",[258,948,673],{"class":284},[503,950,951,954,956,957,960,961,964,965,967,968,971,972,988,990,991,993,994,997,998,1003,1004,1012],{},[506,952,953],{},"Setting cookies is much more complicated",[510,955],{},"It's quite common to want to set some cookies before ",[240,958,959],{},"visit","ing a page that\nyou intend to test, whether it's to mock a user session, or toggle a\nsetting. With Poltergeist, this process is really simple. You can use\n",[240,962,963],{},"page.driver.set_cookie",", provide a simple key/value pair, and it will just\nwork as expected, setting a cookie with the correct domain and scope.",[510,966],{},"Selenium is quite a bit more strict. The method is now\n",[240,969,970],{},"page.driver.browser.manage.add_cookie",", and it comes with two caveats:",[973,974,975,981],"ul",{},[503,976,977,978,980],{},"You cannot set cookies until you ",[240,979,959],{}," a page in the domain you intend\nto scope your cookies to.",[503,982,983,984,987],{},"Annoyingly, you cannot alter the ",[240,985,986],{},"path"," parameter (or at least we could\nnever get this to work), so it is best to set cookies at the root path.",[510,989],{},"Before you ",[240,992,959],{}," your page, Chrome's url is technically sitting at\nsomething like ",[240,995,996],{},"about:blank;",". When you attempt to set a cookie there, it\nwill refuse because there is no hostname, and you cannot coerce one by\nproviding a domain as an argument. The ",[26,999,1002],{"href":1000,"rel":1001},"http://docs.seleniumhq.org/docs/03_webdriver.jsp#cookies",[],"Selenium\ndocumentation","\nsuggests that you do the following:",[135,1005,1006],{},[12,1007,1008,1009,72],{},"If you are trying to preset cookies before you start interacting with a\nsite and your homepage is large / takes a while to load, an alternative is\nto find a smaller page on the site (typically the 404 page is small, e.g.\n",[240,1010,1011],{},"http://example.com/some404page",[249,1013,1015],{"className":251,"code":1014,"language":253,"meta":254,"style":254},"# Before\nbefore do\n  page.driver.set_cookie('name', 'value')\nend\n\n# After\nbefore do\n  visit '/some-root-path'\n  page.driver.browser.manage.add_cookie(name: 'name', value: 'value')\nend\n",[240,1016,1017,1021,1028,1053,1057,1061,1065,1071,1079,1118],{"__ignoreMap":254},[258,1018,1019],{"class":260,"line":261},[258,1020,551],{"class":550},[258,1022,1023,1026],{"class":260,"line":272},[258,1024,1025],{"class":284},"before ",[258,1027,571],{"class":264},[258,1029,1030,1033,1036,1038,1041,1043,1046,1048,1051],{"class":260,"line":278},[258,1031,1032],{"class":284},"  page.",[258,1034,1035],{"class":288},"driver",[258,1037,285],{"class":284},[258,1039,1040],{"class":288},"set_cookie",[258,1042,562],{"class":284},[258,1044,1045],{"class":268},"'name'",[258,1047,381],{"class":284},[258,1049,1050],{"class":268},"'value'",[258,1052,673],{"class":284},[258,1054,1055],{"class":260,"line":301},[258,1056,393],{"class":264},[258,1058,1059],{"class":260,"line":338},[258,1060,275],{"emptyLinePlaceholder":161},[258,1062,1063],{"class":260,"line":347},[258,1064,592],{"class":550},[258,1066,1067,1069],{"class":260,"line":353},[258,1068,1025],{"class":284},[258,1070,571],{"class":264},[258,1072,1073,1076],{"class":260,"line":390},[258,1074,1075],{"class":284},"  visit ",[258,1077,1078],{"class":268},"'/some-root-path'\n",[258,1080,1081,1083,1085,1087,1090,1092,1095,1097,1100,1102,1105,1108,1110,1113,1116],{"class":260,"line":396},[258,1082,1032],{"class":284},[258,1084,1035],{"class":288},[258,1086,285],{"class":284},[258,1088,1089],{"class":288},"browser",[258,1091,285],{"class":284},[258,1093,1094],{"class":288},"manage",[258,1096,285],{"class":284},[258,1098,1099],{"class":288},"add_cookie",[258,1101,562],{"class":284},[258,1103,1104],{"class":281},"name:",[258,1106,1107],{"class":268}," 'name'",[258,1109,381],{"class":284},[258,1111,1112],{"class":281},"value:",[258,1114,1115],{"class":268}," 'value'",[258,1117,673],{"class":284},[258,1119,1120],{"class":260,"line":401},[258,1121,393],{"class":264},[503,1123,1124,1127,1129,1130,1133,1134,1137,1138,1141,1142,1254,1256,1257,1262,1263,1268,1269,1274,1275,1280,1281,1284,1285,1372,1374,1375,1378,1379,1381,1382,1384,1385,1426,1428,1429],{},[506,1125,1126],{},"Page request/response inspection methods are missing",[510,1128],{},"Poltergeist very conveniently implemented methods like ",[240,1131,1132],{},"page.status_code","\nand ",[240,1135,1136],{},"page.response_headers"," which are also present in Capybara's default\n",[240,1139,1140],{},"RackTest"," driver, making it easy to inspect the raw response from the\nserver, in addition to the way that response is rendered by the browser. It\nalso allowed you to inject headers into the requests made to the server,\ne.g.:",[249,1143,1145],{"className":251,"code":1144,"language":253,"meta":254,"style":254},"# Before\nbefore do\n  page.driver.add_header('Accept', '*/*')\nend\n\nit 'returns a 404 page'\n  visit some_path\n\n  expect(page.status_code).to eq(404)\n  expect(page).to have_css('.some-selector')\nend\n",[240,1146,1147,1151,1157,1180,1184,1188,1196,1201,1205,1231,1249],{"__ignoreMap":254},[258,1148,1149],{"class":260,"line":261},[258,1150,551],{"class":550},[258,1152,1153,1155],{"class":260,"line":272},[258,1154,1025],{"class":284},[258,1156,571],{"class":264},[258,1158,1159,1161,1163,1165,1168,1170,1173,1175,1178],{"class":260,"line":278},[258,1160,1032],{"class":284},[258,1162,1035],{"class":288},[258,1164,285],{"class":284},[258,1166,1167],{"class":288},"add_header",[258,1169,562],{"class":284},[258,1171,1172],{"class":268},"'Accept'",[258,1174,381],{"class":284},[258,1176,1177],{"class":268},"'*/*'",[258,1179,673],{"class":284},[258,1181,1182],{"class":260,"line":301},[258,1183,393],{"class":264},[258,1185,1186],{"class":260,"line":338},[258,1187,275],{"emptyLinePlaceholder":161},[258,1189,1190,1193],{"class":260,"line":347},[258,1191,1192],{"class":284},"it ",[258,1194,1195],{"class":268},"'returns a 404 page'\n",[258,1197,1198],{"class":260,"line":353},[258,1199,1200],{"class":284},"  visit some_path\n",[258,1202,1203],{"class":260,"line":390},[258,1204,275],{"emptyLinePlaceholder":161},[258,1206,1207,1210,1213,1216,1218,1221,1224,1226,1229],{"class":260,"line":396},[258,1208,1209],{"class":288},"  expect",[258,1211,1212],{"class":284},"(page.",[258,1214,1215],{"class":288},"status_code",[258,1217,72],{"class":284},[258,1219,1220],{"class":288},"to",[258,1222,1223],{"class":288}," eq",[258,1225,562],{"class":284},[258,1227,1228],{"class":281},"404",[258,1230,673],{"class":284},[258,1232,1233,1235,1238,1240,1243,1245,1247],{"class":260,"line":401},[258,1234,1209],{"class":288},[258,1236,1237],{"class":284},"(page).",[258,1239,1220],{"class":288},[258,1241,1242],{"class":288}," have_css",[258,1244,562],{"class":284},[258,1246,565],{"class":268},[258,1248,673],{"class":284},[258,1250,1252],{"class":260,"line":1251},11,[258,1253,393],{"class":264},[510,1255],{},"Selenium does not implement these methods, and ",[26,1258,1261],{"href":1259,"rel":1260},"https://github.com/seleniumhq/selenium-google-code-issue-archive/issues/141#issuecomment-191404986",[],"the authors do not intend\nto add support for them",",\nso we needed to develop a workaround. Several people have suggested running\na proxy alongside ChromeDriver that would intercept all traffic to and from\nthe server, but this seemed to us like overkill. Instead, we opted to\ncreate a ",[26,1264,1267],{"href":1265,"rel":1266},"https://gitlab.com/gitlab-org/gitlab-ce/blob/a8b9852837/lib/gitlab/testing/request_inspector_middleware.rb",[],"lightweight Rack middleware","\nand a corresponding ",[26,1270,1273],{"href":1271,"rel":1272},"https://gitlab.com/gitlab-org/gitlab-ce/blob/a8b9852837/spec/support/inspect_requests.rb",[],"helper class","\nthat would intercept the traffic for inspection. This is similar to our\n",[26,1276,1279],{"href":1277,"rel":1278},"https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/testing/request_blocker_middleware.rb",[],"RequestBlockerMiddleware","\nthat we were already using to intelligently ",[240,1282,1283],{},"wait_for_requests"," to complete\nwithin our tests. It works like this:",[249,1286,1288],{"className":251,"code":1287,"language":253,"meta":254,"style":254},"# After\nit 'returns a 404 page'\n  requests = inspect_requests do\n    visit some_path\n  end\n\n  expect(requests.first.status_code).to eq(404)\n  expect(page).to have_css('.some-selector')\nend\n",[240,1289,1290,1294,1300,1312,1317,1322,1326,1352,1368],{"__ignoreMap":254},[258,1291,1292],{"class":260,"line":261},[258,1293,592],{"class":550},[258,1295,1296,1298],{"class":260,"line":272},[258,1297,1192],{"class":284},[258,1299,1195],{"class":268},[258,1301,1302,1305,1307,1310],{"class":260,"line":278},[258,1303,1304],{"class":304},"  requests",[258,1306,308],{"class":264},[258,1308,1309],{"class":284}," inspect_requests ",[258,1311,571],{"class":264},[258,1313,1314],{"class":260,"line":301},[258,1315,1316],{"class":284},"    visit some_path\n",[258,1318,1319],{"class":260,"line":338},[258,1320,1321],{"class":264},"  end\n",[258,1323,1324],{"class":260,"line":347},[258,1325,275],{"emptyLinePlaceholder":161},[258,1327,1328,1330,1333,1336,1338,1340,1342,1344,1346,1348,1350],{"class":260,"line":353},[258,1329,1209],{"class":288},[258,1331,1332],{"class":284},"(requests.",[258,1334,1335],{"class":288},"first",[258,1337,285],{"class":284},[258,1339,1215],{"class":288},[258,1341,72],{"class":284},[258,1343,1220],{"class":288},[258,1345,1223],{"class":288},[258,1347,562],{"class":284},[258,1349,1228],{"class":281},[258,1351,673],{"class":284},[258,1353,1354,1356,1358,1360,1362,1364,1366],{"class":260,"line":390},[258,1355,1209],{"class":288},[258,1357,1237],{"class":284},[258,1359,1220],{"class":288},[258,1361,1242],{"class":288},[258,1363,562],{"class":284},[258,1365,565],{"class":268},[258,1367,673],{"class":284},[258,1369,1370],{"class":260,"line":396},[258,1371,393],{"class":264},[510,1373],{},"Within the ",[240,1376,1377],{},"inspect_requests"," block, the Rack middleware will log all\nrequests and responses, and return them as an array for inspection. This\nwill include the page being ",[240,1380,959],{},"ed as well as the subsequent XHR and\nasset requests, but the initial path request will be the first in the array.",[510,1383],{},"You can also inject headers using the same helper like so:",[249,1386,1388],{"className":251,"code":1387,"language":253,"meta":254,"style":254},"# After\ninspect_requests(inject_headers: { 'Accept' => '*/*' }) do\n  visit some_path\nend\n",[240,1389,1390,1394,1418,1422],{"__ignoreMap":254},[258,1391,1392],{"class":260,"line":261},[258,1393,592],{"class":550},[258,1395,1396,1398,1400,1403,1406,1408,1411,1413,1416],{"class":260,"line":272},[258,1397,1377],{"class":288},[258,1399,562],{"class":284},[258,1401,1402],{"class":281},"inject_headers:",[258,1404,1405],{"class":284}," { ",[258,1407,1172],{"class":268},[258,1409,1410],{"class":284}," => ",[258,1412,1177],{"class":268},[258,1414,1415],{"class":284}," }) ",[258,1417,571],{"class":264},[258,1419,1420],{"class":260,"line":278},[258,1421,1200],{"class":284},[258,1423,1424],{"class":260,"line":301},[258,1425,393],{"class":264},[510,1427],{},"This middleware should be injected early in the stack to ensure any other\nmiddleware that might intercept or modify the request/response will be\nseen by our tests. We include this line in our test environment config:",[249,1430,1432],{"className":251,"code":1431,"language":253,"meta":254,"style":254},"config.middleware.insert_before('ActionDispatch::Static', 'Gitlab::Testing::RequestInspectorMiddleware')\n",[240,1433,1434],{"__ignoreMap":254},[258,1435,1436,1439,1442,1444,1447,1449,1452,1454,1457],{"class":260,"line":261},[258,1437,1438],{"class":284},"config.",[258,1440,1441],{"class":288},"middleware",[258,1443,285],{"class":284},[258,1445,1446],{"class":288},"insert_before",[258,1448,562],{"class":284},[258,1450,1451],{"class":268},"'ActionDispatch::Static'",[258,1453,381],{"class":284},[258,1455,1456],{"class":268},"'Gitlab::Testing::RequestInspectorMiddleware'",[258,1458,673],{"class":284},[503,1460,1461,1464,1466,1467,1470,1471,1474,1475,1477,1478,1637,1639,1640,1667,1669,1670,1675],{},[506,1462,1463],{},"Browser console output is no longer output to the terminal",[510,1465],{},"Poltergeist would automatically output any ",[240,1468,1469],{},"console"," messages directly into\nthe terminal in real time as tests were run. If you had a bug in the frontend\ncode that caused a test to fail, this feature would make debugging much\neasier as you could inspect the terminal output of the test for an error\nmessage or a stack trace, or inject your own ",[240,1472,1473],{},"console.log()"," into the\nJavaScript to see what is going on. With Selenium this is sadly no longer the\ncase.",[510,1476],{},"You can, however, collect browser logs by configuring Capybara like so:",[249,1479,1481],{"className":251,"code":1480,"language":253,"meta":254,"style":254},"capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(\n  loggingPrefs: {\n    browser: \"ALL\",\n    client: \"ALL\",\n    driver: \"ALL\",\n    server: \"ALL\"\n  }\n)\n\n# ...\n\nCapybara::Selenium::Driver.new(\n  app,\n  browser: :chrome,\n  desired_capabilities: capabilities,\n  options: options\n)\n",[240,1482,1483,1513,1521,1531,1540,1549,1557,1562,1566,1570,1575,1579,1598,1604,1614,1623,1632],{"__ignoreMap":254},[258,1484,1485,1488,1490,1492,1494,1496,1498,1501,1503,1506,1508,1511],{"class":260,"line":261},[258,1486,1487],{"class":304},"capabilities",[258,1489,308],{"class":264},[258,1491,311],{"class":281},[258,1493,314],{"class":284},[258,1495,317],{"class":281},[258,1497,314],{"class":284},[258,1499,1500],{"class":281},"Remote",[258,1502,314],{"class":284},[258,1504,1505],{"class":281},"Capabilities",[258,1507,285],{"class":284},[258,1509,1510],{"class":288},"chrome",[258,1512,335],{"class":284},[258,1514,1515,1518],{"class":260,"line":272},[258,1516,1517],{"class":281},"  loggingPrefs:",[258,1519,1520],{"class":284}," {\n",[258,1522,1523,1526,1529],{"class":260,"line":278},[258,1524,1525],{"class":281},"    browser:",[258,1527,1528],{"class":268}," \"ALL\"",[258,1530,532],{"class":284},[258,1532,1533,1536,1538],{"class":260,"line":301},[258,1534,1535],{"class":281},"    client:",[258,1537,1528],{"class":268},[258,1539,532],{"class":284},[258,1541,1542,1545,1547],{"class":260,"line":338},[258,1543,1544],{"class":281},"    driver:",[258,1546,1528],{"class":268},[258,1548,532],{"class":284},[258,1550,1551,1554],{"class":260,"line":347},[258,1552,1553],{"class":281},"    server:",[258,1555,1556],{"class":268}," \"ALL\"\n",[258,1558,1559],{"class":260,"line":353},[258,1560,1561],{"class":284},"  }\n",[258,1563,1564],{"class":260,"line":390},[258,1565,673],{"class":284},[258,1567,1568],{"class":260,"line":396},[258,1569,275],{"emptyLinePlaceholder":161},[258,1571,1572],{"class":260,"line":401},[258,1573,1574],{"class":550},"# ...\n",[258,1576,1577],{"class":260,"line":1251},[258,1578,275],{"emptyLinePlaceholder":161},[258,1580,1582,1584,1586,1588,1590,1592,1594,1596],{"class":260,"line":1581},12,[258,1583,222],{"class":281},[258,1585,314],{"class":284},[258,1587,361],{"class":281},[258,1589,314],{"class":284},[258,1591,366],{"class":281},[258,1593,285],{"class":284},[258,1595,332],{"class":264},[258,1597,335],{"class":284},[258,1599,1601],{"class":260,"line":1600},13,[258,1602,1603],{"class":284},"  app,\n",[258,1605,1607,1610,1612],{"class":260,"line":1606},14,[258,1608,1609],{"class":281},"  browser:",[258,1611,292],{"class":281},[258,1613,532],{"class":284},[258,1615,1617,1620],{"class":260,"line":1616},15,[258,1618,1619],{"class":281},"  desired_capabilities:",[258,1621,1622],{"class":284}," capabilities,\n",[258,1624,1626,1629],{"class":260,"line":1625},16,[258,1627,1628],{"class":281},"  options:",[258,1630,1631],{"class":284}," options\n",[258,1633,1635],{"class":260,"line":1634},17,[258,1636,673],{"class":284},[510,1638],{},"This will allow you to access logs with the following, i.e. in the event of\na test failure:",[249,1641,1643],{"className":251,"code":1642,"language":253,"meta":254,"style":254},"page.driver.manage.get_log(:browser)\n",[240,1644,1645],{"__ignoreMap":254},[258,1646,1647,1649,1651,1653,1655,1657,1660,1662,1665],{"class":260,"line":261},[258,1648,556],{"class":284},[258,1650,1035],{"class":288},[258,1652,285],{"class":284},[258,1654,1094],{"class":288},[258,1656,285],{"class":284},[258,1658,1659],{"class":288},"get_log",[258,1661,562],{"class":284},[258,1663,1664],{"class":281},":browser",[258,1666,673],{"class":284},[510,1668],{},"This is far more cumbersome than it was in Poltergeist, but it's the best\nmethod we've found so far. Thanks to ",[26,1671,1674],{"href":1672,"rel":1673},"http://technopragmatica.blogspot.com/2017/10/switching-to-headless-chrome-for-rails_31.html",[],"Larry Reid's blog post","\nfor the tip!",[19,1677,1679],{"id":1678},"results","Results",[12,1681,1682,1683,1687,1688,1691],{},"Regarding performance, we attempted to quantify the change with a\nnon-scientific analysis of 10 full-suite RSpec test runs ",[1684,1685,1686],"em",{},"before"," this change,\nand 10 more runs from ",[1684,1689,1690],{},"after"," this change, factoring out any tests that were\nadded or removed between these pipelines. The end result was:",[12,1693,1694,1697,1698,1701],{},[506,1695,1696],{},"Before:"," 5h 18m 52s\n",[506,1699,1700],{},"After:"," 5h 12m 34s",[12,1703,1704],{},"A savings of about six minutes, or roughly 2 percent of the total compute time, is\nstatistically insignificant, so I'm not going to claim we improved our test\nspeed with this change.",[12,1706,1707,1708,1711],{},"What we did improve was test accuracy, and we vastly improved the tools at our\ndisposal to write and debug tests. Now, all of the Capybara screenshots\ngenerated when a CI/CD job fails look exactly as they do on your own browser\nrather than resembling the broken PhantomJS screenshot above. Inspecting a\nfailing test locally can now be done interactively by turning off headless\nmode, dropping a ",[240,1709,1710],{},"byebug"," line into the spec file, and watching the browser\nwindow as you type commands into the prompt. This technique proved extremely\nuseful while working on this project.",[12,1713,1714,1715,285],{},"You can find all of the changes we made in ",[26,1716,1719],{"href":1717,"rel":1718},"https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12244",[],"the original merge request page\non GitLab.com",[19,1721,1723],{"id":1722},"what-are-some-additional-uses-for-headless-chrome","What are some additional uses for headless Chrome?",[12,1725,1726],{},"We have also been utilizing headless Chrome to analyze frontend performance, and have found it to be useful in detecting issues.",[12,1728,1729,1730,1734],{},"We'd like to make it easier for other companies to embrace as well, so as part of the upcoming 10.3 release of GitLab we are releasing Browser Performance Testing. Leveraging ",[26,1731,1733],{"href":1732},"/solutions/continuous-integration/","GitLab CI/CD",", headless Chrome is launched against a set of pages and an overall performance score is calculated. Then for each merge request the scores are compared between the source and target branches, making it easier detect performance regressions prior to merge.",[19,1736,1738],{"id":1737},"acknowledgements","Acknowledgements",[12,1740,1741],{},"I sincerely hope this information will prove useful to anybody else looking to\nmake the switch from PhantomJS to headless Chrome for their Rails application.",[12,1743,1744],{},"Thanks to the Google team for their very helpful documentation, thanks to the\nmany bloggers out there who shared their own experiences with hacking headless\nChrome in the early days of its availability, and special thanks to Vitaly\nSlobodin and the rest of the contributors to PhantomJS who provided us with an\nextremely useful tool that served us for many years. 🙇‍",[1746,1747,1748],"style",{},"\n\n.center {\n  text-align: center;\n  display: block;\n  margin-right: auto;\n  margin-left: auto;\n}\n\ncode, kbd {\n  font-size: 80%;\n}\n\n",[1746,1750,1751],{},"html pre.shiki code .sD7c4, html code.shiki .sD7c4{--shiki-default:#D73A49}html pre.shiki code .sYBdl, html code.shiki .sYBdl{--shiki-default:#032F62}html pre.shiki code .sYu0t, html code.shiki .sYu0t{--shiki-default:#005CC5}html pre.shiki code .sgsFI, html code.shiki .sgsFI{--shiki-default:#24292E}html pre.shiki code .s7eDp, html code.shiki .s7eDp{--shiki-default:#6F42C1}html pre.shiki code .sqxcx, html code.shiki .sqxcx{--shiki-default:#E36209}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":254,"searchDepth":272,"depth":272,"links":1753},[1754,1755,1760,1761,1762],{"id":21,"depth":272,"text":22},{"id":97,"depth":272,"text":98,"children":1756},[1757,1758,1759],{"id":176,"depth":278,"text":177},{"id":213,"depth":278,"text":214},{"id":494,"depth":278,"text":495},{"id":1678,"depth":272,"text":1679},{"id":1722,"depth":272,"text":1723},{"id":1737,"depth":272,"text":1738},"engineering","2017-12-19","A detailed explanation with examples of how GitLab made the switch to headless Chrome.","md",null,false,"https://res.cloudinary.com/about-gitlab-com/image/upload/v1749680270/Blog/Hero%20Images/headless-chrome-cover.jpg",{"excerpt":1771},{"type":9,"value":1772},[1773],[12,1774,14],{},"/en-us/blog/moving-to-headless-chrome",{"title":5,"description":1765,"ogTitle":5,"ogDescription":1765,"noIndex":1768,"ogImage":1769,"ogUrl":1777,"ogSiteName":1778,"ogType":1779,"canonicalUrls":1777},"https://about.gitlab.com/blog/moving-to-headless-chrome","https://about.gitlab.com","article","moving-to-headless-chrome","en-us/blog/moving-to-headless-chrome",[1783,1784,1785],"inside GitLab","frontend","testing","BlogPost","LlFEJ4HWE1sBH7LzjV56wc2C8JzqHF4dK6fpo1sJ-ic",{"logo":1789,"freeTrial":1794,"sales":1799,"login":1804,"items":1809,"search":2134,"minimal":2165,"duo":2184,"switchNav":2193,"pricingDeployment":2204},{"config":1790},{"href":1791,"dataGaName":1792,"dataGaLocation":1793},"/","gitlab logo","header",{"text":1795,"config":1796},"Get free trial",{"href":1797,"dataGaName":1798,"dataGaLocation":1793},"https://gitlab.com/-/trial_registrations/new?glm_source=about.gitlab.com&glm_content=default-saas-trial/","free trial",{"text":1800,"config":1801},"Request a demo",{"href":1802,"dataGaName":1803,"dataGaLocation":1793},"/sales/?contact-topic=request-demo","sales",{"text":1805,"config":1806},"Sign in",{"href":1807,"dataGaName":1808,"dataGaLocation":1793},"https://gitlab.com/users/sign_in/","sign in",[1810,1839,1938,1943,2056,2112],{"text":1811,"config":1812,"menu":1814},"Platform",{"dataNavLevelOne":1813},"platform",{"type":1815,"columns":1816},"cards",[1817,1823,1831],{"title":1811,"description":1818,"link":1819},"The intelligent orchestration platform for DevSecOps",{"text":1820,"config":1821},"Explore our Platform",{"href":1822,"dataGaName":1813,"dataGaLocation":1793},"/platform/",{"title":1824,"description":1825,"link":1826},"GitLab Duo Agent Platform","Agentic AI for the entire software lifecycle",{"text":1827,"config":1828},"Meet GitLab Duo",{"href":1829,"dataGaName":1830,"dataGaLocation":1793},"/gitlab-duo-agent-platform/","gitlab duo agent platform",{"title":1832,"description":1833,"link":1834},"Why GitLab","See the top reasons enterprises choose GitLab",{"text":1835,"config":1836},"Learn more",{"href":1837,"dataGaName":1838,"dataGaLocation":1793},"/why-gitlab/","why gitlab",{"text":1840,"left":161,"config":1841,"menu":1843},"Product",{"dataNavLevelOne":1842},"solutions",{"type":1844,"link":1845,"columns":1849,"feature":1917},"lists",{"text":1846,"config":1847},"View all Solutions",{"href":1848,"dataGaName":1842,"dataGaLocation":1793},"/solutions/",[1850,1873,1896],{"title":1851,"description":1852,"link":1853,"items":1858},"Automation","CI/CD and automation to accelerate deployment",{"config":1854},{"icon":1855,"href":1856,"dataGaName":1857,"dataGaLocation":1793},"AutomatedCodeAlt","/solutions/delivery-automation/","automated software delivery",[1859,1862,1865,1869],{"text":1860,"config":1861},"CI/CD",{"href":1732,"dataGaLocation":1793,"dataGaName":1860},{"text":1824,"config":1863},{"href":1829,"dataGaLocation":1793,"dataGaName":1864},"gitlab duo agent platform - product menu",{"text":1866,"config":1867},"Source Code Management",{"href":1868,"dataGaLocation":1793,"dataGaName":1866},"/solutions/source-code-management/",{"text":1870,"config":1871},"Automated Software Delivery",{"href":1856,"dataGaLocation":1793,"dataGaName":1872},"Automated software delivery",{"title":1874,"description":1875,"link":1876,"items":1881},"Security","Deliver code faster without compromising security",{"config":1877},{"href":1878,"dataGaName":1879,"dataGaLocation":1793,"icon":1880},"/solutions/application-security-testing/","security and compliance","ShieldCheckLight",[1882,1886,1891],{"text":1883,"config":1884},"Application Security Testing",{"href":1878,"dataGaName":1885,"dataGaLocation":1793},"Application security testing",{"text":1887,"config":1888},"Software Supply Chain Security",{"href":1889,"dataGaLocation":1793,"dataGaName":1890},"/solutions/supply-chain/","Software supply chain security",{"text":1892,"config":1893},"Software Compliance",{"href":1894,"dataGaName":1895,"dataGaLocation":1793},"/solutions/software-compliance/","software compliance",{"title":1897,"link":1898,"items":1903},"Measurement",{"config":1899},{"icon":1900,"href":1901,"dataGaName":1902,"dataGaLocation":1793},"DigitalTransformation","/solutions/visibility-measurement/","visibility and measurement",[1904,1908,1912],{"text":1905,"config":1906},"Visibility & Measurement",{"href":1901,"dataGaLocation":1793,"dataGaName":1907},"Visibility and Measurement",{"text":1909,"config":1910},"Value Stream Management",{"href":1911,"dataGaLocation":1793,"dataGaName":1909},"/solutions/value-stream-management/",{"text":1913,"config":1914},"Analytics & Insights",{"href":1915,"dataGaLocation":1793,"dataGaName":1916},"/solutions/analytics-and-insights/","Analytics and insights",{"title":1918,"type":1844,"items":1919},"GitLab for",[1920,1926,1932],{"text":1921,"config":1922},"Enterprise",{"icon":1923,"href":1924,"dataGaLocation":1793,"dataGaName":1925},"Building","/enterprise/","enterprise",{"text":1927,"config":1928},"Small Business",{"icon":1929,"href":1930,"dataGaLocation":1793,"dataGaName":1931},"Work","/small-business/","small business",{"text":1933,"config":1934},"Public Sector",{"icon":1935,"href":1936,"dataGaLocation":1793,"dataGaName":1937},"Organization","/solutions/public-sector/","public sector",{"text":1939,"config":1940},"Pricing",{"href":1941,"dataGaName":1942,"dataGaLocation":1793,"dataNavLevelOne":1942},"/pricing/","pricing",{"text":1944,"config":1945,"menu":1947},"Resources",{"dataNavLevelOne":1946},"resources",{"type":1844,"link":1948,"columns":1952,"feature":2045},{"text":1949,"config":1950},"View all resources",{"href":1951,"dataGaName":1946,"dataGaLocation":1793},"/resources/",[1953,1986,2012],{"title":1954,"items":1955},"Getting started",[1956,1961,1966,1971,1976,1981],{"text":1957,"config":1958},"Install",{"href":1959,"dataGaName":1960,"dataGaLocation":1793},"/install/","install",{"text":1962,"config":1963},"Quick start guides",{"href":1964,"dataGaName":1965,"dataGaLocation":1793},"/get-started/","quick setup checklists",{"text":1967,"config":1968},"Learn",{"href":1969,"dataGaLocation":1793,"dataGaName":1970},"https://university.gitlab.com/","learn",{"text":1972,"config":1973},"Product documentation",{"href":1974,"dataGaName":1975,"dataGaLocation":1793},"https://docs.gitlab.com/","product documentation",{"text":1977,"config":1978},"Best practice videos",{"href":1979,"dataGaName":1980,"dataGaLocation":1793},"/getting-started-videos/","best practice videos",{"text":1982,"config":1983},"Integrations",{"href":1984,"dataGaName":1985,"dataGaLocation":1793},"/integrations/","integrations",{"title":1987,"items":1988},"Discover",[1989,1994,1999,2004,2008],{"text":1990,"config":1991},"Customer success stories",{"href":1992,"dataGaName":1993,"dataGaLocation":1793},"/customers/","customer success stories",{"text":1995,"config":1996},"Blog",{"href":1997,"dataGaName":1998,"dataGaLocation":1793},"/blog/","blog",{"text":2000,"config":2001},"Demo Hub",{"href":2002,"dataGaName":2003,"dataGaLocation":1793},"/demo-hub/","demo hub",{"text":2005,"config":2006},"The Source",{"href":2007,"dataGaName":1998,"dataGaLocation":1793},"/the-source/",{"text":1500,"config":2009},{"href":2010,"dataGaName":2011,"dataGaLocation":1793},"https://handbook.gitlab.com/handbook/company/culture/all-remote/","remote",{"title":2013,"items":2014},"Connect",[2015,2020,2025,2030,2035,2040],{"text":2016,"config":2017},"GitLab Services",{"href":2018,"dataGaName":2019,"dataGaLocation":1793},"/services/","services",{"text":2021,"config":2022},"Contribute",{"href":2023,"dataGaName":2024,"dataGaLocation":1793},"https://contributors.gitlab.com","contribute",{"text":2026,"config":2027},"Community",{"href":2028,"dataGaName":2029,"dataGaLocation":1793},"/community/","community",{"text":2031,"config":2032},"Forum",{"href":2033,"dataGaName":2034,"dataGaLocation":1793},"https://forum.gitlab.com/","forum",{"text":2036,"config":2037},"Events",{"href":2038,"dataGaName":2039,"dataGaLocation":1793},"/events/","events",{"text":2041,"config":2042},"Partners",{"href":2043,"dataGaName":2044,"dataGaLocation":1793},"/partners/","partners",{"config":2046,"title":2049,"text":2050,"link":2051},{"background":2047,"textColor":2048},"url('https://res.cloudinary.com/about-gitlab-com/image/upload/v1777322348/qpq8yrgn8knii57omj0c.png')","#000","What’s new in GitLab","Stay updated with our latest features and improvements.",{"text":2052,"config":2053},"Read the latest",{"href":2054,"dataGaName":2055,"dataGaLocation":1793},"/whats-new/","whats new",{"text":2057,"config":2058,"menu":2060},"Company",{"dataNavLevelOne":2059},"company",{"type":1844,"columns":2061},[2062],{"items":2063},[2064,2069,2075,2077,2082,2087,2092,2097,2102,2107],{"text":2065,"config":2066},"About",{"href":2067,"dataGaName":2068,"dataGaLocation":1793},"/company/","about",{"text":2070,"config":2071,"footerGa":2074},"Jobs",{"href":2072,"dataGaName":2073,"dataGaLocation":1793},"/jobs/","jobs",{"dataGaName":2073},{"text":2036,"config":2076},{"href":2038,"dataGaName":2039,"dataGaLocation":1793},{"text":2078,"config":2079},"Leadership",{"href":2080,"dataGaName":2081,"dataGaLocation":1793},"/company/team/e-group/","leadership",{"text":2083,"config":2084},"Handbook",{"href":2085,"dataGaName":2086,"dataGaLocation":1793},"https://handbook.gitlab.com/","handbook",{"text":2088,"config":2089},"Investor relations",{"href":2090,"dataGaName":2091,"dataGaLocation":1793},"https://ir.gitlab.com/overview/default.aspx","investor relations",{"text":2093,"config":2094},"Trust Center",{"href":2095,"dataGaName":2096,"dataGaLocation":1793},"/security/","trust center",{"text":2098,"config":2099},"AI Transparency Center",{"href":2100,"dataGaName":2101,"dataGaLocation":1793},"/ai-transparency-center/","ai transparency center",{"text":2103,"config":2104},"Newsletter",{"href":2105,"dataGaName":2106,"dataGaLocation":1793},"/company/contact/#contact-forms","newsletter",{"text":2108,"config":2109},"Press",{"href":2110,"dataGaName":2111,"dataGaLocation":1793},"/press/","press",{"text":2113,"config":2114,"menu":2115},"Contact us",{"dataNavLevelOne":2059},{"type":1844,"columns":2116},[2117],{"items":2118},[2119,2124,2129],{"text":2120,"config":2121},"Talk to sales",{"href":2122,"dataGaName":2123,"dataGaLocation":1793},"/sales/","talk to sales",{"text":2125,"config":2126},"Support portal",{"href":2127,"dataGaName":2128,"dataGaLocation":1793},"https://support.gitlab.com/hc/en-us","support portal",{"text":2130,"config":2131},"Customer portal",{"href":2132,"dataGaName":2133,"dataGaLocation":1793},"https://customers.gitlab.com/customers/sign_in/","customer portal",{"close":2135,"login":2136,"suggestions":2143},"Close",{"text":2137,"link":2138},"To search repositories and projects, login to",{"text":2139,"config":2140},"gitlab.com",{"href":1807,"dataGaName":2141,"dataGaLocation":2142},"search login","search",{"text":2144,"default":2145},"Suggestions",[2146,2148,2152,2154,2158,2162],{"text":1824,"config":2147},{"href":1829,"dataGaName":1824,"dataGaLocation":2142},{"text":2149,"config":2150},"Code Suggestions (AI)",{"href":2151,"dataGaName":2149,"dataGaLocation":2142},"/solutions/code-suggestions/",{"text":1860,"config":2153},{"href":1732,"dataGaName":1860,"dataGaLocation":2142},{"text":2155,"config":2156},"GitLab on AWS",{"href":2157,"dataGaName":2155,"dataGaLocation":2142},"/partners/technology-partners/aws/",{"text":2159,"config":2160},"GitLab on Google Cloud",{"href":2161,"dataGaName":2159,"dataGaLocation":2142},"/partners/technology-partners/google-cloud-platform/",{"text":2163,"config":2164},"Why GitLab?",{"href":1837,"dataGaName":2163,"dataGaLocation":2142},{"freeTrial":2166,"mobileIcon":2171,"desktopIcon":2176,"secondaryButton":2179},{"text":2167,"config":2168},"Start free trial",{"href":2169,"dataGaName":1798,"dataGaLocation":2170},"https://gitlab.com/-/trials/new/","nav",{"altText":2172,"config":2173},"Gitlab Icon",{"src":2174,"dataGaName":2175,"dataGaLocation":2170},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1758203874/jypbw1jx72aexsoohd7x.svg","gitlab icon",{"altText":2172,"config":2177},{"src":2178,"dataGaName":2175,"dataGaLocation":2170},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1758203875/gs4c8p8opsgvflgkswz9.svg",{"text":2180,"config":2181},"Get Started",{"href":2182,"dataGaName":2183,"dataGaLocation":2170},"https://gitlab.com/-/trial_registrations/new?glm_source=about.gitlab.com/get-started/","get started",{"freeTrial":2185,"mobileIcon":2189,"desktopIcon":2191},{"text":2186,"config":2187},"Learn more about GitLab Duo",{"href":1829,"dataGaName":2188,"dataGaLocation":2170},"gitlab duo",{"altText":2172,"config":2190},{"src":2174,"dataGaName":2175,"dataGaLocation":2170},{"altText":2172,"config":2192},{"src":2178,"dataGaName":2175,"dataGaLocation":2170},{"button":2194,"mobileIcon":2199,"desktopIcon":2201},{"text":2195,"config":2196},"/switch",{"href":2197,"dataGaName":2198,"dataGaLocation":2170},"#contact","switch",{"altText":2172,"config":2200},{"src":2174,"dataGaName":2175,"dataGaLocation":2170},{"altText":2172,"config":2202},{"src":2203,"dataGaName":2175,"dataGaLocation":2170},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1773335277/ohhpiuoxoldryzrnhfrh.png",{"freeTrial":2205,"mobileIcon":2210,"desktopIcon":2212},{"text":2206,"config":2207},"Back to pricing",{"href":1941,"dataGaName":2208,"dataGaLocation":2170,"icon":2209},"back to pricing","GoBack",{"altText":2172,"config":2211},{"src":2174,"dataGaName":2175,"dataGaLocation":2170},{"altText":2172,"config":2213},{"src":2178,"dataGaName":2175,"dataGaLocation":2170},{"title":2215,"titleMobile":2216,"button":2217,"config":2222},"Duo Agent Platform delivers 400% ROI, per new Forrester Consulting study.","400% ROI: Forrester TEI for GitLab Duo",{"text":1835,"config":2218},{"href":2219,"dataGaName":2220,"dataGaLocation":2221},"https://about.gitlab.com/blog/gitlab-duo-agent-platform-delivers-400-percent-roi/","forrester-tei-dap-banner","global-banner",{"layout":2223,"disabled":1768},"release",{"data":2225},{"text":2226,"source":2227,"edit":2233,"contribute":2238,"config":2243,"items":2248,"minimal":2457},"Git is a trademark of Software Freedom Conservancy and our use of 'GitLab' is under license",{"text":2228,"config":2229},"View page source",{"href":2230,"dataGaName":2231,"dataGaLocation":2232},"https://gitlab.com/gitlab-com/marketing/digital-experience/about-gitlab-com/","page source","footer",{"text":2234,"config":2235},"Edit this page",{"href":2236,"dataGaName":2237,"dataGaLocation":2232},"https://gitlab.com/gitlab-com/marketing/digital-experience/about-gitlab-com/-/blob/main/content/","web ide",{"text":2239,"config":2240},"Please contribute",{"href":2241,"dataGaName":2242,"dataGaLocation":2232},"https://gitlab.com/gitlab-com/marketing/digital-experience/about-gitlab-com/-/blob/main/CONTRIBUTING.md/","please contribute",{"twitter":2244,"facebook":2245,"youtube":2246,"linkedin":2247},"https://twitter.com/gitlab","https://www.facebook.com/gitlab","https://www.youtube.com/channel/UCnMGQ8QHMAnVIsI3xJrihhg","https://www.linkedin.com/company/gitlab-com",[2249,2296,2349,2393,2425],{"title":1939,"links":2250,"subMenu":2265},[2251,2255,2260],{"text":2252,"config":2253},"View plans",{"href":1941,"dataGaName":2254,"dataGaLocation":2232},"view plans",{"text":2256,"config":2257},"Why Premium?",{"href":2258,"dataGaName":2259,"dataGaLocation":2232},"/pricing/premium/","why premium",{"text":2261,"config":2262},"Why Ultimate?",{"href":2263,"dataGaName":2264,"dataGaLocation":2232},"/pricing/ultimate/","why ultimate",[2266],{"title":2267,"links":2268},"Contact Us",[2269,2272,2274,2276,2281,2286,2291],{"text":2270,"config":2271},"Contact sales",{"href":2122,"dataGaName":1803,"dataGaLocation":2232},{"text":2125,"config":2273},{"href":2127,"dataGaName":2128,"dataGaLocation":2232},{"text":2130,"config":2275},{"href":2132,"dataGaName":2133,"dataGaLocation":2232},{"text":2277,"config":2278},"Status",{"href":2279,"dataGaName":2280,"dataGaLocation":2232},"https://status.gitlab.com/","status",{"text":2282,"config":2283},"Terms of use",{"href":2284,"dataGaName":2285,"dataGaLocation":2232},"/terms/","terms of use",{"text":2287,"config":2288},"Privacy statement",{"href":2289,"dataGaName":2290,"dataGaLocation":2232},"/privacy/","privacy statement",{"text":2292,"config":2293},"Cookie preferences",{"dataGaName":2294,"dataGaLocation":2232,"id":2295,"isOneTrustButton":161},"cookie preferences","ot-sdk-btn",{"title":1840,"links":2297,"subMenu":2306},[2298,2302],{"text":2299,"config":2300},"DevSecOps platform",{"href":1822,"dataGaName":2301,"dataGaLocation":2232},"devsecops platform",{"text":2303,"config":2304},"AI-Assisted Development",{"href":1829,"dataGaName":2305,"dataGaLocation":2232},"ai-assisted development",[2307],{"title":2308,"links":2309},"Topics",[2310,2314,2319,2324,2329,2334,2339,2344],{"text":2311,"config":2312},"CICD",{"href":432,"dataGaName":2313,"dataGaLocation":2232},"cicd",{"text":2315,"config":2316},"GitOps",{"href":2317,"dataGaName":2318,"dataGaLocation":2232},"/topics/gitops/","gitops",{"text":2320,"config":2321},"DevOps",{"href":2322,"dataGaName":2323,"dataGaLocation":2232},"/topics/devops/","devops",{"text":2325,"config":2326},"Version Control",{"href":2327,"dataGaName":2328,"dataGaLocation":2232},"/topics/version-control/","version control",{"text":2330,"config":2331},"DevSecOps",{"href":2332,"dataGaName":2333,"dataGaLocation":2232},"/topics/devsecops/","devsecops",{"text":2335,"config":2336},"Cloud Native",{"href":2337,"dataGaName":2338,"dataGaLocation":2232},"/topics/cloud-native/","cloud native",{"text":2340,"config":2341},"AI for Coding",{"href":2342,"dataGaName":2343,"dataGaLocation":2232},"/topics/devops/ai-for-coding/","ai for coding",{"text":2345,"config":2346},"Agentic AI",{"href":2347,"dataGaName":2348,"dataGaLocation":2232},"/topics/agentic-ai/","agentic ai",{"title":2350,"links":2351},"Solutions",[2352,2354,2356,2361,2365,2368,2372,2375,2377,2380,2383,2388],{"text":1883,"config":2353},{"href":1878,"dataGaName":1883,"dataGaLocation":2232},{"text":1872,"config":2355},{"href":1856,"dataGaName":1857,"dataGaLocation":2232},{"text":2357,"config":2358},"Agile development",{"href":2359,"dataGaName":2360,"dataGaLocation":2232},"/solutions/agile-delivery/","agile delivery",{"text":2362,"config":2363},"SCM",{"href":1868,"dataGaName":2364,"dataGaLocation":2232},"source code management",{"text":2311,"config":2366},{"href":1732,"dataGaName":2367,"dataGaLocation":2232},"continuous integration & delivery",{"text":2369,"config":2370},"Value stream management",{"href":1911,"dataGaName":2371,"dataGaLocation":2232},"value stream management",{"text":2315,"config":2373},{"href":2374,"dataGaName":2318,"dataGaLocation":2232},"/solutions/gitops/",{"text":1921,"config":2376},{"href":1924,"dataGaName":1925,"dataGaLocation":2232},{"text":2378,"config":2379},"Small business",{"href":1930,"dataGaName":1931,"dataGaLocation":2232},{"text":2381,"config":2382},"Public sector",{"href":1936,"dataGaName":1937,"dataGaLocation":2232},{"text":2384,"config":2385},"Education",{"href":2386,"dataGaName":2387,"dataGaLocation":2232},"/solutions/education/","education",{"text":2389,"config":2390},"Financial services",{"href":2391,"dataGaName":2392,"dataGaLocation":2232},"/solutions/finance/","financial services",{"title":1944,"links":2394},[2395,2397,2399,2401,2404,2406,2409,2411,2413,2415,2417,2419,2421,2423],{"text":1957,"config":2396},{"href":1959,"dataGaName":1960,"dataGaLocation":2232},{"text":1962,"config":2398},{"href":1964,"dataGaName":1965,"dataGaLocation":2232},{"text":1967,"config":2400},{"href":1969,"dataGaName":1970,"dataGaLocation":2232},{"text":1972,"config":2402},{"href":1974,"dataGaName":2403,"dataGaLocation":2232},"docs",{"text":1995,"config":2405},{"href":1997,"dataGaName":1998,"dataGaLocation":2232},{"text":2407,"config":2408},"What's new",{"href":2054,"dataGaName":2055,"dataGaLocation":2232},{"text":1990,"config":2410},{"href":1992,"dataGaName":1993,"dataGaLocation":2232},{"text":1500,"config":2412},{"href":2010,"dataGaName":2011,"dataGaLocation":2232},{"text":2016,"config":2414},{"href":2018,"dataGaName":2019,"dataGaLocation":2232},{"text":2021,"config":2416},{"href":2023,"dataGaName":2024,"dataGaLocation":2232},{"text":2026,"config":2418},{"href":2028,"dataGaName":2029,"dataGaLocation":2232},{"text":2031,"config":2420},{"href":2033,"dataGaName":2034,"dataGaLocation":2232},{"text":2036,"config":2422},{"href":2038,"dataGaName":2039,"dataGaLocation":2232},{"text":2041,"config":2424},{"href":2043,"dataGaName":2044,"dataGaLocation":2232},{"title":2057,"links":2426},[2427,2429,2431,2433,2435,2437,2441,2446,2448,2450,2452],{"text":2065,"config":2428},{"href":2067,"dataGaName":2059,"dataGaLocation":2232},{"text":2070,"config":2430},{"href":2072,"dataGaName":2073,"dataGaLocation":2232},{"text":2078,"config":2432},{"href":2080,"dataGaName":2081,"dataGaLocation":2232},{"text":2083,"config":2434},{"href":2085,"dataGaName":2086,"dataGaLocation":2232},{"text":2088,"config":2436},{"href":2090,"dataGaName":2091,"dataGaLocation":2232},{"text":2438,"config":2439},"Sustainability",{"href":2440,"dataGaName":2438,"dataGaLocation":2232},"/sustainability/",{"text":2442,"config":2443},"Diversity, inclusion and belonging (DIB)",{"href":2444,"dataGaName":2445,"dataGaLocation":2232},"/diversity-inclusion-belonging/","Diversity, inclusion and belonging",{"text":2093,"config":2447},{"href":2095,"dataGaName":2096,"dataGaLocation":2232},{"text":2103,"config":2449},{"href":2105,"dataGaName":2106,"dataGaLocation":2232},{"text":2108,"config":2451},{"href":2110,"dataGaName":2111,"dataGaLocation":2232},{"text":2453,"config":2454},"Modern Slavery Transparency Statement",{"href":2455,"dataGaName":2456,"dataGaLocation":2232},"https://handbook.gitlab.com/handbook/legal/modern-slavery-act-transparency-statement/","modern slavery transparency statement",{"items":2458},[2459,2462,2465],{"text":2460,"config":2461},"Terms",{"href":2284,"dataGaName":2285,"dataGaLocation":2232},{"text":2463,"config":2464},"Cookies",{"dataGaName":2294,"dataGaLocation":2232,"id":2295,"isOneTrustButton":161},{"text":2466,"config":2467},"Privacy",{"href":2289,"dataGaName":2290,"dataGaLocation":2232},[2469],{"id":2470,"title":7,"body":1767,"config":2471,"content":2473,"description":1767,"extension":2476,"meta":2477,"navigation":161,"path":2478,"seo":2479,"stem":2480,"__hash__":2481},"blogAuthors/en-us/blog/authors/mike-greiling.yml",{"template":2472},"BlogAuthor",{"name":7,"config":2474},{"headshot":254,"ctfId":2475},"mikegreiling","yml",{},"/en-us/blog/authors/mike-greiling",{},"en-us/blog/authors/mike-greiling","l18WTAPRoXYgxjFyTbaTBYl0PyN18S0Rc815wzobnyY",[2483,2492,2500],{"title":2484,"description":2485,"heroImage":2486,"category":1763,"date":2487,"authors":2488,"slug":2491,"externalUrl":1767},"Confidential AI for GitLab Self-Hosted","Give developers AI coding agents in GitLab Duo without source code leaving a hardware-encrypted boundary — no GPUs needed.","https://res.cloudinary.com/about-gitlab-com/image/upload/v1773866173/vte9qh8rriznvyclhkes.png","2026-08-06",[2489,2490],"Mathias Ewald","Martin Paloncy, Edgeless Systems","confidential-ai-for-gitlab-self-hosted",{"title":2493,"description":2494,"heroImage":2495,"category":1763,"date":2496,"authors":2497,"slug":2499,"externalUrl":1767},"Green DevOps: Why carbon measurement belongs in your CI/CD pipeline","CI/CD pipelines have a hidden carbon cost. Here's why measuring it matters, and how you can get started with Eco CI and Carmen in GitLab.","https://res.cloudinary.com/about-gitlab-com/image/upload/v1765809212/noh0mdfn9o94ry9ykura.png","2026-07-09",[2498],"Lysanne Pinto","green-devops-carbon-measurement-cicd-pipeline",{"title":2501,"description":2502,"heroImage":2503,"category":1763,"date":2504,"authors":2505,"slug":2507,"externalUrl":1767},"How to build CI/CD observability at scale","This practical guide to GitLab pipeline analytics helps self-managed users gain operational insights using Prometheus and Grafana.","https://res.cloudinary.com/about-gitlab-com/image/upload/v1774465167/n5hlvrsrheadeccyr1oz.png","2026-04-28",[2506],"Paul Meresanu","how-to-build-ci-cd-observability-at-scale",{"promotions":2509},[2510,2524,2536,2548],{"id":2511,"categories":2512,"header":2514,"text":2515,"button":2516,"image":2521},"ai-modernization",[2513],"ai","Is AI achieving its promise at scale?","Quiz will take 5 minutes or less",{"text":2517,"config":2518},"Get your AI maturity score",{"href":2519,"dataGaName":2520,"dataGaLocation":1998},"/assessments/ai-modernization-assessment/","modernization assessment",{"config":2522},{"src":2523},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1772138786/qix0m7kwnd8x2fh1zq49.png",{"id":2525,"categories":2526,"header":2528,"text":2515,"button":2529,"image":2533},"devops-modernization",[2527,2333],"product","Are you just managing tools or shipping innovation?",{"text":2530,"config":2531},"Get your DevOps maturity score",{"href":2532,"dataGaName":2520,"dataGaLocation":1998},"/assessments/devops-modernization-assessment/",{"config":2534},{"src":2535},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1772138785/eg818fmakweyuznttgid.png",{"id":2537,"categories":2538,"header":2540,"text":2515,"button":2541,"image":2545},"security-modernization",[2539],"security","Are you trading speed for security?",{"text":2542,"config":2543},"Get your security maturity score",{"href":2544,"dataGaName":2520,"dataGaLocation":1998},"/assessments/security-modernization-assessment/",{"config":2546},{"src":2547},"https://res.cloudinary.com/about-gitlab-com/image/upload/v1772138786/p4pbqd9nnjejg5ds6mdk.png",{"id":2549,"paths":2550,"header":2553,"text":2554,"button":2555,"image":2560},"github-azure-migration",[2551,2552],"migration-from-azure-devops-to-gitlab","integrating-azure-devops-scm-and-gitlab","Is your team ready for GitHub's Azure move?","GitHub is already rebuilding around Azure. Find out what it means for you.",{"text":2556,"config":2557},"See how GitLab compares to GitHub",{"href":2558,"dataGaName":2559,"dataGaLocation":1998},"/compare/gitlab-vs-github/github-azure-migration/","github azure migration",{"config":2561},{"src":2535},{"header":2563,"blurb":2564,"button":2565,"secondaryButton":2570},"Start building faster today","See what your team can do with the intelligent orchestration platform for DevSecOps.\n",{"text":2566,"config":2567},"Get your free trial",{"href":2568,"dataGaName":1798,"dataGaLocation":2569},"https://gitlab.com/-/trial_registrations/new?glm_content=default-saas-trial&glm_source=about.gitlab.com/","feature",{"text":2270,"config":2571},{"href":2122,"dataGaName":1803,"dataGaLocation":2569},1786803771699]