Commit dfd955c5 authored by Miguel Rincon's avatar Miguel Rincon Committed by Natalia Tepluhina

Make URLs in logs lines into clickable links

Adds a disabled feature flag `ci_job_line_links` that allwos jobs to
render http and https urls as clickable links.
parent e9ab64ae
<script> <script>
import { linkRegex } from '../../utils';
import LineNumber from './line_number.vue'; import LineNumber from './line_number.vue';
export default { export default {
...@@ -16,7 +18,36 @@ export default { ...@@ -16,7 +18,36 @@ export default {
render(h, { props }) { render(h, { props }) {
const { line, path } = props; const { line, path } = props;
const chars = line.content.map(content => { let chars;
if (gon?.features?.ciJobLineLinks) {
chars = line.content.map(content => {
return h(
'span',
{
class: ['gl-white-space-pre-wrap', content.style],
},
// Simple "tokenization": Split text in chunks of text
// which alternate between text and urls.
content.text.split(linkRegex).map(chunk => {
// Return normal string for non-links
if (!chunk.match(linkRegex)) {
return chunk;
}
return h(
'a',
{
attrs: {
href: chunk,
rel: 'nofollow noopener noreferrer', // eslint-disable-line @gitlab/require-i18n-strings
},
},
chunk,
);
}),
);
});
} else {
chars = line.content.map(content => {
return h( return h(
'span', 'span',
{ {
...@@ -25,6 +56,7 @@ export default { ...@@ -25,6 +56,7 @@ export default {
content.text, content.text,
); );
}); });
}
return h('div', { class: 'js-line log-line' }, [ return h('div', { class: 'js-line log-line' }, [
h(LineNumber, { h(LineNumber, {
......
// capture anything starting with http:// or https://
// up until a disallowed character or whitespace
export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+)/g;
export default { linkRegex };
...@@ -14,6 +14,9 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -14,6 +14,9 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize before_action :verify_proxy_request!, only: :proxy_websocket_authorize
before_action do
push_frontend_feature_flag(:ci_job_line_links, @project)
end
layout 'project' layout 'project'
......
---
name: ci_job_line_links
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47532
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/281727
milestone: '13.6'
type: development
group: group::continuous integration
default_enabled: false
...@@ -2,21 +2,26 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,21 +2,26 @@ import { shallowMount } from '@vue/test-utils';
import Line from '~/jobs/components/log/line.vue'; import Line from '~/jobs/components/log/line.vue';
import LineNumber from '~/jobs/components/log/line_number.vue'; import LineNumber from '~/jobs/components/log/line_number.vue';
describe('Job Log Line', () => { const httpUrl = 'http://example.com';
let wrapper; const httpsUrl = 'https://example.com';
const data = { const mockProps = ({ text = 'Running with gitlab-runner 12.1.0 (de7731dd)' } = {}) => ({
line: { line: {
content: [ content: [
{ {
text: 'Running with gitlab-runner 12.1.0 (de7731dd)', text,
style: 'term-fg-l-green', style: 'term-fg-l-green',
}, },
], ],
lineNumber: 0, lineNumber: 0,
}, },
path: '/jashkenas/underscore/-/jobs/335', path: '/jashkenas/underscore/-/jobs/335',
}; });
describe('Job Log Line', () => {
let wrapper;
let data;
let originalGon;
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMount(Line, { wrapper = shallowMount(Line, {
...@@ -26,12 +31,25 @@ describe('Job Log Line', () => { ...@@ -26,12 +31,25 @@ describe('Job Log Line', () => {
}); });
}; };
const findLine = () => wrapper.find('span');
const findLink = () => findLine().find('a');
const findLinksAt = i =>
findLine()
.findAll('a')
.at(i);
beforeEach(() => { beforeEach(() => {
originalGon = window.gon;
window.gon.features = {
ciJobLineLinks: false,
};
data = mockProps();
createComponent(data); createComponent(data);
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); window.gon = originalGon;
}); });
it('renders the line number component', () => { it('renders the line number component', () => {
...@@ -39,10 +57,103 @@ describe('Job Log Line', () => { ...@@ -39,10 +57,103 @@ describe('Job Log Line', () => {
}); });
it('renders a span the provided text', () => { it('renders a span the provided text', () => {
expect(wrapper.find('span').text()).toBe(data.line.content[0].text); expect(findLine().text()).toBe(data.line.content[0].text);
}); });
it('renders the provided style as a class attribute', () => { it('renders the provided style as a class attribute', () => {
expect(wrapper.find('span').classes()).toContain(data.line.content[0].style); expect(findLine().classes()).toContain(data.line.content[0].style);
});
describe.each([true, false])('when feature ci_job_line_links enabled = %p', ciJobLineLinks => {
beforeEach(() => {
window.gon.features = {
ciJobLineLinks,
};
});
it('renders text with symbols', () => {
const text = 'apt-get update < /dev/null > /dev/null';
createComponent(mockProps({ text }));
expect(findLine().text()).toBe(text);
});
it.each`
tag | text
${'a'} | ${'<a href="#">linked</a>'}
${'script'} | ${'<script>doEvil();</script>'}
${'strong'} | ${'<strong>highlighted</strong>'}
`('escapes `<$tag>` tags in text', ({ tag, text }) => {
createComponent(mockProps({ text }));
expect(
findLine()
.find(tag)
.exists(),
).toBe(false);
expect(findLine().text()).toBe(text);
});
});
describe('when ci_job_line_links is enabled', () => {
beforeEach(() => {
window.gon.features = {
ciJobLineLinks: true,
};
});
it('renders an http link', () => {
createComponent(mockProps({ text: httpUrl }));
expect(findLink().text()).toBe(httpUrl);
expect(findLink().attributes().href).toBe(httpUrl);
});
it('renders an https link', () => {
createComponent(mockProps({ text: httpsUrl }));
expect(findLink().text()).toBe(httpsUrl);
expect(findLink().attributes().href).toBe(httpsUrl);
});
it('renders a multiple links surrounded by text', () => {
createComponent(mockProps({ text: `My HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl}` }));
expect(findLine().text()).toBe(
'My HTTP url: http://example.com and my HTTPS url: https://example.com',
);
expect(findLinksAt(0).attributes().href).toBe(httpUrl);
expect(findLinksAt(1).attributes().href).toBe(httpsUrl);
});
it('renders a link with rel nofollow and noopener', () => {
createComponent(mockProps({ text: httpsUrl }));
expect(findLink().attributes().rel).toBe('nofollow noopener noreferrer');
});
it('render links surrounded by text', () => {
createComponent(
mockProps({ text: `My HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl} are here.` }),
);
expect(findLine().text()).toBe(
'My HTTP url: http://example.com and my HTTPS url: https://example.com are here.',
);
expect(findLinksAt(0).attributes().href).toBe(httpUrl);
expect(findLinksAt(1).attributes().href).toBe(httpsUrl);
});
const jshref = 'javascript:doEvil();'; // eslint-disable-line no-script-url
test.each`
type | text
${'js'} | ${jshref}
${'file'} | ${'file:///a-file'}
${'ftp'} | ${'ftp://example.com/file'}
${'email'} | ${'email@example.com'}
${'no scheme'} | ${'example.com/page'}
`('does not render a $type link', ({ text }) => {
createComponent(mockProps({ text }));
expect(findLink().exists()).toBe(false);
});
}); });
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment