diff --git a/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue b/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue
index f65a46dcc70853e3306308fb1bc2fca51e6fc955..ebe7ebb7375519fb43036ffe00cdb246392d2cbc 100644
--- a/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue
+++ b/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue
@@ -1,5 +1,5 @@
 <script>
-import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick, GlSprintf } from '@gitlab/ui';
 import { scrollToElement, backOff } from '~/lib/utils/common_utils';
 import { numberToHumanSize } from '~/lib/utils/number_utils';
 import { s__, sprintf } from '~/locale';
@@ -28,6 +28,7 @@ export default {
     GlLink,
     GlButton,
     GlSearchBoxByClick,
+    GlSprintf,
     HelpPopover,
   },
   directives: {
@@ -78,6 +79,11 @@ export default {
       required: false,
       default: false,
     },
+    logViewerPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
   },
   data() {
     return {
@@ -190,7 +196,22 @@ export default {
     <div class="gl-display-none gl-sm-display-block gl-text-truncate" data-testid="showing-last">
       <template v-if="isJobLogSizeVisible">
         {{ jobLogSize }}
-        <gl-link v-if="rawPath" :href="rawPath">{{ s__('Job|View raw') }}</gl-link>
+        <gl-sprintf
+          v-if="rawPath && isComplete && logViewerPath"
+          :message="
+            s__(
+              'Job|%{rawLinkStart}View raw%{rawLinkEnd} or %{fullLinkStart}view full log%{fullLinkEnd}.',
+            )
+          "
+        >
+          <template #rawLink="{ content }">
+            <gl-link :href="rawPath">{{ content }}</gl-link>
+          </template>
+          <template #fullLink="{ content }">
+            <gl-link :href="logViewerPath"> {{ content }}</gl-link>
+          </template>
+        </gl-sprintf>
+        <gl-link v-else-if="rawPath" :href="rawPath">{{ s__('Job|View raw') }}</gl-link>
       </template>
     </div>
     <!-- eo truncated log information -->
diff --git a/app/assets/javascripts/ci/job_details/index.js b/app/assets/javascripts/ci/job_details/index.js
index 9aa01c4686e9b24a896a35ccfec257b9e5efed7d..a0c6971a939f4a86a48ca69bf942eafb69e31c6f 100644
--- a/app/assets/javascripts/ci/job_details/index.js
+++ b/app/assets/javascripts/ci/job_details/index.js
@@ -32,6 +32,7 @@ export const initJobDetails = () => {
     aiRootCauseAnalysisAvailable,
     testReportSummaryUrl,
     pipelineTestReportUrl,
+    logViewerPath,
   } = el.dataset;
 
   const fullScreenAPIAvailable = document.fullscreenEnabled;
@@ -63,6 +64,7 @@ export const initJobDetails = () => {
           deploymentHelpUrl,
           runnerSettingsUrl,
           subscriptionsMoreMinutesUrl,
+          logViewerPath,
         },
       });
     },
diff --git a/app/assets/javascripts/ci/job_details/job_app.vue b/app/assets/javascripts/ci/job_details/job_app.vue
index 99446029cef2c7433ba92b483842d5a4826a517b..7f04f5cf7985d53dafda6b1151ffb975d696dc0f 100644
--- a/app/assets/javascripts/ci/job_details/job_app.vue
+++ b/app/assets/javascripts/ci/job_details/job_app.vue
@@ -61,6 +61,11 @@ export default {
       required: false,
       default: null,
     },
