diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index d63125ce44b89a01c194cc294bd4757848fe6d26..5133e3610010bf637635fa95cd9060d278997088 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -8,56 +8,55 @@
     Build.state = null;
 
     function Build(options) {
-      this.page_url = options.page_url;
-      this.build_url = options.build_url;
-      this.build_status = options.build_status;
+      options = options || $('.js-build-options').data();
+      this.pageUrl = options.pageUrl;
+      this.buildUrl = options.buildUrl;
+      this.buildStatus = options.buildStatus;
       this.state = options.state1;
-      this.build_stage = options.build_stage;
-      this.hideSidebar = bind(this.hideSidebar, this);
-      this.toggleSidebar = bind(this.toggleSidebar, this);
+      this.buildStage = options.buildStage;
       this.updateDropdown = bind(this.updateDropdown, this);
       this.$document = $(document);
       clearInterval(Build.interval);
       // Init breakpoint checker
       this.bp = Breakpoints.get();
+
       this.initSidebar();
+      this.$buildScroll = $('#js-build-scroll');
 
-      this.populateJobs(this.build_stage);
-      this.updateStageDropdownText(this.build_stage);
+      this.populateJobs(this.buildStage);
+      this.updateStageDropdownText(this.buildStage);
+      this.sidebarOnResize();
 
-      $(window).off('resize.build').on('resize.build', this.hideSidebar);
+      this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this));
       this.$document.off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown);
-      $('#js-build-scroll > a').off('click').on('click', this.stepTrace);
+      $(window).off('resize.build').on('resize.build', this.sidebarOnResize.bind(this));
+      $('a', this.$buildScroll).off('click.stepTrace').on('click.stepTrace', this.stepTrace);
       this.updateArtifactRemoveDate();
       if ($('#build-trace').length) {
         this.getInitialBuildTrace();
-        this.initScrollButtons();
+        this.initScrollButtonAffix();
       }
-      if (this.build_status === "running" || this.build_status === "pending") {
+      if (this.buildStatus === "running" || this.buildStatus === "pending") {
+        // Bind autoscroll button to follow build output
         $('#autoscroll-button').on('click', function() {
           var state;
           state = $(this).data("state");
           if ("enabled" === state) {
             $(this).data("state", "disabled");
-            return $(this).text("enable autoscroll");
+            return $(this).text("Enable autoscroll");
           } else {
             $(this).data("state", "enabled");
-            return $(this).text("disable autoscroll");
+            return $(this).text("Disable autoscroll");
           }
-        //
-        // Bind autoscroll button to follow build output
-        //
         });
         Build.interval = setInterval((function(_this) {
+          // Check for new build output if user still watching build page
+          // Only valid for runnig build when output changes during time
           return function() {
-            if (window.location.href.split("#").first() === _this.page_url) {
+            if (_this.location() === _this.pageUrl) {
               return _this.getBuildTrace();
             }
           };
-        //
-        // Check for new build output if user still watching build page
-        // Only valid for runnig build when output changes during time
-        //
         })(this), 4000);
       }
     }
@@ -72,20 +71,23 @@
         top: this.sidebarTranslationLimits.max
       });
       this.$sidebar.niceScroll();
-      this.hideSidebar();
       this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
       this.$document.off('scroll.translateSidebar').on('scroll.translateSidebar', this.translateSidebar.bind(this));
     };
 
