Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-03-23 00:07:35 +00:00
parent 532eca0362
commit 1b6de87b5c
16 changed files with 465 additions and 14 deletions

View File

@ -33,6 +33,7 @@ import {
TH_CREATED_AT_TEST_ID,
TH_INCIDENT_SLA_TEST_ID,
TH_SEVERITY_TEST_ID,
TH_ESCALATION_STATUS_TEST_ID,
TH_PUBLISHED_TEST_ID,
INCIDENT_DETAILS_PATH,
trackIncidentCreateNewOptions,
@ -67,8 +68,11 @@ export default {
{
key: 'escalationStatus',
label: s__('IncidentManagement|Status'),
thClass: `${thClass} gl-w-eighth gl-pointer-events-none`,
tdClass,
thClass: `${thClass} gl-w-eighth`,
tdClass: `${tdClass} sortable-cell`,
actualSortKey: 'ESCALATION_STATUS',
sortable: true,
thAttr: TH_ESCALATION_STATUS_TEST_ID,
},
{
key: 'createdAt',

View File

@ -47,6 +47,7 @@ export const ESCALATION_STATUSES = {
export const DEFAULT_PAGE_SIZE = 20;
export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
export const TH_ESCALATION_STATUS_TEST_ID = { 'data-testid': 'incident-management-status-sort' };
export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla' };
export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
export const INCIDENT_DETAILS_PATH = 'incident';

View File

@ -61,7 +61,7 @@ class IssueEntity < IssuableEntity
end
expose :locked_discussion_docs_path, if: -> (issue) { issue.discussion_locked? } do |issue|
help_page_path('user/discussions/index.md', anchor: 'lock-discussions')
help_page_path('user/discussions/index.md', anchor: 'prevent-comments-by-locking-an-issue')
end
expose :is_project_archived do |issue|

View File

@ -43,7 +43,7 @@ class MergeRequestNoteableEntity < IssuableEntity
end
expose :locked_discussion_docs_path, if: -> (merge_request) { merge_request.discussion_locked? } do |merge_request|
help_page_path('user/discussions/index.md', anchor: 'lock-discussions')
help_page_path('user/discussions/index.md', anchor: 'prevent-comments-by-locking-an-issue')
end
expose :is_project_archived do |merge_request|

View File

@ -345,8 +345,8 @@ By default, the API fuzzer uses the Postman file to resolve Postman variable val
is set in a GitLab CI/CD variable `FUZZAPI_POSTMAN_COLLECTION_VARIABLES`, then the JSON
file takes precedence to get Postman variable values.
Although Postman can export environment variables into a JSON file, the format is not compatible
with the JSON expected by `FUZZAPI_POSTMAN_COLLECTION_VARIABLES`.
WARNING:
Although Postman can export environment variables into a JSON file, the format is not compatible with the JSON expected by `FUZZAPI_POSTMAN_COLLECTION_VARIABLES`.
Here is an example of using `FUZZAPI_POSTMAN_COLLECTION_VARIABLES`:

View File

@ -391,8 +391,8 @@ By default, the DAST API scanner uses the Postman file to resolve Postman variab
is set in a GitLab CI environment variable `DAST_API_POSTMAN_COLLECTION_VARIABLES`, then the JSON
file takes precedence to get Postman variable values.
Although Postman can export environment variables into a JSON file, the format is not compatible
with the JSON expected by `DAST_API_POSTMAN_COLLECTION_VARIABLES`.
WARNING:
Although Postman can export environment variables into a JSON file, the format is not compatible with the JSON expected by `DAST_API_POSTMAN_COLLECTION_VARIABLES`.
Here is an example of using `DAST_API_POSTMAN_COLLECTION_VARIABLES`:

View File

@ -6,6 +6,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Harbor container registry integration **(FREE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80999) in GitLab 14.9.
Use Harbor as the container registry for your GitLab project.
[Harbor](https://goharbor.io/) is an open source registry that can help you manage artifacts across cloud native compute platforms, like Kubernetes and Docker.

View File

@ -0,0 +1,6 @@
<html>
<head></head>
<body>
<h1>Hello world</h1>
</body>
</html>

View File

@ -194,6 +194,8 @@ module QA
def initialize(instance, page_class)
@session_address = Runtime::Address.new(instance, page_class)
@page_class = page_class
Session.enable_interception if Runtime::Env.can_intercept?
end
def url
@ -255,6 +257,27 @@ module QA
@network_conditions_configured = false
end
def self.enable_interception
script = File.read("#{__dir__}/script_extensions/interceptor.js")
command = {
cmd: 'Page.addScriptToEvaluateOnNewDocument',
params: {
source: script
}
}
@interceptor_script_params = Capybara.current_session.driver.browser.send(:bridge).send_command(command)
end
def self.disable_interception
return unless @interceptor_script_params
command = {
cmd: 'Page.removeScriptToEvaluateOnNewDocument',
params: @interceptor_script_params
}
Capybara.current_session.driver.browser.send(:bridge).send_command(command)
end
private
def simulate_slow_connection

View File

@ -37,6 +37,14 @@ module QA
ENV['QA_PRAEFECT_REPOSITORY_STORAGE']
end
def interception_enabled?
enabled?(ENV['INTERCEPT_REQUESTS'], default: true)
end
def can_intercept?
browser == :chrome && interception_enabled?
end
def ci_job_url
ENV['CI_JOB_URL']
end

View File

@ -0,0 +1,158 @@
(() => {
const CACHE_NAME = 'INTERCEPTOR_CACHE';
/**
* Fetches and parses JSON from the sessionStorage cache
* @returns {(Object)}
*/
const getCache = () => {
return JSON.parse(sessionStorage.getItem(CACHE_NAME));
};
/**
* Commits an object to the sessionStorage cache
* @param {Object} data
*/
const saveCache = (data) => {
sessionStorage.setItem(CACHE_NAME, JSON.stringify(data));
};
/**
* Checks if the cache is available
* and if the current context has access to it
* @returns {boolean} can we access the cache?
*/
const checkCache = () => {
try {
getCache();
return true;
} catch (error) {
// eslint-disable-next-line no-console
console.warn(`Couldn't access cache: ${error.toString()}`);
return false;
}
};
/**
* @callback cacheCommitCallback
* @param {object} cache
* @return {object} mutated cache
*/
/**
* If the cache is available, takes a callback function that is called
* with an object returned from getCache,
* and saves whatever is returned from the callback function
* to the cache
* @param {cacheCommitCallback} cb
*/
const commitToCache = (cb) => {
if (checkCache()) {
const cache = cb(getCache());
saveCache(cache);
}
};
window.Interceptor = {
saveCache,
commitToCache,
getCache,
checkCache,
activeFetchRequests: 0,
};
const pureFetch = window.fetch;
const pureXHROpen = window.XMLHttpRequest.prototype.open;
/**
* Replacement for XMLHttpRequest.prototype.open
* listens for complete xhr events
* if the xhr response has a status code higher than 400
* then commit request/response metadata to the cache
* @param method intercepted HTTP method (GET|POST|etc..)
* @param url intercepted HTTP url
* @param args intercepted XHR arguments (credentials, headers, options
* @return {Promise} the result of the original XMLHttpRequest.prototype.open implementation
*/
function interceptXhr(method, url, ...args) {
this.addEventListener(
'readystatechange',
() => {
const self = this;
if (this.readyState === XMLHttpRequest.DONE) {
if (this.status >= 400 || this.status === 0) {
commitToCache((cache) => {
// eslint-disable-next-line no-param-reassign
cache.errors ||= [];
cache.errors.push({
status: self.status === 0 ? -1 : self.status,
url,
method,
headers: { 'x-request-id': self.getResponseHeader('x-request-id') },
});
return cache;
});
}
}
},
false,
);
return pureXHROpen.apply(this, [method, url, ...args]);
}
/**
* Replacement for fetch implementation
* tracks active requests, and commits metadata to the cache
* if the response is not ok or was cancelled.
* Additionally tracks activeFetchRequests on the Interceptor object
* @param url target HTTP url
* @param opts fetch options, including request method, body, etc
* @param args additional fetch arguments
* @returns {Promise<"success"|"error">} the result of the original fetch call
*/
async function interceptedFetch(url, opts, ...args) {
const method = opts && opts.method ? opts.method : 'GET';
window.Interceptor.activeFetchRequests += 1;
try {
const response = await pureFetch(url, opts, ...args);
window.Interceptor.activeFetchRequests += -1;
const clone = response.clone();
if (!clone.ok) {
commitToCache((cache) => {
// eslint-disable-next-line no-param-reassign
cache.errors ||= [];
cache.errors.push({
status: clone.status,
url,
method,
headers: { 'x-request-id': clone.headers.get('x-request-id') },
});
return cache;
});
}
return response;
} catch (error) {
commitToCache((cache) => {
// eslint-disable-next-line no-param-reassign
cache.errors ||= [];
cache.errors.push({
status: -1,
url,
method,
});
return cache;
});
window.Interceptor.activeFetchRequests += -1;
throw error;
}
}
if (checkCache()) {
saveCache({});
}
window.fetch = interceptedFetch;
window.XMLHttpRequest.prototype.open = interceptXhr;
})();

View File

@ -61,6 +61,39 @@ module QA
end
end
# Log request errors triggered from async api calls from the browser
#
# If any errors are found in the session, log them
# using QA::Runtime::Logger
# @param [Capybara::Session] page
def log_request_errors(page)
return if QA::Runtime::Browser.blank_page?
url = page.driver.browser.current_url
QA::Runtime::Logger.debug "Fetching API error cache for #{url}"
cache = page.execute_script <<~JS
return !(typeof(Interceptor)==="undefined") ? Interceptor.getCache() : null;
JS
return unless cache&.dig('errors')
grouped_errors = group_errors(cache['errors'])
errors = grouped_errors.map do |error_metadata, request_id_string|
"#{error_metadata} -- #{request_id_string}"
end
unless errors.nil? || errors.empty?
QA::Runtime::Logger.error "Interceptor Api Errors\n#{errors.join("\n")}"
end
# clear the cache after logging the errors
page.execute_script <<~JS
Interceptor && Interceptor.saveCache({});
JS
end
def error_report_for(logs)
logs
.map(&:message)
@ -70,6 +103,16 @@ module QA
def logs(page)
page.driver.browser.manage.logs.get(:browser)
end
private
def group_errors(errors)
errors.each_with_object({}) do |error, memo|
url = error['url']&.split('?')&.first || 'Unknown url'
key = "[#{error['status']}] #{error['method']} #{url}"
memo[key] = "Correlation Id: #{error.dig('headers', 'x-request-id') || 'Correlation Id not found'}"
end
end
end
end
end

View File

@ -16,12 +16,16 @@ module QA
Waiter.wait_until(log: false) do
finished_all_ajax_requests? && (!skip_finished_loading_check ? finished_loading?(wait: 1) : true)
end
QA::Support::PageErrorChecker.log_request_errors(Capybara.page) if QA::Runtime::Env.can_intercept?
rescue Repeater::WaitExceededError
raise $!, 'Page did not fully load. This could be due to an unending async request or loading icon.'
end
def finished_all_ajax_requests?
Capybara.page.evaluate_script('window.pendingRequests || window.pendingRailsUJSRequests || 0').zero? # rubocop:disable Style/NumericPredicate
requests = %w[window.pendingRequests window.pendingRailsUJSRequests 0]
requests.unshift('(window.Interceptor && window.Interceptor.activeFetchRequests)') if Runtime::Env.can_intercept?
script = requests.join(' || ')
Capybara.page.evaluate_script(script).zero? # rubocop:disable Style/NumericPredicate
end
def finished_loading?(wait: DEFAULT_MAX_WAIT_TIME)

View File

@ -0,0 +1,118 @@
# frozen_string_literal: true
RSpec.describe 'Interceptor' do
let(:browser) { Capybara.current_session }
# need a real host for the js runtime
let(:url) { "file://#{__dir__}/../../../qa/fixtures/script_extensions/test.html" }
before(:context) do
skip 'Only can test for chrome' unless QA::Runtime::Env.can_intercept?
QA::Runtime::Browser::Session.enable_interception
end
after(:context) do
QA::Runtime::Browser::Session.disable_interception
end
before do
browser.visit url
clear_cache
end
after do
browser.visit 'about:blank'
end
context 'with Interceptor' do
context 'caching' do
it 'checks the cache' do
expect(check_cache).to be(true)
end
it 'returns false if the cache cannot be accessed' do
browser.visit 'about:blank'
expect(check_cache).to be(false)
end
it 'gets and sets the cache data' do
commit_to_cache({ foo: 'bar' })
expect(get_cache['data']).to eql({ 'foo' => 'bar' })
end
end
context 'when intercepting' do
let(:resource_url) { 'chrome://chrome-urls' }
it 'intercepts fetch errors' do
trigger_fetch(resource_url, 'GET')
errors = get_cache['errors']
expect(errors.size).to be(1)
expect(errors[0]['status']).to be(-1)
expect(errors[0]['method']).to eql('GET')
expect(errors[0]['url']).to eql(resource_url)
end
it 'intercepts xhr' do
trigger_xhr(resource_url, 'POST')
errors = get_cache['errors']
expect(errors.size).to be(1)
expect(errors[0]['status']).to be(-1)
expect(errors[0]['method']).to eql('POST')
expect(errors[0]['url']).to eql(resource_url)
end
end
end
def clear_cache
browser.execute_script <<~JS
Interceptor.saveCache({})
JS
end
def check_cache
browser.execute_script <<~JS
return Interceptor.checkCache()
JS
end
def trigger_fetch(url, method)
browser.execute_script <<~JS
(() => {
fetch('#{url}', { method: '#{method}' })
})()
JS
end
def trigger_xhr(url, method)
browser.execute_script <<~JS
(() => {
let xhr = new XMLHttpRequest();
xhr.open('#{method}', '#{url}')
xhr.send()
})()
JS
end
def commit_to_cache(payload)
browser.execute_script <<~JS
Interceptor.commitToCache((cache) => {
cache.data = JSON.parse('#{payload.to_json}');
return cache
})
JS
end
def get_cache
browser.execute_script <<~JS
return Interceptor.getCache()
JS
end
end

View File

@ -238,6 +238,88 @@ RSpec.describe QA::Support::PageErrorChecker do
end
end
describe '::log_request_errors' do
let(:page_url) { 'https://baz.foo' }
let(:browser) { double('browser', current_url: page_url) }
let(:driver) { double('driver', browser: browser) }
let(:session) { double('session', driver: driver) }
before do
allow(Capybara).to receive(:current_session).and_return(session)
end
it 'logs from the error cache' do
error = {
'url' => 'https://foo.bar',
'status' => 500,
'method' => 'GET',
'headers' => { 'x-request-id' => '12345' }
}
expect(page).to receive(:driver).and_return(driver)
expect(page).to receive(:execute_script).and_return({ 'errors' => [error] })
expect(page).to receive(:execute_script)
expect(QA::Runtime::Logger).to receive(:debug).with("Fetching API error cache for #{page_url}")
expect(QA::Runtime::Logger).to receive(:error).with(<<~ERROR.chomp)
Interceptor Api Errors
[500] GET https://foo.bar -- Correlation Id: 12345
ERROR
QA::Support::PageErrorChecker.log_request_errors(page)
end
it 'removes duplicates' do
error = {
'url' => 'https://foo.bar',
'status' => 500,
'method' => 'GET',
'headers' => { 'x-request-id' => '12345' }
}
expect(page).to receive(:driver).and_return(driver)
expect(page).to receive(:execute_script).and_return({ 'errors' => [error, error, error] })
expect(page).to receive(:execute_script)
expect(QA::Runtime::Logger).to receive(:debug).with("Fetching API error cache for #{page_url}")
expect(QA::Runtime::Logger).to receive(:error).with(<<~ERROR.chomp).exactly(1).time
Interceptor Api Errors
[500] GET https://foo.bar -- Correlation Id: 12345
ERROR
QA::Support::PageErrorChecker.log_request_errors(page)
end
it 'chops the url query string' do
error = {
'url' => 'https://foo.bar?query={ sensitive-data: 12345 }',
'status' => 500,
'method' => 'GET',
'headers' => { 'x-request-id' => '12345' }
}
expect(page).to receive(:driver).and_return(driver)
expect(page).to receive(:execute_script).and_return({ 'errors' => [error] })
expect(page).to receive(:execute_script)
expect(QA::Runtime::Logger).to receive(:debug).with("Fetching API error cache for #{page_url}")
expect(QA::Runtime::Logger).to receive(:error).with(<<~ERROR.chomp)
Interceptor Api Errors
[500] GET https://foo.bar -- Correlation Id: 12345
ERROR
QA::Support::PageErrorChecker.log_request_errors(page)
end
it 'returns if cache is nil' do
expect(page).to receive(:driver).and_return(driver)
expect(page).to receive(:execute_script).and_return(nil)
expect(QA::Runtime::Logger).to receive(:debug).with("Fetching API error cache for #{page_url}")
expect(QA::Runtime::Logger).not_to receive(:error)
QA::Support::PageErrorChecker.log_request_errors(page)
end
end
describe '.logs' do
before do
logs_class = Class.new do

View File

@ -7,6 +7,7 @@ import {
I18N,
TH_CREATED_AT_TEST_ID,
TH_SEVERITY_TEST_ID,
TH_ESCALATION_STATUS_TEST_ID,
TH_PUBLISHED_TEST_ID,
TH_INCIDENT_SLA_TEST_ID,
trackIncidentCreateNewOptions,
@ -294,11 +295,12 @@ describe('Incidents List', () => {
const noneSort = 'none';
it.each`
description | selector | initialSort | firstSort | nextSort
${'creation date'} | ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort}
${'severity'} | ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
${'publish date'} | ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
${'due date'} | ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort}
description | selector | initialSort | firstSort | nextSort
${'creation date'} | ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort}
${'severity'} | ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
${'status'} | ${TH_ESCALATION_STATUS_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
${'publish date'} | ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
${'due date'} | ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort}
`(
'updates sort with new direction when sorting by $description',
async ({ selector, initialSort, firstSort, nextSort }) => {