+    logViewerPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
   },
   data() {
     return {
@@ -290,6 +295,7 @@ export default {
             }"
             :size="jobLogSize"
             :raw-path="job.raw_path"
+            :log-viewer-path="logViewerPath"
             :is-scroll-bottom-disabled="isScrollBottomDisabled"
             :is-scroll-top-disabled="isScrollTopDisabled"
             :is-job-log-size-visible="isJobLogSizeVisible"
diff --git a/app/assets/javascripts/ci/job_log_viewer/components/log_viewer.vue b/app/assets/javascripts/ci/job_log_viewer/components/log_viewer.vue
new file mode 100644
index 0000000000000000000000000000000000000000..98b0a778481f0cee478b509ccd97c5e96fd2646c
--- /dev/null
+++ b/app/assets/javascripts/ci/job_log_viewer/components/log_viewer.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+  name: 'LogViewer',
+  components: {
+    GlIcon,
+  },
+  props: {
+    log: {
+      type: Array,
+      default: () => [],
+      required: false,
+    },
+    loading: {
+      type: Boolean,
+      default: false,
+      required: false,
+    },
+  },
+  data() {
+    return {
+      hiddenSections: new Set(), // Use Set instead of Array. has() is more performant than includes() and it's executed more frequently
+    };
+  },
+  methods: {
+    isLineHidden(sections = []) {
+      for (const s of sections) {
+        if (this.hiddenSections.has(s)) {
+          return true;
+        }
+      }
+      return false;
+    },
+    toggleSection(section) {
+      if (this.hiddenSections.has(section)) {
+        this.hiddenSections.delete(section);
+      } else {
+        this.hiddenSections.add(section);
+      }
+      this.hiddenSections = new Set(this.hiddenSections); // `Set` is not reactive in Vue 2, create new instance it to trigger reactivity
+    },
+  },
+};
+</script>
+
+<template>
+  <div
+    class="job-log-viewer gl-font-monospace gl-p-3 gl-font-sm gl-rounded-base"
+    role="log"
+    aria-live="polite"
+    :aria-busy="loading"
+  >
+    <div
+      v-for="(line, index) in log"
+      v-show="!isLineHidden(line.sections)"
+      :key="index"
+      class="log-line"
+      :class="{ 'log-line-header': line.header }"
+      v-on="line.header ? { click: () => toggleSection(line.header) } : {}"
+    >
+      <div>
+        <gl-icon
+          v-if="line.header"
+          :name="hiddenSections.has(line.header) ? 'chevron-lg-right' : 'chevron-lg-down'"
+        /><a :id="`L${index + 1}`" :href="`#L${index + 1}`" class="log-line-number" @click.stop>{{
+          index + 1
+        }}</a>
+      </div>
+      <div>
+        <span v-for="(c, j) in line.content" :key="j" :class="c.style">{{ c.text }}</span>
+      </div>
+    </div>
+    <div v-if="loading" class="loader-animation gl-p-3">
+      <span class="gl-sr-only">{{ __('Loading...') }}</span>
+      <div class="dot"></div>
+      <div class="dot"></div>
+      <div class="dot"></div>
+    </div>
+  </div>
+</template>
diff --git a/app/assets/javascripts/ci/job_log_viewer/components/log_viewer_top_bar.vue b/app/assets/javascripts/ci/job_log_viewer/components/log_viewer_top_bar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..af0342e571c4d2b3a19beacd7d4911af6f3068c8
--- /dev/null
+++ b/app/assets/javascripts/ci/job_log_viewer/components/log_viewer_top_bar.vue
@@ -0,0 +1,24 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import ExperimentBadge from '~/vue_shared/components/badges/experiment_badge.vue';
+
+export default {
+  name: 'LogViewerFeedbackPopover',
+  components: {
+    GlLink,
+    ExperimentBadge,
+  },
+};
+</script>
+<template>
+  <div
+    class="job-log-viewer-top-bar gl-display-flex gl-align-items-center gl-justify-content-space-between"
+  >
+    <div>{{ s__('Job|Full log viewer') }} <experiment-badge class="gl-display-inline" /></div>
+    <div>
+      <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/454817" target="_blank">{{
+        s__('Job|Feedback issue')
+      }}</gl-link>
+    </div>
+  </div>
+</template>
diff --git a/app/assets/javascripts/ci/job_log_viewer/index.js b/app/assets/javascripts/ci/job_log_viewer/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..a66d74816104d26c594c6f09b934650f95ee5cdc
--- /dev/null
+++ b/app/assets/javascripts/ci/job_log_viewer/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import LogViewerApp from './log_viewer_app.vue';
+
+export const initJobLogViewer = async () => {
+  const el = document.getElementById('js-job-log-viewer');
+  const { rawLogPath } = el.dataset;
+
+  return new Vue({
+    el,
+    render(h) {
+      return h(LogViewerApp, {
+        props: {
+          rawLogPath,
+        },
+      });
+    },
+  });
+};
diff --git a/app/assets/javascripts/ci/job_log_viewer/lib/ansi_evaluator.js b/app/assets/javascripts/ci/job_log_viewer/lib/ansi_evaluator.js
new file mode 100644
index 0000000000000000000000000000000000000000..127e61ec34e57a1361c73e1d8bb802a1551b2180
--- /dev/null
+++ b/app/assets/javascripts/ci/job_log_viewer/lib/ansi_evaluator.js
@@ -0,0 +1,183 @@
+const STYLES_MAP = {
+  bold: 1,
+  italic: 2,
+  underline: 4,
+  conceal: 8,
+  cross: 16,
+};
+
+const get256Color = (stack) => {
+  if (stack.length < 2 || stack[0] !== '5') {
+    return null;
+  }
+
+  stack.shift();
+
+  const color = parseInt(stack.shift(), 10);
+  if (color < 0 || color > 255) {
+    return null;
+  }
+  return color;
+};
+
+/**
+ * Reads an ansi "Select Graphic Rendition (SGR)" sequence and returns
+ * CSS classes corresponding to it.
+ *
+ */
+export class AnsiEvaluator {
+  constructor() {
+    this.reset();
+  }
+
+  /**
+   * Starts evaluation of a "Select Graphic Rendition (SGR)" sequence
+   *
+   * Sequences ending in 'm' are SGR and set display attributes in the logs
+   */
+  evaluate(op) {
+    if (op?.endsWith('m')) {
+      this.#evaluateStack(op.substring(0, op.length - 1).split(';'));
+    }
+  }
+
+  /**
+   * Return currently present classes, should be used once the sequence has been
+   * evaluated.
+   */
+  getClasses() {
+    const classes = [];
+
+    if (this.fgColor !== null) {
+      classes.push(`xterm-fg-${this.fgColor}`);
+    }
+
+    if (this.bgColor !== null) {
+      classes.push(`xterm-bg-${this.bgColor}`);
+    }
+
+    for (const s in STYLES_MAP) {
+      // eslint-disable-next-line no-bitwise
+      if ((this.styleMask & STYLES_MAP[s]) !== 0) {
+        classes.push(`term-${s}`);
+      }
+    }
+
+    return classes;
+  }
+
+  /**
+   * Reset the current state to neutral/unassigned styles
+   */
+  reset() {
+    this.fgColor = null;
+    this.bgColor = null;
+    this.styleMask = 0;
+  }
+
+  // Private properties
+
+  #evaluateStack(stack) {
+    const command = stack.shift();
+    if (!command) {
+      return;
+    }
+
+    switch (command) {
+      case '38': {
+        const color = get256Color(stack);
+        if (color !== null) {
+          this.fgColor = color;
+        }
+        break;
+      }
+      case '48': {
+        const color = get256Color(stack);
+        if (color !== null) {
+          this.bgColor = color;
+        }
+        break;
+      }
+      default: {
+        this.#dispatch[command]?.();
+      }
+    }
+
+    this.#evaluateStack(stack);
+  }
+
+  #dispatch = {
+    0: () => this.reset(),
+
+    1: () => this.#enableStyle('bold'),
+    3: () => this.#enableStyle('italic'),
+    4: () => this.#enableStyle('underline'),
+    8: () => this.#enableStyle('conceal'),
+    9: () => this.#enableStyle('cross'),
+
+    21: () => this.#disableStyle('bold'),
+    22: () => this.#disableStyle('bold'),
+    23: () => this.#disableStyle('italic'),
+    24: () => this.#disableStyle('underline'),
+    28: () => this.#disableStyle('conceal'),
+    29: () => this.#disableStyle('cross'),
+
+    30: () => this.#setFg(0),
+    31: () => this.#setFg(9),
+    32: () => this.#setFg(10),
+    33: () => this.#setFg(11),
+    34: () => this.#setFg(12),
+    35: () => this.#setFg(13),
+    36: () => this.#setFg(14),
+    37: () => this.#setFg(15),
+    39: () => this.#setFg(null),
+
+    40: () => this.#setBg(0),
+    41: () => this.#setBg(9),
+    42: () => this.#setBg(10),
+    43: () => this.#setBg(11),
+    44: () => this.#setBg(12),
+    45: () => this.#setBg(13),
+    46: () => this.#setBg(14),
+    47: () => this.#setBg(15),
+    49: () => this.#setBg(null),
+
+    90: () => this.#setFg(8),
+    91: () => this.#setFg(9),
+    92: () => this.#setFg(10),
+    93: () => this.#setFg(11),
+    94: () => this.#setFg(12),
+    95: () => this.#setFg(13),
+    96: () => this.#setFg(14),
+    97: () => this.#setFg(15),
+    99: () => this.#setFg(null),
+
+    100: () => this.#setBg(8),
+    101: () => this.#setBg(9),
+    102: () => this.#setBg(10),
+    103: () => this.#setBg(11),
+    104: () => this.#setBg(12),
+    105: () => this.#setBg(13),
+    106: () => this.#setBg(14),
+    107: () => this.#setBg(15),
+    109: () => this.#setBg(null),
+  };
+
+  #enableStyle(flag) {
+    // eslint-disable-next-line no-bitwise
+    this.styleMask |= STYLES_MAP[flag];
+  }
+
+  #disableStyle(flag) {
+    // eslint-disable-next-line no-bitwise
+    this.styleMask &= STYLES_MAP[flag];
+  }
+
+  #setFg(color) {
+    this.fgColor = color;
+  }
+
+  #setBg(color) {
+    this.bgColor = color;
+  }
+}
diff --git a/app/assets/javascripts/ci/job_log_viewer/lib/generate_stream.js b/app/assets/javascripts/ci/job_log_viewer/lib/generate_stream.js
new file mode 100644
index 0000000000000000000000000000000000000000..2fbe8275111c890f369914b2bd34fbb315d4e748
--- /dev/null
+++ b/app/assets/javascripts/ci/job_log_viewer/lib/generate_stream.js
@@ -0,0 +1,61 @@
+import Scanner from './scanner';
+
+/**
+ * Turns a fetch stream into an async iterable.
+ *
+ * Could be removed if Chrome implements:
+ * https://issues.chromium.org/issues/40612900
+ */
+async function* getIterableFileStream(path) {
+  const response = await fetch(path);
+  const reader = response.body.getReader();
+
+  while (true) {
+    // eslint-disable-next-line no-await-in-loop
+    const { done, value } = await reader.read();
+    if (done) break;
+    yield value;
+  }
+}
+
+/**
+ * Obtains stream lines as an async iterable
+ *
+ * NOTE: This code wrongly assumes each chunk has no cut lines.
+ * Large logs may contain several chunk and this may effectively
+ * split some lines in two.
+ */
+async function* getLogStreamLines(stream) {
+  const textDecoder = new TextDecoder();
+
+  for await (const chunk of stream) {
+    const decodedChunk = textDecoder.decode(chunk);
+    const lines = decodedChunk.split('\n');
+    for (const line of lines) {
+      if (line.trim() !== '') {
+        yield {
+          text: line,
+        };
+      }
+    }
+  }
+}
+
+/**
+ * Fetches a raw log and returns a promise with
+ * the entire log as an array the can be rendered.
+ */
+export async function fetchLogLines(path) {
+  const iterableStream = getIterableFileStream(path);
+  const lines = getLogStreamLines(iterableStream);
+
+  const res = [];
+  const scanner = new Scanner();
+
+  for await (const line of lines) {
+    const scanned = scanner.scan(line.text);
+    res.push(scanned);
+  }
+
+  return res;
+}
diff --git a/app/assets/javascripts/ci/job_log_viewer/lib/scanner.js b/app/assets/javascripts/ci/job_log_viewer/lib/scanner.js
new file mode 100644
index 0000000000000000000000000000000000000000..319a9a9f6b0b7a295002d59ede00ba4c561f1634
--- /dev/null
+++ b/app/assets/javascripts/ci/job_log_viewer/lib/scanner.js
@@ -0,0 +1,158 @@
+import { AnsiEvaluator } from './ansi_evaluator';
+
+const SECTION_PREFIX = 'section_';
+const SECTION_START = 'start';
+const SECTION_END = 'end';
+const SECTION_SEPARATOR = ':';
+
+const ANSI_CSI = '\u001b[';
+const CR_LINE_ENDING = '\r';
+
+const parseSection = (input, offset) => {
+  const from = offset;
+  let to = offset;
+
+  for (; to < input.length; to += 1) {
+    const c = input[to];
+
+    // if we find CR, indicates ending of section line
+    if (c === CR_LINE_ENDING) {
+      to += 1;
+      break;
+    }
+  }
+
+  const section = input
+    .slice(from, to)
+    .split(SECTION_SEPARATOR)
+    .map((s) => s.trim());
+  if (section.length === 3) {
+    return [section, to];
+  }
+
+  return [null, to];
+};
+
+const parseAnsi = (input, offset) => {
+  const from = offset;
+  let to = offset;
+
+  // find any number of parameter bytes (0x30-0x3f)
+  for (; to < input.length; to += 1) {
+    const c = input.charCodeAt(to);
+    if (!(c >= 0x30 && c <= 0x3f)) {
+      break;
+    }
+  }
+
+  // any number of intermediate bytes (0x20–0x2f)
+  for (; to < input.length; to += 1) {
+    const c = input.charCodeAt(to);
+    if (!(c >= 0x20 && c <= 0x2f)) {
+      break;
+    }
+  }
+
+  // single final byte (0x40–0x7e)
+  const c = input.charCodeAt(to);
+  if (c >= 0x40 && c <= 0x7e) {
+    to += 1;
+  }
+
+  return [input.slice(from, to), to];
+};
+
+export default class {
+  constructor() {
+    this.ansi = new AnsiEvaluator();
+    this.content = [];
+    this.sections = [];
+  }
+
+  scan(input) {
+    let start = 0;
+    let offset = 0;
+
+    while (offset < input.length) {
+      if (input.startsWith(ANSI_CSI, offset)) {
+        this.append(input.slice(start, offset));
+
+        let op;
+        [op, offset] = parseAnsi(input, offset + ANSI_CSI.length);
+
+        this.ansi.evaluate(op);
+
+        start = offset;
+      } else if (input.startsWith(SECTION_PREFIX, offset)) {
+        this.append(input.slice(start, offset));
+
+        let section;
+        [section, offset] = parseSection(input, offset + SECTION_PREFIX.length);
+
+        if (section !== null) {
+          this.handleSection(section[0], section[1], section[2]);
+        }
+
+        start = offset;
+      } else {
+        offset += 1;
+      }
+    }
+
+    this.append(input.slice(start, offset));
+
+    const { content } = this;
+    this.content = [];
+
+    const section = this.sections[this.sections.length - 1];
+
+    if (section?.start) {
+      section.start = false;
+      // returns a header line, which can toggle other lines
+      return {
+        header: section.name,
+        sections: this.sections.map(({ name }) => name).slice(0, -1),
+        content,
+      };
+    }
+
+    return {
+      sections: this.sections.map(({ name }) => name),
+      content,
+    };
+  }
+
+  append(text) {
+    if (text.length === 0) {
+      return;
+    }
+
+    this.content.push({
+      style: this.ansi.getClasses(),
+      text,
+    });
+  }
+
+  handleSection(type, time, name) {
+    switch (type) {
+      case SECTION_START: {
+        this.sections.push({ name, time, start: true });
+        break;
+      }
+      case SECTION_END: {
+        if (this.sections.length === 0) {
+          return;
+        }
+
+        const section = this.sections[this.sections.length - 1];
+        if (section.name === name) {
+          const duration = time - section.time;
+          this.content.push({ section: name, duration });
+          this.sections.pop();
+        }
+        break;
+      }
+      default:
+    }
+  }
+}
diff --git a/app/assets/javascripts/ci/job_log_viewer/log_viewer_app.vue b/app/assets/javascripts/ci/job_log_viewer/log_viewer_app.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ded25ebc56320f061944a501dfcbce6e45a74f45
--- /dev/null
+++ b/app/assets/javascripts/ci/job_log_viewer/log_viewer_app.vue
@@ -0,0 +1,75 @@
+<script>
+import { s__ } from '~/locale';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import { createAlert } from '~/alert';
+
+import LogViewer from './components/log_viewer.vue';
+import LogViewerTopBar from './components/log_viewer_top_bar.vue';
+
+import { fetchLogLines } from './lib/generate_stream';
+
+export default {
+  name: 'LogViewerApp',
+  components: {
+    LogViewerTopBar,
+    LogViewer,
+  },
+  props: {
+    rawLogPath: {
+      required: true,
+      type: String,
+      default: null,
+    },
+  },
+  data() {
+    return {
+      log: [],
+      loading: false,
+    };
+  },
+  async mounted() {
+    performance.mark('LogViewerApp-showLogStart');
+    await this.fetchLog();
+    performance.mark('LogViewerApp-showLogEnd');
+
+    // scroll once log is loaded and rendered
+    this.scrollToLine();
+  },
+  methods: {
+    async fetchLog() {
+      this.loading = true;
+
+      try {
+        const log = await fetchLogLines(this.rawLogPath);
+        Object.freeze(log); // freezing object removes reactivity and lowers memory consumption for large objects
+
+        this.log = log;
+      } catch (error) {
+        createAlert({
+          message: s__('Job|Something went wrong while loading the log.'),
+          captureError: true,
+          error,
+        });
+      } finally {
+        this.loading = false;
+      }
+    },
+    scrollToLine() {
+      const { hash } = window.location;
+      if (hash) {
+        try {
+          scrollToElement(document.querySelector(hash));
+        } catch {
+          // selector provider by user is invalid, pass through
+        }
+      }
+    },
+  },
+};
+</script>
+<template>
+  <div class="build-page gl-m-3">
+    <log-viewer-top-bar />
+    <log-viewer :log="log" :loading="loading" />
+  </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/jobs/viewer/index.js b/app/assets/javascripts/pages/projects/jobs/viewer/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..1d3abb27dd4130c79db35daab30d41840b90901c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/jobs/viewer/index.js
@@ -0,0 +1,3 @@
+import { initJobLogViewer } from '~/ci/job_log_viewer';
+
+initJobLogViewer();
diff --git a/app/assets/stylesheets/page_bundles/log_viewer.scss b/app/assets/stylesheets/page_bundles/log_viewer.scss
new file mode 100644
index 0000000000000000000000000000000000000000..af3a653f26820f96a54f742146206c95639f9822
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/log_viewer.scss
@@ -0,0 +1,63 @@
+@import 'mixins_and_variables_and_functions';
+
+.job-log-viewer-top-bar {
+  @include job-log-top-bar;
+}
+
+.job-log-viewer {
+  background-color: $builds-log-bg; // stays the same in dark more
+  color: $white-contrast; // stays the same in dark more
+
+  .log-line {
+    display: flex;
+    position: relative;
+  }
+
+  .log-line-header {
+    cursor: pointer;
+
+    &:hover {
+      background-color: rgba($white, 0.2);
+    }
+  }
+
+  // line number
+  .log-line div:first-child {
+    flex-shrink: 0;
+    flex-basis: $gl-spacing-scale-9;
+    text-align: right;
+    user-select: none;
+    margin-right: $gl-spacing-scale-3;
+    margin-left: $gl-spacing-scale-3;
+
+    .gl-icon {
+      position: absolute;
+      left: 0;
+    }
+
+    a {
+      color: $gray-500;
+
+      &:hover,
+      &:active,
+      &:visited {
+        text-decoration: underline;
+        color: $gray-500;
+      }
+    }
+  }
+
+  // line text
+  .log-line div:last-child {
+    white-space: pre;
+    text-wrap: wrap;
+    word-break: break-all;
+    word-wrap: break-word;
+    flex-grow: 0;
+  }
+
+  .loader-animation {
+    @include build-loader-animation;
+  }
+}
+
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 4062e625e0753daf68dc4077be69c62545697a6f..5b40dc3140bf7b506c3e4dcf8924fb8367e93220 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -6,15 +6,15 @@ class Projects::JobsController < Projects::ApplicationController
   include ContinueParams
   include ProjectStatsRefreshConflictsGuard
 