+    Build.prototype.location = function() {
+      return window.location.href.split("#")[0];
+    };
+
     Build.prototype.getInitialBuildTrace = function() {
       var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped']
 
       return $.ajax({
-        url: this.build_url,
+        url: this.buildUrl,
         dataType: 'json',
-        success: function(build_data) {
-          $('.js-build-output').html(build_data.trace_html);
-          if (removeRefreshStatuses.indexOf(build_data.status) >= 0) {
+        success: function(buildData) {
+          $('.js-build-output').html(buildData.trace_html);
+          if (removeRefreshStatuses.indexOf(buildData.status) >= 0) {
             return $('.js-build-refresh').remove();
           }
         }
@@ -94,7 +96,7 @@
 
     Build.prototype.getBuildTrace = function() {
       return $.ajax({
-        url: this.page_url + "/trace.json?state=" + (encodeURIComponent(this.state)),
+        url: this.pageUrl + "/trace.json?state=" + (encodeURIComponent(this.state)),
         dataType: "json",
         success: (function(_this) {
           return function(log) {
@@ -108,8 +110,8 @@
                 $('.js-build-output').html(log.html);
               }
               return _this.checkAutoscroll();
-            } else if (log.status !== _this.build_status) {
-              return Turbolinks.visit(_this.page_url);
+            } else if (log.status !== _this.buildStatus) {
+              return Turbolinks.visit(_this.pageUrl);
             }
           };
         })(this)
@@ -122,12 +124,11 @@
       }
     };
 
-    Build.prototype.initScrollButtons = function() {
-      var $body, $buildScroll, $buildTrace;
-      $buildScroll = $('#js-build-scroll');
+    Build.prototype.initScrollButtonAffix = function() {
+      var $body, $buildTrace;
       $body = $('body');
       $buildTrace = $('#build-trace');
-      return $buildScroll.affix({
+      return this.$buildScroll.affix({
         offset: {
           bottom: function() {
             return $body.outerHeight() - ($buildTrace.outerHeight() + $buildTrace.offset().top);
@@ -136,18 +137,12 @@
       });
     };
 
-    Build.prototype.shouldHideSidebar = function() {
+    Build.prototype.shouldHideSidebarForViewport = function() {
       var bootstrapBreakpoint;
       bootstrapBreakpoint = this.bp.getBreakpointSize();
       return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
     };
 
-    Build.prototype.toggleSidebar = function() {
-      if (this.shouldHideSidebar()) {
-        return this.$sidebar.toggleClass('right-sidebar-expanded right-sidebar-collapsed');
-      }
-    };
-
     Build.prototype.translateSidebar = function(e) {
       var newPosition = this.sidebarTranslationLimits.max - (document.body.scrollTop || document.documentElement.scrollTop);
       if (newPosition < this.sidebarTranslationLimits.min) newPosition = this.sidebarTranslationLimits.min;
@@ -156,12 +151,20 @@
       });
     };
 
-    Build.prototype.hideSidebar = function() {
-      if (this.shouldHideSidebar()) {
-        return this.$sidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
-      } else {
-        return this.$sidebar.removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded');
-      }
+    Build.prototype.toggleSidebar = function(shouldHide) {
+      var shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
+      this.$buildScroll.toggleClass('sidebar-expanded', shouldShow)
+        .toggleClass('sidebar-collapsed', shouldHide);
+      this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow)
+        .toggleClass('right-sidebar-collapsed', shouldHide);
+    };
+
+    Build.prototype.sidebarOnResize = function() {
+      this.toggleSidebar(this.shouldHideSidebarForViewport());
+    };
+
+    Build.prototype.sidebarOnClick = function() {
+      if (this.shouldHideSidebarForViewport()) this.toggleSidebar();
     };
 
     Build.prototype.updateArtifactRemoveDate = function() {
diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6
index 8e4fd1f19ba396682acf6dbdbe6bfc88787ebf98..756a24cc0fc00e8a8928f512dee114e17dd2a889 100644
--- a/app/assets/javascripts/dispatcher.js.es6
+++ b/app/assets/javascripts/dispatcher.js.es6
@@ -29,6 +29,9 @@
         case 'projects:boards:index':
           shortcut_handler = new ShortcutsNavigation();
           break;
+        case 'projects:builds:show':
+          new Build();
+          break;
         case 'projects:merge_requests:index':
         case 'projects:issues:index':
           Issuable.init();
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 6300ac9662f828df29e6dda4d379b7eef9f77d2c..f1d311cabbe531d21519ddb87a3bede6338479ba 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -14,18 +14,10 @@
     }
   }
 
-  .autoscroll-container {
-    position: fixed;
-    bottom: 20px;
-    right: 20px;
-    z-index: 100;
-  }
-
   .scroll-controls {
-    &.affix-top {
-      position: absolute;
-      top: 10px;
-      right: 25px;
+    .scroll-step {
+      width: 31px;
+      margin: 0 0 0 auto;
     }
 
     &.affix-bottom {
@@ -34,13 +26,13 @@
     }
 
     &.affix {
-      right: 30px;
+      right: 25px;
       bottom: 15px;
       z-index: 1;
+    }
 
-      @media (min-width: $screen-md-min) {
-        right: 26%;
-      }
+    &.sidebar-expanded {
+      right: #{$gutter_width + ($gl-padding * 2)};
     }
 
     a {
diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb
index f3aaff9140de0e7122030504b2028b2d7c199bbc..fde297c588ece6d367c04e3ce6a0a2d3fe9cb4b8 100644
--- a/app/helpers/builds_helper.rb
+++ b/app/helpers/builds_helper.rb
@@ -5,4 +5,14 @@ module BuildsHelper
     build_class += ' retried' if build.retried?
     build_class
   end
+
+  def javascript_build_options
+    {
+      page_url: namespace_project_build_url(@project.namespace, @project, @build),
+      build_url: namespace_project_build_url(@project.namespace, @project, @build, :json),
+      build_status: @build.status,
+      build_stage: @build.stage,
+      state1: @build.trace_with_state[:state]
+    }
+  end
 end
diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml
index b5e8b0bf6eb5e894c9b1681d6580b932a9a147dc..ae7a7ecb392e8eba965776a519efc8f62e68dc72 100644
--- a/app/views/projects/builds/show.html.haml
+++ b/app/views/projects/builds/show.html.haml
@@ -1,6 +1,5 @@
 - @no_container = true
 - page_title "#{@build.name} (##{@build.id})", "Builds"
-- trace_with_state = @build.trace_with_state
 - header_title project_title(@project, "Builds", project_builds_path(@project))
 = render "projects/pipelines/head", build_subnav: true
 
@@ -28,32 +27,27 @@
               Runners page
 
     .prepend-top-default
-      - if @build.active?
-        .autoscroll-container
-          %button.btn.btn-success.btn-sm#autoscroll-button{:type => "button", :data => {:state => 'disabled'}} enable autoscroll
       - if @build.erased?
         .erased.alert.alert-warning
           - erased_by = "by #{link_to @build.erased_by.name, user_path(@build.erased_by)}" if @build.erased_by
           Build has been erased #{erased_by.html_safe} #{time_ago_with_tooltip(@build.erased_at)}
       - else
         #js-build-scroll.scroll-controls
-          = link_to '#build-trace', class: 'btn' do
-            %i.fa.fa-angle-up
-          = link_to '#down-build-trace', class: 'btn' do
-            %i.fa.fa-angle-down
+          .scroll-step
+            = link_to '#build-trace', class: 'btn' do
+              %i.fa.fa-angle-up
+            = link_to '#down-build-trace', class: 'btn' do
+              %i.fa.fa-angle-down
+          - if @build.active?
+            .autoscroll-container
+              %button.btn.btn-sm#autoscroll-button{:type => "button", :data => {:state => 'disabled'}}
+                Enable autoscroll
         %pre.build-trace#build-trace
           %code.bash.js-build-output
           = icon("refresh spin", class: "js-build-refresh")
 
-      #down-build-trace
+        #down-build-trace
 
   = render "sidebar"
 
-  :javascript
-    new Build({
-      page_url: "#{namespace_project_build_url(@project.namespace, @project, @build)}",
-      build_url: "#{namespace_project_build_url(@project.namespace, @project, @build, :json)}",
-      build_status: "#{@build.status}",
-      build_stage: "#{@build.stage}",
-      state1: "#{trace_with_state[:state]}"
-    })
+.js-build-options{ data: javascript_build_options }
diff --git a/package.json b/package.json
index a303c9c1eac9a7b024be8467568f6994e217e105..e75e070451b4f31b8efde868712081d73626f94a 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
     "eslint-config-airbnb": "^12.0.0",
     "eslint-plugin-filenames": "^1.1.0",
     "eslint-plugin-import": "^2.0.1",
+    "eslint-plugin-jasmine": "^1.8.1",
     "eslint-plugin-jsx-a11y": "^2.2.3",
     "eslint-plugin-react": "^6.4.1"
   }
diff --git a/spec/javascripts/.eslintrc b/spec/javascripts/.eslintrc
new file mode 100644
index 0000000000000000000000000000000000000000..90388929612708671adf1a1ab47c0f2135dd2b48
--- /dev/null
+++ b/spec/javascripts/.eslintrc
@@ -0,0 +1,11 @@
+{
+  "plugins": ["jasmine"],
+  "env": {
+    "jasmine": true
+  },
+  "extends": "plugin:jasmine/recommended",
+  "rules": {
+    "prefer-arrow-callback": 0,
+    "func-names": 0
+  }
+}
diff --git a/spec/javascripts/build_spec.js.es6 b/spec/javascripts/build_spec.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..370944b6a8c9f55267f5aa4a30b9ff9c10b614e9
--- /dev/null
+++ b/spec/javascripts/build_spec.js.es6
@@ -0,0 +1,175 @@
+/* global Build */
+/* eslint-disable no-new */
+//= require build
+//= require breakpoints
+//= require jquery.nicescroll
+//= require turbolinks
+
+(() => {
+  describe('Build', () => {
+    fixture.preload('build.html');
+
+    beforeEach(function () {
+      fixture.load('build.html');
+      spyOn($, 'ajax');
+    });
+
+    describe('constructor', () => {
+      beforeEach(function () {
+        jasmine.clock().install();
+      });
+
+      afterEach(() => {
+        jasmine.clock().uninstall();
+      });
+
+      describe('setup', function () {
+        beforeEach(function () {
+          this.build = new Build();
+        });
+
+        it('copies build options', function () {
+          expect(this.build.pageUrl).toBe('http://example.com/root/test-build/builds/2');
+          expect(this.build.buildUrl).toBe('http://example.com/root/test-build/builds/2.json');
+          expect(this.build.buildStatus).toBe('passed');
+          expect(this.build.buildStage).toBe('test');
+          expect(this.build.state).toBe('buildstate');
+        });
+
+        it('only shows the jobs matching the current stage', function () {
+          expect($('.build-job[data-stage="build"]').is(':visible')).toBe(false);
+          expect($('.build-job[data-stage="test"]').is(':visible')).toBe(true);
+          expect($('.build-job[data-stage="deploy"]').is(':visible')).toBe(false);
+        });
+
+        it('selects the current stage in the build dropdown menu', function () {
+          expect($('.stage-selection').text()).toBe('test');
+        });
+
+        it('updates the jobs when the build dropdown changes', function () {
+          $('.stage-item:contains("build")').click();
+
+          expect($('.stage-selection').text()).toBe('build');
+          expect($('.build-job[data-stage="build"]').is(':visible')).toBe(true);
+          expect($('.build-job[data-stage="test"]').is(':visible')).toBe(false);
+          expect($('.build-job[data-stage="deploy"]').is(':visible')).toBe(false);
+        });
+      });
+
+      describe('initial build trace', function () {
+        beforeEach(function () {
+          new Build();
+        });
+
+        it('displays the initial build trace', function () {
+          expect($.ajax.calls.count()).toBe(1);
+          const [{ url, dataType, success, context }] = $.ajax.calls.argsFor(0);
+          expect(url).toBe('http://example.com/root/test-build/builds/2.json');
+          expect(dataType).toBe('json');
+          expect(success).toEqual(jasmine.any(Function));
+
+          success.call(context, { trace_html: '<span>Example</span>', status: 'running' });
+
+          expect($('#build-trace .js-build-output').text()).toMatch(/Example/);
+        });
+
+        it('removes the spinner', function () {
+          const [{ success, context }] = $.ajax.calls.argsFor(0);
+          success.call(context, { trace_html: '<span>Example</span>', status: 'success' });
+
+          expect($('.js-build-refresh').length).toBe(0);
+        });
+      });
+
+      describe('running build', function () {
+        beforeEach(function () {
+          $('.js-build-options').data('buildStatus', 'running');
+          this.build = new Build();
+          spyOn(this.build, 'location')
+            .and.returnValue('http://example.com/root/test-build/builds/2');
+        });
+
+        it('updates the build trace on an interval', function () {
+          jasmine.clock().tick(4001);
+
+          expect($.ajax.calls.count()).toBe(2);
+          let [{ url, dataType, success, context }] = $.ajax.calls.argsFor(1);
+          expect(url).toBe(
+            'http://example.com/root/test-build/builds/2/trace.json?state=buildstate'
+          );
+          expect(dataType).toBe('json');
+          expect(success).toEqual(jasmine.any(Function));
+
+          success.call(context, {
+            html: '<span>Update<span>',
+            status: 'running',
+            state: 'newstate',
+            append: true,
+          });
+
+          expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
+          expect(this.build.state).toBe('newstate');
+
+          jasmine.clock().tick(4001);
+
+          expect($.ajax.calls.count()).toBe(3);
+          [{ url, dataType, success, context }] = $.ajax.calls.argsFor(2);
+          expect(url).toBe(
+            'http://example.com/root/test-build/builds/2/trace.json?state=newstate'
+          );
+          expect(dataType).toBe('json');
+          expect(success).toEqual(jasmine.any(Function));
+
+          success.call(context, {
+            html: '<span>More</span>',
+            status: 'running',
+            state: 'finalstate',
+            append: true,
+          });
+
+          expect($('#build-trace .js-build-output').text()).toMatch(/UpdateMore/);
+          expect(this.build.state).toBe('finalstate');
+        });
+
+        it('replaces the entire build trace', function () {
+          jasmine.clock().tick(4001);
+          let [{ success, context }] = $.ajax.calls.argsFor(1);
+          success.call(context, {
+            html: '<span>Update</span>',
+            status: 'running',
+            append: true,
+          });
+
+          expect($('#build-trace .js-build-output').text()).toMatch(/Update/);
+
+          jasmine.clock().tick(4001);
+          [{ success, context }] = $.ajax.calls.argsFor(2);
+          success.call(context, {
+            html: '<span>Different</span>',
+            status: 'running',
+            append: false,
+          });
+
+          expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/);
+          expect($('#build-trace .js-build-output').text()).toMatch(/Different/);
+        });
+
+        it('reloads the page when the build is done', function () {
+          spyOn(Turbolinks, 'visit');
+
+          jasmine.clock().tick(4001);
+          const [{ success, context }] = $.ajax.calls.argsFor(1);
+          success.call(context, {
+            html: '<span>Final</span>',
+            status: 'passed',
+            append: true,
+          });
+
+          expect(Turbolinks.visit).toHaveBeenCalledWith(
+            'http://example.com/root/test-build/builds/2'
+          );
+        });
+      });
+    });
+  });
+})();
diff --git a/spec/javascripts/fixtures/build.html.haml b/spec/javascripts/fixtures/build.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..a2bc81c6be7f93b1dd496d09f1377419eb8728f5
--- /dev/null
+++ b/spec/javascripts/fixtures/build.html.haml
@@ -0,0 +1,57 @@
+.build-page
+  .prepend-top-default
+    .autoscroll-container
+      %button.btn.btn-success.btn-sm#autoscroll-button{:type => "button", :data => {:state => 'disabled'}} enable autoscroll
+    #js-build-scroll.scroll-controls
+      %a.btn{href: '#build-trace'}
+        %i.fa.fa-angle-up
+      %a.btn{href: '#down-build-trace'}
+        %i.fa.fa-angle-down
+    %pre.build-trace#build-trace
+      %code.bash.js-build-output
+      %i.fa.fa-refresh.fa-spin.js-build-refresh
+
+%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar
+  .block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default
+    Build
+    %strong #1
+    %a.gutter-toggle.pull-right.js-sidebar-build-toggle{ href: "#" }
+      %i.fa.fa-angle-double-right
+  .blocks-container
+    .dropdown.build-dropdown
+      .title Stage
+      %button.dropdown-menu-toggle{type: 'button', 'data-toggle' => 'dropdown'}
+        %span.stage-selection More
+        %i.fa.fa-caret-down
+      %ul.dropdown-menu
+        %li
+          %a.stage-item build
+        %li
+          %a.stage-item test
+        %li
+          %a.stage-item deploy
+  .builds-container
+    .build-job{data: {stage: 'build'}}
+      %a{href: 'http://example.com/root/test-build/builds/1'}
+        %i.fa.fa-check
+        %i.fa.fa-check-circle-o
+        %span
+          Setup
+    .build-job{data: {stage: 'test'}}
+      %a{href: 'http://example.com/root/test-build/builds/2'}
+        %i.fa.fa-check
+        %i.fa.fa-check-circle-o
+        %span
+          Tests
+    .build-job{data: {stage: 'deploy'}}
+      %a{href: 'http://example.com/root/test-build/builds/3'}
+        %i.fa.fa-check
+        %i.fa.fa-check-circle-o
+        %span
+          Deploy
+
+.js-build-options{ data: { page_url: 'http://example.com/root/test-build/builds/2',
+  build_url: 'http://example.com/root/test-build/builds/2.json',
+  build_status: 'passed',
+  build_stage: 'test',
+  state1: 'buildstate' }}