From a2003de16196f9c44e9c7b5396179423ddcd30be Mon Sep 17 00:00:00 2001
From: Chen Charnolevsky <ccharnolevsky@gitlab.com>
Date: Tue, 26 Nov 2024 17:49:56 +0000
Subject: [PATCH] Marking code flow content issues

---
 .../code_flow/code_flow_file_viewer.vue       |   9 +-
 .../code_flow/code_flow_steps_section.vue     |  96 +-------
 .../components/code_flow/utils/utils.js       | 120 +++++++++-
 .../code_flow/vulnerability_code_flow.vue     |   8 +-
 .../vulnerability_file_content_viewer.vue     |  44 ++--
 .../code_flow/code_flow_file_viewer_spec.js   |   9 +-
 .../components/code_flow/utils/utils_spec.js  | 212 ++++++++++++++++++
 .../vulnerability_file_content_viewer_spec.js |   2 +
 8 files changed, 386 insertions(+), 114 deletions(-)

diff --git a/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_file_viewer.vue b/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_file_viewer.vue
index d5994a6051ac1..5b8d2c0a93375 100644
--- a/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_file_viewer.vue
+++ b/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_file_viewer.vue
@@ -41,6 +41,11 @@ export default {
       type: String,
       required: true,
     },
+    selectedStepNumber: {
+      type: Number,
+      required: false,
+      default: undefined,
+    },
   },
   data() {
     return {
@@ -95,7 +100,6 @@ export default {
           } else {
             this.highlightedContent = res;
           }
-          this.$emit('codeFlowFileLoaded');
         })
         .catch((error) => {
           Sentry.captureException(error);
@@ -173,6 +177,7 @@ export default {
         class="file-content code js-syntax-highlight blob-content blob-viewer gl-flex gl-w-full gl-flex-col gl-overflow-auto"
         :class="userColorScheme"
         data-type="simple"
+        data-testid="file-content"
       >
         <div v-for="(highlightSectionInfo, index) in codeBlocks" :key="index">
           <div
@@ -198,7 +203,7 @@ export default {
             :start-line="highlightSectionInfo.blockStartLine"
             :end-line="highlightSectionInfo.blockEndLine"
             :highlight-info="highlightSectionInfo.highlightInfo"
-            @codeFlowFileLoaded="$emit('codeFlowFileLoaded')"
+            :selected-step-number="selectedStepNumber"
           />
 
           <div v-if="isEndOfCodeBlock(index)" class="expansion-line gl-bg-gray-50 gl-p-1">
diff --git a/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_steps_section.vue b/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_steps_section.vue
index 3c43089cc8341..0389609bedc94 100644
--- a/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_steps_section.vue
+++ b/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_steps_section.vue
@@ -11,15 +11,6 @@ import {
 } from '@gitlab/ui';
 import { flattenDeep } from 'lodash';
 import { __, s__, sprintf } from '~/locale';
-import {
-  numMarkerIdPrefix,
-  selectedInlineItemMark,
-  selectedInlineSectionMarker,
-  selectedInlineNumberMark,
-  textMarkerIdPrefix,
-  textSpanMarkerIdPrefix,
-  unselectedInlineNumberMark,
-} from 'ee/vue_shared/components/code_flow/utils/constants';
 
 export default {
   name: 'CodeFlowStepsSection',
@@ -103,6 +94,9 @@ export default {
   mounted() {
     this.stepsExpanded = Array(this.vulnerabilityFlowDetails.length).fill(true);
   },
+  created() {
+    this.$emit('onSelectedStep', this.selectedStepNumber);
+  },
   methods: {
     openFileSteps(index) {
       const copyStepsExpanded = [...this.stepsExpanded];
@@ -121,71 +115,7 @@ export default {
     selectStep(vulnerabilityItem) {
       this.selectedStepNumber = vulnerabilityItem.stepNumber;
       this.selectedVulnerability = vulnerabilityItem;
-      this.markdownBlobData();
-      this.scrollToSpecificCodeFlow();
-    },
-    markdownRowContent() {
-      // Highlights the selected markdown row content
-      const elements = document.querySelectorAll(`[id^=${textMarkerIdPrefix}]`);
-      elements.forEach((el) => {
-        el.classList.remove(selectedInlineSectionMarker);
-      });
-
-      // Examples of ID: 'TEXT-MARKER-1,2-L8', 'TEXT-MARKER-3-L7'
-      const stepMarkerSelectors = [
-        `[id^="${textMarkerIdPrefix}"][id*="${this.selectedStepNumber}-L"]`,
-        `[id^="${textMarkerIdPrefix}"][id*=",${this.selectedStepNumber}-L"]`,
-        `[id^="${textMarkerIdPrefix}"][id*="${this.selectedStepNumber},"][id*="-L"]`,
-      ];
-      const selector = stepMarkerSelectors.join(', ');
-      const element = document.querySelectorAll(selector);
-
-      if (element) {
-        element.forEach((el) => el.classList.add(selectedInlineSectionMarker));
-      }
-    },
-    markdownStepNumber() {
-      // Highlights the step number in the markdown
-      const elements = document.querySelectorAll(`[id^=${textMarkerIdPrefix}]`);
-      elements.forEach((el) => {
-        const spans = el.querySelectorAll('span.inline-item-mark');
-        spans.forEach((span) => {
-          span.classList.remove(selectedInlineItemMark);
-        });
-      });
-      const element = document.querySelector(
-        `[id^="${textSpanMarkerIdPrefix}${this.selectedStepNumber}"]`,
-      );
-      if (element) {
-        element.classList.add(selectedInlineItemMark, 'gs');
-      }
-    },
-    markdownRowNumber() {
-      // Highlights the row number in the markdown
-      const elements = document.querySelectorAll(`[id^="${numMarkerIdPrefix}"]`);
-      elements.forEach((el) => {
-        el.classList.remove(selectedInlineNumberMark);
-        el.classList.add(unselectedInlineNumberMark);
-      });
-
-      // Examples of ID: 'NUM-MARKER-1,2-L8', 'NUM-MARKER-3-L7'
-      const numMarkerSelectors = [
-        `[id^="${numMarkerIdPrefix}"][id*="${this.selectedStepNumber}-L"]`,
-        `[id^="${numMarkerIdPrefix}"][id*=",${this.selectedStepNumber}-L"]`,
-        `[id^="${numMarkerIdPrefix}"][id*="${this.selectedStepNumber},"][id*="-L"]`,
-      ];
-      const selector = numMarkerSelectors.join(', ');
-      const element = document.querySelector(selector);
-
-      if (element) {
-        element.classList.add(selectedInlineNumberMark);
-        element.classList.remove(unselectedInlineNumberMark);
-      }
-    },
-    markdownBlobData() {
-      this.markdownRowContent();
-      this.markdownStepNumber();
-      this.markdownRowNumber();
+      this.$emit('onSelectedStep', this.selectedStepNumber);
     },
     getNextIndex(isNext) {
       return isNext ? this.selectedStepNumber + 1 : this.selectedStepNumber - 1;
@@ -200,23 +130,7 @@ export default {
         (item) => item.stepNumber === this.getNextIndex(isNextVulnerability),
       );
       this.selectedStepNumber = this.selectedVulnerability.stepNumber;
-      this.markdownBlobData();
-      this.scrollToSpecificCodeFlow();
-    },
-    scrollToSpecificCodeFlow() {
-      const element = document.querySelector(
-        `[id^=${textMarkerIdPrefix}${this.selectedStepNumber}]`,
-      );
-      if (element) {
-        const subScroller = document.querySelector(`[id=code-flows-container]`);
-        const subScrollerRect = subScroller.getBoundingClientRect();
-        const elementRect = element.getBoundingClientRect();
-        const offsetTop = elementRect.top - subScrollerRect.top + subScroller.scrollTop;
-        subScroller.scrollTo({
-          top: offsetTop - subScroller.clientHeight / 2 + element.clientHeight / 2,
-          behavior: 'smooth',
-        });
-      }
+      this.$emit('onSelectedStep', this.selectedStepNumber);
     },
     showNodeTypePopover(nodeType) {
       return nodeType === 'source'
diff --git a/ee/app/assets/javascripts/vue_shared/components/code_flow/utils/utils.js b/ee/app/assets/javascripts/vue_shared/components/code_flow/utils/utils.js
index d5af92c6448dc..6beb22a89b1af 100644
--- a/ee/app/assets/javascripts/vue_shared/components/code_flow/utils/utils.js
+++ b/ee/app/assets/javascripts/vue_shared/components/code_flow/utils/utils.js
@@ -1,5 +1,14 @@
 import { differenceWith, isEqual } from 'lodash';
-import { linesPadding } from 'ee/vue_shared/components/code_flow/utils/constants';
+import {
+  linesPadding,
+  numMarkerIdPrefix,
+  selectedInlineItemMark,
+  selectedInlineNumberMark,
+  selectedInlineSectionMarker,
+  textMarkerIdPrefix,
+  textSpanMarkerIdPrefix,
+  unselectedInlineNumberMark,
+} from 'ee/vue_shared/components/code_flow/utils/constants';
 import { splitByLineBreaks } from '~/vue_shared/components/source_viewer/workers/highlight_utils';
 
 /**
@@ -145,3 +154,112 @@ export const createHighlightSourcesInfo = (normalizedCodeFlows, rawTextBlobs) =>
     return acc;
   }, {});
 };
+
+/**
+ * Generates an array of CSS selector strings for identifying elements based on a prefix and step number.
+ * @param {string} idPrefix - The prefix of the ID to match.
+ * @param {number} selectedStepNumber - The selected step number to include in the selectors.
+ * @returns {string[]} An array of three CSS selector strings.
+ */
+const getIdSelectors = (idPrefix, selectedStepNumber) => {
+  // Examples of ID: '<idPrefix>-<selectedStepNumber>>,2-L8', '<idPrefix>-<selectedStepNumber>-L7'
+  // Examples of ID: 'NUM-MARKER-1,2-L8', 'NUM-MARKER-3-L7'
+  // Examples of ID: 'TEXT-MARKER-1,2-L8', 'TEXT-MARKER-3-L7'
+  return [
+    `[id^="${idPrefix}"][id*="${selectedStepNumber}-L"]`,
+    `[id^="${idPrefix}"][id*=",${selectedStepNumber}-L"]`,
+    `[id^="${idPrefix}"][id*="${selectedStepNumber},"][id*="-L"]`,
+  ];
+};
+
+/**
+ * Highlights the content of the selected markdown row and unhighlights others.
+ * @param {number} selectedStepNumber - The step number corresponding to the row to highlight.
+ */
+export const markdownRowContent = (selectedStepNumber) => {
+  // Highlights the selected markdown row content
+  const textMarkerElements = document.querySelectorAll(`[id^=${textMarkerIdPrefix}]`);
+  textMarkerElements.forEach((el) => {
+    el.classList.remove(selectedInlineSectionMarker);
+  });
+
+  const textMarkerSelectors = getIdSelectors(textMarkerIdPrefix, selectedStepNumber);
+  const selector = textMarkerSelectors.join(', ');
+  const allTextMarkerElements = document.querySelectorAll(selector);
+  if (allTextMarkerElements.length > 0) {
+    allTextMarkerElements.forEach((el) => el.classList.add(selectedInlineSectionMarker));
+  }
+};
+
+/**
+ * Highlights the selected step number in the markdown and unhighlights others.
+ * @param {number} selectedStepNumber - The step number to highlight.
+ */
+export const markdownStepNumber = (selectedStepNumber) => {
+  // Highlights the step number in the markdown
+  const textMarkerElements = document.querySelectorAll(`[id^=${textMarkerIdPrefix}]`);
+  textMarkerElements.forEach((el) => {
+    const spans = el.querySelectorAll('span.inline-item-mark');
+    spans.forEach((span) => {
+      span.classList.remove(selectedInlineItemMark);
+    });
+  });
+  const textSpanMarkerElements = document.querySelectorAll(
+    `[id^="${textSpanMarkerIdPrefix}${selectedStepNumber}"]`,
+  );
+  if (textSpanMarkerElements.length > 0) {
+    textSpanMarkerElements.forEach((el) => el.classList.add(selectedInlineItemMark, 'gs'));
+  }
+};
+
+/**
+ * Highlights the selected row number in the markdown and unhighlights others.
+ * @param {number} selectedStepNumber - The step number to highlight.
+ */
+export const markdownRowNumber = (selectedStepNumber) => {
+  // Highlights the row number in the markdown
+  const numMarkerElements = document.querySelectorAll(`[id^="${numMarkerIdPrefix}"]`);
+  numMarkerElements.forEach((el) => {
+    el.classList.remove(selectedInlineNumberMark);
+    el.classList.add(unselectedInlineNumberMark);
+  });
+
+  const numMarkerSelectors = getIdSelectors(numMarkerIdPrefix, selectedStepNumber);
+  const selector = numMarkerSelectors.join(', ');
+  const allNumMarkerElements = document.querySelectorAll(selector);
+
+  if (allNumMarkerElements.length > 0) {
+    allNumMarkerElements.forEach((el) => {
+      el.classList.add(selectedInlineNumberMark);
+      el.classList.remove(unselectedInlineNumberMark);
+    });
+  }
+};
+
+/**
+ * call the markdown functions when the step is selected
+ * @param {Number} selectedStepNumber - The selected step number.
+ */
+export const markdownBlobData = (selectedStepNumber) => {
+  markdownRowContent(selectedStepNumber);
+  markdownStepNumber(selectedStepNumber);
+  markdownRowNumber(selectedStepNumber);
+};
+
+/**
+ * Scrolls to a specific code flow element within a container.
+ * @param {number} selectedStepNumber - The number of the step to scroll to.
+ */
+export const scrollToSpecificCodeFlow = (selectedStepNumber) => {
+  const element = document.querySelector(`[id^=${textMarkerIdPrefix}${selectedStepNumber}]`);
+  if (element) {
+    const subScroller = document.getElementById('code-flows-container');
+    const subScrollerRect = subScroller.getBoundingClientRect();
+    const elementRect = element.getBoundingClientRect();
+    const offsetTop = elementRect.top - subScrollerRect.top + subScroller.scrollTop;
+    subScroller.scrollTo({
+      top: offsetTop - subScroller.clientHeight / 2 + element.clientHeight / 2,
+      behavior: 'smooth',
+    });
+  }
+};
diff --git a/ee/app/assets/javascripts/vue_shared/components/code_flow/vulnerability_code_flow.vue b/ee/app/assets/javascripts/vue_shared/components/code_flow/vulnerability_code_flow.vue
index 889fd1962f758..ce01d4d9eff4a 100644
--- a/ee/app/assets/javascripts/vue_shared/components/code_flow/vulnerability_code_flow.vue
+++ b/ee/app/assets/javascripts/vue_shared/components/code_flow/vulnerability_code_flow.vue
@@ -55,6 +55,7 @@ export default {
       rawTextBlobs: {},
       highlightSourcesInfo: {},
       modalContentHeight: 0,
+      selectedStepNumber: undefined,
     };
   },
   computed: {
@@ -110,8 +111,8 @@ export default {
     },
   },
   methods: {
-    codeFlowFileLoaded() {
-      this.$refs.codeFlowStepSection.markdownBlobData();
+    onSelectedStep(value) {
+      this.selectedStepNumber = value;
     },
     specificBlobInfo(fileName) {
       return this.blobInfo[fileName]?.data || {};
@@ -154,6 +155,7 @@ export default {
         :description-html="descriptionHtml"
         :details="details"
         :raw-text-blobs="rawTextBlobs"
+        @onSelectedStep="onSelectedStep"
       />
 
       <div
@@ -170,7 +172,7 @@ export default {
           :branch-ref="branchRef"
           :hl-info="highlightSourcesInfo[fileName]"
           :blob-info="specificBlobInfo(fileName)"
-          @codeFlowFileLoaded="codeFlowFileLoaded"
+          :selected-step-number="selectedStepNumber"
         />
       </div>
     </template>
diff --git a/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_file_content_viewer.vue b/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_file_content_viewer.vue
index aae9b89403878..87d3b9d0c2887 100644
--- a/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_file_content_viewer.vue
+++ b/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_file_content_viewer.vue
@@ -5,6 +5,10 @@ import {
   unselectedInlineNumberMark,
   numMarkerIdPrefix,
 } from 'ee/vue_shared/components/code_flow/utils/constants';
+import {
+  markdownBlobData,
+  scrollToSpecificCodeFlow,
+} from 'ee/vue_shared/components/code_flow/utils/utils';
 
 export default {
   name: 'VulnerabilityFileContentViewer',
@@ -18,11 +22,7 @@ export default {
     isHighlighted: { type: Boolean, required: false, default: false },
     content: { type: String, required: true },
     highlightInfo: { type: Array, required: false, default: () => [] },
-  },
-  data() {
-    return {
-      loadingError: false,
-    };
+    selectedStepNumber: { type: Number, required: false, default: undefined },
   },
   computed: {
     lineNumbers() {
@@ -58,16 +58,34 @@ export default {
     },
   },
   watch: {
-    lineNumbers() {
-      // When the user clicks `handleToggleFile` in `code_flow_file_viewer.vue` parent,
-      // The `expanded` value changed, and the `lineNumbers` are recalculated and updated in the DOM.
-      // We need to ensure that the highlighting of the selected step in the code flow viewer
-      // remains intact for the user.
-      this.$nextTick(() => {
-        this.$emit('codeFlowFileLoaded');
-      });
+    /**
+     * when the user selects a different step in the code flow
+     * we need to highlight the new row and scroll to it
+     */
+    selectedStepNumber() {
+      markdownBlobData(this.selectedStepNumber);
+      scrollToSpecificCodeFlow(this.selectedStepNumber);
     },
   },
+  mounted() {
+    /**
+     * when the user closes and opens the collapse, this component is mounted again,
+     * so we need to highlight the new row and scroll to it
+     */
+    markdownBlobData(this.selectedStepNumber);
+    scrollToSpecificCodeFlow(this.selectedStepNumber);
+  },
+  /**
+   * 1. when the component is load for the first time, the sourceCodeLines is empty
+   * (because of async function 'highlightContent' in the parent),
+   * so when sourceCodeLines is updated, we need to highlight the new row and scroll to it
+   * 2. when the user opens more code blocks, this component is updated,
+   * so we need to highlight the new row and scroll to it
+   */
+  updated() {
+    markdownBlobData(this.selectedStepNumber);
+    scrollToSpecificCodeFlow(this.selectedStepNumber);
+  },
   methods: {
     lineNumberId(line) {
       return `${numMarkerIdPrefix}${line.stepNumbers}-L${line.lineNumber}`;
diff --git a/ee/spec/frontend/vue_shared/components/code_flow/code_flow_file_viewer_spec.js b/ee/spec/frontend/vue_shared/components/code_flow/code_flow_file_viewer_spec.js
index da06fc9a4657b..1ccf4f7bf131c 100644
--- a/ee/spec/frontend/vue_shared/components/code_flow/code_flow_file_viewer_spec.js
+++ b/ee/spec/frontend/vue_shared/components/code_flow/code_flow_file_viewer_spec.js
@@ -21,6 +21,7 @@ describe('Vulnerability Code Flow File Viewer component', () => {
     filePath: 'samples/test.js',
     branchRef: '123',
     hlInfo: [],
+    selectedStepNumber: 1,
   };
 
   const hlInfo = [
@@ -59,13 +60,13 @@ describe('Vulnerability Code Flow File Viewer component', () => {
         filePath: defaultProps.filePath,
         branchRef: defaultProps.branchRef,
         hlInfo: defaultProps.hlInfo,
+        selectedStepNumber: defaultProps.selectedStepNumber,
         ...props,
       },
-      stubs: { GlSprintf, GlButton },
+      stubs: { GlSprintf, GlButton, BlobHeader },
     });
   };
 
-  const findButton = () => wrapper.findComponent(GlButton);
   const findVulFileContentViewer = () => wrapper.findComponent(VulnerabilityFileContentViewer);
   const findBlobHeader = () => wrapper.findComponent(BlobHeader);
   const findGlAlert = () => wrapper.findComponent(GlAlert);
@@ -97,7 +98,7 @@ describe('Vulnerability Code Flow File Viewer component', () => {
     it('shows the source code without markdown', () => {
       createWrapper();
       expect(findBlobHeader().exists()).toBe(true);
-      expect(findButton().exists()).toBe(true);
+      expect(findCollapseExpandButton().exists()).toBe(true);
       expect(findVulFileContentViewer().exists()).toBe(false);
     });
 
@@ -115,7 +116,7 @@ describe('Vulnerability Code Flow File Viewer component', () => {
 
     it('renders GlButton with correct aria-label when file is expanded', () => {
       createWrapper();
-      expect(findButton().attributes('aria-label')).toBe('Hide file contents');
+      expect(findCollapseExpandButton().attributes('aria-label')).toBe('Hide file contents');
     });
 
     it('renders the expand buttons with correct aria-label', () => {
diff --git a/ee/spec/frontend/vue_shared/components/code_flow/utils/utils_spec.js b/ee/spec/frontend/vue_shared/components/code_flow/utils/utils_spec.js
index 7baa63da27ee0..6ad20f495a8f1 100644
--- a/ee/spec/frontend/vue_shared/components/code_flow/utils/utils_spec.js
+++ b/ee/spec/frontend/vue_shared/components/code_flow/utils/utils_spec.js
@@ -5,6 +5,10 @@ import {
   normalizeCodeFlowsInfo,
   createHighlightSourcesInfo,
   createSourceBlockHighlightInfo,
+  scrollToSpecificCodeFlow,
+  markdownRowContent,
+  markdownRowNumber,
+  markdownStepNumber,
 } from 'ee/vue_shared/components/code_flow/utils/utils';
 
 jest.mock('~/vue_shared/components/source_viewer/workers/highlight_utils', () => ({
@@ -12,6 +16,12 @@ jest.mock('~/vue_shared/components/source_viewer/workers/highlight_utils', () =>
   splitByLineBreaks: jest.fn((text) => text.split('\n')),
 }));
 
+const mockQuerySelector = jest.fn();
+const mockGetElementById = jest.fn();
+const mockScrollTo = jest.fn();
+document.querySelector = mockQuerySelector;
+document.getElementById = mockGetElementById;
+
 describe('updateCodeBlocks', () => {
   it('should return an empty array when input is empty', () => {
     expect(updateCodeBlocks([])).toEqual([]);
@@ -317,3 +327,205 @@ describe('createSourceBlockHighlightInfo', () => {
     });
   });
 });
+
+describe('scrollToSpecificCodeFlow', () => {
+  beforeEach(() => {
+    const mockElement = {
+      getBoundingClientRect: jest.fn(() => ({
+        top: 300,
+      })),
+      clientHeight: 20,
+    };
+
+    const mockSubScroller = {
+      getBoundingClientRect: jest.fn(() => ({
+        top: 100,
+      })),
+      scrollTo: mockScrollTo,
+      scrollTop: 50,
+      clientHeight: 500,
+    };
+
+    mockQuerySelector.mockImplementation((selector) => {
+      if (selector.startsWith('[id^=TEXT-MARKER')) {
+        return mockElement;
+      }
+      return null;
+    });
+
+    mockGetElementById.mockImplementation((id) => {
+      if (id === 'code-flows-container') {
+        return mockSubScroller;
+      }
+      return null;
+    });
+  });
+
+  afterEach(() => {
+    jest.resetAllMocks();
+  });
+
+  it('should scroll to the specific code flow when element is found', () => {
+    scrollToSpecificCodeFlow(5);
+    expect(mockQuerySelector).toHaveBeenCalledWith('[id^=TEXT-MARKER-5]');
+    expect(mockGetElementById).toHaveBeenCalledWith('code-flows-container');
+    expect(mockScrollTo).toHaveBeenCalledWith({
+      top: 10, // (300 - 100 + 50) - 500/2 + 20/2
+      behavior: 'smooth',
+    });
+  });
+
+  it('should not scroll when the specific element is not found', () => {
+    mockQuerySelector.mockImplementation((selector) => {
+      if (selector === '[id=code-flows-container]') {
+        return {
+          getBoundingClientRect: jest.fn(() => ({
+            top: 100,
+          })),
+          scrollTo: mockScrollTo,
+          scrollTop: 50,
+          clientHeight: 500,
+        };
+      }
+      return null; // Simulating element not found
+    });
+    scrollToSpecificCodeFlow(10);
+    expect(mockQuerySelector).toHaveBeenCalledWith('[id^=TEXT-MARKER-10]');
+    expect(mockScrollTo).not.toHaveBeenCalled();
+  });
+
+  it('should handle different step numbers', () => {
+    scrollToSpecificCodeFlow(1);
+    expect(mockQuerySelector).toHaveBeenCalledWith('[id^=TEXT-MARKER-1]');
+
+    scrollToSpecificCodeFlow(100);
+    expect(mockQuerySelector).toHaveBeenCalledWith('[id^=TEXT-MARKER-100]');
+  });
+});
+
+describe('markdownRowContent', () => {
+  const createMockElement = (id, className = '') => {
+    const el = document.createElement('div');
+    el.setAttribute('id', id);
+    if (className) {
+      el.classList.add(className);
+    }
+    document.body.appendChild(el);
+    return el;
+  };
+
+  afterEach(() => {
+    document.body.innerHTML = '';
+  });
+
+  it('should remove `selected-inline-section-marker` class from all elements starting with `TEXT-MARKER-`', () => {
+    const mockEl1 = createMockElement('TEXT-MARKER-1', 'selected-inline-section-marker');
+    const mockEl2 = createMockElement('TEXT-MARKER-2', 'selected-inline-section-marker');
+    const mockEl3 = createMockElement('TEXT-MARKER-3', 'some-other-class');
+    markdownRowContent(1);
+    expect(mockEl1.classList.contains('selected-inline-section-marker')).toBe(false);
+    expect(mockEl2.classList.contains('selected-inline-section-marker')).toBe(false);
+    expect(mockEl3.classList.contains('selected-inline-section-marker')).toBe(false);
+  });
+
+  it('should add `selected-inline-section-marker` class to elements matching the selectedStepNumber', () => {
+    const mockEl1 = createMockElement('TEXT-MARKER-1-L7');
+    const mockEl2 = createMockElement('TEXT-MARKER-2-L7');
+    const mockEl3 = createMockElement('TEXT-MARKER-1,2-L7');
+    markdownRowContent(1);
+    expect(mockEl1.classList.contains('selected-inline-section-marker')).toBe(true);
+    expect(mockEl2.classList.contains('selected-inline-section-marker')).toBe(false);
+    expect(mockEl3.classList.contains('selected-inline-section-marker')).toBe(true);
+  });
+
+  it('should do nothing if no element matches the selectedStepNumber', () => {
+    const mockEl1 = createMockElement('TEXT-MARKER-2-L7');
+    markdownRowContent(3);
+    expect(mockEl1.classList.contains('selected-inline-section-marker')).toBe(false);
+  });
+});
+
+describe('markdownStepNumber', () => {
+  const createMockElement = (id, innerHTML = '') => {
+    const el = document.createElement('span');
+    el.setAttribute('id', id);
+    el.setAttribute('class', '');
+    if (innerHTML) {
+      el.innerHTML = innerHTML;
+    }
+    document.body.appendChild(el);
+    return el;
+  };
+
+  afterEach(() => {
+    document.body.innerHTML = '';
+  });
+
+  it('should remove `selected-inline-item-mark` class from all span elements inside matching `TEXT-MARKER-` elements', () => {
+    const mockEl1 = createMockElement(
+      `TEXT-MARKER-1`,
+      '<span class="inline-item-mark selected-inline-item-mark"></span>',
+    );
+    const mockEl2 = createMockElement(
+      `TEXT-MARKER-2`,
+      '<span class="inline-item-mark selected-inline-item-mark"></span>',
+    );
+    markdownStepNumber(1);
+    const span1 = mockEl1.querySelector('span.inline-item-mark');
+    const span2 = mockEl2.querySelector('span.inline-item-mark');
+    expect(span1.classList.contains('selected-inline-item-mark')).toBe(false);
+    expect(span2.classList.contains('selected-inline-item-mark')).toBe(false);
+  });
+
+  it('should add `selected-inline-item-mark` and `gs` classes to the element matching selectedStepNumber', () => {
+    const matchingEl = createMockElement(`TEXT-SPAN-MARKER-1`);
+    markdownStepNumber(1);
+    expect(matchingEl.classList.contains('selected-inline-item-mark')).toBe(true);
+    expect(matchingEl.classList.contains('gs')).toBe(true);
+  });
+
+  it('should do nothing if no element matches the selectedStepNumber', () => {
+    const nonMatchingEl = createMockElement(`TEXT-SPAN-MARKER-2`);
+    markdownStepNumber(1);
+    expect(nonMatchingEl.classList.contains('selected-inline-item-mark')).toBe(false);
+    expect(nonMatchingEl.classList.contains('gs')).toBe(false);
+  });
+});
+
+describe('markdownRowNumber', () => {
+  const createMockElement = (id, classNames = '') => {
+    const el = document.createElement('div');
+    el.setAttribute('id', id);
+    el.className = classNames;
+    document.body.appendChild(el);
+    return el;
+  };
+
+  afterEach(() => {
+    document.body.innerHTML = '';
+  });
+
+  it('should remove `selected-inline-number-mark` and add `unselected-inline-number-mark` to all elements with id starting with "NUM-MARKER-"', () => {
+    const mockEl1 = createMockElement('NUM-MARKER-1', 'selected-inline-number-mark');
+    const mockEl2 = createMockElement('NUM-MARKER-2', 'selected-inline-number-mark');
+    markdownRowNumber(1);
+    expect(mockEl1.classList.contains('selected-inline-number-mark')).toBe(false);
+    expect(mockEl1.classList.contains('unselected-inline-number-mark')).toBe(true);
+    expect(mockEl2.classList.contains('selected-inline-number-mark')).toBe(false);
+    expect(mockEl2.classList.contains('unselected-inline-number-mark')).toBe(true);
+  });
+
+  it('should add `selected-inline-number-mark` and remove `unselected-inline-number-mark` to element matching selectedStepNumber', () => {
+    const matchingEl = createMockElement('NUM-MARKER-1-L8', 'unselected-inline-number-mark');
+    markdownRowNumber(1);
+    expect(matchingEl.classList.contains('selected-inline-number-mark')).toBe(true);
+    expect(matchingEl.classList.contains('unselected-inline-number-mark')).toBe(false);
+  });
+
+  it('should do nothing if no element matches the selectedStepNumber', () => {
+    const nonMatchingEl = createMockElement('NUM-MARKER-2-L8', 'unselected-inline-number-mark');
+    markdownRowNumber(1);
+    expect(nonMatchingEl.classList.contains('selected-inline-number-mark')).toBe(false);
+    expect(nonMatchingEl.classList.contains('unselected-inline-number-mark')).toBe(true);
+  });
+});
diff --git a/ee/spec/frontend/vulnerabilities/vulnerability_file_content_viewer_spec.js b/ee/spec/frontend/vulnerabilities/vulnerability_file_content_viewer_spec.js
index 8b84ff1a1e3fe..be94cb78336e0 100644
--- a/ee/spec/frontend/vulnerabilities/vulnerability_file_content_viewer_spec.js
+++ b/ee/spec/frontend/vulnerabilities/vulnerability_file_content_viewer_spec.js
@@ -12,6 +12,7 @@ const vulFileContent = {
   isHighlighted: false,
   content: 'line 1\nline 2\nline 3\nline 4\nline 5',
   highlightInfo: [],
+  selectedStepNumber: 1,
 };
 const spanContent = '<span class="plain-lines">line 2\nline 3\nline 4</span>';
 const codeContent = '<code class="d-block">line 2\nline 3\nline 4</code>';
@@ -26,6 +27,7 @@ describe('Vulnerability File Contents Viewer component', () => {
       isHighlighted: vulFileContent.isHighlighted, // Ensure this matches the required prop
       content: vulFileContent.content,
       highlightInfo: vulFileContent.highlightInfo,
+      selectedStepNumber: vulFileContent.selectedStepNumber,
       ...vulnerabilityOverrides,
     };
 
-- 
GitLab