-  urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :raw, :test_report_summary]
+  urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :viewer, :raw, :test_report_summary]
 
   before_action :find_job_as_build, except: [:index, :play, :retry, :show]
   before_action :find_job_as_processable, only: [:play, :retry, :show]
-  before_action :authorize_read_build_trace!, only: [:trace, :raw]
+  before_action :authorize_read_build_trace!, only: [:trace, :viewer, :raw]
   before_action :authorize_read_build!, except: [:test_report_summary]
   before_action :authorize_read_build_report_results!, only: [:test_report_summary]
   before_action :authorize_update_build!,
-    except: [:index, :show, :raw, :trace, :erase, :cancel, :unschedule, :test_report_summary]
+    except: [:index, :show, :viewer, :raw, :trace, :erase, :cancel, :unschedule, :test_report_summary]
   before_action :authorize_cancel_build!, only: [:cancel]
   before_action :authorize_erase_build!, only: [:erase]
   before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize]
@@ -154,6 +154,8 @@ def raw
     end
   end
 
+  def viewer; end
+
   def test_report_summary
     return not_found unless @build.report_results.present?
 
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index 37b008611e6700ff43c2619d9bb42a5534be1840..3575616f5946a0c83935922d61f562c0d8a1011b 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -13,7 +13,8 @@ def jobs_data(project, build)
         "deployment_help_url" => help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'troubleshooting'),
         "runner_settings_url" => project_runners_path(build.project, anchor: 'js-runners-settings'),
         "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs'),
