Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-03-23 21:13:49 +00:00
parent 68caf5fd88
commit 00ab3a60fe
26 changed files with 714 additions and 217 deletions

View File

@ -4,7 +4,7 @@ import { stringify, parse } from 'yaml';
import { set, omit, trim } from 'lodash';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql';
import getRunnerTags from '../../graphql/queries/runner_tags.query.graphql';
import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, i18n } from './constants';
import { removeEmptyObj, trimFields } from './utils';
import JobSetupItem from './accordion_items/job_setup_item.vue';
@ -48,7 +48,7 @@ export default {
},
apollo: {
runners: {
query: getAllRunners,
query: getRunnerTags,
update(data) {
return data?.runners?.nodes || [];
},

View File

@ -0,0 +1,8 @@
query getRunnerTags {
runners {
nodes {
id
tagList
}
}
}

View File

@ -8,6 +8,9 @@ import {
GlTooltipDirective,
GlPopover,
} from '@gitlab/ui';
import semverLt from 'semver/functions/lt';
import semverInc from 'semver/functions/inc';
import semverPrerelease from 'semver/functions/prerelease';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { helpPagePath } from '~/helpers/help_page_helper';
@ -134,18 +137,26 @@ export default {
isVersionMismatch(agent) {
return agent.versions.length > 1;
},
// isVersionOutdated determines if the agent version is outdated compared to the KAS / GitLab version
// using the following heuristics:
// - KAS Version is used as *server* version if available, otherwise the GitLab version is used.
// - returns `outdated` if the agent has a different major version than the server
// - returns `outdated` if the agents minor version is at least two proper versions older than the server
// - *proper* -> not a prerelease version. Meaning that server prereleases (with `-rcN`) suffix are counted as the previous minor version
//
// Note that it does NOT support if the agent is newer than the server version.
isVersionOutdated(agent) {
if (!agent.versions.length) return false;
const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.');
const [serverMajorVersion, serverMinorVersion] = this.serverVersion.split('.');
const agentVersion = this.getAgentVersionString(agent);
let allowableAgentVersion = semverInc(agentVersion, 'minor');
const majorVersionMismatch = agentMajorVersion !== serverMajorVersion;
const isServerPrerelease = Boolean(semverPrerelease(this.serverVersion));
if (isServerPrerelease) {
allowableAgentVersion = semverInc(allowableAgentVersion, 'minor');
}
// We should warn user if their current GitLab and agent versions are more than 1 minor version apart:
const minorVersionMismatch = Math.abs(agentMinorVersion - serverMinorVersion) > 1;
return majorVersionMismatch || minorVersionMismatch;
return semverLt(allowableAgentVersion, this.serverVersion);
},
getVersionPopoverTitle(agent) {

View File

@ -51,7 +51,7 @@ export default {
language="python"
:code="code"
:max-height="maxHeight"
class="gl-border"
class="gl-border gl-p-4!"
/>
</div>
</template>

View File

@ -0,0 +1,46 @@
<script>
import JSONTable from '~/behaviors/components/json_table.vue';
import Prompt from '../prompt.vue';
import { convertHtmlTableToJson } from './dataframe_util';
export default {
name: 'DataframeOutput',
components: {
Prompt,
JSONTable,
},
props: {
count: {
type: Number,
required: true,
},
rawCode: {
type: String,
required: true,
},
index: {
type: Number,
required: true,
},
},
computed: {
showOutput() {
return this.index === 0;
},
dataframeAsJSONTable() {
return {
...convertHtmlTableToJson(this.rawCode),
caption: '',
hasFilter: true,
};
},
},
};
</script>
<template>
<div class="output">
<prompt type="Out" :count="count" :show-output="showOutput" />
<j-s-o-n-table v-bind="dataframeAsJSONTable" class="gl-overflow-auto" />
</div>
</template>

View File

@ -0,0 +1,44 @@
import { sanitize } from '~/lib/dompurify';
/**
* Converts a dataframe in the output of a Jupyter Notebook cell to a json object
*
* @param {string} input - the dataframe
* @param {DOMParser} parser - the html parser
* @returns {Object} The converted JSON object with an `items` property containing the rows.
*/
export function convertHtmlTableToJson(input, domParser) {
const parser = domParser || new DOMParser();
const htmlDoc = parser.parseFromString(sanitize(input), 'text/html');
if (!htmlDoc) return { fields: [], items: [] };
const columnNames = [...htmlDoc.querySelectorAll('table > thead th')].map(
(head) => head.innerText,
);
if (!columnNames) return { fields: [], items: [] };
const itemValues = [...htmlDoc.querySelectorAll('table > tbody > tr')].map((row) =>
[...row.querySelectorAll('td')].map((item) => item.innerText),
);
return {
fields: columnNames.map((column) => ({
key: column === '' ? 'index' : column,
label: column,
sortable: true,
})),
items: itemValues.map((values, itemIndex) => ({
index: itemIndex,
...Object.fromEntries(values.map((value, index) => [columnNames[index + 1], value])),
})),
};
}
export function isDataframe(output) {
const htmlData = output.data['text/html'];
if (!htmlData) return false;
return htmlData.slice(0, 20).some((line) => line.includes('dataframe'));
}

View File

@ -5,6 +5,8 @@ import ImageOutput from './image.vue';
import LatexOutput from './latex.vue';
import MarkdownOutput from './markdown.vue';
import ErrorOutput from './error.vue';
import DataframeOutput from './dataframe.vue';
import { isDataframe } from './dataframe_util';
const TEXT_MARKDOWN = 'text/markdown';
const ERROR_OUTPUT_TYPE = 'error';
@ -66,6 +68,8 @@ export default {
return ImageOutput;
} else if (output.data['image/jpeg']) {
return ImageOutput;
} else if (isDataframe(output)) {
return DataframeOutput;
} else if (output.data['text/html']) {
return HtmlOutput;
} else if (output.data['text/latex']) {

View File

@ -491,7 +491,7 @@ span.idiff {
// element stretching over multiple rows we instead create a repeating background image
// for the line
background: repeating-linear-gradient(to right, var(--gray-100, $gray-100), var(--gray-100, $gray-100) 1px, transparent 1px, transparent 14px);
background-size: calc(var(--level) * 14px);
background-size: calc(var(--level) * 14px) 100%;
background-repeat: no-repeat;
background-position: 14px;
}

View File

@ -291,9 +291,9 @@
.project-cell {
@include gl-display-table-cell;
@include gl-border-b;
@include gl-vertical-align-top;
@include gl-py-4;
border-bottom: 1px solid $gray-50;
}
.project-row:last-of-type {

View File

@ -295,6 +295,13 @@ module Types
def detailed_merge_status
::MergeRequests::Mergeability::DetailedMergeStatusService.new(merge_request: object).execute
end
# This is temporary to fix a bug where `committers` is already loaded and memoized
# and calling it again with a certain GraphQL query can cause the Rails to to throw
# a ActiveRecord::ImmutableRelation error
def committers
object.commits.committers
end
end
end

View File

@ -1,7 +1,7 @@
%p
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
%p
= html_escape(_('A new personal access token, named %{token_name}, has been created.')) % { token_name: @token_name }
= html_escape(_('A new personal access token, named %{code_start}%{token_name}%{code_end}, has been created.')) % { code_start: '<code>'.html_safe, token_name: @token_name, code_end: '</code>'.html_safe }
%p
- pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
= html_escape(_('You can check it in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }

View File

@ -4,9 +4,37 @@ group: Source Code
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
# Introduction to GitLab Flow **(FREE)**
# Introduction to Git workflows **(FREE)**
With Git, you can use a variety of branching strategies and workflows.
Having a structured workflow for collaboration in complex projects is
crucial for several reasons:
- **Code organization**: Keep the codebase organized, prevent
overlapping work, and ensure focused efforts towards a common goal.
- **Version control**: Allow simultaneous work on different features
without conflicts, maintaining code stability.
- **Code quality**: A code review and approval process helps maintain high
code quality and adherence to coding standards.
- **Traceability and accountability**: Enable tracking of changes and their authors,
simplifying issue identification and responsibility assignment.
- **Easier onboarding**: Help new team members quickly grasp the
development process, and start contributing effectively.
- **Time and resource management**: Enable better planning, resource
allocation, and meeting deadlines, ensuring an efficient development
process.
- **CI/CD**: Incorporate automated testing and deployment
processes, streamlining the release cycle and delivering high-quality
software consistently.
A structured workflow promotes organization, efficiency, and code
quality, leading to a more successful and streamlined development process.
Because the default workflow is not specifically defined, many organizations
end up with workflows that are too complicated, not clearly defined, or
@ -14,12 +42,51 @@ not integrated with their issue tracking systems.
Your organization can use GitLab with any workflow you choose.
However, if you are looking for guidance on best practices, you can use
the GitLab Flow. This workflow combines [feature-driven development](https://en.wikipedia.org/wiki/Feature-driven_development)
and [feature branches](https://martinfowler.com/bliki/FeatureBranch.html) with issue tracking.
## Workflow types
While this workflow used at GitLab, you can choose whichever workflow
suits your organization best.
Here are some of the most common Git workflows.
### Centralized workflow
Best suited for small teams transitioning from a centralized version
control system like SVN. All team members work on a single branch,
usually `main`, and push their changes directly to the central
repository.
### Feature branch workflow
Developers create separate branches for each feature or bugfix,
keeping the 'main' branch stable. When a feature is complete, the
developer submits a pull request or merge request to integrate the
changes back into the `main` branch after a code review.
### Forking workflow
Commonly used in open-source projects, this workflow allows external
contributors to work without direct access to the main repository.
Developers create a fork (a personal copy) of the main repository,
make changes in their fork, and then submit a pull request or merge
request to have their changes integrated into the main repository.
### Git flow workflow
This workflow is best for projects with a structured release cycle.
It introduces two long-lived branches: `main` for production-ready
code and `develop` for integrating features. Additional branches like
`feature`, `release`, and `hotfix` are used for specific purposes,
ensuring a strict and organized development process.
### GitLab/GitHub flow
A simplified workflow primarily used for web development and
continuous deployment. It combines aspects of the Feature branch
workflow and the Git flow workflow. Developers create feature branches
from `main`, and after the changes are complete, they are merged back
into the `main` branch, which is then immediately deployed.
Each of these Git workflows has its advantages and is suited to
different project types and team structures. Below the most popular
workflows are reviewed in more details.
## Git workflow
@ -99,6 +166,16 @@ This flow is clean and straightforward, and many organizations have adopted it w
Atlassian recommends [a similar strategy](https://www.atlassian.com/blog/git/simple-git-workflow-is-simple), although they rebase feature branches.
Merging everything into the `main` branch and frequently deploying means you minimize the amount of unreleased code. This approach is in line with lean and continuous delivery best practices.
However, this flow still leaves a lot of questions unanswered regarding deployments, environments, releases, and integrations with issues.
## Introduction to GitLab Flow **(FREE)**
However, if you are looking for guidance on best practices, you can use
the GitLab Flow. This workflow combines [feature-driven development](https://en.wikipedia.org/wiki/Feature-driven_development)
and [feature branches](https://martinfowler.com/bliki/FeatureBranch.html) with issue tracking.
While this workflow used at GitLab, you can choose whichever workflow
suits your organization best.
With GitLab flow, we offer additional guidance for these questions.
## Production branch with GitLab flow
@ -113,7 +190,8 @@ While this is possible in some cases, such as SaaS applications, there are some
In these cases, you can create a production branch that reflects the deployed code.
You can deploy a new version by merging `main` into the `production` branch.
While not shown in the graph below, the work on the `main` branch works just like in GitHub flow, i.e. with feature-branches being merged into `main`.
While not shown in the graph below, the work on the `main` branch works just like in GitHub flow:
with feature branches being merged into `main`.
```mermaid
graph TD
@ -255,7 +333,8 @@ In GitLab, each change to the codebase starts with an issue in the issue trackin
If there is no issue yet, create the issue if the change requires more than an hour's work.
In many organizations, raising an issue is part of the development process because they are used in sprint planning.
The issue title should describe the desired state of the system.
For example, the issue title "As an administrator, I want to remove users without receiving an error" is better than "Administrators can't remove users."
For example, the issue title `As an administrator, I want to remove users without receiving an error`
is better than "Administrators can't remove users."
When you are ready to code, create a branch for the issue from the `main` branch.
This branch is the place for any work related to this change.
@ -267,7 +346,7 @@ When you are done or want to discuss the code, open a merge request.
A merge request is an online place to discuss the change and review the code.
If you open the merge request but do not assign it to anyone, it is a [draft merge request](../user/project/merge_requests/drafts.md).
These are used to discuss the proposed implementation but are not ready for inclusion in the `main` branch yet.
Drafts are used to discuss the proposed implementation but are not ready for inclusion in the `main` branch yet.
Start the title of the merge request with `[Draft]`, `Draft:` or `(Draft)` to prevent it from being merged before it's ready.
When you think the code is ready, assign the merge request to a reviewer.
@ -356,7 +435,11 @@ Sometimes you can reuse recorded resolutions (`rerere`), but merging is better,
Atlassian has [a more thorough explanation of the tradeoffs between merging and rebasing](https://www.atlassian.com/blog/git/git-team-workflows-merge-or-rebase) on their blog.
A good way to prevent creating many merge commits is to not frequently merge `main` into the feature branch.
There are three reasons to merge in `main`: utilizing new code, resolving merge conflicts, and updating long-running branches.
Three reasons to merge in `main`:
1. Utilizing new code.
1. Resolving merge conflicts.
1. Updating long-running branches.
If you need to use some code that was introduced in `main` after you created the feature branch, you can often solve this by just cherry-picking a commit.

View File

@ -57,9 +57,9 @@ This workflow has a weaker security model and is not recommended for production
GitLab supports the following Kubernetes versions. You can upgrade your
Kubernetes version to a supported version at any time:
- 1.26 (support ends on March 22, 2024 or when 1.29 becomes supported)
- 1.25 (support ends on October 22, 2023 or when 1.28 becomes supported)
- 1.24 (support ends on July 22, 2023 or when 1.27 becomes supported)
- 1.23 (support ends on February 22, 2023 or when 1.26 becomes supported)
GitLab aims to support a new minor Kubernetes version three months after its initial release. GitLab supports at least three production-ready Kubernetes minor
versions at any given time.

View File

@ -138,6 +138,23 @@ By default, the Helm installation command generated by GitLab:
To see the full list of customizations available, see the Helm chart's [default values file](https://gitlab.com/gitlab-org/charts/gitlab-agent/-/blob/main/values.yaml).
##### Use the agent when KAS is behind a self-signed certificate
When [KAS]((../../../../administration/clusters/kas.md) is behind a self-signed certificate,
you can set the value of `config.caCert` to the certificate. For example:
```shell
helm update --install gitlab-agent gitlab/gitlab-agent \
--set-file config.caCert=my-custom-ca.pem
```
In this example, `my-custom-ca.pem` is the path to a local file that contains
the CA certificate used by KAS. The certificate is automatically stored in a
config map and mounted in the `agentk` pod.
If KAS is installed with the GitLab chart, and the chart is configured to provide
an [auto-generated self-signed wildcard certificate](https://docs.gitlab.com/charts/installation/tls.html#option-4-use-auto-generated-self-signed-wildcard-certificate), you can extract the CA certificate from the `RELEASE-wildcard-tls-ca` secret.
##### Use the agent behind an HTTP proxy
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/351867) in GitLab 15.0, the GitLab agent Helm chart supports setting environment variables.

View File

@ -1758,6 +1758,9 @@ msgstr ""
msgid "A new personal access token has been created"
msgstr ""
msgid "A new personal access token, named %{code_start}%{token_name}%{code_end}, has been created."
msgstr ""
msgid "A new personal access token, named %{token_name}, has been created."
msgstr ""

View File

@ -170,6 +170,7 @@
"remark-parse": "^10.0.1",
"remark-rehype": "^10.1.0",
"scrollparent": "^2.0.1",
"semver": "^7.3.4",
"sentrybrowser5": "npm:@sentry/browser@5.30.0",
"sentrybrowser7": "npm:@sentry/browser@^7.21.1",
"sortablejs": "^1.10.2",
@ -234,8 +235,8 @@
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-no-jquery": "2.7.0",
"eslint-plugin-no-unsanitized": "^4.0.2",
"gettext-extractor": "^3.5.3",
"gettext-extractor-vue": "^5.0.0",
"gettext-extractor": "^3.7.0",
"gettext-extractor-vue": "^5.1.0",
"glob": "^7.1.6",
"istanbul-lib-coverage": "^3.0.0",
"istanbul-lib-report": "^3.0.0",

View File

@ -19,7 +19,7 @@ extractor.addMessageTransformFunction(ensureSingleLine);
const jsParser = extractor.createJsParser([
// Place all the possible expressions to extract here:
JsExtractors.callExpression('__', {
JsExtractors.callExpression(['__', 's__'], {
arguments: {
text: 0,
},
@ -30,15 +30,13 @@ const jsParser = extractor.createJsParser([
textPlural: 1,
},
}),
JsExtractors.callExpression('s__', {
arguments: {
text: 0,
},
}),
]);
const vueParser = decorateJSParserWithVueSupport(jsParser, {
vue2TemplateCompiler,
// All of our expressions contain `__`.
// So we can safely ignore parsing files _not_ containing it.
guard: '__',
});
function printJson() {

View File

@ -5,16 +5,13 @@ import { stringify } from 'yaml';
import JobAssistantDrawer from '~/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue';
import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue';
import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue';
import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql';
import getRunnerTags from '~/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
mockAllRunnersQueryResponse,
mockLintResponse,
mockCiYml,
} from 'jest/ci/pipeline_editor/mock_data';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
import { mockRunnersTagsQueryResponse, mockLintResponse, mockCiYml } from '../../mock_data';
Vue.use(VueApollo);
@ -36,7 +33,7 @@ describe('Job assistant drawer', () => {
const createComponent = () => {
mockApollo = createMockApollo([
[getAllRunners, jest.fn().mockResolvedValue(mockAllRunnersQueryResponse)],
[getRunnerTags, jest.fn().mockResolvedValue(mockRunnersTagsQueryResponse)],
]);
wrapper = mountExtended(JobAssistantDrawer, {
@ -58,6 +55,15 @@ describe('Job assistant drawer', () => {
expect(findJobSetupItem().exists()).toBe(true);
});
it('job setup item should have tag options', () => {
expect(findJobSetupItem().props('tagOptions')).toEqual([
{ id: 'tag1', name: 'tag1' },
{ id: 'tag2', name: 'tag2' },
{ id: 'tag3', name: 'tag3' },
{ id: 'tag4', name: 'tag4' },
]);
});
it('should contain image accordion', () => {
expect(findImageItem().exists()).toBe(true);
});

View File

@ -583,86 +583,31 @@ export const mockCommitCreateResponse = {
},
};
export const mockAllRunnersQueryResponse = {
export const mockRunnersTagsQueryResponse = {
data: {
runners: {
nodes: [
{
id: 'gid://gitlab/Ci::Runner/1',
description: 'test',
runnerType: 'PROJECT_TYPE',
shortSha: 'DdTYMQGS',
version: '15.6.1',
ipAddress: '127.0.0.1',
active: true,
locked: true,
jobCount: 0,
jobExecutionStatus: 'IDLE',
tagList: ['tag1', 'tag2', 'tag3'],
createdAt: '2022-11-29T09:37:43Z',
contactedAt: null,
status: 'NEVER_CONTACTED',
userPermissions: {
updateRunner: true,
deleteRunner: true,
__typename: 'RunnerPermissions',
},
groups: null,
ownerProject: {
id: 'gid://gitlab/Project/1',
name: '123',
nameWithNamespace: 'Administrator / 123',
webUrl: 'http://127.0.0.1:3000/root/test',
__typename: 'Project',
},
tagList: ['tag1', 'tag2'],
__typename: 'CiRunner',
upgradeStatus: 'NOT_AVAILABLE',
adminUrl: 'http://127.0.0.1:3000/admin/runners/1',
editAdminUrl: 'http://127.0.0.1:3000/admin/runners/1/edit',
},
{
id: 'gid://gitlab/Ci::Runner/2',
description: 'test',
runnerType: 'PROJECT_TYPE',
shortSha: 'DdTYMQGA',
version: '15.6.1',
ipAddress: '127.0.0.1',
active: true,
locked: true,
jobCount: 0,
jobExecutionStatus: 'IDLE',
tagList: ['tag3', 'tag4'],
createdAt: '2022-11-29T09:37:43Z',
contactedAt: null,
status: 'NEVER_CONTACTED',
userPermissions: {
updateRunner: true,
deleteRunner: true,
__typename: 'RunnerPermissions',
},
groups: null,
ownerProject: {
id: 'gid://gitlab/Project/1',
name: '123',
nameWithNamespace: 'Administrator / 123',
webUrl: 'http://127.0.0.1:3000/root/test',
__typename: 'Project',
},
tagList: ['tag2', 'tag3'],
__typename: 'CiRunner',
},
{
id: 'gid://gitlab/Ci::Runner/3',
tagList: ['tag2', 'tag4'],
__typename: 'CiRunner',
},
{
id: 'gid://gitlab/Ci::Runner/4',
tagList: [],
__typename: 'CiRunner',
upgradeStatus: 'NOT_AVAILABLE',
adminUrl: 'http://127.0.0.1:3000/admin/runners/2',
editAdminUrl: 'http://127.0.0.1:3000/admin/runners/2/edit',
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0yOSAwOTozNzo0My40OTEwNTEwMDAgKzAwMDAiLCJpZCI6IjIifQ',
endCursor:
'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0yOSAwOTozNzo0My40OTEwNTEwMDAgKzAwMDAiLCJpZCI6IjIifQ',
__typename: 'PageInfo',
},
__typename: 'CiRunnerConnection',
},
},

View File

@ -13,9 +13,9 @@ const defaultConfigHelpUrl =
const provideData = {
gitlabVersion: '14.8',
kasVersion: '14.8',
kasVersion: '14.8.0',
};
const propsData = {
const defaultProps = {
agents: clusterAgents,
};
@ -26,9 +26,6 @@ const DeleteAgentButtonStub = stubComponent(DeleteAgentButton, {
const outdatedTitle = I18N_AGENT_TABLE.versionOutdatedTitle;
const mismatchTitle = I18N_AGENT_TABLE.versionMismatchTitle;
const mismatchOutdatedTitle = I18N_AGENT_TABLE.versionMismatchOutdatedTitle;
const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, {
version: provideData.kasVersion,
});
const mismatchText = I18N_AGENT_TABLE.versionMismatchText;
describe('AgentTable', () => {
@ -43,123 +40,134 @@ describe('AgentTable', () => {
wrapper.findAllByTestId('cluster-agent-configuration-link').at(at);
const findDeleteAgentButton = () => wrapper.findAllComponents(DeleteAgentButton);
beforeEach(() => {
const createWrapper = ({ provide = provideData, propsData = defaultProps } = {}) => {
wrapper = mountExtended(AgentTable, {
propsData,
provide: provideData,
provide,
stubs: {
DeleteAgentButton: DeleteAgentButtonStub,
},
});
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
});
};
describe('agent table', () => {
it.each`
agentName | link | lineNumber
${'agent-1'} | ${'/agent-1'} | ${0}
${'agent-2'} | ${'/agent-2'} | ${1}
`('displays agent link for $agentName', ({ agentName, link, lineNumber }) => {
expect(findAgentLink(lineNumber).text()).toBe(agentName);
expect(findAgentLink(lineNumber).attributes('href')).toBe(link);
describe('default', () => {
beforeEach(() => {
createWrapper();
});
it.each`
agentName | link | lineNumber
${'agent-1'} | ${'/agent-1'} | ${0}
${'agent-2'} | ${'/agent-2'} | ${1}
`('displays agent link for $agentName', ({ agentName, link, lineNumber }) => {
expect(findAgentLink(lineNumber).text()).toBe(agentName);
expect(findAgentLink(lineNumber).attributes('href')).toBe(link);
});
it.each`
status | iconName | lineNumber
${'Never connected'} | ${'status-neutral'} | ${0}
${'Connected'} | ${'status-success'} | ${1}
${'Not connected'} | ${'status-alert'} | ${2}
`(
'displays agent connection status as "$status" at line $lineNumber',
({ status, iconName, lineNumber }) => {
expect(findStatusText(lineNumber).text()).toBe(status);
expect(findStatusIcon(lineNumber).props('name')).toBe(iconName);
},
);
it.each`
lastContact | lineNumber
${'Never'} | ${0}
${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1}
${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2}
`(
'displays agent last contact time as "$lastContact" at line $lineNumber',
({ lastContact, lineNumber }) => {
expect(findLastContactText(lineNumber).text()).toBe(lastContact);
},
);
it.each`
agentConfig | link | lineNumber
${'.gitlab/agents/agent-1'} | ${'/agent/full/path'} | ${0}
${'Default configuration'} | ${defaultConfigHelpUrl} | ${1}
`(
'displays config file path as "$agentPath" at line $lineNumber',
({ agentConfig, link, lineNumber }) => {
const findLink = findConfiguration(lineNumber).findComponent(GlLink);
expect(findLink.attributes('href')).toBe(link);
expect(findConfiguration(lineNumber).text()).toBe(agentConfig);
},
);
it('displays actions menu for each agent', () => {
expect(findDeleteAgentButton()).toHaveLength(clusterAgents.length);
});
});
it.each`
status | iconName | lineNumber
${'Never connected'} | ${'status-neutral'} | ${0}
${'Connected'} | ${'status-success'} | ${1}
${'Not connected'} | ${'status-alert'} | ${2}
`(
'displays agent connection status as "$status" at line $lineNumber',
({ status, iconName, lineNumber }) => {
expect(findStatusText(lineNumber).text()).toBe(status);
expect(findStatusIcon(lineNumber).props('name')).toBe(iconName);
},
);
it.each`
lastContact | lineNumber
${'Never'} | ${0}
${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1}
${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2}
`(
'displays agent last contact time as "$lastContact" at line $lineNumber',
({ lastContact, lineNumber }) => {
expect(findLastContactText(lineNumber).text()).toBe(lastContact);
},
);
describe.each`
agent | version | podsNumber | versionMismatch | versionOutdated | title | texts | lineNumber
${'agent-1'} | ${''} | ${1} | ${false} | ${false} | ${''} | ${''} | ${0}
${'agent-2'} | ${'14.8'} | ${2} | ${false} | ${false} | ${''} | ${''} | ${1}
${'agent-3'} | ${'14.5'} | ${1} | ${false} | ${true} | ${outdatedTitle} | ${[outdatedText]} | ${2}
${'agent-4'} | ${'14.7'} | ${2} | ${true} | ${false} | ${mismatchTitle} | ${[mismatchText]} | ${3}
${'agent-5'} | ${'14.3'} | ${2} | ${true} | ${true} | ${mismatchOutdatedTitle} | ${[mismatchText, outdatedText]} | ${4}
agentMockIdx | agentVersion | kasVersion | versionMismatch | versionOutdated | title
${0} | ${''} | ${'14.8.0'} | ${false} | ${false} | ${''}
${1} | ${'14.8.0'} | ${'14.8.0'} | ${false} | ${false} | ${''}
${2} | ${'14.6.0'} | ${'14.8.0'} | ${false} | ${true} | ${outdatedTitle}
${3} | ${'14.7.0'} | ${'14.8.0'} | ${true} | ${false} | ${mismatchTitle}
${4} | ${'14.3.0'} | ${'14.8.0'} | ${true} | ${true} | ${mismatchOutdatedTitle}
${5} | ${'14.6.0'} | ${'14.8.0-rc1'} | ${false} | ${false} | ${''}
${6} | ${'14.8.0'} | ${'15.0.0'} | ${false} | ${true} | ${outdatedTitle}
${7} | ${'14.8.0'} | ${'15.0.0-rc1'} | ${false} | ${true} | ${outdatedTitle}
${8} | ${'14.8.0'} | ${'14.8.10'} | ${false} | ${false} | ${''}
`(
'agent version column at line $lineNumber',
({
agent,
version,
podsNumber,
versionMismatch,
versionOutdated,
title,
texts,
lineNumber,
}) => {
const findIcon = () => findVersionText(lineNumber).findComponent(GlIcon);
const findPopover = () => wrapper.findByTestId(`popover-${agent}`);
const versionWarning = versionMismatch || versionOutdated;
'when agent version is "$agentVersion", KAS version is "$kasVersion" and version mismatch is "$versionMismatch"',
({ agentMockIdx, agentVersion, kasVersion, versionMismatch, versionOutdated, title }) => {
const currentAgent = clusterAgents[agentMockIdx];
it('shows the correct agent version', () => {
expect(findVersionText(lineNumber).text()).toBe(version);
const findIcon = () => findVersionText(0).findComponent(GlIcon);
const findPopover = () => wrapper.findByTestId(`popover-${currentAgent.name}`);
const versionWarning = versionMismatch || versionOutdated;
const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, {
version: kasVersion,
});
beforeEach(() => {
createWrapper({
provide: { gitlabVersion: '14.8', kasVersion },
propsData: { agents: [currentAgent] },
});
});
it('shows the correct agent version text', () => {
expect(findVersionText(0).text()).toBe(agentVersion);
});
if (versionWarning) {
it(`shows a warning icon when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated} and the number of pods is ${podsNumber}`, () => {
it('shows a warning icon', () => {
expect(findIcon().props('name')).toBe('warning');
});
it(`renders correct title for the popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => {
expect(findPopover().props('title')).toBe(title);
});
it(`renders correct text for the popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => {
texts.forEach((text) => {
expect(findPopover().text()).toContain(text);
if (versionMismatch) {
it(`renders correct text for the popover when agent versions mismatch is ${versionMismatch}`, () => {
expect(findPopover().text()).toContain(mismatchText);
});
});
}
if (versionOutdated) {
it(`renders correct text for the popover when agent versions outdated is ${versionOutdated}`, () => {
expect(findPopover().text()).toContain(outdatedText);
});
}
} else {
it(`doesn't show a warning icon with a popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated} and the number of pods is ${podsNumber}`, () => {
it(`doesn't show a warning icon with a popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => {
expect(findIcon().exists()).toBe(false);
expect(findPopover().exists()).toBe(false);
});
}
},
);
it.each`
agentConfig | link | lineNumber
${'.gitlab/agents/agent-1'} | ${'/agent/full/path'} | ${0}
${'Default configuration'} | ${defaultConfigHelpUrl} | ${1}
`(
'displays config file path as "$agentPath" at line $lineNumber',
({ agentConfig, link, lineNumber }) => {
const findLink = findConfiguration(lineNumber).findComponent(GlLink);
expect(findLink.attributes('href')).toBe(link);
expect(findConfiguration(lineNumber).text()).toBe(agentConfig);
},
);
it('displays actions menu for each agent', () => {
expect(findDeleteAgentButton()).toHaveLength(5);
});
});
});

View File

@ -37,10 +37,10 @@ export const clusterAgents = [
connections: {
nodes: [
{
metadata: { version: 'v14.8' },
metadata: { version: 'v14.8.0' },
},
{
metadata: { version: 'v14.8' },
metadata: { version: 'v14.8.0' },
},
],
},
@ -61,7 +61,7 @@ export const clusterAgents = [
connections: {
nodes: [
{
metadata: { version: 'v14.5' },
metadata: { version: 'v14.6.0' },
},
],
},
@ -82,10 +82,10 @@ export const clusterAgents = [
connections: {
nodes: [
{
metadata: { version: 'v14.7' },
metadata: { version: 'v14.7.0' },
},
{
metadata: { version: 'v14.8' },
metadata: { version: 'v14.8.0' },
},
],
},
@ -106,10 +106,94 @@ export const clusterAgents = [
connections: {
nodes: [
{
metadata: { version: 'v14.5' },
metadata: { version: 'v14.5.0' },
},
{
metadata: { version: 'v14.3' },
metadata: { version: 'v14.3.0' },
},
],
},
tokens: {
nodes: [
{
lastUsedAt: connectedTimeInactive,
},
],
},
},
{
name: 'agent-6',
id: 'agent-6-id',
webPath: '/agent-6',
status: 'inactive',
lastContact: connectedTimeInactive.getTime(),
connections: {
nodes: [
{
metadata: { version: 'v14.6.0' },
},
],
},
tokens: {
nodes: [
{
lastUsedAt: connectedTimeInactive,
},
],
},
},
{
name: 'agent-7',
id: 'agent-7-id',
webPath: '/agent-7',
status: 'inactive',
lastContact: connectedTimeInactive.getTime(),
connections: {
nodes: [
{
metadata: { version: 'v14.8.0' },
},
],
},
tokens: {
nodes: [
{
lastUsedAt: connectedTimeInactive,
},
],
},
},
{
name: 'agent-8',
id: 'agent-8-id',
webPath: '/agent-8',
status: 'inactive',
lastContact: connectedTimeInactive.getTime(),
connections: {
nodes: [
{
metadata: { version: 'v14.8.0' },
},
],
},
tokens: {
nodes: [
{
lastUsedAt: connectedTimeInactive,
},
],
},
},
{
name: 'agent-9',
id: 'agent-9-id',
webPath: '/agent-9',
status: 'inactive',
lastContact: connectedTimeInactive.getTime(),
connections: {
nodes: [
{
metadata: { version: 'v14.8.0' },
},
],
},

View File

@ -0,0 +1,59 @@
import { shallowMount } from '@vue/test-utils';
import DataframeOutput from '~/notebook/cells/output/dataframe.vue';
import JSONTable from '~/behaviors/components/json_table.vue';
import { outputWithDataframe } from '../../mock_data';
describe('~/notebook/cells/output/DataframeOutput', () => {
let wrapper;
function createComponent(rawCode) {
wrapper = shallowMount(DataframeOutput, {
propsData: {
rawCode,
count: 0,
index: 0,
},
});
}
const findTable = () => wrapper.findComponent(JSONTable);
describe('with valid dataframe', () => {
beforeEach(() => createComponent(outputWithDataframe.data['text/html'].join('')));
it('mounts the table', () => {
expect(findTable().exists()).toBe(true);
});
it('table caption is empty', () => {
expect(findTable().props().caption).toEqual('');
});
it('allows filtering', () => {
expect(findTable().props().hasFilter).toBe(true);
});
it('sets the correct fields', () => {
expect(findTable().props().fields).toEqual([
{ key: 'index', label: '', sortable: true },
{ key: 'column_1', label: 'column_1', sortable: true },
{ key: 'column_2', label: 'column_2', sortable: true },
]);
});
it('sets the correct items', () => {
expect(findTable().props().items).toEqual([
{ index: 0, column_1: 'abc de f', column_2: 'a' },
{ index: 1, column_1: 'True', column_2: '0.1' },
]);
});
});
describe('invalid dataframe', () => {
it('still displays the table', () => {
createComponent('dataframe');
expect(findTable().exists()).toBe(true);
});
});
});

View File

@ -0,0 +1,113 @@
import { isDataframe, convertHtmlTableToJson } from '~/notebook/cells/output/dataframe_util';
import { outputWithDataframeContent } from '../../mock_data';
import sanitizeTests from './html_sanitize_fixtures';
describe('notebook/cells/output/dataframe_utils', () => {
describe('isDataframe', () => {
describe('when output data has no text/html', () => {
it('is is not a dataframe', () => {
const input = { data: { 'image/png': ['blah'] } };
expect(isDataframe(input)).toBe(false);
});
});
describe('when output data has no text/html, but no mention of dataframe', () => {
it('is is not a dataframe', () => {
const input = { data: { 'text/html': ['blah'] } };
expect(isDataframe(input)).toBe(false);
});
});
describe('when output data has text/html, but no mention of dataframe in the first 20 lines', () => {
it('is is not a dataframe', () => {
const input = { data: { 'text/html': [...new Array(20).fill('a'), 'dataframe'] } };
expect(isDataframe(input)).toBe(false);
});
});
describe('when output data has text/html, and includes "dataframe" within the first 20 lines', () => {
it('is is not a dataframe', () => {
const input = { data: { 'text/html': ['dataframe'] } };
expect(isDataframe(input)).toBe(true);
});
});
});
describe('convertHtmlTableToJson', () => {
it('converts table correctly', () => {
const input = outputWithDataframeContent;
const output = {
fields: [
{ key: 'index', label: '', sortable: true },
{ key: 'column_1', label: 'column_1', sortable: true },
{ key: 'column_2', label: 'column_2', sortable: true },
],
items: [
{ index: 0, column_1: 'abc de f', column_2: 'a' },
{ index: 1, column_1: 'True', column_2: '0.1' },
],
};
expect(convertHtmlTableToJson(input)).toEqual(output);
});
describe('sanitizes input before parsing table', () => {
it('sanitizes input html', () => {
const parser = new DOMParser();
const spy = jest.spyOn(parser, 'parseFromString');
const input = 'hello<style>p {width:50%;}</style><script>alert(1)</script>';
convertHtmlTableToJson(input, parser);
expect(spy).toHaveBeenCalledWith('hello', 'text/html');
});
});
describe('does not include harmful html', () => {
const makeDataframeWithHtml = (html) => {
return [
'<table border="1" class="dataframe">\n',
' <thead>\n',
' <tr style="text-align: right;">\n',
' <th></th>\n',
' <th>column_1</th>\n',
' </tr>\n',
' </thead>\n',
' <tbody>\n',
' <tr>\n',
' <th>0</th>\n',
` <td>${html}</td>\n`,
' </tr>\n',
' </tbody>\n',
'</table>\n',
'</div>',
];
};
it.each([
['table', 0],
['style', 1],
['iframe', 2],
['svg', 3],
])('sanitizes output for: %p', (tag, index) => {
const inputHtml = makeDataframeWithHtml(sanitizeTests[index][1].input);
const convertedHtml = convertHtmlTableToJson(inputHtml).items[0].column_1;
expect(convertedHtml).not.toContain(tag);
});
});
describe('when dataframe is invalid', () => {
it('returns empty', () => {
const input = [' dataframe', ' blah'];
expect(convertHtmlTableToJson(input)).toEqual({ fields: [], items: [] });
});
});
});
});

View File

@ -2,7 +2,13 @@ import { mount } from '@vue/test-utils';
import json from 'test_fixtures/blob/notebook/basic.json';
import Output from '~/notebook/cells/output/index.vue';
import MarkdownOutput from '~/notebook/cells/output/markdown.vue';
import { relativeRawPath, markdownCellContent } from '../../mock_data';
import DataframeOutput from '~/notebook/cells/output/dataframe.vue';
import {
relativeRawPath,
markdownCellContent,
outputWithDataframe,
outputWithDataframeContent,
} from '../../mock_data';
describe('Output component', () => {
let wrapper;
@ -105,6 +111,16 @@ describe('Output component', () => {
});
});
describe('Dataframe output', () => {
it('renders DataframeOutput component', () => {
createComponent(outputWithDataframe);
expect(wrapper.findComponent(DataframeOutput).props('rawCode')).toBe(
outputWithDataframeContent.join(''),
);
});
});
describe('default to plain text', () => {
beforeEach(() => {
const unknownType = json.cells[6];

View File

@ -6,3 +6,47 @@ export const errorOutputContent = [
'\u001b[0;32m/var/folders/cq/l637k4x13gx6y9p_gfs4c_gc0000gn/T/ipykernel_79203/294318627.py\u001b[0m in \u001b[0;36m<module>\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mTo\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m',
"\u001b[0;31mNameError\u001b[0m: name 'To' is not defined",
];
export const outputWithDataframeContent = [
'<div>\n',
'<style scoped>\n',
' .dataframe tbody tr th:only-of-type {\n',
' vertical-align: middle;\n',
' }\n',
'\n',
' .dataframe tbody tr th {\n',
' vertical-align: top;\n',
' }\n',
'\n',
' .dataframe thead th {\n',
' text-align: right;\n',
' }\n',
'</style>\n',
'<table border="1" class="dataframe">\n',
' <thead>\n',
' <tr style="text-align: right;">\n',
' <th></th>\n',
' <th>column_1</th>\n',
' <th>column_2</th>\n',
' </tr>\n',
' </thead>\n',
' <tbody>\n',
' <tr>\n',
' <th>0</th>\n',
' <td>abc de f</td>\n',
' <td>a</td>\n',
' </tr>\n',
' <tr>\n',
' <th>1</th>\n',
' <td>True</td>\n',
' <td>0.1</td>\n',
' </tr>\n',
' </tbody>\n',
'</table>\n',
'</div>',
];
export const outputWithDataframe = {
data: {
'text/html': outputWithDataframeContent,
},
};

View File

@ -6293,17 +6293,17 @@ get-value@^2.0.3, get-value@^2.0.6:
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
gettext-extractor-vue@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/gettext-extractor-vue/-/gettext-extractor-vue-5.0.0.tgz#dc463868d49e14097c4545c8ed4851d8d3edd6dd"
integrity sha512-OSuEJlOexxkjYQL2SGf385oWIiy4weWMfUp/ZlOWzMSz0a+HB/Hlv0S4KFTz4A4GuOp3gu15qwXl61GQJ/u+1w==
gettext-extractor-vue@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/gettext-extractor-vue/-/gettext-extractor-vue-5.1.0.tgz#faabdc2398751d9ac05bbcaa2e60d679b1634af5"
integrity sha512-LFzDyTJGsZO7KPEO7cZKVEMwpLiuC7AEbcZLZt/PEeujE+kRXEFGOQWWSumi1oOtTD2+hdpC6wQG2G8EQybqMg==
dependencies:
glob "^7.1.6"
gettext-extractor@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/gettext-extractor/-/gettext-extractor-3.5.3.tgz#6ed46931c154a7485a80fa8b91b835ff7b8d0411"
integrity sha512-9EgJ+hmbtAbATdMIvCj4WnrkeDWH6fv1z+IJJ1XCxdcUMGx6JQdVVFTdzJkSyIHh4td53ngoB5EQbavbKJU9Og==
gettext-extractor@^3.7.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/gettext-extractor/-/gettext-extractor-3.7.0.tgz#6dd1742dfd9cfebf5fb6dd02eea65c75cc28df09"
integrity sha512-rLXrJIp2XgXzfIVNaGKj9KnJeI/Gn6FfbPurBXZtkRKwd60JCyaa3l/HlMW6SW/lcO9qfRvqD6C1A5C9yoLhUQ==
dependencies:
"@types/glob" "5 - 7"
"@types/parse5" "^5"