-        "pipeline_test_report_url" => test_report_project_pipeline_path(project, build.pipeline)
+        "pipeline_test_report_url" => test_report_project_pipeline_path(project, build.pipeline),
+        "log_viewer_path" => viewer_project_job_path(project, build)
       }
     end
 
diff --git a/app/views/projects/jobs/viewer.html.haml b/app/views/projects/jobs/viewer.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..a86d5051d1b3187bb6dee25d3e9b3250926725ff
--- /dev/null
+++ b/app/views/projects/jobs/viewer.html.haml
@@ -0,0 +1,13 @@
+- @no_container = true
+- add_to_breadcrumbs _("Jobs"), project_jobs_path(@project)
+- add_to_breadcrumbs "##{@build.id}", project_job_path(@project, @build)
+- breadcrumb_title s_("Job|Full log viewer")
+- page_title "#{@build.name} (##{@build.id})", _("Jobs")
+
+- add_page_specific_style 'page_bundles/xterm'
+- add_page_specific_style 'page_bundles/log_viewer'
+
+%h1.gl-font-size-h-display.gl-m-3
+  = @build.name
+
+#js-job-log-viewer{ data: { raw_log_path: raw_project_job_path(@project, @build) } }
diff --git a/config/application.rb b/config/application.rb
index 448acc9c400a3ff88a44889e1a4bea2815dab526..93df40f07528dc5359e37fc92136dd487239c9fc 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -317,6 +317,7 @@ class Application < Rails::Application
     config.assets.precompile << "page_bundles/issues_show.css"
     config.assets.precompile << "page_bundles/jira_connect.css"
     config.assets.precompile << "page_bundles/learn_gitlab.css"
+    config.assets.precompile << "page_bundles/log_viewer.css"
     config.assets.precompile << "page_bundles/login.css"
     config.assets.precompile << "page_bundles/members.css"
     config.assets.precompile << "page_bundles/merge_conflicts.css"
diff --git a/config/routes/project.rb b/config/routes/project.rb
index c4479ff2c6304f75870394652e9ebdd818c09ab6..f509676152d1b5a539c7f6c9a4f5bc5e5d46cf76 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -68,6 +68,7 @@
             post :erase
             get :trace, defaults: { format: 'json' }
             get :raw
+            get :viewer
             get :terminal
             get :proxy
             get :test_report_summary
diff --git a/ee/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue b/ee/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue
index 7f3eb96aef67e6554133d8e88c84f8fff2785b10..0abd0d8b194fab29b5cc1f65f4a5973f67bed9e4 100644
--- a/ee/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue
+++ b/ee/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue
@@ -121,6 +121,7 @@ export default {
       :job-log="jobLog"
       :full-screen-mode-available="fullScreenModeAvailable"
       :full-screen-enabled="fullScreenEnabled"
+      v-bind="$attrs"
       @scrollJobLogTop="handleScrollTop"
       @scrollJobLogBottom="handleScrollBottom"
       @searchResults="handleSearchResults"
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index dcd300631ea6c3b673111435229a1caa591dd994..d78eb41529449040eb81b3bf6c5dd519fc7f4e2a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -29283,6 +29283,9 @@ msgstr ""
 msgid "Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source} into %{target}"
 msgstr ""
 
+msgid "Job|%{rawLinkStart}View raw%{rawLinkEnd} or %{fullLinkStart}view full log%{fullLinkEnd}."
+msgstr ""
+
 msgid "Job|%{searchLength} results found for %{searchTerm}"
 msgstr ""
 
@@ -29319,9 +29322,15 @@ msgstr ""
 msgid "Job|Failed"
 msgstr ""
 
+msgid "Job|Feedback issue"
+msgstr ""
+
 msgid "Job|Finished at"
 msgstr ""
 
+msgid "Job|Full log viewer"
+msgstr ""
+
 msgid "Job|Full screen mode is not available"
 msgstr ""
 
@@ -29406,6 +29415,9 @@ msgstr ""
 msgid "Job|Skipped"
 msgstr ""
 
+msgid "Job|Something went wrong while loading the log."
+msgstr ""
+
 msgid "Job|Status"
 msgstr ""
 
diff --git a/spec/frontend/ci/job_details/components/job_log_top_bar_spec.js b/spec/frontend/ci/job_details/components/job_log_top_bar_spec.js
index 252af6ef27b8abd218680c47cffc5a39e3ddeed3..40e68e0103d28f815ef55777d2954321e40d7462 100644
--- a/spec/frontend/ci/job_details/components/job_log_top_bar_spec.js
+++ b/spec/frontend/ci/job_details/components/job_log_top_bar_spec.js
@@ -21,6 +21,7 @@ describe('JobLogTopBar', () => {
 
   const defaultProps = {
     rawPath: '/raw',
+    logViewerPath: '/viewer',
     size: 511952,
     isScrollTopDisabled: false,
     isScrollBottomDisabled: false,
@@ -72,7 +73,21 @@ describe('JobLogTopBar', () => {
         expect(findShowingLast().text()).toMatch('Showing last 499.95 KiB of log.');
       });
 
-      it('renders link', () => {
+      it('renders links', () => {
+        expect(findShowingLastLinks()).toHaveLength(2);
+        expect(findShowingLastLinks().at(0).attributes('href')).toBe('/raw');
+        expect(findShowingLastLinks().at(1).attributes('href')).toBe('/viewer');
+      });
+    });
+
+    describe('with isJobLogSizeVisible and log viewer is not available', () => {
+      beforeEach(() => {
+        createWrapper({
+          logViewerPath: null,
+        });
+      });
+
+      it('renders size information', () => {
         expect(findShowingLastLinks()).toHaveLength(1);
         expect(findShowingLastLinks().at(0).attributes('href')).toBe('/raw');
       });
diff --git a/spec/frontend/ci/job_log_viewer/component/log_viewer_spec.js b/spec/frontend/ci/job_log_viewer/component/log_viewer_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..e52f907f02f6623715037f79fa406946c4b2a039
--- /dev/null
+++ b/spec/frontend/ci/job_log_viewer/component/log_viewer_spec.js
@@ -0,0 +1,129 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+import LogViewer from '~/ci/job_log_viewer/components/log_viewer.vue';
+
+describe('LogViewer', () => {
+  let wrapper;
+
+  const createWrapper = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
+    wrapper = mountFn(LogViewer, {
+      propsData: {
+        ...props,
+      },
+    });
+  };
+
+  const findLogLineAt = (i) => wrapper.findAll('.log-line').at(i);
+  const getShownLines = () => {
+    return wrapper
+      .findAll('.log-line')
+      .wrappers.filter((w) => w.isVisible())
+      .map((w) => w.text());
+  };
+
+  it('displays an empty log', () => {
+    createWrapper();
+
+    expect(wrapper.attributes()).toMatchObject({
+      'aria-live': 'polite',
+      role: 'log',
+    });
+    expect(wrapper.text()).toBe('');
+  });
+
+  it('displays a log', () => {
+    createWrapper({
+      props: {
+        log: [{ content: [{ text: 'line' }] }],
+      },
+    });
+
+    expect(wrapper.text()).toBe('1 line');
+  });
+
+  it('displays log with style', () => {
+    createWrapper({
+      props: {
+        log: [
+          {
+            content: [
+              { style: ['class-a'], text: 'key:' },
+              { style: ['class-b'], text: 'value' },
+            ],
+          },
+        ],
+      },
+    });
+
+    expect(wrapper.text()).toBe('1 key:value');
+
+    expect(wrapper.find('.class-a').text()).toBe('key:');
+    expect(wrapper.find('.class-b').text()).toBe('value');
+  });
+
+  it('displays loading log', () => {
+    createWrapper({
+      props: {
+        log: [],
+        loading: true,
+      },
+    });
+
+    expect(wrapper.attributes('aria-busy')).toBe('true');
+    expect(wrapper.text()).toBe('Loading...');
+  });
+
+  describe('when displaying a section', () => {
+    beforeEach(() => {
+      createWrapper({
+        props: {
+          log: [
+            {
+              sections: [],
+              content: [{ text: 'log:' }],
+            },
+            {
+              sections: [],
+              header: 'section_1',
+              content: [{ text: 'header' }],
+            },
+            {
+              sections: ['section_1'],
+              header: 'section_1_1',
+              content: [{ text: 'line 1' }],
+            },
+            {
+              sections: ['section_1', 'section_1_1'],
+              content: [{ text: 'line 1.1' }],
+            },
+            {
+              sections: [],
+              content: [{ text: 'done!' }],
+            },
+          ],
+        },
+      });
+    });
+
+    it('shows an open section', () => {
+      expect(findLogLineAt(1).findComponent(GlIcon).props('name')).toBe('chevron-lg-down');
+
+      expect(getShownLines()).toEqual(['1 log:', '2 header', '3 line 1', '4 line 1.1', '5 done!']);
+    });
+
+    it('collapses a section', async () => {
+      await findLogLineAt(1).trigger('click');
+
+      expect(findLogLineAt(1).findComponent(GlIcon).props('name')).toBe('chevron-lg-right');
+      expect(getShownLines()).toEqual(['1 log:', '2 header', '5 done!']);
+    });
+
+    it('collapses a subsection', async () => {
+      await findLogLineAt(2).trigger('click');
+
+      expect(findLogLineAt(2).findComponent(GlIcon).props('name')).toBe('chevron-lg-right');
+      expect(getShownLines()).toEqual(['1 log:', '2 header', '3 line 1', '5 done!']);
+    });
+  });
+});
diff --git a/spec/frontend/ci/job_log_viewer/component/log_viewer_top_bar_spec.js b/spec/frontend/ci/job_log_viewer/component/log_viewer_top_bar_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..dd0047099ff45adb0a993466f8cee35fd6998b2d
--- /dev/null
+++ b/spec/frontend/ci/job_log_viewer/component/log_viewer_top_bar_spec.js
@@ -0,0 +1,30 @@
+import { GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ExperimentBadge from '~/vue_shared/components/badges/experiment_badge.vue';
+
+import LogViewerTopBar from '~/ci/job_log_viewer/components/log_viewer_top_bar.vue';
+
+describe('LogViewerTopBar', () => {
+  let wrapper;
+
+  const createWrapper = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
+    wrapper = mountFn(LogViewerTopBar, {
+      propsData: {
+        ...props,
+      },
+    });
+  };
+
+  const findExperimentBadge = () => wrapper.findComponent(ExperimentBadge);
+  const findLink = () => wrapper.findComponent(GlLink);
+
+  it('renders help experiment badge with link', () => {
+    createWrapper();
+
+    expect(findExperimentBadge().exists()).toBe(true);
+
+    expect(findLink().attributes('href')).toEqual(
+      'https://gitlab.com/gitlab-org/gitlab/-/issues/454817',
+    );
+  });
+});
diff --git a/spec/frontend/ci/job_log_viewer/lib/ansi_evaluator_spec.js b/spec/frontend/ci/job_log_viewer/lib/ansi_evaluator_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c6180de4caa7af84647ab821afbd6b64f6e36dd0
--- /dev/null
+++ b/spec/frontend/ci/job_log_viewer/lib/ansi_evaluator_spec.js
@@ -0,0 +1,99 @@
+import { AnsiEvaluator } from '~/ci/job_log_viewer/lib/ansi_evaluator';
+
+describe('AnsiEvaluator', () => {
+  let ansi;
+
+  beforeEach(() => {
+    ansi = new AnsiEvaluator();
+  });
+
+  it('parses empty stack', () => {
+    ansi.evaluate('');
+
+    expect(ansi.getClasses()).toEqual([]);
+  });
+
+  it('parses no style', () => {
+    ansi.evaluate('0m');
+
+    expect(ansi.getClasses()).toEqual([]);
+  });
+
+  it('parses bold', () => {
+    ansi.evaluate('1m');
+
+    expect(ansi.getClasses()).toEqual(['term-bold']);
+  });
+
+  it('parses cyan', () => {
+    ansi.evaluate('36m');
+
+    expect(ansi.getClasses()).toEqual(['xterm-fg-14']);
+  });
+
+  it('parses  cyan + bold', () => {
+    ansi.evaluate('36;1m');
+
+    expect(ansi.getClasses()).toEqual(['xterm-fg-14', 'term-bold']);
+  });
+
+  it('parses green + bold', () => {
+    ansi.evaluate('32;1m');
+
+    expect(ansi.getClasses()).toEqual(['xterm-fg-10', 'term-bold']);
+  });
+
+  it('parses "set foreground color"', () => {
+    ansi.evaluate('38;5;100m');
+
+    expect(ansi.getClasses()).toEqual(['xterm-fg-100']);
+  });
+
+  it('parses "set background color"', () => {
+    ansi.evaluate('48;5;100m');
+
+    expect(ansi.getClasses()).toEqual(['xterm-bg-100']);
+  });
+
+  it('parses "set foreground color" + "set background color"', () => {
+    ansi.evaluate('48;5;100;38;5;100m');
+
+    expect(ansi.getClasses()).toEqual(['xterm-fg-100', 'xterm-bg-100']);
+  });
+
+  it('parses non-color styles', () => {
+    ansi.evaluate('1;3;4;8;9m');
+
+    expect(ansi.getClasses()).toEqual([
+      'term-bold',
+      'term-italic',
+      'term-underline',
+      'term-conceal',
+      'term-cross',
+    ]);
+  });
+
+  it('parses styles that get a reset', () => {
+    ansi.evaluate('1;3;4;8;9;0m');
+
+    expect(ansi.getClasses()).toEqual([]);
+  });
+
+  it('safely parses non-ansi string', () => {
+    ansi.evaluate('0K');
+
+    expect(ansi.getClasses()).toEqual([]);
+  });
+
+  it('safely parses unknown command', () => {
+    ansi.evaluate('1000m');
+
+    expect(ansi.getClasses()).toEqual([]);
+  });
+
+  it('safely parses unknown color keeping previous colors', () => {
+    ansi.evaluate('48;5;100;48;5;256m');
+
+    expect(ansi.getClasses()).toEqual(['xterm-bg-100']);
+  });
+});
diff --git a/spec/frontend/ci/job_log_viewer/lib/generate_stream_spec.js b/spec/frontend/ci/job_log_viewer/lib/generate_stream_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4984268dad58658be70f9d635d98a446e8a4c81d
--- /dev/null
+++ b/spec/frontend/ci/job_log_viewer/lib/generate_stream_spec.js
@@ -0,0 +1,70 @@
+import { fetchLogLines } from '~/ci/job_log_viewer/lib/generate_stream';
+
+const mockFetchResponse = (chunks) => {
+  const encoder = new TextEncoder();
+  const queue = chunks.map((chunk) => encoder.encode(chunk));
+
+  global.fetch.mockResolvedValue({
+    body: {
+      getReader() {
+        return new ReadableStream({
+          pull(controller) {
+            if (queue.length) {
+              controller.enqueue(queue.shift());
+            } else {
+              controller.close();
+            }
+          },
+        }).getReader();
+      },
+    },
+  });
+};
+
+describe('generate stream', () => {
+  beforeEach(() => {
+    global.fetch = jest.fn();
+  });
+
+  it('uses path to fetch', async () => {
+    mockFetchResponse([]);
+
+    await fetchLogLines('/jobs/1/raw');
+
+    expect(global.fetch).toHaveBeenCalledWith('/jobs/1/raw');
+  });
+
+  it('fetches lines in chunk', async () => {
+    mockFetchResponse(['line 1\nline 2']);
+
+    expect(await fetchLogLines()).toEqual([
+      { content: [{ style: [], text: 'line 1' }], sections: [] },
+      { content: [{ style: [], text: 'line 2' }], sections: [] },
+    ]);
+  });
+
+  it('fetches lines in separate chunks', async () => {
+    mockFetchResponse(['line 1\nline 2\n', 'line 3\nline 4']);
+
+    expect(await fetchLogLines()).toEqual([
+      { content: [{ style: [], text: 'line 1' }], sections: [] },
+      { content: [{ style: [], text: 'line 2' }], sections: [] },
+      { content: [{ style: [], text: 'line 3' }], sections: [] },
+      { content: [{ style: [], text: 'line 4' }], sections: [] },
+    ]);
+  });
+
+  it('decodes using utf-8', async () => {
+    mockFetchResponse(['🐤🐤🐤🐤']);
+
+    expect(await fetchLogLines('/raw')).toEqual([
+      { content: [{ style: [], text: '🐤🐤🐤🐤' }], sections: [] },
+    ]);
+  });
+
+  it('skips an empty log line', async () => {
+    mockFetchResponse(['\n']);
+
+    expect(await fetchLogLines('/raw')).toEqual([]);
+  });
+});
diff --git a/spec/frontend/ci/job_log_viewer/lib/scanner_spec.js b/spec/frontend/ci/job_log_viewer/lib/scanner_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..40c0cd4ffea5e6475b36c01a0772079033996373
--- /dev/null
+++ b/spec/frontend/ci/job_log_viewer/lib/scanner_spec.js
@@ -0,0 +1,97 @@
+import Scanner from '~/ci/job_log_viewer/lib/scanner';
+
+describe('Log scanner', () => {
+  let scanner;
+
+  beforeEach(() => {
+    scanner = new Scanner();
+  });
+
+  it('scans a line', () => {
+    expect(scanner.scan('line')).toEqual({
+      content: [{ style: [], text: 'line' }],
+      sections: [],
+    });
+  });
+
+  it('scans a line with spacing', () => {
+    expect(scanner.scan('  on runner')).toEqual({
+      content: [{ style: [], text: '  on runner' }],
+      sections: [],
+    });
+  });
+
+  it('scans a line with a style', () => {
+    expect(scanner.scan('\u001b[1;41mline 2\u001b[0;m')).toEqual({
+      content: [
+        {
+          text: 'line 2',
+          style: ['xterm-bg-9', 'term-bold'],
+        },
+      ],
+      sections: [],
+    });
+  });
+
+  it('scans a line with styles', () => {
+    expect(scanner.scan('\u001b[32;1mline 1\u001b[0;m')).toEqual({
+      content: [
+        {
+          text: 'line 1',
+          style: ['xterm-fg-10', 'term-bold'],
+        },
+      ],
+      sections: [],
+    });
+  });
+
+  it('scans a section with its duration', () => {
+    const lines = [
+      'section_start:1000:my_section\rheader 1',
+      'line 1',
+      'line 2',
+      'section_end:1010:my_section',
+    ];
+
+    expect(lines.map((l) => scanner.scan(l))).toEqual([
+      {
+        content: [{ style: [], text: 'header 1' }],
+        header: 'my_section',
+        sections: [],
+      },
+      { content: [{ style: [], text: 'line 1' }], sections: ['my_section'] },
+      { content: [{ style: [], text: 'line 2' }], sections: ['my_section'] },
+      { content: [{ duration: 10, section: 'my_section' }], sections: [] },
+    ]);
+  });
+
+  it('scans a sub section with their durations', () => {
+    const lines = [
+      'section_start:1010:my_section\rheader 1',
+      'line 1',
+      'section_start:1020:my_sub_section\rheader 2',
+      'line 2',
+      'section_end:1030:my_sub_section',
+      'line 3',
+      'section_end:1040:my_section',
+    ];
+
+    expect(lines.map((l) => scanner.scan(l))).toEqual([
+      {
+        content: [{ style: [], text: 'header 1' }],
+        header: 'my_section',
+        sections: [],
+      },
+      { content: [{ style: [], text: 'line 1' }], sections: ['my_section'] },
+      {
+        content: [{ style: [], text: 'header 2' }],
+        header: 'my_sub_section',
+        sections: ['my_section'],
+      },
+      { content: [{ style: [], text: 'line 2' }], sections: ['my_section', 'my_sub_section'] },
+      { content: [{ duration: 10, section: 'my_sub_section' }], sections: ['my_section'] },
+      { content: [{ style: [], text: 'line 3' }], sections: ['my_section'] },
+      { content: [{ duration: 30, section: 'my_section' }], sections: [] },
+    ]);
+  });
+});
diff --git a/spec/frontend/ci/job_log_viewer/log_viewer_app_spec.js b/spec/frontend/ci/job_log_viewer/log_viewer_app_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..61f495e33e4343439ead0a9432416fb0780e8bcf
--- /dev/null
+++ b/spec/frontend/ci/job_log_viewer/log_viewer_app_spec.js
@@ -0,0 +1,91 @@
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import { createAlert } from '~/alert';
+import { stubPerformanceWebAPI } from 'helpers/performance';
+
+import LogViewerApp from '~/ci/job_log_viewer/log_viewer_app.vue';
+
+import LogViewerTopBar from '~/ci/job_log_viewer/components/log_viewer_top_bar.vue';
+import LogViewer from '~/ci/job_log_viewer/components/log_viewer.vue';
+import { fetchLogLines } from '~/ci/job_log_viewer/lib/generate_stream';
+
+jest.mock('~/alert');
+jest.mock('~/ci/job_log_viewer/lib/generate_stream');
+jest.mock('~/lib/utils/common_utils');
+
+const mockLog = [{ content: [{ text: 'line' }], sections: [] }];
+
+describe('LogViewerApp', () => {
+  let wrapper;
+
+  const createWrapper = ({ mountFn = shallowMountExtended, ...options } = {}) => {
+    wrapper = mountFn(LogViewerApp, {
+      propsData: {
+        rawLogPath: '/job/1/raw',
+      },
+      ...options,
+    });
+  };
+
+  const findLogViewerTopBar = () => wrapper.findComponent(LogViewerTopBar);
+  const findLogViewer = () => wrapper.findComponent(LogViewer);
+
+  beforeEach(() => {
+    stubPerformanceWebAPI();
+
+    fetchLogLines.mockResolvedValue(mockLog);
+  });
+
+  it('renders help popover', () => {
+    createWrapper();
+
+    expect(findLogViewerTopBar().exists()).toBe(true);
+  });
+
+  it('renders a log', async () => {
+    createWrapper();
+
+    await waitForPromises();
+
+    expect(findLogViewer().props()).toEqual({ loading: false, log: mockLog });
+  });
+
+  it('renders a loading log', async () => {
+    fetchLogLines.mockReturnValue(new Promise(() => {}));
+    createWrapper();
+
+    await waitForPromises();
+
+    expect(findLogViewer().props()).toEqual({ loading: true, log: [] });
+  });
+
+  it('navigates to bookmarked line', async () => {
+    setWindowLocation('#L1');
+
+    createWrapper({
+      mountFn: mountExtended,
+      attachTo: document.body,
+    });
+
+    await waitForPromises();
+
+    expect(scrollToElement).toHaveBeenCalledWith(wrapper.find('#L1').element);
+  });
+
+  it('shows alert when log cannot be fetched', async () => {
+    const error = new Error('Something went wrong');
+    fetchLogLines.mockRejectedValue(error);
+
+    createWrapper();
+    await waitForPromises();
+
+    expect(createAlert).toHaveBeenCalledWith({
+      message: 'Something went wrong while loading the log.',
+      captureError: true,
+      error,
+    });
+    expect(findLogViewer().props()).toEqual({ loading: false, log: [] });
+  });
+});