summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/compare.js86
-rw-r--r--app/assets/javascripts/ide/components/ide_context_bar.vue42
-rw-r--r--app/assets/javascripts/ide/components/ide_external_links.vue43
-rw-r--r--app/assets/javascripts/ide/components/ide_project_branches_tree.vue47
-rw-r--r--app/assets/javascripts/ide/components/ide_project_tree.vue65
-rw-r--r--app/assets/javascripts/ide/components/ide_repo_tree.vue41
-rw-r--r--app/assets/javascripts/pipelines/components/async_button.vue95
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js277
-rw-r--r--app/assets/stylesheets/framework/emoji_sprites.scss1813
-rw-r--r--app/assets/stylesheets/pages/repo.scss.orig786
-rw-r--r--app/views/projects/runners/_form.html.haml55
-rw-r--r--app/views/projects/runners/show.html.haml67
-rwxr-xr-xbin/spinach13
-rw-r--r--changelogs/unreleased/44775-avatar-on-os-fails-with-cdn.yml5
-rw-r--r--changelogs/unreleased/add-jwt-strategy-to-gitlab-suite.yml5
-rw-r--r--changelogs/unreleased/bvl-fix-maintainer-push-error.yml5
-rw-r--r--changelogs/unreleased/bvl-fix-openid-redirect.yml5
-rw-r--r--changelogs/unreleased/dm-commit-trailer-without-gravatar.yml5
-rw-r--r--changelogs/unreleased/fix-file-store-artifacts-and-lfs.yml5
-rw-r--r--changelogs/unreleased/issue_45463.yml5
-rw-r--r--changelogs/unreleased/update-doorkeeper-changelog.yml5
-rw-r--r--ee/app/controllers/ee/ldap/omniauth_callbacks_controller.rb22
-rw-r--r--ee/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb29
-rw-r--r--features/project/builds/artifacts.feature65
-rw-r--r--features/project/commits/commits.feature96
-rw-r--r--features/project/commits/diff_comments.feature96
-rw-r--r--features/project/deploy_keys.feature46
-rw-r--r--features/project/ff_merge_requests.feature41
-rw-r--r--features/project/forked_merge_requests.feature51
-rw-r--r--features/project/issues/references.feature33
-rw-r--r--features/project/merge_requests/references.feature31
-rw-r--r--features/steps/group/members.rb68
-rw-r--r--features/steps/profile/notifications.rb20
-rw-r--r--features/steps/project/builds/artifacts.rb98
-rw-r--r--features/steps/project/commits/branches.rb32
-rw-r--r--features/steps/project/commits/comments.rb6
-rw-r--r--features/steps/project/commits/commits.rb192
-rw-r--r--features/steps/project/commits/diff_comments.rb6
-rw-r--r--features/steps/project/create.rb23
-rw-r--r--features/steps/project/deploy_keys.rb89
-rw-r--r--features/steps/project/ff_merge_requests.rb87
-rw-r--r--features/steps/project/forked_merge_requests.rb139
-rw-r--r--features/steps/project/issues/filter_labels.rb61
-rw-r--r--features/steps/project/issues/issues.rb175
-rw-r--r--features/steps/project/issues/milestones.rb20
-rw-r--r--features/steps/project/issues/references.rb7
-rw-r--r--features/steps/project/merge_requests/references.rb7
-rw-r--r--features/steps/project/source/browse_files.rb435
-rw-r--r--features/steps/shared/active_tab.rb32
-rw-r--r--features/steps/shared/admin.rb11
-rw-r--r--features/steps/shared/authentication.rb75
-rw-r--r--features/steps/shared/builds.rb53
-rw-r--r--features/steps/shared/diff_note.rb237
-rw-r--r--features/steps/shared/group.rb50
-rw-r--r--features/steps/shared/issuable.rb167
-rw-r--r--features/steps/shared/markdown.rb11
-rw-r--r--features/steps/shared/note.rb25
-rw-r--r--features/steps/shared/paths.rb434
-rw-r--r--features/steps/shared/project.rb132
-rw-r--r--features/steps/shared/project_tab.rb66
-rw-r--r--features/steps/shared/shortcuts.rb18
-rw-r--r--features/steps/shared/sidebar_active_tab.rb31
-rw-r--r--features/steps/shared/user.rb41
-rw-r--r--features/support/capybara.rb50
-rw-r--r--features/support/db_cleaner.rb11
-rw-r--r--features/support/env.rb60
-rw-r--r--features/support/gitaly.rb3
-rw-r--r--features/support/login_helpers.rb19
-rw-r--r--features/support/rerun.rb16
-rw-r--r--lib/gitlab/middleware/webpack_proxy.rb26
-rw-r--r--lib/tasks/spinach.rake60
-rw-r--r--spec/features/projects/artifacts/browse_spec.rb67
-rw-r--r--spec/features/projects/artifacts/download_spec.rb61
-rw-r--r--spec/features/projects/commit/user_comments_on_commit_spec.rb110
-rw-r--r--spec/javascripts/ide/components/ide_context_bar_spec.js37
-rw-r--r--spec/javascripts/ide/components/ide_external_links_spec.js43
-rw-r--r--spec/javascripts/ide/components/ide_project_tree_spec.js39
-rw-r--r--spec/javascripts/ide/components/ide_repo_tree_spec.js43
-rw-r--r--spec/javascripts/pipelines/async_button_spec.js62
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js40
-rw-r--r--spec/lib/gitlab/email/email_shared_blocks.rb41
84 files changed, 7666 insertions, 0 deletions
diff --git a/app/assets/javascripts/compare.js b/app/assets/javascripts/compare.js
new file mode 100644
index 00000000000..303a5bf4a53
--- /dev/null
+++ b/app/assets/javascripts/compare.js
@@ -0,0 +1,86 @@
+/* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, no-var, object-shorthand, consistent-return, no-unused-vars, comma-dangle, vars-on-top, prefer-template, max-len */
+
+import $ from 'jquery';
+import { localTimeAgo } from './lib/utils/datetime_utility';
+import axios from './lib/utils/axios_utils';
+
+export default class Compare {
+ constructor(opts) {
+ this.opts = opts;
+ this.source_loading = $(".js-source-loading");
+ this.target_loading = $(".js-target-loading");
+ $('.js-compare-dropdown').each((function(_this) {
+ return function(i, dropdown) {
+ var $dropdown;
+ $dropdown = $(dropdown);
+ return $dropdown.glDropdown({
+ selectable: true,
+ fieldName: $dropdown.data('fieldName'),
+ filterable: true,
+ id: function(obj, $el) {
+ return $el.data('id');
+ },
+ toggleLabel: function(obj, $el) {
+ return $el.text().trim();
+ },
+ clicked: function(e, el) {
+ if ($dropdown.is('.js-target-branch')) {
+ return _this.getTargetHtml();
+ } else if ($dropdown.is('.js-source-branch')) {
+ return _this.getSourceHtml();
+ } else if ($dropdown.is('.js-target-project')) {
+ return _this.getTargetProject();
+ }
+ }
+ });
+ };
+ })(this));
+ this.initialState();
+ }
+
+ initialState() {
+ this.getSourceHtml();
+ this.getTargetHtml();
+ }
+
+ getTargetProject() {
+ $('.mr_target_commit').empty();
+
+ return axios.get(this.opts.targetProjectUrl, {
+ params: {
+ target_project_id: $("input[name='merge_request[target_project_id]']").val(),
+ },
+ }).then(({ data }) => {
+ $('.js-target-branch-dropdown .dropdown-content').html(data);
+ });
+ }
+
+ getSourceHtml() {
+ return this.constructor.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', {
+ ref: $("input[name='merge_request[source_branch]']").val()
+ });
+ }
+
+ getTargetHtml() {
+ return this.constructor.sendAjax(this.opts.targetBranchUrl, this.target_loading, '.mr_target_commit', {
+ target_project_id: $("input[name='merge_request[target_project_id]']").val(),
+ ref: $("input[name='merge_request[target_branch]']").val()
+ });
+ }
+
+ static sendAjax(url, loading, target, params) {
+ const $target = $(target);
+
+ loading.show();
+ $target.empty();
+
+ return axios.get(url, {
+ params,
+ }).then(({ data }) => {
+ loading.hide();
+ $target.html(data);
+ const className = '.' + $target[0].className.replace(' ', '.');
+ localTimeAgo($('.js-timeago', className));
+ });
+ }
+}
diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue
new file mode 100644
index 00000000000..627fbeb9adf
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_context_bar.vue
@@ -0,0 +1,42 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+import panelResizer from '~/vue_shared/components/panel_resizer.vue';
+import repoCommitSection from './repo_commit_section.vue';
+import ResizablePanel from './resizable_panel.vue';
+
+export default {
+ components: {
+ repoCommitSection,
+ icon,
+ panelResizer,
+ ResizablePanel,
+ },
+ props: {
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <resizable-panel
+ :collapsible="true"
+ :initial-width="340"
+ side="right"
+ >
+ <div
+ class="multi-file-commit-panel-section"
+ >
+ <repo-commit-section
+ :no-changes-state-svg-path="noChangesStateSvgPath"
+ :committed-state-svg-path="committedStateSvgPath"
+ />
+ </div>
+ </resizable-panel>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_external_links.vue b/app/assets/javascripts/ide/components/ide_external_links.vue
new file mode 100644
index 00000000000..c6f6e0d2348
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_external_links.vue
@@ -0,0 +1,43 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ icon,
+ },
+ props: {
+ projectUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ goBackUrl() {
+ return document.referrer || this.projectUrl;
+ },
+ },
+};
+</script>
+
+<template>
+ <nav
+ class="ide-external-links"
+ v-once
+ >
+ <p>
+ <a
+ :href="goBackUrl"
+ class="ide-sidebar-link"
+ >
+ <icon
+ :size="16"
+ class="append-right-8"
+ name="go-back"
+ />
+ <span class="ide-external-links-text">
+ {{ s__('Go back') }}
+ </span>
+ </a>
+ </p>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
new file mode 100644
index 00000000000..eb2749e6151
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
@@ -0,0 +1,47 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import repoTree from './ide_repo_tree.vue';
+ import newDropdown from './new_dropdown/index.vue';
+
+ export default {
+ components: {
+ repoTree,
+ icon,
+ newDropdown,
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ branch: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="branch-container">
+ <div class="branch-header">
+ <div class="branch-header-title str-truncated ref-name">
+ <icon
+ name="branch"
+ :size="12"
+ />
+ {{ branch.name }}
+ </div>
+ <div class="branch-header-btns">
+ <new-dropdown
+ :project-id="projectId"
+ :branch="branch.name"
+ path=""
+ />
+ </div>
+ </div>
+ <repo-tree
+ :tree="branch.tree"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue
new file mode 100644
index 00000000000..a6f40286ac1
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_project_tree.vue
@@ -0,0 +1,65 @@
+<script>
+import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
+import Identicon from '../../vue_shared/components/identicon.vue';
+import BranchesTree from './ide_project_branches_tree.vue';
+import ExternalLinks from './ide_external_links.vue';
+
+export default {
+ components: {
+ BranchesTree,
+ ExternalLinks,
+ ProjectAvatarImage,
+ Identicon,
+ },
+ props: {
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="projects-sidebar">
+ <div class="context-header">
+ <a
+ :title="project.name"
+ :href="project.web_url"
+ >
+ <div
+ v-if="project.avatar_url"
+ class="avatar-container s40 project-avatar"
+ >
+ <project-avatar-image
+ class="avatar-container project-avatar"
+ :link-href="project.path"
+ :img-src="project.avatar_url"
+ :img-alt="project.name"
+ :img-size="40"
+ />
+ </div>
+ <identicon
+ v-else
+ size-class="s40"
+ :entity-id="project.id"
+ :entity-name="project.name"
+ />
+ <div class="sidebar-context-title">
+ {{ project.name }}
+ </div>
+ </a>
+ </div>
+ <external-links
+ :project-url="project.web_url"
+ />
+ <div class="multi-file-commit-panel-inner-scroll">
+ <branches-tree
+ v-for="branch in project.branches"
+ :key="branch.name"
+ :project-id="project.path_with_namespace"
+ :branch="branch"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue
new file mode 100644
index 00000000000..e6af88e04bc
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue
@@ -0,0 +1,41 @@
+<script>
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import RepoFile from './repo_file.vue';
+
+export default {
+ components: {
+ RepoFile,
+ SkeletonLoadingContainer,
+ },
+ props: {
+ tree: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="ide-file-list"
+ >
+ <template v-if="tree.loading">
+ <div
+ class="multi-file-loading-container"
+ v-for="n in 3"
+ :key="n"
+ >
+ <skeleton-loading-container />
+ </div>
+ </template>
+ <template v-else>
+ <repo-file
+ v-for="file in tree.tree"
+ :key="file.key"
+ :file="file"
+ :level="0"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue
new file mode 100644
index 00000000000..0cdffbde05b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/async_button.vue
@@ -0,0 +1,95 @@
+<script>
+ /* eslint-disable no-alert */
+
+ import eventHub from '../event_hub';
+ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+ import icon from '../../vue_shared/components/icon.vue';
+ import tooltip from '../../vue_shared/directives/tooltip';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ loadingIcon,
+ icon,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ required: true,
+ },
+ cssClass: {
+ type: String,
+ required: true,
+ },
+ pipelineId: {
+ type: Number,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ computed: {
+ buttonClass() {
+ return `btn ${this.cssClass}`;
+ },
+ },
+ created() {
+ // We're using eventHub to listen to the modal here instead of
+ // using props because it would would make the parent components
+ // much more complex to keep track of the loading state of each button
+ eventHub.$on('postAction', this.setLoading);
+ },
+ beforeDestroy() {
+ eventHub.$off('postAction', this.setLoading);
+ },
+ methods: {
+ onClick() {
+ eventHub.$emit('openConfirmationModal', {
+ pipelineId: this.pipelineId,
+ endpoint: this.endpoint,
+ type: this.type,
+ });
+ },
+ setLoading(endpoint) {
+ if (endpoint === this.endpoint) {
+ this.isLoading = true;
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ v-tooltip
+ type="button"
+ @click="onClick"
+ :class="buttonClass"
+ :title="title"
+ :aria-label="title"
+ data-container="body"
+ data-placement="top"
+ :disabled="isLoading">
+ <icon
+ :name="icon"
+ />
+ <loading-icon v-if="isLoading" />
+ </button>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
new file mode 100644
index 00000000000..bf987562647
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
@@ -0,0 +1,15 @@
+export default {
+ name: 'time-tracking-spent-only-pane',
+ props: {
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-spend-only-pane">
+ <span class="bold">Spent:</span>
+ {{ timeSpentHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue
new file mode 100644
index 00000000000..f0298f732ea
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue
@@ -0,0 +1,20 @@
+<script>
+ export default {
+ name: 'MRWidgetMaintainerEdit',
+ props: {
+ maintainerEditAllowed: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ };
+</script>
+
+<template>
+ <section class="mr-info-list mr-links">
+ <p v-if="maintainerEditAllowed">
+ {{ s__("mrWidget|Allows edits from maintainers") }}
+ </p>
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js
new file mode 100644
index 00000000000..bf8628d18a6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js
@@ -0,0 +1,15 @@
+/*
+The squash-before-merge button is EE only, but it's located right in the middle
+of the readyToMerge state component template.
+
+If we didn't declare this component in CE, we'd need to maintain a separate copy
+of the readyToMergeState template in EE, which is pretty big and likely to change.
+
+Instead, in CE, we declare the component, but it's hidden and is configured to do nothing.
+In EE, the configuration extends this object to add a functioning squash-before-merge
+button.
+*/
+
+export default {
+ template: '',
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
new file mode 100644
index 00000000000..345f9ac1b4b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
@@ -0,0 +1,277 @@
+import Project from '~/pages/projects/project';
+import SmartInterval from '~/smart_interval';
+import Flash from '../flash';
+import {
+ WidgetHeader,
+ WidgetMergeHelp,
+ WidgetPipeline,
+ Deployment,
+ WidgetMaintainerEdit,
+ WidgetRelatedLinks,
+ MergedState,
+ ClosedState,
+ MergingState,
+ RebaseState,
+ WorkInProgressState,
+ ArchivedState,
+ ConflictsState,
+ NothingToMergeState,
+ MissingBranchState,
+ NotAllowedState,
+ ReadyToMergeState,
+ ShaMismatchState,
+ UnresolvedDiscussionsState,
+ PipelineBlockedState,
+ PipelineFailedState,
+ FailedToMerge,
+ MergeWhenPipelineSucceedsState,
+ AutoMergeFailed,
+ CheckingState,
+ MRWidgetStore,
+ MRWidgetService,
+ eventHub,
+ stateMaps,
+ SquashBeforeMerge,
+ notify,
+ SourceBranchRemovalStatus,
+} from './dependencies';
+import { setFavicon } from '../lib/utils/common_utils';
+
+export default {
+ el: '#js-vue-mr-widget',
+ name: 'MRWidget',
+ props: {
+ mrData: {
+ type: Object,
+ required: false,
+ },
+ },
+ data() {
+ const store = new MRWidgetStore(this.mrData || window.gl.mrWidgetData);
+ const service = this.createService(store);
+ return {
+ mr: store,
+ service,
+ };
+ },
+ computed: {
+ componentName() {
+ return stateMaps.stateToComponentMap[this.mr.state];
+ },
+ shouldRenderMergeHelp() {
+ return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1;
+ },
+ shouldRenderPipelines() {
+ return this.mr.hasCI;
+ },
+ shouldRenderRelatedLinks() {
+ return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState;
+ },
+ shouldRenderSourceBranchRemovalStatus() {
+ return !this.mr.canRemoveSourceBranch && this.mr.shouldRemoveSourceBranch &&
+ (!this.mr.isNothingToMergeState && !this.mr.isMergedState);
+ },
+ },
+ methods: {
+ createService(store) {
+ const endpoints = {
+ mergePath: store.mergePath,
+ mergeCheckPath: store.mergeCheckPath,
+ cancelAutoMergePath: store.cancelAutoMergePath,
+ removeWIPPath: store.removeWIPPath,
+ sourceBranchPath: store.sourceBranchPath,
+ ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath,
+ statusPath: store.statusPath,
+ mergeActionsContentPath: store.mergeActionsContentPath,
+ rebasePath: store.rebasePath,
+ };
+ return new MRWidgetService(endpoints);
+ },
+ checkStatus(cb) {
+ return this.service.checkStatus()
+ .then(res => res.data)
+ .then((data) => {
+ this.handleNotification(data);
+ this.mr.setData(data);
+ this.setFaviconHelper();
+
+ if (cb) {
+ cb.call(null, data);
+ }
+ })
+ .catch(() => new Flash('Something went wrong. Please try again.'));
+ },
+ initPolling() {
+ this.pollingInterval = new SmartInterval({
+ callback: this.checkStatus,
+ startingInterval: 10000,
+ maxInterval: 30000,
+ hiddenInterval: 120000,
+ incrementByFactorOf: 5000,
+ });
+ },
+ initDeploymentsPolling() {
+ this.deploymentsInterval = new SmartInterval({
+ callback: this.fetchDeployments,
+ startingInterval: 30000,
+ maxInterval: 120000,
+ hiddenInterval: 240000,
+ incrementByFactorOf: 15000,
+ immediateExecution: true,
+ });
+ },
+ setFaviconHelper() {
+ if (this.mr.ciStatusFaviconPath) {
+ setFavicon(this.mr.ciStatusFaviconPath);
+ }
+ },
+ fetchDeployments() {
+ return this.service.fetchDeployments()
+ .then(res => res.data)
+ .then((data) => {
+ if (data.length) {
+ this.mr.deployments = data;
+ }
+ })
+ .catch(() => {
+ new Flash('Something went wrong while fetching the environments for this merge request. Please try again.'); // eslint-disable-line
+ });
+ },
+ fetchActionsContent() {
+ this.service.fetchMergeActionsContent()
+ .then((res) => {
+ if (res.data) {
+ const el = document.createElement('div');
+ el.innerHTML = res.data;
+ document.body.appendChild(el);
+ Project.initRefSwitcher();
+ }
+ })
+ .catch(() => new Flash('Something went wrong. Please try again.'));
+ },
+ handleNotification(data) {
+ if (data.ci_status === this.mr.ciStatus) return;
+ if (!data.pipeline) return;
+
+ const label = data.pipeline.details.status.label;
+ const title = `Pipeline ${label}`;
+ const message = `Pipeline ${label} for "${data.title}"`;
+
+ notify.notifyMe(title, message, this.mr.gitlabLogo);
+ },
+ resumePolling() {
+ this.pollingInterval.resume();
+ },
+ stopPolling() {
+ this.pollingInterval.stopTimer();
+ },
+ bindEventHubListeners() {
+ eventHub.$on('MRWidgetUpdateRequested', (cb) => {
+ this.checkStatus(cb);
+ });
+
+ // `params` should be an Array contains a Boolean, like `[true]`
+ // Passing parameter as Boolean didn't work.
+ eventHub.$on('SetBranchRemoveFlag', (params) => {
+ this.mr.isRemovingSourceBranch = params[0];
+ });
+
+ eventHub.$on('FailedToMerge', (mergeError) => {
+ this.mr.state = 'failedToMerge';
+ this.mr.mergeError = mergeError;
+ });
+
+ eventHub.$on('UpdateWidgetData', (data) => {
+ this.mr.setData(data);
+ });
+
+ eventHub.$on('FetchActionsContent', () => {
+ this.fetchActionsContent();
+ });
+
+ eventHub.$on('EnablePolling', () => {
+ this.resumePolling();
+ });
+
+ eventHub.$on('DisablePolling', () => {
+ this.stopPolling();
+ });
+ },
+ handleMounted() {
+ this.setFaviconHelper();
+ this.initDeploymentsPolling();
+ },
+ },
+ created() {
+ this.initPolling();
+ this.bindEventHubListeners();
+ },
+ mounted() {
+ this.handleMounted();
+ },
+ components: {
+ 'mr-widget-header': WidgetHeader,
+ 'mr-widget-merge-help': WidgetMergeHelp,
+ 'mr-widget-pipeline': WidgetPipeline,
+ Deployment,
+ 'mr-widget-maintainer-edit': WidgetMaintainerEdit,
+ 'mr-widget-related-links': WidgetRelatedLinks,
+ 'mr-widget-merged': MergedState,
+ 'mr-widget-closed': ClosedState,
+ 'mr-widget-merging': MergingState,
+ 'mr-widget-failed-to-merge': FailedToMerge,
+ 'mr-widget-wip': WorkInProgressState,
+ 'mr-widget-archived': ArchivedState,
+ 'mr-widget-conflicts': ConflictsState,
+ 'mr-widget-nothing-to-merge': NothingToMergeState,
+ 'mr-widget-not-allowed': NotAllowedState,
+ 'mr-widget-missing-branch': MissingBranchState,
+ 'mr-widget-ready-to-merge': ReadyToMergeState,
+ 'mr-widget-sha-mismatch': ShaMismatchState,
+ 'mr-widget-squash-before-merge': SquashBeforeMerge,
+ 'mr-widget-checking': CheckingState,
+ 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
+ 'mr-widget-pipeline-blocked': PipelineBlockedState,
+ 'mr-widget-pipeline-failed': PipelineFailedState,
+ 'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState,
+ 'mr-widget-auto-merge-failed': AutoMergeFailed,
+ 'mr-widget-rebase': RebaseState,
+ SourceBranchRemovalStatus,
+ },
+ template: `
+ <div class="mr-state-widget prepend-top-default">
+ <mr-widget-header :mr="mr" />
+ <mr-widget-pipeline
+ v-if="shouldRenderPipelines"
+ :pipeline="mr.pipeline"
+ :ci-status="mr.ciStatus"
+ :has-ci="mr.hasCI"
+ />
+ <deployment
+ v-for="deployment in mr.deployments"
+ :key="deployment.id"
+ :deployment="deployment"
+ />
+ <div class="mr-widget-section">
+ <component
+ :is="componentName"
+ :mr="mr"
+ :service="service" />
+ <mr-widget-maintainer-edit
+ :maintainerEditAllowed="mr.maintainerEditAllowed" />
+ <mr-widget-related-links
+ v-if="shouldRenderRelatedLinks"
+ :state="mr.state"
+ :related-links="mr.relatedLinks" />
+ <source-branch-removal-status
+ v-if="shouldRenderSourceBranchRemovalStatus"
+ />
+ </div>
+ <div
+ class="mr-widget-footer"
+ v-if="shouldRenderMergeHelp">
+ <mr-widget-merge-help />
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/stylesheets/framework/emoji_sprites.scss b/app/assets/stylesheets/framework/emoji_sprites.scss
new file mode 100644
index 00000000000..0174e17b660
--- /dev/null
+++ b/app/assets/stylesheets/framework/emoji_sprites.scss
@@ -0,0 +1,1813 @@
+.emoji-zzz { background-position: 0 0; }
+.emoji-1234 { background-position: -20px 0; }
+.emoji-1F627 { background-position: 0 -20px; }
+.emoji-8ball { background-position: -20px -20px; }
+.emoji-a { background-position: -40px 0; }
+.emoji-ab { background-position: -40px -20px; }
+.emoji-abc { background-position: 0 -40px; }
+.emoji-abcd { background-position: -20px -40px; }
+.emoji-accept { background-position: -40px -40px; }
+.emoji-aerial_tramway { background-position: -60px 0; }
+.emoji-airplane { background-position: -60px -20px; }
+.emoji-airplane_arriving { background-position: -60px -40px; }
+.emoji-airplane_departure { background-position: 0 -60px; }
+.emoji-airplane_small { background-position: -20px -60px; }
+.emoji-alarm_clock { background-position: -40px -60px; }
+.emoji-alembic { background-position: -60px -60px; }
+.emoji-alien { background-position: -80px 0; }
+.emoji-ambulance { background-position: -80px -20px; }
+.emoji-amphora { background-position: -80px -40px; }
+.emoji-anchor { background-position: -80px -60px; }
+.emoji-angel { background-position: 0 -80px; }
+.emoji-angel_tone1 { background-position: -20px -80px; }
+.emoji-angel_tone2 { background-position: -40px -80px; }
+.emoji-angel_tone3 { background-position: -60px -80px; }
+.emoji-angel_tone4 { background-position: -80px -80px; }
+.emoji-angel_tone5 { background-position: -100px 0; }
+.emoji-anger { background-position: -100px -20px; }
+.emoji-anger_right { background-position: -100px -40px; }
+.emoji-angry { background-position: -100px -60px; }
+.emoji-ant { background-position: -100px -80px; }
+.emoji-apple { background-position: 0 -100px; }
+.emoji-aquarius { background-position: -20px -100px; }
+.emoji-aries { background-position: -40px -100px; }
+.emoji-arrow_backward { background-position: -60px -100px; }
+.emoji-arrow_double_down { background-position: -80px -100px; }
+.emoji-arrow_double_up { background-position: -100px -100px; }
+.emoji-arrow_down { background-position: -120px 0; }
+.emoji-arrow_down_small { background-position: -120px -20px; }
+.emoji-arrow_forward { background-position: -120px -40px; }
+.emoji-arrow_heading_down { background-position: -120px -60px; }
+.emoji-arrow_heading_up { background-position: -120px -80px; }
+.emoji-arrow_left { background-position: -120px -100px; }
+.emoji-arrow_lower_left { background-position: 0 -120px; }
+.emoji-arrow_lower_right { background-position: -20px -120px; }
+.emoji-arrow_right { background-position: -40px -120px; }
+.emoji-arrow_right_hook { background-position: -60px -120px; }
+.emoji-arrow_up { background-position: -80px -120px; }
+.emoji-arrow_up_down { background-position: -100px -120px; }
+.emoji-arrow_up_small { background-position: -120px -120px; }
+.emoji-arrow_upper_left { background-position: -140px 0; }
+.emoji-arrow_upper_right { background-position: -140px -20px; }
+.emoji-arrows_clockwise { background-position: -140px -40px; }
+.emoji-arrows_counterclockwise { background-position: -140px -60px; }
+.emoji-art { background-position: -140px -80px; }
+.emoji-articulated_lorry { background-position: -140px -100px; }
+.emoji-asterisk { background-position: -140px -120px; }
+.emoji-astonished { background-position: 0 -140px; }
+.emoji-athletic_shoe { background-position: -20px -140px; }
+.emoji-atm { background-position: -40px -140px; }
+.emoji-atom { background-position: -60px -140px; }
+.emoji-avocado { background-position: -80px -140px; }
+.emoji-b { background-position: -100px -140px; }
+.emoji-baby { background-position: -120px -140px; }
+.emoji-baby_bottle { background-position: -140px -140px; }
+.emoji-baby_chick { background-position: -160px 0; }
+.emoji-baby_symbol { background-position: -160px -20px; }
+.emoji-baby_tone1 { background-position: -160px -40px; }
+.emoji-baby_tone2 { background-position: -160px -60px; }
+.emoji-baby_tone3 { background-position: -160px -80px; }
+.emoji-baby_tone4 { background-position: -160px -100px; }
+.emoji-baby_tone5 { background-position: -160px -120px; }
+.emoji-back { background-position: -160px -140px; }
+.emoji-bacon { background-position: 0 -160px; }
+.emoji-badminton { background-position: -20px -160px; }
+.emoji-baggage_claim { background-position: -40px -160px; }
+.emoji-balloon { background-position: -60px -160px; }
+.emoji-ballot_box { background-position: -80px -160px; }
+.emoji-ballot_box_with_check { background-position: -100px -160px; }
+.emoji-bamboo { background-position: -120px -160px; }
+.emoji-banana { background-position: -140px -160px; }
+.emoji-bangbang { background-position: -160px -160px; }
+.emoji-bank { background-position: -180px 0; }
+.emoji-bar_chart { background-position: -180px -20px; }
+.emoji-barber { background-position: -180px -40px; }
+.emoji-baseball { background-position: -180px -60px; }
+.emoji-basketball { background-position: -180px -80px; }
+.emoji-basketball_player { background-position: -180px -100px; }
+.emoji-basketball_player_tone1 { background-position: -180px -120px; }
+.emoji-basketball_player_tone2 { background-position: -180px -140px; }
+.emoji-basketball_player_tone3 { background-position: -180px -160px; }
+.emoji-basketball_player_tone4 { background-position: 0 -180px; }
+.emoji-basketball_player_tone5 { background-position: -20px -180px; }
+.emoji-bat { background-position: -40px -180px; }
+.emoji-bath { background-position: -60px -180px; }
+.emoji-bath_tone1 { background-position: -80px -180px; }
+.emoji-bath_tone2 { background-position: -100px -180px; }
+.emoji-bath_tone3 { background-position: -120px -180px; }
+.emoji-bath_tone4 { background-position: -140px -180px; }
+.emoji-bath_tone5 { background-position: -160px -180px; }
+.emoji-bathtub { background-position: -180px -180px; }
+.emoji-battery { background-position: -200px 0; }
+.emoji-beach { background-position: -200px -20px; }
+.emoji-beach_umbrella { background-position: -200px -40px; }
+.emoji-bear { background-position: -200px -60px; }
+.emoji-bed { background-position: -200px -80px; }
+.emoji-bee { background-position: -200px -100px; }
+.emoji-beer { background-position: -200px -120px; }
+.emoji-beers { background-position: -200px -140px; }
+.emoji-beetle { background-position: -200px -160px; }
+.emoji-beginner { background-position: -200px -180px; }
+.emoji-bell { background-position: 0 -200px; }
+.emoji-bellhop { background-position: -20px -200px; }
+.emoji-bento { background-position: -40px -200px; }
+.emoji-bicyclist { background-position: -60px -200px; }
+.emoji-bicyclist_tone1 { background-position: -80px -200px; }
+.emoji-bicyclist_tone2 { background-position: -100px -200px; }
+.emoji-bicyclist_tone3 { background-position: -120px -200px; }
+.emoji-bicyclist_tone4 { background-position: -140px -200px; }
+.emoji-bicyclist_tone5 { background-position: -160px -200px; }
+.emoji-bike { background-position: -180px -200px; }
+.emoji-bikini { background-position: -200px -200px; }
+.emoji-biohazard { background-position: -220px 0; }
+.emoji-bird { background-position: -220px -20px; }
+.emoji-birthday { background-position: -220px -40px; }
+.emoji-black_circle { background-position: -220px -60px; }
+.emoji-black_heart { background-position: -220px -80px; }
+.emoji-black_joker { background-position: -220px -100px; }
+.emoji-black_large_square { background-position: -220px -120px; }
+.emoji-black_medium_small_square { background-position: -220px -140px; }
+.emoji-black_medium_square { background-position: -220px -160px; }
+.emoji-black_nib { background-position: -220px -180px; }
+.emoji-black_small_square { background-position: -220px -200px; }
+.emoji-black_square_button { background-position: 0 -220px; }
+.emoji-blossom { background-position: -20px -220px; }
+.emoji-blowfish { background-position: -40px -220px; }
+.emoji-blue_book { background-position: -60px -220px; }
+.emoji-blue_car { background-position: -80px -220px; }
+.emoji-blue_heart { background-position: -100px -220px; }
+.emoji-blush { background-position: -120px -220px; }
+.emoji-boar { background-position: -140px -220px; }
+.emoji-bomb { background-position: -160px -220px; }
+.emoji-book { background-position: -180px -220px; }
+.emoji-bookmark { background-position: -200px -220px; }
+.emoji-bookmark_tabs { background-position: -220px -220px; }
+.emoji-books { background-position: -240px 0; }
+.emoji-boom { background-position: -240px -20px; }
+.emoji-boot { background-position: -240px -40px; }
+.emoji-bouquet { background-position: -240px -60px; }
+.emoji-bow { background-position: -240px -80px; }
+.emoji-bow_and_arrow { background-position: -240px -100px; }
+.emoji-bow_tone1 { background-position: -240px -120px; }
+.emoji-bow_tone2 { background-position: -240px -140px; }
+.emoji-bow_tone3 { background-position: -240px -160px; }
+.emoji-bow_tone4 { background-position: -240px -180px; }
+.emoji-bow_tone5 { background-position: -240px -200px; }
+.emoji-bowling { background-position: -240px -220px; }
+.emoji-boxing_glove { background-position: 0 -240px; }
+.emoji-boy { background-position: -20px -240px; }
+.emoji-boy_tone1 { background-position: -40px -240px; }
+.emoji-boy_tone2 { background-position: -60px -240px; }
+.emoji-boy_tone3 { background-position: -80px -240px; }
+.emoji-boy_tone4 { background-position: -100px -240px; }
+.emoji-boy_tone5 { background-position: -120px -240px; }
+.emoji-bread { background-position: -140px -240px; }
+.emoji-bride_with_veil { background-position: -160px -240px; }
+.emoji-bride_with_veil_tone1 { background-position: -180px -240px; }
+.emoji-bride_with_veil_tone2 { background-position: -200px -240px; }
+.emoji-bride_with_veil_tone3 { background-position: -220px -240px; }
+.emoji-bride_with_veil_tone4 { background-position: -240px -240px; }
+.emoji-bride_with_veil_tone5 { background-position: -260px 0; }
+.emoji-bridge_at_night { background-position: -260px -20px; }
+.emoji-briefcase { background-position: -260px -40px; }
+.emoji-broken_heart { background-position: -260px -60px; }
+.emoji-bug { background-position: -260px -80px; }
+.emoji-bulb { background-position: -260px -100px; }
+.emoji-bullettrain_front { background-position: -260px -120px; }
+.emoji-bullettrain_side { background-position: -260px -140px; }
+.emoji-burrito { background-position: -260px -160px; }
+.emoji-bus { background-position: -260px -180px; }
+.emoji-busstop { background-position: -260px -200px; }
+.emoji-bust_in_silhouette { background-position: -260px -220px; }
+.emoji-busts_in_silhouette { background-position: -260px -240px; }
+.emoji-butterfly { background-position: 0 -260px; }
+.emoji-cactus { background-position: -20px -260px; }
+.emoji-cake { background-position: -40px -260px; }
+.emoji-calendar { background-position: -60px -260px; }
+.emoji-calendar_spiral { background-position: -80px -260px; }
+.emoji-call_me { background-position: -100px -260px; }
+.emoji-call_me_tone1 { background-position: -120px -260px; }
+.emoji-call_me_tone2 { background-position: -140px -260px; }
+.emoji-call_me_tone3 { background-position: -160px -260px; }
+.emoji-call_me_tone4 { background-position: -180px -260px; }
+.emoji-call_me_tone5 { background-position: -200px -260px; }
+.emoji-calling { background-position: -220px -260px; }
+.emoji-camel { background-position: -240px -260px; }
+.emoji-camera { background-position: -260px -260px; }
+.emoji-camera_with_flash { background-position: -280px 0; }
+.emoji-camping { background-position: -280px -20px; }
+.emoji-cancer { background-position: -280px -40px; }
+.emoji-candle { background-position: -280px -60px; }
+.emoji-candy { background-position: -280px -80px; }
+.emoji-canoe { background-position: -280px -100px; }
+.emoji-capital_abcd { background-position: -280px -120px; }
+.emoji-capricorn { background-position: -280px -140px; }
+.emoji-card_box { background-position: -280px -160px; }
+.emoji-card_index { background-position: -280px -180px; }
+.emoji-carousel_horse { background-position: -280px -200px; }
+.emoji-carrot { background-position: -280px -220px; }
+.emoji-cartwheel { background-position: -280px -240px; }
+.emoji-cartwheel_tone1 { background-position: -280px -260px; }
+.emoji-cartwheel_tone2 { background-position: 0 -280px; }
+.emoji-cartwheel_tone3 { background-position: -20px -280px; }
+.emoji-cartwheel_tone4 { background-position: -40px -280px; }
+.emoji-cartwheel_tone5 { background-position: -60px -280px; }
+.emoji-cat { background-position: -80px -280px; }
+.emoji-cat2 { background-position: -100px -280px; }
+.emoji-cd { background-position: -120px -280px; }
+.emoji-chains { background-position: -140px -280px; }
+.emoji-champagne { background-position: -160px -280px; }
+.emoji-champagne_glass { background-position: -180px -280px; }
+.emoji-chart { background-position: -200px -280px; }
+.emoji-chart_with_downwards_trend { background-position: -220px -280px; }
+.emoji-chart_with_upwards_trend { background-position: -240px -280px; }
+.emoji-checkered_flag { background-position: -260px -280px; }
+.emoji-cheese { background-position: -280px -280px; }
+.emoji-cherries { background-position: -300px 0; }
+.emoji-cherry_blossom { background-position: -300px -20px; }
+.emoji-chestnut { background-position: -300px -40px; }
+.emoji-chicken { background-position: -300px -60px; }
+.emoji-children_crossing { background-position: -300px -80px; }
+.emoji-chipmunk { background-position: -300px -100px; }
+.emoji-chocolate_bar { background-position: -300px -120px; }
+.emoji-christmas_tree { background-position: -300px -140px; }
+.emoji-church { background-position: -300px -160px; }
+.emoji-cinema { background-position: -300px -180px; }
+.emoji-circus_tent { background-position: -300px -200px; }
+.emoji-city_dusk { background-position: -300px -220px; }
+.emoji-city_sunset { background-position: -300px -240px; }
+.emoji-cityscape { background-position: -300px -260px; }
+.emoji-cl { background-position: -300px -280px; }
+.emoji-clap { background-position: 0 -300px; }
+.emoji-clap_tone1 { background-position: -20px -300px; }
+.emoji-clap_tone2 { background-position: -40px -300px; }
+.emoji-clap_tone3 { background-position: -60px -300px; }
+.emoji-clap_tone4 { background-position: -80px -300px; }
+.emoji-clap_tone5 { background-position: -100px -300px; }
+.emoji-clapper { background-position: -120px -300px; }
+.emoji-classical_building { background-position: -140px -300px; }
+.emoji-clipboard { background-position: -160px -300px; }
+.emoji-clock { background-position: -180px -300px; }
+.emoji-clock1 { background-position: -200px -300px; }
+.emoji-clock10 { background-position: -220px -300px; }
+.emoji-clock1030 { background-position: -240px -300px; }
+.emoji-clock11 { background-position: -260px -300px; }
+.emoji-clock1130 { background-position: -280px -300px; }
+.emoji-clock12 { background-position: -300px -300px; }
+.emoji-clock1230 { background-position: -320px 0; }
+.emoji-clock130 { background-position: -320px -20px; }
+.emoji-clock2 { background-position: -320px -40px; }
+.emoji-clock230 { background-position: -320px -60px; }
+.emoji-clock3 { background-position: -320px -80px; }
+.emoji-clock330 { background-position: -320px -100px; }
+.emoji-clock4 { background-position: -320px -120px; }
+.emoji-clock430 { background-position: -320px -140px; }
+.emoji-clock5 { background-position: -320px -160px; }
+.emoji-clock530 { background-position: -320px -180px; }
+.emoji-clock6 { background-position: -320px -200px; }
+.emoji-clock630 { background-position: -320px -220px; }
+.emoji-clock7 { background-position: -320px -240px; }
+.emoji-clock730 { background-position: -320px -260px; }
+.emoji-clock8 { background-position: -320px -280px; }
+.emoji-clock830 { background-position: -320px -300px; }
+.emoji-clock9 { background-position: 0 -320px; }
+.emoji-clock930 { background-position: -20px -320px; }
+.emoji-closed_book { background-position: -40px -320px; }
+.emoji-closed_lock_with_key { background-position: -60px -320px; }
+.emoji-closed_umbrella { background-position: -80px -320px; }
+.emoji-cloud { background-position: -100px -320px; }
+.emoji-cloud_lightning { background-position: -120px -320px; }
+.emoji-cloud_rain { background-position: -140px -320px; }
+.emoji-cloud_snow { background-position: -160px -320px; }
+.emoji-cloud_tornado { background-position: -180px -320px; }
+.emoji-clown { background-position: -200px -320px; }
+.emoji-clubs { background-position: -220px -320px; }
+.emoji-cocktail { background-position: -240px -320px; }
+.emoji-coffee { background-position: -260px -320px; }
+.emoji-coffin { background-position: -280px -320px; }
+.emoji-cold_sweat { background-position: -300px -320px; }
+.emoji-comet { background-position: -320px -320px; }
+.emoji-compression { background-position: -340px 0; }
+.emoji-computer { background-position: -340px -20px; }
+.emoji-confetti_ball { background-position: -340px -40px; }
+.emoji-confounded { background-position: -340px -60px; }
+.emoji-confused { background-position: -340px -80px; }
+.emoji-congratulations { background-position: -340px -100px; }
+.emoji-construction { background-position: -340px -120px; }
+.emoji-construction_site { background-position: -340px -140px; }
+.emoji-construction_worker { background-position: -340px -160px; }
+.emoji-construction_worker_tone1 { background-position: -340px -180px; }
+.emoji-construction_worker_tone2 { background-position: -340px -200px; }
+.emoji-construction_worker_tone3 { background-position: -340px -220px; }
+.emoji-construction_worker_tone4 { background-position: -340px -240px; }
+.emoji-construction_worker_tone5 { background-position: -340px -260px; }
+.emoji-control_knobs { background-position: -340px -280px; }
+.emoji-convenience_store { background-position: -340px -300px; }
+.emoji-cookie { background-position: -340px -320px; }
+.emoji-cooking { background-position: 0 -340px; }
+.emoji-cool { background-position: -20px -340px; }
+.emoji-cop { background-position: -40px -340px; }
+.emoji-cop_tone1 { background-position: -60px -340px; }
+.emoji-cop_tone2 { background-position: -80px -340px; }
+.emoji-cop_tone3 { background-position: -100px -340px; }
+.emoji-cop_tone4 { background-position: -120px -340px; }
+.emoji-cop_tone5 { background-position: -140px -340px; }
+.emoji-copyright { background-position: -160px -340px; }
+.emoji-corn { background-position: -180px -340px; }
+.emoji-couch { background-position: -200px -340px; }
+.emoji-couple { background-position: -220px -340px; }
+.emoji-couple_mm { background-position: -240px -340px; }
+.emoji-couple_with_heart { background-position: -260px -340px; }
+.emoji-couple_ww { background-position: -280px -340px; }
+.emoji-couplekiss { background-position: -300px -340px; }
+.emoji-cow { background-position: -320px -340px; }
+.emoji-cow2 { background-position: -340px -340px; }
+.emoji-cowboy { background-position: -360px 0; }
+.emoji-crab { background-position: -360px -20px; }
+.emoji-crayon { background-position: -360px -40px; }
+.emoji-credit_card { background-position: -360px -60px; }
+.emoji-crescent_moon { background-position: -360px -80px; }
+.emoji-cricket { background-position: -360px -100px; }
+.emoji-crocodile { background-position: -360px -120px; }
+.emoji-croissant { background-position: -360px -140px; }
+.emoji-cross { background-position: -360px -160px; }
+.emoji-crossed_flags { background-position: -360px -180px; }
+.emoji-crossed_swords { background-position: -360px -200px; }
+.emoji-crown { background-position: -360px -220px; }
+.emoji-cruise_ship { background-position: -360px -240px; }
+.emoji-cry { background-position: -360px -260px; }
+.emoji-crying_cat_face { background-position: -360px -280px; }
+.emoji-crystal_ball { background-position: -360px -300px; }
+.emoji-cucumber { background-position: -360px -320px; }
+.emoji-cupid { background-position: -360px -340px; }
+.emoji-curly_loop { background-position: 0 -360px; }
+.emoji-currency_exchange { background-position: -20px -360px; }
+.emoji-curry { background-position: -40px -360px; }
+.emoji-custard { background-position: -60px -360px; }
+.emoji-customs { background-position: -80px -360px; }
+.emoji-cyclone { background-position: -100px -360px; }
+.emoji-dagger { background-position: -120px -360px; }
+.emoji-dancer { background-position: -140px -360px; }
+.emoji-dancer_tone1 { background-position: -160px -360px; }
+.emoji-dancer_tone2 { background-position: -180px -360px; }
+.emoji-dancer_tone3 { background-position: -200px -360px; }
+.emoji-dancer_tone4 { background-position: -220px -360px; }
+.emoji-dancer_tone5 { background-position: -240px -360px; }
+.emoji-dancers { background-position: -260px -360px; }
+.emoji-dango { background-position: -280px -360px; }
+.emoji-dark_sunglasses { background-position: -300px -360px; }
+.emoji-dart { background-position: -320px -360px; }
+.emoji-dash { background-position: -340px -360px; }
+.emoji-date { background-position: -360px -360px; }
+.emoji-deciduous_tree { background-position: -380px 0; }
+.emoji-deer { background-position: -380px -20px; }
+.emoji-department_store { background-position: -380px -40px; }
+.emoji-desert { background-position: -380px -60px; }
+.emoji-desktop { background-position: -380px -80px; }
+.emoji-diamond_shape_with_a_dot_inside { background-position: -380px -100px; }
+.emoji-diamonds { background-position: -380px -120px; }
+.emoji-disappointed { background-position: -380px -140px; }
+.emoji-disappointed_relieved { background-position: -380px -160px; }
+.emoji-dividers { background-position: -380px -180px; }
+.emoji-dizzy { background-position: -380px -200px; }
+.emoji-dizzy_face { background-position: -380px -220px; }
+.emoji-do_not_litter { background-position: -380px -240px; }
+.emoji-dog { background-position: -380px -260px; }
+.emoji-dog2 { background-position: -380px -280px; }
+.emoji-dollar { background-position: -380px -300px; }
+.emoji-dolls { background-position: -380px -320px; }
+.emoji-dolphin { background-position: -380px -340px; }
+.emoji-door { background-position: -380px -360px; }
+.emoji-doughnut { background-position: 0 -380px; }
+.emoji-dove { background-position: -20px -380px; }
+.emoji-dragon { background-position: -40px -380px; }
+.emoji-dragon_face { background-position: -60px -380px; }
+.emoji-dress { background-position: -80px -380px; }
+.emoji-dromedary_camel { background-position: -100px -380px; }
+.emoji-drooling_face { background-position: -120px -380px; }
+.emoji-droplet { background-position: -140px -380px; }
+.emoji-drum { background-position: -160px -380px; }
+.emoji-duck { background-position: -180px -380px; }
+.emoji-dvd { background-position: -200px -380px; }
+.emoji-e-mail { background-position: -220px -380px; }
+.emoji-eagle { background-position: -240px -380px; }
+.emoji-ear { background-position: -260px -380px; }
+.emoji-ear_of_rice { background-position: -280px -380px; }
+.emoji-ear_tone1 { background-position: -300px -380px; }
+.emoji-ear_tone2 { background-position: -320px -380px; }
+.emoji-ear_tone3 { background-position: -340px -380px; }
+.emoji-ear_tone4 { background-position: -360px -380px; }
+.emoji-ear_tone5 { background-position: -380px -380px; }
+.emoji-earth_africa { background-position: -400px 0; }
+.emoji-earth_americas { background-position: -400px -20px; }
+.emoji-earth_asia { background-position: -400px -40px; }
+.emoji-egg { background-position: -400px -60px; }
+.emoji-eggplant { background-position: -400px -80px; }
+.emoji-eight { background-position: -400px -100px; }
+.emoji-eight_pointed_black_star { background-position: -400px -120px; }
+.emoji-eight_spoked_asterisk { background-position: -400px -140px; }
+.emoji-eject { background-position: -400px -160px; }
+.emoji-electric_plug { background-position: -400px -180px; }
+.emoji-elephant { background-position: -400px -200px; }
+.emoji-end { background-position: -400px -220px; }
+.emoji-envelope { background-position: -400px -240px; }
+.emoji-envelope_with_arrow { background-position: -400px -260px; }
+.emoji-euro { background-position: -400px -280px; }
+.emoji-european_castle { background-position: -400px -300px; }
+.emoji-european_post_office { background-position: -400px -320px; }
+.emoji-evergreen_tree { background-position: -400px -340px; }
+.emoji-exclamation { background-position: -400px -360px; }
+.emoji-expressionless { background-position: -400px -380px; }
+.emoji-eye { background-position: 0 -400px; }
+.emoji-eye_in_speech_bubble { background-position: -20px -400px; }
+.emoji-eyeglasses { background-position: -40px -400px; }
+.emoji-eyes { background-position: -60px -400px; }
+.emoji-face_palm { background-position: -80px -400px; }
+.emoji-face_palm_tone1 { background-position: -100px -400px; }
+.emoji-face_palm_tone2 { background-position: -120px -400px; }
+.emoji-face_palm_tone3 { background-position: -140px -400px; }
+.emoji-face_palm_tone4 { background-position: -160px -400px; }
+.emoji-face_palm_tone5 { background-position: -180px -400px; }
+.emoji-factory { background-position: -200px -400px; }
+.emoji-fallen_leaf { background-position: -220px -400px; }
+.emoji-family { background-position: -240px -400px; }
+.emoji-family_mmb { background-position: -260px -400px; }
+.emoji-family_mmbb { background-position: -280px -400px; }
+.emoji-family_mmg { background-position: -300px -400px; }
+.emoji-family_mmgb { background-position: -320px -400px; }
+.emoji-family_mmgg { background-position: -340px -400px; }
+.emoji-family_mwbb { background-position: -360px -400px; }
+.emoji-family_mwg { background-position: -380px -400px; }
+.emoji-family_mwgb { background-position: -400px -400px; }
+.emoji-family_mwgg { background-position: -420px 0; }
+.emoji-family_wwb { background-position: -420px -20px; }
+.emoji-family_wwbb { background-position: -420px -40px; }
+.emoji-family_wwg { background-position: -420px -60px; }
+.emoji-family_wwgb { background-position: -420px -80px; }
+.emoji-family_wwgg { background-position: -420px -100px; }
+.emoji-fast_forward { background-position: -420px -120px; }
+.emoji-fax { background-position: -420px -140px; }
+.emoji-fearful { background-position: -420px -160px; }
+.emoji-feet { background-position: -420px -180px; }
+.emoji-fencer { background-position: -420px -200px; }
+.emoji-ferris_wheel { background-position: -420px -220px; }
+.emoji-ferry { background-position: -420px -240px; }
+.emoji-field_hockey { background-position: -420px -260px; }
+.emoji-file_cabinet { background-position: -420px -280px; }
+.emoji-file_folder { background-position: -420px -300px; }
+.emoji-film_frames { background-position: -420px -320px; }
+.emoji-fingers_crossed { background-position: -420px -340px; }
+.emoji-fingers_crossed_tone1 { background-position: -420px -360px; }
+.emoji-fingers_crossed_tone2 { background-position: -420px -380px; }
+.emoji-fingers_crossed_tone3 { background-position: -420px -400px; }
+.emoji-fingers_crossed_tone4 { background-position: 0 -420px; }
+.emoji-fingers_crossed_tone5 { background-position: -20px -420px; }
+.emoji-fire { background-position: -40px -420px; }
+.emoji-fire_engine { background-position: -60px -420px; }
+.emoji-fireworks { background-position: -80px -420px; }
+.emoji-first_place { background-position: -100px -420px; }
+.emoji-first_quarter_moon { background-position: -120px -420px; }
+.emoji-first_quarter_moon_with_face { background-position: -140px -420px; }
+.emoji-fish { background-position: -160px -420px; }
+.emoji-fish_cake { background-position: -180px -420px; }
+.emoji-fishing_pole_and_fish { background-position: -200px -420px; }
+.emoji-fist { background-position: -220px -420px; }
+.emoji-fist_tone1 { background-position: -240px -420px; }
+.emoji-fist_tone2 { background-position: -260px -420px; }
+.emoji-fist_tone3 { background-position: -280px -420px; }
+.emoji-fist_tone4 { background-position: -300px -420px; }
+.emoji-fist_tone5 { background-position: -320px -420px; }
+.emoji-five { background-position: -340px -420px; }
+.emoji-flag_ac { background-position: -360px -420px; }
+.emoji-flag_ad { background-position: -380px -420px; }
+.emoji-flag_ae { background-position: -400px -420px; }
+.emoji-flag_af { background-position: -420px -420px; }
+.emoji-flag_ag { background-position: -440px 0; }
+.emoji-flag_ai { background-position: -440px -20px; }
+.emoji-flag_al { background-position: -440px -40px; }
+.emoji-flag_am { background-position: -440px -60px; }
+.emoji-flag_ao { background-position: -440px -80px; }
+.emoji-flag_aq { background-position: -440px -100px; }
+.emoji-flag_ar { background-position: -440px -120px; }
+.emoji-flag_as { background-position: -440px -140px; }
+.emoji-flag_at { background-position: -440px -160px; }
+.emoji-flag_au { background-position: -440px -180px; }
+.emoji-flag_aw { background-position: -440px -200px; }
+.emoji-flag_ax { background-position: -440px -220px; }
+.emoji-flag_az { background-position: -440px -240px; }
+.emoji-flag_ba { background-position: -440px -260px; }
+.emoji-flag_bb { background-position: -440px -280px; }
+.emoji-flag_bd { background-position: -440px -300px; }
+.emoji-flag_be { background-position: -440px -320px; }
+.emoji-flag_bf { background-position: -440px -340px; }
+.emoji-flag_bg { background-position: -440px -360px; }
+.emoji-flag_bh { background-position: -440px -380px; }
+.emoji-flag_bi { background-position: -440px -400px; }
+.emoji-flag_bj { background-position: -440px -420px; }
+.emoji-flag_bl { background-position: 0 -440px; }
+.emoji-flag_black { background-position: -20px -440px; }
+.emoji-flag_bm { background-position: -40px -440px; }
+.emoji-flag_bn { background-position: -60px -440px; }
+.emoji-flag_bo { background-position: -80px -440px; }
+.emoji-flag_bq { background-position: -100px -440px; }
+.emoji-flag_br { background-position: -120px -440px; }
+.emoji-flag_bs { background-position: -140px -440px; }
+.emoji-flag_bt { background-position: -160px -440px; }
+.emoji-flag_bv { background-position: -180px -440px; }
+.emoji-flag_bw { background-position: -200px -440px; }
+.emoji-flag_by { background-position: -220px -440px; }
+.emoji-flag_bz { background-position: -240px -440px; }
+.emoji-flag_ca { background-position: -260px -440px; }
+.emoji-flag_cc { background-position: -280px -440px; }
+.emoji-flag_cd { background-position: -300px -440px; }
+.emoji-flag_cf { background-position: -320px -440px; }
+.emoji-flag_cg { background-position: -340px -440px; }
+.emoji-flag_ch { background-position: -360px -440px; }
+.emoji-flag_ci { background-position: -380px -440px; }
+.emoji-flag_ck { background-position: -400px -440px; }
+.emoji-flag_cl { background-position: -420px -440px; }
+.emoji-flag_cm { background-position: -440px -440px; }
+.emoji-flag_cn { background-position: -460px 0; }
+.emoji-flag_co { background-position: -460px -20px; }
+.emoji-flag_cp { background-position: -460px -40px; }
+.emoji-flag_cr { background-position: -460px -60px; }
+.emoji-flag_cu { background-position: -460px -80px; }
+.emoji-flag_cv { background-position: -460px -100px; }
+.emoji-flag_cw { background-position: -460px -120px; }
+.emoji-flag_cx { background-position: -460px -140px; }
+.emoji-flag_cy { background-position: -460px -160px; }
+.emoji-flag_cz { background-position: -460px -180px; }
+.emoji-flag_de { background-position: -460px -200px; }
+.emoji-flag_dg { background-position: -460px -220px; }
+.emoji-flag_dj { background-position: -460px -240px; }
+.emoji-flag_dk { background-position: -460px -260px; }
+.emoji-flag_dm { background-position: -460px -280px; }
+.emoji-flag_do { background-position: -460px -300px; }
+.emoji-flag_dz { background-position: -460px -320px; }
+.emoji-flag_ea { background-position: -460px -340px; }
+.emoji-flag_ec { background-position: -460px -360px; }
+.emoji-flag_ee { background-position: -460px -380px; }
+.emoji-flag_eg { background-position: -460px -400px; }
+.emoji-flag_eh { background-position: -460px -420px; }
+.emoji-flag_er { background-position: -460px -440px; }
+.emoji-flag_es { background-position: 0 -460px; }
+.emoji-flag_et { background-position: -20px -460px; }
+.emoji-flag_eu { background-position: -40px -460px; }
+.emoji-flag_fi { background-position: -60px -460px; }
+.emoji-flag_fj { background-position: -80px -460px; }
+.emoji-flag_fk { background-position: -100px -460px; }
+.emoji-flag_fm { background-position: -120px -460px; }
+.emoji-flag_fo { background-position: -140px -460px; }
+.emoji-flag_fr { background-position: -160px -460px; }
+.emoji-flag_ga { background-position: -180px -460px; }
+.emoji-flag_gb { background-position: -200px -460px; }
+.emoji-flag_gd { background-position: -220px -460px; }
+.emoji-flag_ge { background-position: -240px -460px; }
+.emoji-flag_gf { background-position: -260px -460px; }
+.emoji-flag_gg { background-position: -280px -460px; }
+.emoji-flag_gh { background-position: -300px -460px; }
+.emoji-flag_gi { background-position: -320px -460px; }
+.emoji-flag_gl { background-position: -340px -460px; }
+.emoji-flag_gm { background-position: -360px -460px; }
+.emoji-flag_gn { background-position: -380px -460px; }
+.emoji-flag_gp { background-position: -400px -460px; }
+.emoji-flag_gq { background-position: -420px -460px; }
+.emoji-flag_gr { background-position: -440px -460px; }
+.emoji-flag_gs { background-position: -460px -460px; }
+.emoji-flag_gt { background-position: -480px 0; }
+.emoji-flag_gu { background-position: -480px -20px; }
+.emoji-flag_gw { background-position: -480px -40px; }
+.emoji-flag_gy { background-position: -480px -60px; }
+.emoji-flag_hk { background-position: -480px -80px; }
+.emoji-flag_hm { background-position: -480px -100px; }
+.emoji-flag_hn { background-position: -480px -120px; }
+.emoji-flag_hr { background-position: -480px -140px; }
+.emoji-flag_ht { background-position: -480px -160px; }
+.emoji-flag_hu { background-position: -480px -180px; }
+.emoji-flag_ic { background-position: -480px -200px; }
+.emoji-flag_id { background-position: -480px -220px; }
+.emoji-flag_ie { background-position: -480px -240px; }
+.emoji-flag_il { background-position: -480px -260px; }
+.emoji-flag_im { background-position: -480px -280px; }
+.emoji-flag_in { background-position: -480px -300px; }
+.emoji-flag_io { background-position: -480px -320px; }
+.emoji-flag_iq { background-position: -480px -340px; }
+.emoji-flag_ir { background-position: -480px -360px; }
+.emoji-flag_is { background-position: -480px -380px; }
+.emoji-flag_it { background-position: -480px -400px; }
+.emoji-flag_je { background-position: -480px -420px; }
+.emoji-flag_jm { background-position: -480px -440px; }
+.emoji-flag_jo { background-position: -480px -460px; }
+.emoji-flag_jp { background-position: 0 -480px; }
+.emoji-flag_ke { background-position: -20px -480px; }
+.emoji-flag_kg { background-position: -40px -480px; }
+.emoji-flag_kh { background-position: -60px -480px; }
+.emoji-flag_ki { background-position: -80px -480px; }
+.emoji-flag_km { background-position: -100px -480px; }
+.emoji-flag_kn { background-position: -120px -480px; }
+.emoji-flag_kp { background-position: -140px -480px; }
+.emoji-flag_kr { background-position: -160px -480px; }
+.emoji-flag_kw { background-position: -180px -480px; }
+.emoji-flag_ky { background-position: -200px -480px; }
+.emoji-flag_kz { background-position: -220px -480px; }
+.emoji-flag_la { background-position: -240px -480px; }
+.emoji-flag_lb { background-position: -260px -480px; }
+.emoji-flag_lc { background-position: -280px -480px; }
+.emoji-flag_li { background-position: -300px -480px; }
+.emoji-flag_lk { background-position: -320px -480px; }
+.emoji-flag_lr { background-position: -340px -480px; }
+.emoji-flag_ls { background-position: -360px -480px; }
+.emoji-flag_lt { background-position: -380px -480px; }
+.emoji-flag_lu { background-position: -400px -480px; }
+.emoji-flag_lv { background-position: -420px -480px; }
+.emoji-flag_ly { background-position: -440px -480px; }
+.emoji-flag_ma { background-position: -460px -480px; }
+.emoji-flag_mc { background-position: -480px -480px; }
+.emoji-flag_md { background-position: -500px 0; }
+.emoji-flag_me { background-position: -500px -20px; }
+.emoji-flag_mf { background-position: -500px -40px; }
+.emoji-flag_mg { background-position: -500px -60px; }
+.emoji-flag_mh { background-position: -500px -80px; }
+.emoji-flag_mk { background-position: -500px -100px; }
+.emoji-flag_ml { background-position: -500px -120px; }
+.emoji-flag_mm { background-position: -500px -140px; }
+.emoji-flag_mn { background-position: -500px -160px; }
+.emoji-flag_mo { background-position: -500px -180px; }
+.emoji-flag_mp { background-position: -500px -200px; }
+.emoji-flag_mq { background-position: -500px -220px; }
+.emoji-flag_mr { background-position: -500px -240px; }
+.emoji-flag_ms { background-position: -500px -260px; }
+.emoji-flag_mt { background-position: -500px -280px; }
+.emoji-flag_mu { background-position: -500px -300px; }
+.emoji-flag_mv { background-position: -500px -320px; }
+.emoji-flag_mw { background-position: -500px -340px; }
+.emoji-flag_mx { background-position: -500px -360px; }
+.emoji-flag_my { background-position: -500px -380px; }
+.emoji-flag_mz { background-position: -500px -400px; }
+.emoji-flag_na { background-position: -500px -420px; }
+.emoji-flag_nc { background-position: -500px -440px; }
+.emoji-flag_ne { background-position: -500px -460px; }
+.emoji-flag_nf { background-position: -500px -480px; }
+.emoji-flag_ng { background-position: 0 -500px; }
+.emoji-flag_ni { background-position: -20px -500px; }
+.emoji-flag_nl { background-position: -40px -500px; }
+.emoji-flag_no { background-position: -60px -500px; }
+.emoji-flag_np { background-position: -80px -500px; }
+.emoji-flag_nr { background-position: -100px -500px; }
+.emoji-flag_nu { background-position: -120px -500px; }
+.emoji-flag_nz { background-position: -140px -500px; }
+.emoji-flag_om { background-position: -160px -500px; }
+.emoji-flag_pa { background-position: -180px -500px; }
+.emoji-flag_pe { background-position: -200px -500px; }
+.emoji-flag_pf { background-position: -220px -500px; }
+.emoji-flag_pg { background-position: -240px -500px; }
+.emoji-flag_ph { background-position: -260px -500px; }
+.emoji-flag_pk { background-position: -280px -500px; }
+.emoji-flag_pl { background-position: -300px -500px; }
+.emoji-flag_pm { background-position: -320px -500px; }
+.emoji-flag_pn { background-position: -340px -500px; }
+.emoji-flag_pr { background-position: -360px -500px; }
+.emoji-flag_ps { background-position: -380px -500px; }
+.emoji-flag_pt { background-position: -400px -500px; }
+.emoji-flag_pw { background-position: -420px -500px; }
+.emoji-flag_py { background-position: -440px -500px; }
+.emoji-flag_qa { background-position: -460px -500px; }
+.emoji-flag_re { background-position: -480px -500px; }
+.emoji-flag_ro { background-position: -500px -500px; }
+.emoji-flag_rs { background-position: -520px 0; }
+.emoji-flag_ru { background-position: -520px -20px; }
+.emoji-flag_rw { background-position: -520px -40px; }
+.emoji-flag_sa { background-position: -520px -60px; }
+.emoji-flag_sb { background-position: -520px -80px; }
+.emoji-flag_sc { background-position: -520px -100px; }
+.emoji-flag_sd { background-position: -520px -120px; }
+.emoji-flag_se { background-position: -520px -140px; }
+.emoji-flag_sg { background-position: -520px -160px; }
+.emoji-flag_sh { background-position: -520px -180px; }
+.emoji-flag_si { background-position: -520px -200px; }
+.emoji-flag_sj { background-position: -520px -220px; }
+.emoji-flag_sk { background-position: -520px -240px; }
+.emoji-flag_sl { background-position: -520px -260px; }
+.emoji-flag_sm { background-position: -520px -280px; }
+.emoji-flag_sn { background-position: -520px -300px; }
+.emoji-flag_so { background-position: -520px -320px; }
+.emoji-flag_sr { background-position: -520px -340px; }
+.emoji-flag_ss { background-position: -520px -360px; }
+.emoji-flag_st { background-position: -520px -380px; }
+.emoji-flag_sv { background-position: -520px -400px; }
+.emoji-flag_sx { background-position: -520px -420px; }
+.emoji-flag_sy { background-position: -520px -440px; }
+.emoji-flag_sz { background-position: -520px -460px; }
+.emoji-flag_ta { background-position: -520px -480px; }
+.emoji-flag_tc { background-position: -520px -500px; }
+.emoji-flag_td { background-position: 0 -520px; }
+.emoji-flag_tf { background-position: -20px -520px; }
+.emoji-flag_tg { background-position: -40px -520px; }
+.emoji-flag_th { background-position: -60px -520px; }
+.emoji-flag_tj { background-position: -80px -520px; }
+.emoji-flag_tk { background-position: -100px -520px; }
+.emoji-flag_tl { background-position: -120px -520px; }
+.emoji-flag_tm { background-position: -140px -520px; }
+.emoji-flag_tn { background-position: -160px -520px; }
+.emoji-flag_to { background-position: -180px -520px; }
+.emoji-flag_tr { background-position: -200px -520px; }
+.emoji-flag_tt { background-position: -220px -520px; }
+.emoji-flag_tv { background-position: -240px -520px; }
+.emoji-flag_tw { background-position: -260px -520px; }
+.emoji-flag_tz { background-position: -280px -520px; }
+.emoji-flag_ua { background-position: -300px -520px; }
+.emoji-flag_ug { background-position: -320px -520px; }
+.emoji-flag_um { background-position: -340px -520px; }
+.emoji-flag_us { background-position: -360px -520px; }
+.emoji-flag_uy { background-position: -380px -520px; }
+.emoji-flag_uz { background-position: -400px -520px; }
+.emoji-flag_va { background-position: -420px -520px; }
+.emoji-flag_vc { background-position: -440px -520px; }
+.emoji-flag_ve { background-position: -460px -520px; }
+.emoji-flag_vg { background-position: -480px -520px; }
+.emoji-flag_vi { background-position: -500px -520px; }
+.emoji-flag_vn { background-position: -520px -520px; }
+.emoji-flag_vu { background-position: -540px 0; }
+.emoji-flag_wf { background-position: -540px -20px; }
+.emoji-flag_white { background-position: -540px -40px; }
+.emoji-flag_ws { background-position: -540px -60px; }
+.emoji-flag_xk { background-position: -540px -80px; }
+.emoji-flag_ye { background-position: -540px -100px; }
+.emoji-flag_yt { background-position: -540px -120px; }
+.emoji-flag_za { background-position: -540px -140px; }
+.emoji-flag_zm { background-position: -540px -160px; }
+.emoji-flag_zw { background-position: -540px -180px; }
+.emoji-flags { background-position: -540px -200px; }
+.emoji-flashlight { background-position: -540px -220px; }
+.emoji-fleur-de-lis { background-position: -540px -240px; }
+.emoji-floppy_disk { background-position: -540px -260px; }
+.emoji-flower_playing_cards { background-position: -540px -280px; }
+.emoji-flushed { background-position: -540px -300px; }
+.emoji-fog { background-position: -540px -320px; }
+.emoji-foggy { background-position: -540px -340px; }
+.emoji-football { background-position: -540px -360px; }
+.emoji-footprints { background-position: -540px -380px; }
+.emoji-fork_and_knife { background-position: -540px -400px; }
+.emoji-fork_knife_plate { background-position: -540px -420px; }
+.emoji-fountain { background-position: -540px -440px; }
+.emoji-four { background-position: -540px -460px; }
+.emoji-four_leaf_clover { background-position: -540px -480px; }
+.emoji-fox { background-position: -540px -500px; }
+.emoji-frame_photo { background-position: -540px -520px; }
+.emoji-free { background-position: 0 -540px; }
+.emoji-french_bread { background-position: -20px -540px; }
+.emoji-fried_shrimp { background-position: -40px -540px; }
+.emoji-fries { background-position: -60px -540px; }
+.emoji-frog { background-position: -80px -540px; }
+.emoji-frowning { background-position: -100px -540px; }
+.emoji-frowning2 { background-position: -120px -540px; }
+.emoji-fuelpump { background-position: -140px -540px; }
+.emoji-full_moon { background-position: -160px -540px; }
+.emoji-full_moon_with_face { background-position: -180px -540px; }
+.emoji-game_die { background-position: -200px -540px; }
+.emoji-gay_pride_flag { background-position: -220px -540px; }
+.emoji-gear { background-position: -240px -540px; }
+.emoji-gem { background-position: -260px -540px; }
+.emoji-gemini { background-position: -280px -540px; }
+.emoji-ghost { background-position: -300px -540px; }
+.emoji-gift { background-position: -320px -540px; }
+.emoji-gift_heart { background-position: -340px -540px; }
+.emoji-girl { background-position: -360px -540px; }
+.emoji-girl_tone1 { background-position: -380px -540px; }
+.emoji-girl_tone2 { background-position: -400px -540px; }
+.emoji-girl_tone3 { background-position: -420px -540px; }
+.emoji-girl_tone4 { background-position: -440px -540px; }
+.emoji-girl_tone5 { background-position: -460px -540px; }
+.emoji-globe_with_meridians { background-position: -480px -540px; }
+.emoji-goal { background-position: -500px -540px; }
+.emoji-goat { background-position: -520px -540px; }
+.emoji-golf { background-position: -540px -540px; }
+.emoji-golfer { background-position: -560px 0; }
+.emoji-gorilla { background-position: -560px -20px; }
+.emoji-grapes { background-position: -560px -40px; }
+.emoji-green_apple { background-position: -560px -60px; }
+.emoji-green_book { background-position: -560px -80px; }
+.emoji-green_heart { background-position: -560px -100px; }
+.emoji-grey_exclamation { background-position: -560px -120px; }
+.emoji-grey_question { background-position: -560px -140px; }
+.emoji-grimacing { background-position: -560px -160px; }
+.emoji-grin { background-position: -560px -180px; }
+.emoji-grinning { background-position: -560px -200px; }
+.emoji-guardsman { background-position: -560px -220px; }
+.emoji-guardsman_tone1 { background-position: -560px -240px; }
+.emoji-guardsman_tone2 { background-position: -560px -260px; }
+.emoji-guardsman_tone3 { background-position: -560px -280px; }
+.emoji-guardsman_tone4 { background-position: -560px -300px; }
+.emoji-guardsman_tone5 { background-position: -560px -320px; }
+.emoji-guitar { background-position: -560px -340px; }
+.emoji-gun { background-position: -560px -360px; }
+.emoji-haircut { background-position: -560px -380px; }
+.emoji-haircut_tone1 { background-position: -560px -400px; }
+.emoji-haircut_tone2 { background-position: -560px -420px; }
+.emoji-haircut_tone3 { background-position: -560px -440px; }
+.emoji-haircut_tone4 { background-position: -560px -460px; }
+.emoji-haircut_tone5 { background-position: -560px -480px; }
+.emoji-hamburger { background-position: -560px -500px; }
+.emoji-hammer { background-position: -560px -520px; }
+.emoji-hammer_pick { background-position: -560px -540px; }
+.emoji-hamster { background-position: 0 -560px; }
+.emoji-hand_splayed { background-position: -20px -560px; }
+.emoji-hand_splayed_tone1 { background-position: -40px -560px; }
+.emoji-hand_splayed_tone2 { background-position: -60px -560px; }
+.emoji-hand_splayed_tone3 { background-position: -80px -560px; }
+.emoji-hand_splayed_tone4 { background-position: -100px -560px; }
+.emoji-hand_splayed_tone5 { background-position: -120px -560px; }
+.emoji-handbag { background-position: -140px -560px; }
+.emoji-handball { background-position: -160px -560px; }
+.emoji-handball_tone1 { background-position: -180px -560px; }
+.emoji-handball_tone2 { background-position: -200px -560px; }
+.emoji-handball_tone3 { background-position: -220px -560px; }
+.emoji-handball_tone4 { background-position: -240px -560px; }
+.emoji-handball_tone5 { background-position: -260px -560px; }
+.emoji-handshake { background-position: -280px -560px; }
+.emoji-handshake_tone1 { background-position: -300px -560px; }
+.emoji-handshake_tone2 { background-position: -320px -560px; }
+.emoji-handshake_tone3 { background-position: -340px -560px; }
+.emoji-handshake_tone4 { background-position: -360px -560px; }
+.emoji-handshake_tone5 { background-position: -380px -560px; }
+.emoji-hash { background-position: -400px -560px; }
+.emoji-hatched_chick { background-position: -420px -560px; }
+.emoji-hatching_chick { background-position: -440px -560px; }
+.emoji-head_bandage { background-position: -460px -560px; }
+.emoji-headphones { background-position: -480px -560px; }
+.emoji-hear_no_evil { background-position: -500px -560px; }
+.emoji-heart { background-position: -520px -560px; }
+.emoji-heart_decoration { background-position: -540px -560px; }
+.emoji-heart_exclamation { background-position: -560px -560px; }
+.emoji-heart_eyes { background-position: -580px 0; }
+.emoji-heart_eyes_cat { background-position: -580px -20px; }
+.emoji-heartbeat { background-position: -580px -40px; }
+.emoji-heartpulse { background-position: -580px -60px; }
+.emoji-hearts { background-position: -580px -80px; }
+.emoji-heavy_check_mark { background-position: -580px -100px; }
+.emoji-heavy_division_sign { background-position: -580px -120px; }
+.emoji-heavy_dollar_sign { background-position: -580px -140px; }
+.emoji-heavy_minus_sign { background-position: -580px -160px; }
+.emoji-heavy_multiplication_x { background-position: -580px -180px; }
+.emoji-heavy_plus_sign { background-position: -580px -200px; }
+.emoji-helicopter { background-position: -580px -220px; }
+.emoji-helmet_with_cross { background-position: -580px -240px; }
+.emoji-herb { background-position: -580px -260px; }
+.emoji-hibiscus { background-position: -580px -280px; }
+.emoji-high_brightness { background-position: -580px -300px; }
+.emoji-high_heel { background-position: -580px -320px; }
+.emoji-hockey { background-position: -580px -340px; }
+.emoji-hole { background-position: -580px -360px; }
+.emoji-homes { background-position: -580px -380px; }
+.emoji-honey_pot { background-position: -580px -400px; }
+.emoji-horse { background-position: -580px -420px; }
+.emoji-horse_racing { background-position: -580px -440px; }
+.emoji-horse_racing_tone1 { background-position: -580px -460px; }
+.emoji-horse_racing_tone2 { background-position: -580px -480px; }
+.emoji-horse_racing_tone3 { background-position: -580px -500px; }
+.emoji-horse_racing_tone4 { background-position: -580px -520px; }
+.emoji-horse_racing_tone5 { background-position: -580px -540px; }
+.emoji-hospital { background-position: -580px -560px; }
+.emoji-hot_pepper { background-position: 0 -580px; }
+.emoji-hotdog { background-position: -20px -580px; }
+.emoji-hotel { background-position: -40px -580px; }
+.emoji-hotsprings { background-position: -60px -580px; }
+.emoji-hourglass { background-position: -80px -580px; }
+.emoji-hourglass_flowing_sand { background-position: -100px -580px; }
+.emoji-house { background-position: -120px -580px; }
+.emoji-house_abandoned { background-position: -140px -580px; }
+.emoji-house_with_garden { background-position: -160px -580px; }
+.emoji-hugging { background-position: -180px -580px; }
+.emoji-hushed { background-position: -200px -580px; }
+.emoji-ice_cream { background-position: -220px -580px; }
+.emoji-ice_skate { background-position: -240px -580px; }
+.emoji-icecream { background-position: -260px -580px; }
+.emoji-id { background-position: -280px -580px; }
+.emoji-ideograph_advantage { background-position: -300px -580px; }
+.emoji-imp { background-position: -320px -580px; }
+.emoji-inbox_tray { background-position: -340px -580px; }
+.emoji-incoming_envelope { background-position: -360px -580px; }
+.emoji-information_desk_person { background-position: -380px -580px; }
+.emoji-information_desk_person_tone1 { background-position: -400px -580px; }
+.emoji-information_desk_person_tone2 { background-position: -420px -580px; }
+.emoji-information_desk_person_tone3 { background-position: -440px -580px; }
+.emoji-information_desk_person_tone4 { background-position: -460px -580px; }
+.emoji-information_desk_person_tone5 { background-position: -480px -580px; }
+.emoji-information_source { background-position: -500px -580px; }
+.emoji-innocent { background-position: -520px -580px; }
+.emoji-interrobang { background-position: -540px -580px; }
+.emoji-iphone { background-position: -560px -580px; }
+.emoji-island { background-position: -580px -580px; }
+.emoji-izakaya_lantern { background-position: -600px 0; }
+.emoji-jack_o_lantern { background-position: -600px -20px; }
+.emoji-japan { background-position: -600px -40px; }
+.emoji-japanese_castle { background-position: -600px -60px; }
+.emoji-japanese_goblin { background-position: -600px -80px; }
+.emoji-japanese_ogre { background-position: -600px -100px; }
+.emoji-jeans { background-position: -600px -120px; }
+.emoji-joy { background-position: -600px -140px; }
+.emoji-joy_cat { background-position: -600px -160px; }
+.emoji-joystick { background-position: -600px -180px; }
+.emoji-juggling { background-position: -600px -200px; }
+.emoji-juggling_tone1 { background-position: -600px -220px; }
+.emoji-juggling_tone2 { background-position: -600px -240px; }
+.emoji-juggling_tone3 { background-position: -600px -260px; }
+.emoji-juggling_tone4 { background-position: -600px -280px; }
+.emoji-juggling_tone5 { background-position: -600px -300px; }
+.emoji-kaaba { background-position: -600px -320px; }
+.emoji-key { background-position: -600px -340px; }
+.emoji-key2 { background-position: -600px -360px; }
+.emoji-keyboard { background-position: -600px -380px; }
+.emoji-kimono { background-position: -600px -400px; }
+.emoji-kiss { background-position: -600px -420px; }
+.emoji-kiss_mm { background-position: -600px -440px; }
+.emoji-kiss_ww { background-position: -600px -460px; }
+.emoji-kissing { background-position: -600px -480px; }
+.emoji-kissing_cat { background-position: -600px -500px; }
+.emoji-kissing_closed_eyes { background-position: -600px -520px; }
+.emoji-kissing_heart { background-position: -600px -540px; }
+.emoji-kissing_smiling_eyes { background-position: -600px -560px; }
+.emoji-kiwi { background-position: -600px -580px; }
+.emoji-knife { background-position: 0 -600px; }
+.emoji-koala { background-position: -20px -600px; }
+.emoji-koko { background-position: -40px -600px; }
+.emoji-label { background-position: -60px -600px; }
+.emoji-large_blue_circle { background-position: -80px -600px; }
+.emoji-large_blue_diamond { background-position: -100px -600px; }
+.emoji-large_orange_diamond { background-position: -120px -600px; }
+.emoji-last_quarter_moon { background-position: -140px -600px; }
+.emoji-last_quarter_moon_with_face { background-position: -160px -600px; }
+.emoji-laughing { background-position: -180px -600px; }
+.emoji-leaves { background-position: -200px -600px; }
+.emoji-ledger { background-position: -220px -600px; }
+.emoji-left_facing_fist { background-position: -240px -600px; }
+.emoji-left_facing_fist_tone1 { background-position: -260px -600px; }
+.emoji-left_facing_fist_tone2 { background-position: -280px -600px; }
+.emoji-left_facing_fist_tone3 { background-position: -300px -600px; }
+.emoji-left_facing_fist_tone4 { background-position: -320px -600px; }
+.emoji-left_facing_fist_tone5 { background-position: -340px -600px; }
+.emoji-left_luggage { background-position: -360px -600px; }
+.emoji-left_right_arrow { background-position: -380px -600px; }
+.emoji-leftwards_arrow_with_hook { background-position: -400px -600px; }
+.emoji-lemon { background-position: -420px -600px; }
+.emoji-leo { background-position: -440px -600px; }
+.emoji-leopard { background-position: -460px -600px; }
+.emoji-level_slider { background-position: -480px -600px; }
+.emoji-levitate { background-position: -500px -600px; }
+.emoji-libra { background-position: -520px -600px; }
+.emoji-lifter { background-position: -540px -600px; }
+.emoji-lifter_tone1 { background-position: -560px -600px; }
+.emoji-lifter_tone2 { background-position: -580px -600px; }
+.emoji-lifter_tone3 { background-position: -600px -600px; }
+.emoji-lifter_tone4 { background-position: -620px 0; }
+.emoji-lifter_tone5 { background-position: -620px -20px; }
+.emoji-light_rail { background-position: -620px -40px; }
+.emoji-link { background-position: -620px -60px; }
+.emoji-lion_face { background-position: -620px -80px; }
+.emoji-lips { background-position: -620px -100px; }
+.emoji-lipstick { background-position: -620px -120px; }
+.emoji-lizard { background-position: -620px -140px; }
+.emoji-lock { background-position: -620px -160px; }
+.emoji-lock_with_ink_pen { background-position: -620px -180px; }
+.emoji-lollipop { background-position: -620px -200px; }
+.emoji-loop { background-position: -620px -220px; }
+.emoji-loud_sound { background-position: -620px -240px; }
+.emoji-loudspeaker { background-position: -620px -260px; }
+.emoji-love_hotel { background-position: -620px -280px; }
+.emoji-love_letter { background-position: -620px -300px; }
+.emoji-low_brightness { background-position: -620px -320px; }
+.emoji-lying_face { background-position: -620px -340px; }
+.emoji-m { background-position: -620px -360px; }
+.emoji-mag { background-position: -620px -380px; }
+.emoji-mag_right { background-position: -620px -400px; }
+.emoji-mahjong { background-position: -620px -420px; }
+.emoji-mailbox { background-position: -620px -440px; }
+.emoji-mailbox_closed { background-position: -620px -460px; }
+.emoji-mailbox_with_mail { background-position: -620px -480px; }
+.emoji-mailbox_with_no_mail { background-position: -620px -500px; }
+.emoji-man { background-position: -620px -520px; }
+.emoji-man_dancing { background-position: -620px -540px; }
+.emoji-man_dancing_tone1 { background-position: -620px -560px; }
+.emoji-man_dancing_tone2 { background-position: -620px -580px; }
+.emoji-man_dancing_tone3 { background-position: -620px -600px; }
+.emoji-man_dancing_tone4 { background-position: 0 -620px; }
+.emoji-man_dancing_tone5 { background-position: -20px -620px; }
+.emoji-man_in_tuxedo { background-position: -40px -620px; }
+.emoji-man_in_tuxedo_tone1 { background-position: -60px -620px; }
+.emoji-man_in_tuxedo_tone2 { background-position: -80px -620px; }
+.emoji-man_in_tuxedo_tone3 { background-position: -100px -620px; }
+.emoji-man_in_tuxedo_tone4 { background-position: -120px -620px; }
+.emoji-man_in_tuxedo_tone5 { background-position: -140px -620px; }
+.emoji-man_tone1 { background-position: -160px -620px; }
+.emoji-man_tone2 { background-position: -180px -620px; }
+.emoji-man_tone3 { background-position: -200px -620px; }
+.emoji-man_tone4 { background-position: -220px -620px; }
+.emoji-man_tone5 { background-position: -240px -620px; }
+.emoji-man_with_gua_pi_mao { background-position: -260px -620px; }
+.emoji-man_with_gua_pi_mao_tone1 { background-position: -280px -620px; }
+.emoji-man_with_gua_pi_mao_tone2 { background-position: -300px -620px; }
+.emoji-man_with_gua_pi_mao_tone3 { background-position: -320px -620px; }
+.emoji-man_with_gua_pi_mao_tone4 { background-position: -340px -620px; }
+.emoji-man_with_gua_pi_mao_tone5 { background-position: -360px -620px; }
+.emoji-man_with_turban { background-position: -380px -620px; }
+.emoji-man_with_turban_tone1 { background-position: -400px -620px; }
+.emoji-man_with_turban_tone2 { background-position: -420px -620px; }
+.emoji-man_with_turban_tone3 { background-position: -440px -620px; }
+.emoji-man_with_turban_tone4 { background-position: -460px -620px; }
+.emoji-man_with_turban_tone5 { background-position: -480px -620px; }
+.emoji-mans_shoe { background-position: -500px -620px; }
+.emoji-map { background-position: -520px -620px; }
+.emoji-maple_leaf { background-position: -540px -620px; }
+.emoji-martial_arts_uniform { background-position: -560px -620px; }
+.emoji-mask { background-position: -580px -620px; }
+.emoji-massage { background-position: -600px -620px; }
+.emoji-massage_tone1 { background-position: -620px -620px; }
+.emoji-massage_tone2 { background-position: -640px 0; }
+.emoji-massage_tone3 { background-position: -640px -20px; }
+.emoji-massage_tone4 { background-position: -640px -40px; }
+.emoji-massage_tone5 { background-position: -640px -60px; }
+.emoji-meat_on_bone { background-position: -640px -80px; }
+.emoji-medal { background-position: -640px -100px; }
+.emoji-mega { background-position: -640px -120px; }
+.emoji-melon { background-position: -640px -140px; }
+.emoji-menorah { background-position: -640px -160px; }
+.emoji-mens { background-position: -640px -180px; }
+.emoji-metal { background-position: -640px -200px; }
+.emoji-metal_tone1 { background-position: -640px -220px; }
+.emoji-metal_tone2 { background-position: -640px -240px; }
+.emoji-metal_tone3 { background-position: -640px -260px; }
+.emoji-metal_tone4 { background-position: -640px -280px; }
+.emoji-metal_tone5 { background-position: -640px -300px; }
+.emoji-metro { background-position: -640px -320px; }
+.emoji-microphone { background-position: -640px -340px; }
+.emoji-microphone2 { background-position: -640px -360px; }
+.emoji-microscope { background-position: -640px -380px; }
+.emoji-middle_finger { background-position: -640px -400px; }
+.emoji-middle_finger_tone1 { background-position: -640px -420px; }
+.emoji-middle_finger_tone2 { background-position: -640px -440px; }
+.emoji-middle_finger_tone3 { background-position: -640px -460px; }
+.emoji-middle_finger_tone4 { background-position: -640px -480px; }
+.emoji-middle_finger_tone5 { background-position: -640px -500px; }
+.emoji-military_medal { background-position: -640px -520px; }
+.emoji-milk { background-position: -640px -540px; }
+.emoji-milky_way { background-position: -640px -560px; }
+.emoji-minibus { background-position: -640px -580px; }
+.emoji-minidisc { background-position: -640px -600px; }
+.emoji-mobile_phone_off { background-position: -640px -620px; }
+.emoji-money_mouth { background-position: 0 -640px; }
+.emoji-money_with_wings { background-position: -20px -640px; }
+.emoji-moneybag { background-position: -40px -640px; }
+.emoji-monkey { background-position: -60px -640px; }
+.emoji-monkey_face { background-position: -80px -640px; }
+.emoji-monorail { background-position: -100px -640px; }
+.emoji-mortar_board { background-position: -120px -640px; }
+.emoji-mosque { background-position: -140px -640px; }
+.emoji-motor_scooter { background-position: -160px -640px; }
+.emoji-motorboat { background-position: -180px -640px; }
+.emoji-motorcycle { background-position: -200px -640px; }
+.emoji-motorway { background-position: -220px -640px; }
+.emoji-mount_fuji { background-position: -240px -640px; }
+.emoji-mountain { background-position: -260px -640px; }
+.emoji-mountain_bicyclist { background-position: -280px -640px; }
+.emoji-mountain_bicyclist_tone1 { background-position: -300px -640px; }
+.emoji-mountain_bicyclist_tone2 { background-position: -320px -640px; }
+.emoji-mountain_bicyclist_tone3 { background-position: -340px -640px; }
+.emoji-mountain_bicyclist_tone4 { background-position: -360px -640px; }
+.emoji-mountain_bicyclist_tone5 { background-position: -380px -640px; }
+.emoji-mountain_cableway { background-position: -400px -640px; }
+.emoji-mountain_railway { background-position: -420px -640px; }
+.emoji-mountain_snow { background-position: -440px -640px; }
+.emoji-mouse { background-position: -460px -640px; }
+.emoji-mouse2 { background-position: -480px -640px; }
+.emoji-mouse_three_button { background-position: -500px -640px; }
+.emoji-movie_camera { background-position: -520px -640px; }
+.emoji-moyai { background-position: -540px -640px; }
+.emoji-mrs_claus { background-position: -560px -640px; }
+.emoji-mrs_claus_tone1 { background-position: -580px -640px; }
+.emoji-mrs_claus_tone2 { background-position: -600px -640px; }
+.emoji-mrs_claus_tone3 { background-position: -620px -640px; }
+.emoji-mrs_claus_tone4 { background-position: -640px -640px; }
+.emoji-mrs_claus_tone5 { background-position: -660px 0; }
+.emoji-muscle { background-position: -660px -20px; }
+.emoji-muscle_tone1 { background-position: -660px -40px; }
+.emoji-muscle_tone2 { background-position: -660px -60px; }
+.emoji-muscle_tone3 { background-position: -660px -80px; }
+.emoji-muscle_tone4 { background-position: -660px -100px; }
+.emoji-muscle_tone5 { background-position: -660px -120px; }
+.emoji-mushroom { background-position: -660px -140px; }
+.emoji-musical_keyboard { background-position: -660px -160px; }
+.emoji-musical_note { background-position: -660px -180px; }
+.emoji-musical_score { background-position: -660px -200px; }
+.emoji-mute { background-position: -660px -220px; }
+.emoji-nail_care { background-position: -660px -240px; }
+.emoji-nail_care_tone1 { background-position: -660px -260px; }
+.emoji-nail_care_tone2 { background-position: -660px -280px; }
+.emoji-nail_care_tone3 { background-position: -660px -300px; }
+.emoji-nail_care_tone4 { background-position: -660px -320px; }
+.emoji-nail_care_tone5 { background-position: -660px -340px; }
+.emoji-name_badge { background-position: -660px -360px; }
+.emoji-nauseated_face { background-position: -660px -380px; }
+.emoji-necktie { background-position: -660px -400px; }
+.emoji-negative_squared_cross_mark { background-position: -660px -420px; }
+.emoji-nerd { background-position: -660px -440px; }
+.emoji-neutral_face { background-position: -660px -460px; }
+.emoji-new { background-position: -660px -480px; }
+.emoji-new_moon { background-position: -660px -500px; }
+.emoji-new_moon_with_face { background-position: -660px -520px; }
+.emoji-newspaper { background-position: -660px -540px; }
+.emoji-newspaper2 { background-position: -660px -560px; }
+.emoji-ng { background-position: -660px -580px; }
+.emoji-night_with_stars { background-position: -660px -600px; }
+.emoji-nine { background-position: -660px -620px; }
+.emoji-no_bell { background-position: -660px -640px; }
+.emoji-no_bicycles { background-position: 0 -660px; }
+.emoji-no_entry { background-position: -20px -660px; }
+.emoji-no_entry_sign { background-position: -40px -660px; }
+.emoji-no_good { background-position: -60px -660px; }
+.emoji-no_good_tone1 { background-position: -80px -660px; }
+.emoji-no_good_tone2 { background-position: -100px -660px; }
+.emoji-no_good_tone3 { background-position: -120px -660px; }
+.emoji-no_good_tone4 { background-position: -140px -660px; }
+.emoji-no_good_tone5 { background-position: -160px -660px; }
+.emoji-no_mobile_phones { background-position: -180px -660px; }
+.emoji-no_mouth { background-position: -200px -660px; }
+.emoji-no_pedestrians { background-position: -220px -660px; }
+.emoji-no_smoking { background-position: -240px -660px; }
+.emoji-non-potable_water { background-position: -260px -660px; }
+.emoji-nose { background-position: -280px -660px; }
+.emoji-nose_tone1 { background-position: -300px -660px; }
+.emoji-nose_tone2 { background-position: -320px -660px; }
+.emoji-nose_tone3 { background-position: -340px -660px; }
+.emoji-nose_tone4 { background-position: -360px -660px; }
+.emoji-nose_tone5 { background-position: -380px -660px; }
+.emoji-notebook { background-position: -400px -660px; }
+.emoji-notebook_with_decorative_cover { background-position: -420px -660px; }
+.emoji-notepad_spiral { background-position: -440px -660px; }
+.emoji-notes { background-position: -460px -660px; }
+.emoji-nut_and_bolt { background-position: -480px -660px; }
+.emoji-o { background-position: -500px -660px; }
+.emoji-o2 { background-position: -520px -660px; }
+.emoji-ocean { background-position: -540px -660px; }
+.emoji-octagonal_sign { background-position: -560px -660px; }
+.emoji-octopus { background-position: -580px -660px; }
+.emoji-oden { background-position: -600px -660px; }
+.emoji-office { background-position: -620px -660px; }
+.emoji-oil { background-position: -640px -660px; }
+.emoji-ok { background-position: -660px -660px; }
+.emoji-ok_hand { background-position: -680px 0; }
+.emoji-ok_hand_tone1 { background-position: -680px -20px; }
+.emoji-ok_hand_tone2 { background-position: -680px -40px; }
+.emoji-ok_hand_tone3 { background-position: -680px -60px; }
+.emoji-ok_hand_tone4 { background-position: -680px -80px; }
+.emoji-ok_hand_tone5 { background-position: -680px -100px; }
+.emoji-ok_woman { background-position: -680px -120px; }
+.emoji-ok_woman_tone1 { background-position: -680px -140px; }
+.emoji-ok_woman_tone2 { background-position: -680px -160px; }
+.emoji-ok_woman_tone3 { background-position: -680px -180px; }
+.emoji-ok_woman_tone4 { background-position: -680px -200px; }
+.emoji-ok_woman_tone5 { background-position: -680px -220px; }
+.emoji-older_man { background-position: -680px -240px; }
+.emoji-older_man_tone1 { background-position: -680px -260px; }
+.emoji-older_man_tone2 { background-position: -680px -280px; }
+.emoji-older_man_tone3 { background-position: -680px -300px; }
+.emoji-older_man_tone4 { background-position: -680px -320px; }
+.emoji-older_man_tone5 { background-position: -680px -340px; }
+.emoji-older_woman { background-position: -680px -360px; }
+.emoji-older_woman_tone1 { background-position: -680px -380px; }
+.emoji-older_woman_tone2 { background-position: -680px -400px; }
+.emoji-older_woman_tone3 { background-position: -680px -420px; }
+.emoji-older_woman_tone4 { background-position: -680px -440px; }
+.emoji-older_woman_tone5 { background-position: -680px -460px; }
+.emoji-om_symbol { background-position: -680px -480px; }
+.emoji-on { background-position: -680px -500px; }
+.emoji-oncoming_automobile { background-position: -680px -520px; }
+.emoji-oncoming_bus { background-position: -680px -540px; }
+.emoji-oncoming_police_car { background-position: -680px -560px; }
+.emoji-oncoming_taxi { background-position: -680px -580px; }
+.emoji-one { background-position: -680px -600px; }
+.emoji-open_file_folder { background-position: -680px -620px; }
+.emoji-open_hands { background-position: -680px -640px; }
+.emoji-open_hands_tone1 { background-position: -680px -660px; }
+.emoji-open_hands_tone2 { background-position: 0 -680px; }
+.emoji-open_hands_tone3 { background-position: -20px -680px; }
+.emoji-open_hands_tone4 { background-position: -40px -680px; }
+.emoji-open_hands_tone5 { background-position: -60px -680px; }
+.emoji-open_mouth { background-position: -80px -680px; }
+.emoji-ophiuchus { background-position: -100px -680px; }
+.emoji-orange_book { background-position: -120px -680px; }
+.emoji-orthodox_cross { background-position: -140px -680px; }
+.emoji-outbox_tray { background-position: -160px -680px; }
+.emoji-owl { background-position: -180px -680px; }
+.emoji-ox { background-position: -200px -680px; }
+.emoji-package { background-position: -220px -680px; }
+.emoji-page_facing_up { background-position: -240px -680px; }
+.emoji-page_with_curl { background-position: -260px -680px; }
+.emoji-pager { background-position: -280px -680px; }
+.emoji-paintbrush { background-position: -300px -680px; }
+.emoji-palm_tree { background-position: -320px -680px; }
+.emoji-pancakes { background-position: -340px -680px; }
+.emoji-panda_face { background-position: -360px -680px; }
+.emoji-paperclip { background-position: -380px -680px; }
+.emoji-paperclips { background-position: -400px -680px; }
+.emoji-park { background-position: -420px -680px; }
+.emoji-parking { background-position: -440px -680px; }
+.emoji-part_alternation_mark { background-position: -460px -680px; }
+.emoji-partly_sunny { background-position: -480px -680px; }
+.emoji-passport_control { background-position: -500px -680px; }
+.emoji-pause_button { background-position: -520px -680px; }
+.emoji-peace { background-position: -540px -680px; }
+.emoji-peach { background-position: -560px -680px; }
+.emoji-peanuts { background-position: -580px -680px; }
+.emoji-pear { background-position: -600px -680px; }
+.emoji-pen_ballpoint { background-position: -620px -680px; }
+.emoji-pen_fountain { background-position: -640px -680px; }
+.emoji-pencil { background-position: -660px -680px; }
+.emoji-pencil2 { background-position: -680px -680px; }
+.emoji-penguin { background-position: -700px 0; }
+.emoji-pensive { background-position: -700px -20px; }
+.emoji-performing_arts { background-position: -700px -40px; }
+.emoji-persevere { background-position: -700px -60px; }
+.emoji-person_frowning { background-position: -700px -80px; }
+.emoji-person_frowning_tone1 { background-position: -700px -100px; }
+.emoji-person_frowning_tone2 { background-position: -700px -120px; }
+.emoji-person_frowning_tone3 { background-position: -700px -140px; }
+.emoji-person_frowning_tone4 { background-position: -700px -160px; }
+.emoji-person_frowning_tone5 { background-position: -700px -180px; }
+.emoji-person_with_blond_hair { background-position: -700px -200px; }
+.emoji-person_with_blond_hair_tone1 { background-position: -700px -220px; }
+.emoji-person_with_blond_hair_tone2 { background-position: -700px -240px; }
+.emoji-person_with_blond_hair_tone3 { background-position: -700px -260px; }
+.emoji-person_with_blond_hair_tone4 { background-position: -700px -280px; }
+.emoji-person_with_blond_hair_tone5 { background-position: -700px -300px; }
+.emoji-person_with_pouting_face { background-position: -700px -320px; }
+.emoji-person_with_pouting_face_tone1 { background-position: -700px -340px; }
+.emoji-person_with_pouting_face_tone2 { background-position: -700px -360px; }
+.emoji-person_with_pouting_face_tone3 { background-position: -700px -380px; }
+.emoji-person_with_pouting_face_tone4 { background-position: -700px -400px; }
+.emoji-person_with_pouting_face_tone5 { background-position: -700px -420px; }
+.emoji-pick { background-position: -700px -440px; }
+.emoji-pig { background-position: -700px -460px; }
+.emoji-pig2 { background-position: -700px -480px; }
+.emoji-pig_nose { background-position: -700px -500px; }
+.emoji-pill { background-position: -700px -520px; }
+.emoji-pineapple { background-position: -700px -540px; }
+.emoji-ping_pong { background-position: -700px -560px; }
+.emoji-pisces { background-position: -700px -580px; }
+.emoji-pizza { background-position: -700px -600px; }
+.emoji-place_of_worship { background-position: -700px -620px; }
+.emoji-play_pause { background-position: -700px -640px; }
+.emoji-point_down { background-position: -700px -660px; }
+.emoji-point_down_tone1 { background-position: -700px -680px; }
+.emoji-point_down_tone2 { background-position: 0 -700px; }
+.emoji-point_down_tone3 { background-position: -20px -700px; }
+.emoji-point_down_tone4 { background-position: -40px -700px; }
+.emoji-point_down_tone5 { background-position: -60px -700px; }
+.emoji-point_left { background-position: -80px -700px; }
+.emoji-point_left_tone1 { background-position: -100px -700px; }
+.emoji-point_left_tone2 { background-position: -120px -700px; }
+.emoji-point_left_tone3 { background-position: -140px -700px; }
+.emoji-point_left_tone4 { background-position: -160px -700px; }
+.emoji-point_left_tone5 { background-position: -180px -700px; }
+.emoji-point_right { background-position: -200px -700px; }
+.emoji-point_right_tone1 { background-position: -220px -700px; }
+.emoji-point_right_tone2 { background-position: -240px -700px; }
+.emoji-point_right_tone3 { background-position: -260px -700px; }
+.emoji-point_right_tone4 { background-position: -280px -700px; }
+.emoji-point_right_tone5 { background-position: -300px -700px; }
+.emoji-point_up { background-position: -320px -700px; }
+.emoji-point_up_2 { background-position: -340px -700px; }
+.emoji-point_up_2_tone1 { background-position: -360px -700px; }
+.emoji-point_up_2_tone2 { background-position: -380px -700px; }
+.emoji-point_up_2_tone3 { background-position: -400px -700px; }
+.emoji-point_up_2_tone4 { background-position: -420px -700px; }
+.emoji-point_up_2_tone5 { background-position: -440px -700px; }
+.emoji-point_up_tone1 { background-position: -460px -700px; }
+.emoji-point_up_tone2 { background-position: -480px -700px; }
+.emoji-point_up_tone3 { background-position: -500px -700px; }
+.emoji-point_up_tone4 { background-position: -520px -700px; }
+.emoji-point_up_tone5 { background-position: -540px -700px; }
+.emoji-police_car { background-position: -560px -700px; }
+.emoji-poodle { background-position: -580px -700px; }
+.emoji-poop { background-position: -600px -700px; }
+.emoji-popcorn { background-position: -620px -700px; }
+.emoji-post_office { background-position: -640px -700px; }
+.emoji-postal_horn { background-position: -660px -700px; }
+.emoji-postbox { background-position: -680px -700px; }
+.emoji-potable_water { background-position: -700px -700px; }
+.emoji-potato { background-position: -720px 0; }
+.emoji-pouch { background-position: -720px -20px; }
+.emoji-poultry_leg { background-position: -720px -40px; }
+.emoji-pound { background-position: -720px -60px; }
+.emoji-pouting_cat { background-position: -720px -80px; }
+.emoji-pray { background-position: -720px -100px; }
+.emoji-pray_tone1 { background-position: -720px -120px; }
+.emoji-pray_tone2 { background-position: -720px -140px; }
+.emoji-pray_tone3 { background-position: -720px -160px; }
+.emoji-pray_tone4 { background-position: -720px -180px; }
+.emoji-pray_tone5 { background-position: -720px -200px; }
+.emoji-prayer_beads { background-position: -720px -220px; }
+.emoji-pregnant_woman { background-position: -720px -240px; }
+.emoji-pregnant_woman_tone1 { background-position: -720px -260px; }
+.emoji-pregnant_woman_tone2 { background-position: -720px -280px; }
+.emoji-pregnant_woman_tone3 { background-position: -720px -300px; }
+.emoji-pregnant_woman_tone4 { background-position: -720px -320px; }
+.emoji-pregnant_woman_tone5 { background-position: -720px -340px; }
+.emoji-prince { background-position: -720px -360px; }
+.emoji-prince_tone1 { background-position: -720px -380px; }
+.emoji-prince_tone2 { background-position: -720px -400px; }
+.emoji-prince_tone3 { background-position: -720px -420px; }
+.emoji-prince_tone4 { background-position: -720px -440px; }
+.emoji-prince_tone5 { background-position: -720px -460px; }
+.emoji-princess { background-position: -720px -480px; }
+.emoji-princess_tone1 { background-position: -720px -500px; }
+.emoji-princess_tone2 { background-position: -720px -520px; }
+.emoji-princess_tone3 { background-position: -720px -540px; }
+.emoji-princess_tone4 { background-position: -720px -560px; }
+.emoji-princess_tone5 { background-position: -720px -580px; }
+.emoji-printer { background-position: -720px -600px; }
+.emoji-projector { background-position: -720px -620px; }
+.emoji-punch { background-position: -720px -640px; }
+.emoji-punch_tone1 { background-position: -720px -660px; }
+.emoji-punch_tone2 { background-position: -720px -680px; }
+.emoji-punch_tone3 { background-position: -720px -700px; }
+.emoji-punch_tone4 { background-position: 0 -720px; }
+.emoji-punch_tone5 { background-position: -20px -720px; }
+.emoji-purple_heart { background-position: -40px -720px; }
+.emoji-purse { background-position: -60px -720px; }
+.emoji-pushpin { background-position: -80px -720px; }
+.emoji-put_litter_in_its_place { background-position: -100px -720px; }
+.emoji-question { background-position: -120px -720px; }
+.emoji-rabbit { background-position: -140px -720px; }
+.emoji-rabbit2 { background-position: -160px -720px; }
+.emoji-race_car { background-position: -180px -720px; }
+.emoji-racehorse { background-position: -200px -720px; }
+.emoji-radio { background-position: -220px -720px; }
+.emoji-radio_button { background-position: -240px -720px; }
+.emoji-radioactive { background-position: -260px -720px; }
+.emoji-rage { background-position: -280px -720px; }
+.emoji-railway_car { background-position: -300px -720px; }
+.emoji-railway_track { background-position: -320px -720px; }
+.emoji-rainbow { background-position: -340px -720px; }
+.emoji-raised_back_of_hand { background-position: -360px -720px; }
+.emoji-raised_back_of_hand_tone1 { background-position: -380px -720px; }
+.emoji-raised_back_of_hand_tone2 { background-position: -400px -720px; }
+.emoji-raised_back_of_hand_tone3 { background-position: -420px -720px; }
+.emoji-raised_back_of_hand_tone4 { background-position: -440px -720px; }
+.emoji-raised_back_of_hand_tone5 { background-position: -460px -720px; }
+.emoji-raised_hand { background-position: -480px -720px; }
+.emoji-raised_hand_tone1 { background-position: -500px -720px; }
+.emoji-raised_hand_tone2 { background-position: -520px -720px; }
+.emoji-raised_hand_tone3 { background-position: -540px -720px; }
+.emoji-raised_hand_tone4 { background-position: -560px -720px; }
+.emoji-raised_hand_tone5 { background-position: -580px -720px; }
+.emoji-raised_hands { background-position: -600px -720px; }
+.emoji-raised_hands_tone1 { background-position: -620px -720px; }
+.emoji-raised_hands_tone2 { background-position: -640px -720px; }
+.emoji-raised_hands_tone3 { background-position: -660px -720px; }
+.emoji-raised_hands_tone4 { background-position: -680px -720px; }
+.emoji-raised_hands_tone5 { background-position: -700px -720px; }
+.emoji-raising_hand { background-position: -720px -720px; }
+.emoji-raising_hand_tone1 { background-position: -740px 0; }
+.emoji-raising_hand_tone2 { background-position: -740px -20px; }
+.emoji-raising_hand_tone3 { background-position: -740px -40px; }
+.emoji-raising_hand_tone4 { background-position: -740px -60px; }
+.emoji-raising_hand_tone5 { background-position: -740px -80px; }
+.emoji-ram { background-position: -740px -100px; }
+.emoji-ramen { background-position: -740px -120px; }
+.emoji-rat { background-position: -740px -140px; }
+.emoji-record_button { background-position: -740px -160px; }
+.emoji-recycle { background-position: -740px -180px; }
+.emoji-red_car { background-position: -740px -200px; }
+.emoji-red_circle { background-position: -740px -220px; }
+.emoji-registered { background-position: -740px -240px; }
+.emoji-relaxed { background-position: -740px -260px; }
+.emoji-relieved { background-position: -740px -280px; }
+.emoji-reminder_ribbon { background-position: -740px -300px; }
+.emoji-repeat { background-position: -740px -320px; }
+.emoji-repeat_one { background-position: -740px -340px; }
+.emoji-restroom { background-position: -740px -360px; }
+.emoji-revolving_hearts { background-position: -740px -380px; }
+.emoji-rewind { background-position: -740px -400px; }
+.emoji-rhino { background-position: -740px -420px; }
+.emoji-ribbon { background-position: -740px -440px; }
+.emoji-rice { background-position: -740px -460px; }
+.emoji-rice_ball { background-position: -740px -480px; }
+.emoji-rice_cracker { background-position: -740px -500px; }
+.emoji-rice_scene { background-position: -740px -520px; }
+.emoji-right_facing_fist { background-position: -740px -540px; }
+.emoji-right_facing_fist_tone1 { background-position: -740px -560px; }
+.emoji-right_facing_fist_tone2 { background-position: -740px -580px; }
+.emoji-right_facing_fist_tone3 { background-position: -740px -600px; }
+.emoji-right_facing_fist_tone4 { background-position: -740px -620px; }
+.emoji-right_facing_fist_tone5 { background-position: -740px -640px; }
+.emoji-ring { background-position: -740px -660px; }
+.emoji-robot { background-position: -740px -680px; }
+.emoji-rocket { background-position: -740px -700px; }
+.emoji-rofl { background-position: -740px -720px; }
+.emoji-roller_coaster { background-position: 0 -740px; }
+.emoji-rolling_eyes { background-position: -20px -740px; }
+.emoji-rooster { background-position: -40px -740px; }
+.emoji-rose { background-position: -60px -740px; }
+.emoji-rosette { background-position: -80px -740px; }
+.emoji-rotating_light { background-position: -100px -740px; }
+.emoji-round_pushpin { background-position: -120px -740px; }
+.emoji-rowboat { background-position: -140px -740px; }
+.emoji-rowboat_tone1 { background-position: -160px -740px; }
+.emoji-rowboat_tone2 { background-position: -180px -740px; }
+.emoji-rowboat_tone3 { background-position: -200px -740px; }
+.emoji-rowboat_tone4 { background-position: -220px -740px; }
+.emoji-rowboat_tone5 { background-position: -240px -740px; }
+.emoji-rugby_football { background-position: -260px -740px; }
+.emoji-runner { background-position: -280px -740px; }
+.emoji-runner_tone1 { background-position: -300px -740px; }
+.emoji-runner_tone2 { background-position: -320px -740px; }
+.emoji-runner_tone3 { background-position: -340px -740px; }
+.emoji-runner_tone4 { background-position: -360px -740px; }
+.emoji-runner_tone5 { background-position: -380px -740px; }
+.emoji-running_shirt_with_sash { background-position: -400px -740px; }
+.emoji-sa { background-position: -420px -740px; }
+.emoji-sagittarius { background-position: -440px -740px; }
+.emoji-sailboat { background-position: -460px -740px; }
+.emoji-sake { background-position: -480px -740px; }
+.emoji-salad { background-position: -500px -740px; }
+.emoji-sandal { background-position: -520px -740px; }
+.emoji-santa { background-position: -540px -740px; }
+.emoji-santa_tone1 { background-position: -560px -740px; }
+.emoji-santa_tone2 { background-position: -580px -740px; }
+.emoji-santa_tone3 { background-position: -600px -740px; }
+.emoji-santa_tone4 { background-position: -620px -740px; }
+.emoji-santa_tone5 { background-position: -640px -740px; }
+.emoji-satellite { background-position: -660px -740px; }
+.emoji-satellite_orbital { background-position: -680px -740px; }
+.emoji-saxophone { background-position: -700px -740px; }
+.emoji-scales { background-position: -720px -740px; }
+.emoji-school { background-position: -740px -740px; }
+.emoji-school_satchel { background-position: -760px 0; }
+.emoji-scissors { background-position: -760px -20px; }
+.emoji-scooter { background-position: -760px -40px; }
+.emoji-scorpion { background-position: -760px -60px; }
+.emoji-scorpius { background-position: -760px -80px; }
+.emoji-scream { background-position: -760px -100px; }
+.emoji-scream_cat { background-position: -760px -120px; }
+.emoji-scroll { background-position: -760px -140px; }
+.emoji-seat { background-position: -760px -160px; }
+.emoji-second_place { background-position: -760px -180px; }
+.emoji-secret { background-position: -760px -200px; }
+.emoji-see_no_evil { background-position: -760px -220px; }
+.emoji-seedling { background-position: -760px -240px; }
+.emoji-selfie { background-position: -760px -260px; }
+.emoji-selfie_tone1 { background-position: -760px -280px; }
+.emoji-selfie_tone2 { background-position: -760px -300px; }
+.emoji-selfie_tone3 { background-position: -760px -320px; }
+.emoji-selfie_tone4 { background-position: -760px -340px; }
+.emoji-selfie_tone5 { background-position: -760px -360px; }
+.emoji-seven { background-position: -760px -380px; }
+.emoji-shallow_pan_of_food { background-position: -760px -400px; }
+.emoji-shamrock { background-position: -760px -420px; }
+.emoji-shark { background-position: -760px -440px; }
+.emoji-shaved_ice { background-position: -760px -460px; }
+.emoji-sheep { background-position: -760px -480px; }
+.emoji-shell { background-position: -760px -500px; }
+.emoji-shield { background-position: -760px -520px; }
+.emoji-shinto_shrine { background-position: -760px -540px; }
+.emoji-ship { background-position: -760px -560px; }
+.emoji-shirt { background-position: -760px -580px; }
+.emoji-shopping_bags { background-position: -760px -600px; }
+.emoji-shopping_cart { background-position: -760px -620px; }
+.emoji-shower { background-position: -760px -640px; }
+.emoji-shrimp { background-position: -760px -660px; }
+.emoji-shrug { background-position: -760px -680px; }
+.emoji-shrug_tone1 { background-position: -760px -700px; }
+.emoji-shrug_tone2 { background-position: -760px -720px; }
+.emoji-shrug_tone3 { background-position: -760px -740px; }
+.emoji-shrug_tone4 { background-position: 0 -760px; }
+.emoji-shrug_tone5 { background-position: -20px -760px; }
+.emoji-signal_strength { background-position: -40px -760px; }
+.emoji-six { background-position: -60px -760px; }
+.emoji-six_pointed_star { background-position: -80px -760px; }
+.emoji-ski { background-position: -100px -760px; }
+.emoji-skier { background-position: -120px -760px; }
+.emoji-skull { background-position: -140px -760px; }
+.emoji-skull_crossbones { background-position: -160px -760px; }
+.emoji-sleeping { background-position: -180px -760px; }
+.emoji-sleeping_accommodation { background-position: -200px -760px; }
+.emoji-sleepy { background-position: -220px -760px; }
+.emoji-slight_frown { background-position: -240px -760px; }
+.emoji-slight_smile { background-position: -260px -760px; }
+.emoji-slot_machine { background-position: -280px -760px; }
+.emoji-small_blue_diamond { background-position: -300px -760px; }
+.emoji-small_orange_diamond { background-position: -320px -760px; }
+.emoji-small_red_triangle { background-position: -340px -760px; }
+.emoji-small_red_triangle_down { background-position: -360px -760px; }
+.emoji-smile { background-position: -380px -760px; }
+.emoji-smile_cat { background-position: -400px -760px; }
+.emoji-smiley { background-position: -420px -760px; }
+.emoji-smiley_cat { background-position: -440px -760px; }
+.emoji-smiling_imp { background-position: -460px -760px; }
+.emoji-smirk { background-position: -480px -760px; }
+.emoji-smirk_cat { background-position: -500px -760px; }
+.emoji-smoking { background-position: -520px -760px; }
+.emoji-snail { background-position: -540px -760px; }
+.emoji-snake { background-position: -560px -760px; }
+.emoji-sneezing_face { background-position: -580px -760px; }
+.emoji-snowboarder { background-position: -600px -760px; }
+.emoji-snowflake { background-position: -620px -760px; }
+.emoji-snowman { background-position: -640px -760px; }
+.emoji-snowman2 { background-position: -660px -760px; }
+.emoji-sob { background-position: -680px -760px; }
+.emoji-soccer { background-position: -700px -760px; }
+.emoji-soon { background-position: -720px -760px; }
+.emoji-sos { background-position: -740px -760px; }
+.emoji-sound { background-position: -760px -760px; }
+.emoji-space_invader { background-position: -780px 0; }
+.emoji-spades { background-position: -780px -20px; }
+.emoji-spaghetti { background-position: -780px -40px; }
+.emoji-sparkle { background-position: -780px -60px; }
+.emoji-sparkler { background-position: -780px -80px; }
+.emoji-sparkles { background-position: -780px -100px; }
+.emoji-sparkling_heart { background-position: -780px -120px; }
+.emoji-speak_no_evil { background-position: -780px -140px; }
+.emoji-speaker { background-position: -780px -160px; }
+.emoji-speaking_head { background-position: -780px -180px; }
+.emoji-speech_balloon { background-position: -780px -200px; }
+.emoji-speech_left { background-position: -780px -220px; }
+.emoji-speedboat { background-position: -780px -240px; }
+.emoji-spider { background-position: -780px -260px; }
+.emoji-spider_web { background-position: -780px -280px; }
+.emoji-spoon { background-position: -780px -300px; }
+.emoji-spy { background-position: -780px -320px; }
+.emoji-spy_tone1 { background-position: -780px -340px; }
+.emoji-spy_tone2 { background-position: -780px -360px; }
+.emoji-spy_tone3 { background-position: -780px -380px; }
+.emoji-spy_tone4 { background-position: -780px -400px; }
+.emoji-spy_tone5 { background-position: -780px -420px; }
+.emoji-squid { background-position: -780px -440px; }
+.emoji-stadium { background-position: -780px -460px; }
+.emoji-star { background-position: -780px -480px; }
+.emoji-star2 { background-position: -780px -500px; }
+.emoji-star_and_crescent { background-position: -780px -520px; }
+.emoji-star_of_david { background-position: -780px -540px; }
+.emoji-stars { background-position: -780px -560px; }
+.emoji-station { background-position: -780px -580px; }
+.emoji-statue_of_liberty { background-position: -780px -600px; }
+.emoji-steam_locomotive { background-position: -780px -620px; }
+.emoji-stew { background-position: -780px -640px; }
+.emoji-stop_button { background-position: -780px -660px; }
+.emoji-stopwatch { background-position: -780px -680px; }
+.emoji-straight_ruler { background-position: -780px -700px; }
+.emoji-strawberry { background-position: -780px -720px; }
+.emoji-stuck_out_tongue { background-position: -780px -740px; }
+.emoji-stuck_out_tongue_closed_eyes { background-position: -780px -760px; }
+.emoji-stuck_out_tongue_winking_eye { background-position: 0 -780px; }
+.emoji-stuffed_flatbread { background-position: -20px -780px; }
+.emoji-sun_with_face { background-position: -40px -780px; }
+.emoji-sunflower { background-position: -60px -780px; }
+.emoji-sunglasses { background-position: -80px -780px; }
+.emoji-sunny { background-position: -100px -780px; }
+.emoji-sunrise { background-position: -120px -780px; }
+.emoji-sunrise_over_mountains { background-position: -140px -780px; }
+.emoji-surfer { background-position: -160px -780px; }
+.emoji-surfer_tone1 { background-position: -180px -780px; }
+.emoji-surfer_tone2 { background-position: -200px -780px; }
+.emoji-surfer_tone3 { background-position: -220px -780px; }
+.emoji-surfer_tone4 { background-position: -240px -780px; }
+.emoji-surfer_tone5 { background-position: -260px -780px; }
+.emoji-sushi { background-position: -280px -780px; }
+.emoji-suspension_railway { background-position: -300px -780px; }
+.emoji-sweat { background-position: -320px -780px; }
+.emoji-sweat_drops { background-position: -340px -780px; }
+.emoji-sweat_smile { background-position: -360px -780px; }
+.emoji-sweet_potato { background-position: -380px -780px; }
+.emoji-swimmer { background-position: -400px -780px; }
+.emoji-swimmer_tone1 { background-position: -420px -780px; }
+.emoji-swimmer_tone2 { background-position: -440px -780px; }
+.emoji-swimmer_tone3 { background-position: -460px -780px; }
+.emoji-swimmer_tone4 { background-position: -480px -780px; }
+.emoji-swimmer_tone5 { background-position: -500px -780px; }
+.emoji-symbols { background-position: -520px -780px; }
+.emoji-synagogue { background-position: -540px -780px; }
+.emoji-syringe { background-position: -560px -780px; }
+.emoji-taco { background-position: -580px -780px; }
+.emoji-tada { background-position: -600px -780px; }
+.emoji-tanabata_tree { background-position: -620px -780px; }
+.emoji-tangerine { background-position: -640px -780px; }
+.emoji-taurus { background-position: -660px -780px; }
+.emoji-taxi { background-position: -680px -780px; }
+.emoji-tea { background-position: -700px -780px; }
+.emoji-telephone { background-position: -720px -780px; }
+.emoji-telephone_receiver { background-position: -740px -780px; }
+.emoji-telescope { background-position: -760px -780px; }
+.emoji-ten { background-position: -780px -780px; }
+.emoji-tennis { background-position: -800px 0; }
+.emoji-tent { background-position: -800px -20px; }
+.emoji-thermometer { background-position: -800px -40px; }
+.emoji-thermometer_face { background-position: -800px -60px; }
+.emoji-thinking { background-position: -800px -80px; }
+.emoji-third_place { background-position: -800px -100px; }
+.emoji-thought_balloon { background-position: -800px -120px; }
+.emoji-three { background-position: -800px -140px; }
+.emoji-thumbsdown { background-position: -800px -160px; }
+.emoji-thumbsdown_tone1 { background-position: -800px -180px; }
+.emoji-thumbsdown_tone2 { background-position: -800px -200px; }
+.emoji-thumbsdown_tone3 { background-position: -800px -220px; }
+.emoji-thumbsdown_tone4 { background-position: -800px -240px; }
+.emoji-thumbsdown_tone5 { background-position: -800px -260px; }
+.emoji-thumbsup { background-position: -800px -280px; }
+.emoji-thumbsup_tone1 { background-position: -800px -300px; }
+.emoji-thumbsup_tone2 { background-position: -800px -320px; }
+.emoji-thumbsup_tone3 { background-position: -800px -340px; }
+.emoji-thumbsup_tone4 { background-position: -800px -360px; }
+.emoji-thumbsup_tone5 { background-position: -800px -380px; }
+.emoji-thunder_cloud_rain { background-position: -800px -400px; }
+.emoji-ticket { background-position: -800px -420px; }
+.emoji-tickets { background-position: -800px -440px; }
+.emoji-tiger { background-position: -800px -460px; }
+.emoji-tiger2 { background-position: -800px -480px; }
+.emoji-timer { background-position: -800px -500px; }
+.emoji-tired_face { background-position: -800px -520px; }
+.emoji-tm { background-position: -800px -540px; }
+.emoji-toilet { background-position: -800px -560px; }
+.emoji-tokyo_tower { background-position: -800px -580px; }
+.emoji-tomato { background-position: -800px -600px; }
+.emoji-tone1 { background-position: -800px -620px; }
+.emoji-tone2 { background-position: -800px -640px; }
+.emoji-tone3 { background-position: -800px -660px; }
+.emoji-tone4 { background-position: -800px -680px; }
+.emoji-tone5 { background-position: -800px -700px; }
+.emoji-tongue { background-position: -800px -720px; }
+.emoji-tools { background-position: -800px -740px; }
+.emoji-top { background-position: -800px -760px; }
+.emoji-tophat { background-position: -800px -780px; }
+.emoji-track_next { background-position: 0 -800px; }
+.emoji-track_previous { background-position: -20px -800px; }
+.emoji-trackball { background-position: -40px -800px; }
+.emoji-tractor { background-position: -60px -800px; }
+.emoji-traffic_light { background-position: -80px -800px; }
+.emoji-train { background-position: -100px -800px; }
+.emoji-train2 { background-position: -120px -800px; }
+.emoji-tram { background-position: -140px -800px; }
+.emoji-triangular_flag_on_post { background-position: -160px -800px; }
+.emoji-triangular_ruler { background-position: -180px -800px; }
+.emoji-trident { background-position: -200px -800px; }
+.emoji-triumph { background-position: -220px -800px; }
+.emoji-trolleybus { background-position: -240px -800px; }
+.emoji-trophy { background-position: -260px -800px; }
+.emoji-tropical_drink { background-position: -280px -800px; }
+.emoji-tropical_fish { background-position: -300px -800px; }
+.emoji-truck { background-position: -320px -800px; }
+.emoji-trumpet { background-position: -340px -800px; }
+.emoji-tulip { background-position: -360px -800px; }
+.emoji-tumbler_glass { background-position: -380px -800px; }
+.emoji-turkey { background-position: -400px -800px; }
+.emoji-turtle { background-position: -420px -800px; }
+.emoji-tv { background-position: -440px -800px; }
+.emoji-twisted_rightwards_arrows { background-position: -460px -800px; }
+.emoji-two { background-position: -480px -800px; }
+.emoji-two_hearts { background-position: -500px -800px; }
+.emoji-two_men_holding_hands { background-position: -520px -800px; }
+.emoji-two_women_holding_hands { background-position: -540px -800px; }
+.emoji-u5272 { background-position: -560px -800px; }
+.emoji-u5408 { background-position: -580px -800px; }
+.emoji-u55b6 { background-position: -600px -800px; }
+.emoji-u6307 { background-position: -620px -800px; }
+.emoji-u6708 { background-position: -640px -800px; }
+.emoji-u6709 { background-position: -660px -800px; }
+.emoji-u6e80 { background-position: -680px -800px; }
+.emoji-u7121 { background-position: -700px -800px; }
+.emoji-u7533 { background-position: -720px -800px; }
+.emoji-u7981 { background-position: -740px -800px; }
+.emoji-u7a7a { background-position: -760px -800px; }
+.emoji-umbrella { background-position: -780px -800px; }
+.emoji-umbrella2 { background-position: -800px -800px; }
+.emoji-unamused { background-position: -820px 0; }
+.emoji-underage { background-position: -820px -20px; }
+.emoji-unicorn { background-position: -820px -40px; }
+.emoji-unlock { background-position: -820px -60px; }
+.emoji-up { background-position: -820px -80px; }
+.emoji-upside_down { background-position: -820px -100px; }
+.emoji-urn { background-position: -820px -120px; }
+.emoji-v { background-position: -820px -140px; }
+.emoji-v_tone1 { background-position: -820px -160px; }
+.emoji-v_tone2 { background-position: -820px -180px; }
+.emoji-v_tone3 { background-position: -820px -200px; }
+.emoji-v_tone4 { background-position: -820px -220px; }
+.emoji-v_tone5 { background-position: -820px -240px; }
+.emoji-vertical_traffic_light { background-position: -820px -260px; }
+.emoji-vhs { background-position: -820px -280px; }
+.emoji-vibration_mode { background-position: -820px -300px; }
+.emoji-video_camera { background-position: -820px -320px; }
+.emoji-video_game { background-position: -820px -340px; }
+.emoji-violin { background-position: -820px -360px; }
+.emoji-virgo { background-position: -820px -380px; }
+.emoji-volcano { background-position: -820px -400px; }
+.emoji-volleyball { background-position: -820px -420px; }
+.emoji-vs { background-position: -820px -440px; }
+.emoji-vulcan { background-position: -820px -460px; }
+.emoji-vulcan_tone1 { background-position: -820px -480px; }
+.emoji-vulcan_tone2 { background-position: -820px -500px; }
+.emoji-vulcan_tone3 { background-position: -820px -520px; }
+.emoji-vulcan_tone4 { background-position: -820px -540px; }
+.emoji-vulcan_tone5 { background-position: -820px -560px; }
+.emoji-walking { background-position: -820px -580px; }
+.emoji-walking_tone1 { background-position: -820px -600px; }
+.emoji-walking_tone2 { background-position: -820px -620px; }
+.emoji-walking_tone3 { background-position: -820px -640px; }
+.emoji-walking_tone4 { background-position: -820px -660px; }
+.emoji-walking_tone5 { background-position: -820px -680px; }
+.emoji-waning_crescent_moon { background-position: -820px -700px; }
+.emoji-waning_gibbous_moon { background-position: -820px -720px; }
+.emoji-warning { background-position: -820px -740px; }
+.emoji-wastebasket { background-position: -820px -760px; }
+.emoji-watch { background-position: -820px -780px; }
+.emoji-water_buffalo { background-position: -820px -800px; }
+.emoji-water_polo { background-position: 0 -820px; }
+.emoji-water_polo_tone1 { background-position: -20px -820px; }
+.emoji-water_polo_tone2 { background-position: -40px -820px; }
+.emoji-water_polo_tone3 { background-position: -60px -820px; }
+.emoji-water_polo_tone4 { background-position: -80px -820px; }
+.emoji-water_polo_tone5 { background-position: -100px -820px; }
+.emoji-watermelon { background-position: -120px -820px; }
+.emoji-wave { background-position: -140px -820px; }
+.emoji-wave_tone1 { background-position: -160px -820px; }
+.emoji-wave_tone2 { background-position: -180px -820px; }
+.emoji-wave_tone3 { background-position: -200px -820px; }
+.emoji-wave_tone4 { background-position: -220px -820px; }
+.emoji-wave_tone5 { background-position: -240px -820px; }
+.emoji-wavy_dash { background-position: -260px -820px; }
+.emoji-waxing_crescent_moon { background-position: -280px -820px; }
+.emoji-waxing_gibbous_moon { background-position: -300px -820px; }
+.emoji-wc { background-position: -320px -820px; }
+.emoji-weary { background-position: -340px -820px; }
+.emoji-wedding { background-position: -360px -820px; }
+.emoji-whale { background-position: -380px -820px; }
+.emoji-whale2 { background-position: -400px -820px; }
+.emoji-wheel_of_dharma { background-position: -420px -820px; }
+.emoji-wheelchair { background-position: -440px -820px; }
+.emoji-white_check_mark { background-position: -460px -820px; }
+.emoji-white_circle { background-position: -480px -820px; }
+.emoji-white_flower { background-position: -500px -820px; }
+.emoji-white_large_square { background-position: -520px -820px; }
+.emoji-white_medium_small_square { background-position: -540px -820px; }
+.emoji-white_medium_square { background-position: -560px -820px; }
+.emoji-white_small_square { background-position: -580px -820px; }
+.emoji-white_square_button { background-position: -600px -820px; }
+.emoji-white_sun_cloud { background-position: -620px -820px; }
+.emoji-white_sun_rain_cloud { background-position: -640px -820px; }
+.emoji-white_sun_small_cloud { background-position: -660px -820px; }
+.emoji-wilted_rose { background-position: -680px -820px; }
+.emoji-wind_blowing_face { background-position: -700px -820px; }
+.emoji-wind_chime { background-position: -720px -820px; }
+.emoji-wine_glass { background-position: -740px -820px; }
+.emoji-wink { background-position: -760px -820px; }
+.emoji-wolf { background-position: -780px -820px; }
+.emoji-woman { background-position: -800px -820px; }
+.emoji-woman_tone1 { background-position: -820px -820px; }
+.emoji-woman_tone2 { background-position: -840px 0; }
+.emoji-woman_tone3 { background-position: -840px -20px; }
+.emoji-woman_tone4 { background-position: -840px -40px; }
+.emoji-woman_tone5 { background-position: -840px -60px; }
+.emoji-womans_clothes { background-position: -840px -80px; }
+.emoji-womans_hat { background-position: -840px -100px; }
+.emoji-womens { background-position: -840px -120px; }
+.emoji-worried { background-position: -840px -140px; }
+.emoji-wrench { background-position: -840px -160px; }
+.emoji-wrestlers { background-position: -840px -180px; }
+.emoji-wrestlers_tone1 { background-position: -840px -200px; }
+.emoji-wrestlers_tone2 { background-position: -840px -220px; }
+.emoji-wrestlers_tone3 { background-position: -840px -240px; }
+.emoji-wrestlers_tone4 { background-position: -840px -260px; }
+.emoji-wrestlers_tone5 { background-position: -840px -280px; }
+.emoji-writing_hand { background-position: -840px -300px; }
+.emoji-writing_hand_tone1 { background-position: -840px -320px; }
+.emoji-writing_hand_tone2 { background-position: -840px -340px; }
+.emoji-writing_hand_tone3 { background-position: -840px -360px; }
+.emoji-writing_hand_tone4 { background-position: -840px -380px; }
+.emoji-writing_hand_tone5 { background-position: -840px -400px; }
+.emoji-x { background-position: -840px -420px; }
+.emoji-yellow_heart { background-position: -840px -440px; }
+.emoji-yen { background-position: -840px -460px; }
+.emoji-yin_yang { background-position: -840px -480px; }
+.emoji-yum { background-position: -840px -500px; }
+.emoji-zap { background-position: -840px -520px; }
+.emoji-zero { background-position: -840px -540px; }
+.emoji-zipper_mouth { background-position: -840px -560px; }
+.emoji-100 { background-position: -840px -580px; }
+
+.emoji-icon {
+ background-image: image-url('emoji.png');
+ background-repeat: no-repeat;
+ color: transparent;
+ text-indent: -99em;
+ height: 20px;
+ width: 20px;
+
+ @media only screen and (-webkit-min-device-pixel-ratio: 2),
+ only screen and (min--moz-device-pixel-ratio: 2),
+ only screen and (-o-min-device-pixel-ratio: 2/1),
+ only screen and (min-device-pixel-ratio: 2),
+ only screen and (min-resolution: 192dpi),
+ only screen and (min-resolution: 2dppx) {
+ background-image: image-url('emoji@2x.png');
+ background-size: 860px 840px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/repo.scss.orig b/app/assets/stylesheets/pages/repo.scss.orig
new file mode 100644
index 00000000000..57b995adb64
--- /dev/null
+++ b/app/assets/stylesheets/pages/repo.scss.orig
@@ -0,0 +1,786 @@
+.project-refs-form,
+.project-refs-target-form {
+ display: inline-block;
+}
+
+.fade-enter,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.commit-message {
+ @include str-truncated(250px);
+}
+
+.editable-mode {
+ display: inline-block;
+}
+
+.ide-view {
+ display: flex;
+ height: calc(100vh - #{$header-height});
+ margin-top: 40px;
+ color: $almost-black;
+ border-top: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+
+ &.is-collapsed {
+ .ide-file-list {
+ max-width: 250px;
+ }
+ }
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ }
+}
+
+.ide-file-list {
+ flex: 1;
+
+ .file {
+ cursor: pointer;
+
+ &.file-open {
+ background: $white-normal;
+ }
+
+ .ide-file-name {
+ flex: 1;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+
+ svg {
+ vertical-align: middle;
+ margin-right: 2px;
+ }
+
+ .loading-container {
+ margin-right: 4px;
+ display: inline-block;
+ }
+ }
+
+ .ide-file-changed-icon {
+ margin-left: auto;
+ }
+
+ .ide-new-btn {
+ display: none;
+ margin-bottom: -4px;
+ margin-right: -8px;
+ }
+
+ &:hover {
+ .ide-new-btn {
+ display: block;
+ }
+ }
+
+ &.folder {
+ svg {
+ fill: $gl-text-color-secondary;
+ }
+ }
+ }
+
+ a {
+ color: $gl-text-color;
+ }
+
+ th {
+ position: sticky;
+ top: 0;
+ }
+}
+
+.file-name,
+.file-col-commit-message {
+ display: flex;
+ overflow: visible;
+ padding: 6px 12px;
+}
+
+.multi-file-loading-container {
+ margin-top: 10px;
+ padding: 10px;
+
+ .animation-container {
+ background: $gray-light;
+
+ div {
+ background: $gray-light;
+ }
+ }
+}
+
+.multi-file-table-col-commit-message {
+ white-space: nowrap;
+ width: 50%;
+}
+
+.multi-file-edit-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ border-left: 1px solid $white-dark;
+ overflow: hidden;
+}
+
+.multi-file-tabs {
+ display: flex;
+ background-color: $white-normal;
+ box-shadow: inset 0 -1px $white-dark;
+
+ > ul {
+ display: flex;
+ overflow-x: auto;
+ }
+
+ li {
+ position: relative;
+ }
+
+ .dropdown {
+ display: flex;
+ margin-left: auto;
+ margin-bottom: 1px;
+ padding: 0 $grid-size;
+ border-left: 1px solid $white-dark;
+ background-color: $white-light;
+
+ &.shadow {
+ box-shadow: 0 0 10px $dropdown-shadow-color;
+ }
+
+ .btn {
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
+}
+
+.multi-file-tab {
+ @include str-truncated(150px);
+ padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding;
+ background-color: $gray-normal;
+ border-right: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+ cursor: pointer;
+
+ svg {
+ vertical-align: middle;
+ }
+
+ &.active {
+ background-color: $white-light;
+ border-bottom-color: $white-light;
+ }
+}
+
+.multi-file-tab-close {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ background: none;
+ border: 0;
+ border-radius: $border-radius-default;
+ color: $theme-gray-900;
+ transform: translateY(-50%);
+
+ svg {
+ position: relative;
+ top: -1px;
+ }
+
+ &:hover {
+ background-color: $theme-gray-200;
+ }
+
+ &:focus {
+ background-color: $blue-500;
+ color: $white-light;
+ outline: 0;
+
+ svg {
+ fill: currentColor;
+ }
+ }
+}
+
+.multi-file-edit-pane-content {
+ flex: 1;
+ height: 0;
+}
+
+.blob-editor-container {
+ flex: 1;
+ height: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ .vertical-center {
+ min-height: auto;
+ }
+
+ .monaco-editor .lines-content .cigr {
+ display: none;
+ }
+
+ .monaco-diff-editor.vs {
+ .editor.modified {
+ box-shadow: none;
+ }
+
+ .diagonal-fill {
+ display: none !important;
+ }
+
+ .diffOverview {
+ background-color: $white-light;
+ border-left: 1px solid $white-dark;
+ cursor: ns-resize;
+ }
+
+ .diffViewport {
+ display: none;
+ }
+
+ .char-insert {
+ background-color: $line-added-dark;
+ }
+
+ .char-delete {
+ background-color: $line-removed-dark;
+ }
+
+ .line-numbers {
+ color: $black-transparent;
+ }
+
+ .view-overlays {
+ .line-insert {
+ background-color: $line-added;
+ }
+
+ .line-delete {
+ background-color: $line-removed;
+ }
+ }
+
+ .margin {
+ background-color: $gray-light;
+ border-right: 1px solid $white-normal;
+
+ .line-insert {
+ border-right: 1px solid $line-added-dark;
+ }
+
+ .line-delete {
+ border-right: 1px solid $line-removed-dark;
+ }
+ }
+
+ .margin-view-overlays .insert-sign,
+ .margin-view-overlays .delete-sign {
+ opacity: 0.4;
+ }
+
+ .cursors-layer {
+ display: none;
+ }
+ }
+}
+
+.multi-file-editor-holder {
+ height: 100%;
+}
+
+.multi-file-editor-btn-group {
+ padding: $gl-bar-padding $gl-padding;
+ border-top: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+ background: $white-light;
+}
+
+.ide-status-bar {
+ padding: $gl-bar-padding $gl-padding;
+ background: $white-light;
+ display: flex;
+ justify-content: space-between;
+
+ svg {
+ vertical-align: middle;
+ }
+}
+
+// Not great, but this is to deal with our current output
+.multi-file-preview-holder {
+ height: 100%;
+ overflow: scroll;
+
+ .file-content.code {
+ display: flex;
+
+ i {
+ margin-left: -10px;
+ }
+ }
+
+ .line-numbers {
+ min-width: 50px;
+ }
+
+ .file-content,
+ .line-numbers,
+ .blob-content,
+ .code {
+ min-height: 100%;
+ }
+}
+
+.file-content.blob-no-preview {
+ a {
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+.multi-file-commit-panel {
+ display: flex;
+ position: relative;
+ flex-direction: column;
+ width: 340px;
+ padding: 0;
+ background-color: $gray-light;
+ padding-right: 3px;
+
+ .projects-sidebar {
+ display: flex;
+ flex-direction: column;
+
+ .context-header {
+ width: auto;
+ margin-right: 0;
+ }
+ }
+
+ .multi-file-commit-panel-inner {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ }
+
+ .multi-file-commit-panel-inner-scroll {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ overflow: auto;
+ }
+
+ &.is-collapsed {
+ width: 60px;
+
+ .multi-file-commit-list {
+ padding-top: $gl-padding;
+ overflow: hidden;
+ }
+
+ .multi-file-context-bar-icon {
+ align-items: center;
+
+ svg {
+ float: none;
+ margin: 0;
+ }
+ }
+ }
+
+ .branch-container {
+ border-left: 4px solid $indigo-700;
+ margin-bottom: $gl-bar-padding;
+ }
+
+ .branch-header {
+ background: $white-dark;
+ display: flex;
+ }
+
+ .branch-header-title {
+ flex: 1;
+ padding: $grid-size $gl-padding;
+ color: $indigo-700;
+ font-weight: $gl-font-weight-bold;
+
+ svg {
+ vertical-align: middle;
+ }
+ }
+
+ .branch-header-btns {
+ padding: $gl-vert-padding $gl-padding;
+ }
+
+ .left-collapse-btn {
+ display: none;
+ background: $gray-light;
+ text-align: left;
+ border-top: 1px solid $white-dark;
+
+ svg {
+ vertical-align: middle;
+ }
+ }
+}
+
+.multi-file-context-bar-icon {
+ padding: 10px;
+
+ svg {
+ margin-right: 10px;
+ float: left;
+ }
+}
+
+.multi-file-commit-panel-section {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.multi-file-commit-empty-state-container {
+ align-items: center;
+ justify-content: center;
+}
+
+.multi-file-commit-panel-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+ border-bottom: 1px solid $white-dark;
+ padding: $gl-btn-padding 0;
+
+ &.is-collapsed {
+ border-bottom: 1px solid $white-dark;
+
+ svg {
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .multi-file-commit-panel-collapse-btn {
+ margin-right: auto;
+ margin-left: auto;
+ border-left: 0;
+ }
+ }
+}
+
+.multi-file-commit-panel-header-title {
+ display: flex;
+ flex: 1;
+ padding: 0 $gl-btn-padding;
+
+ svg {
+ margin-right: $gl-btn-padding;
+ }
+}
+
+.multi-file-commit-panel-collapse-btn {
+ border-left: 1px solid $white-dark;
+}
+
+.multi-file-commit-list {
+ flex: 1;
+ overflow: auto;
+ padding: $gl-padding 0;
+ min-height: 60px;
+}
+
+.multi-file-commit-list-item {
+ display: flex;
+ padding: 0;
+ align-items: center;
+
+ .multi-file-discard-btn {
+ display: none;
+ margin-left: auto;
+ color: $gl-link-color;
+ padding: 0 2px;
+
+ &:focus,
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ &:hover {
+ background: $white-normal;
+
+ .multi-file-discard-btn {
+ display: block;
+ }
+ }
+}
+
+.multi-file-addition {
+ fill: $green-500;
+}
+
+.multi-file-modified {
+ fill: $orange-500;
+}
+
+.multi-file-commit-list-collapsed {
+ display: flex;
+ flex-direction: column;
+
+ > svg {
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ margin-left: 3px;
+ }
+}
+
+.multi-file-commit-list-path {
+ padding: $grid-size / 2;
+ padding-left: $gl-padding;
+ background: none;
+ border: 0;
+ text-align: left;
+ width: 100%;
+ min-width: 0;
+
+ svg {
+ min-width: 16px;
+ vertical-align: middle;
+ display: inline-block;
+ }
+
+ &:hover,
+ &:focus {
+ outline: 0;
+ }
+}
+
+.multi-file-commit-list-file-path {
+ @include str-truncated(100%);
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ text-decoration: none;
+ }
+}
+
+.multi-file-commit-form {
+ padding: $gl-padding;
+ border-top: 1px solid $white-dark;
+
+ .btn {
+ font-size: $gl-font-size;
+ }
+}
+
+.multi-file-commit-message.form-control {
+ height: 160px;
+ resize: none;
+}
+
+.dirty-diff {
+ // !important need to override monaco inline style
+ width: 4px !important;
+ left: 0 !important;
+
+ &-modified {
+ background-color: $blue-500;
+ }
+
+ &-added {
+ background-color: $green-600;
+ }
+
+ &-removed {
+ height: 0 !important;
+ width: 0 !important;
+ bottom: -2px;
+ border-style: solid;
+ border-width: 5px;
+ border-color: transparent transparent transparent $red-500;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100px;
+ height: 1px;
+ background-color: rgba($red-500, 0.5);
+ }
+ }
+}
+
+.ide-loading {
+ display: flex;
+ height: 100vh;
+ align-items: center;
+ justify-content: center;
+}
+
+.ide-empty-state {
+ display: flex;
+ height: 100vh;
+ align-items: center;
+ justify-content: center;
+}
+
+.ide-new-btn {
+ .dropdown-toggle svg {
+ margin-top: -2px;
+ margin-bottom: 2px;
+ }
+
+ .dropdown-menu {
+ left: auto;
+ right: 0;
+
+ label {
+ font-weight: $gl-font-weight-normal;
+ padding: 5px 8px;
+ margin-bottom: 0;
+ }
+ }
+}
+
+.ide {
+ overflow: hidden;
+
+ &.nav-only {
+ .flash-container {
+ margin-top: $header-height;
+ margin-bottom: 0;
+ }
+
+ .alert-wrapper .flash-container .flash-alert:last-child,
+ .alert-wrapper .flash-container .flash-notice:last-child {
+ margin-bottom: 0;
+ }
+
+ .content-wrapper {
+ margin-top: $header-height;
+ padding-bottom: 0;
+ }
+
+ &.flash-shown {
+ .content-wrapper {
+ margin-top: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $flash-height});
+ }
+ }
+
+ .projects-sidebar {
+ .multi-file-commit-panel-inner-scroll {
+ flex: 1;
+ }
+ }
+ }
+}
+
+.with-performance-bar .ide.nav-only {
+ .flash-container {
+ margin-top: #{$header-height + $performance-bar-height};
+ }
+
+ .content-wrapper {
+ margin-top: #{$header-height + $performance-bar-height};
+ padding-bottom: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $performance-bar-height});
+ }
+
+ &.flash-shown {
+ .content-wrapper {
+ margin-top: 0;
+ }
+
+ .ide-view {
+ height: calc(
+ 100vh - #{$header-height + $performance-bar-height + $flash-height}
+ );
+ }
+ }
+}
+
+.dragHandle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background-color: $white-dark;
+
+ &.dragright {
+ right: 0;
+ }
+
+ &.dragleft {
+ left: 0;
+ }
+}
+
+.ide-commit-radios {
+ label {
+ font-weight: normal;
+ }
+
+ .help-block {
+ margin-top: 0;
+ line-height: 0;
+ }
+}
+
+.ide-commit-new-branch {
+ margin-left: 25px;
+}
+
+.ide-external-links {
+ p {
+ margin: 0;
+ }
+}
+
+.ide-sidebar-link {
+ padding: $gl-padding-8 $gl-padding;
+ background: $indigo-700;
+ color: $white-light;
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+
+ &:focus,
+ &:hover {
+ color: $white-light;
+ text-decoration: underline;
+ background: $indigo-500;
+ }
+
+ &:active {
+ background: $indigo-800;
+ }
+}
diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml
new file mode 100644
index 00000000000..6a681736b6f
--- /dev/null
+++ b/app/views/projects/runners/_form.html.haml
@@ -0,0 +1,55 @@
+= form_for runner, url: runner_form_url, html: { class: 'form-horizontal' } do |f|
+ = form_errors(runner)
+ .form-group
+ = label :active, "Active", class: 'control-label'
+ .col-sm-10
+ .checkbox
+ = f.check_box :active
+ %span.light Paused Runners don't accept new jobs
+ .form-group
+ = label :protected, "Protected", class: 'control-label'
+ .col-sm-10
+ .checkbox
+ = f.check_box :access_level, {}, 'ref_protected', 'not_protected'
+ %span.light This runner will only run on pipelines triggered on protected branches
+ .form-group
+ = label :run_untagged, 'Run untagged jobs', class: 'control-label'
+ .col-sm-10
+ .checkbox
+ = f.check_box :run_untagged
+ %span.light Indicates whether this runner can pick jobs without tags
+ .form-group
+ = label :locked, 'Lock to current projects', class: 'control-label'
+ .col-sm-10
+ .checkbox
+ = f.check_box :locked
+ %span.light When a runner is locked, it cannot be assigned to other projects
+ .form-group
+ = label_tag :token, class: 'control-label' do
+ Token
+ .col-sm-10
+ = f.text_field :token, class: 'form-control', readonly: true
+ .form-group
+ = label_tag :ip_address, class: 'control-label' do
+ IP Address
+ .col-sm-10
+ = f.text_field :ip_address, class: 'form-control', readonly: true
+ .form-group
+ = label_tag :description, class: 'control-label' do
+ Description
+ .col-sm-10
+ = f.text_field :description, class: 'form-control'
+ .form-group
+ = label_tag :maximum_timeout_human_readable, class: 'control-label' do
+ Maximum job timeout
+ .col-sm-10
+ = f.text_field :maximum_timeout_human_readable, class: 'form-control'
+ .help-block This timeout will take precedence when lower than Project-defined timeout
+ .form-group
+ = label_tag :tag_list, class: 'control-label' do
+ Tags
+ .col-sm-10
+ = f.text_field :tag_list, value: runner.tag_list.sort.join(', '), class: 'form-control'
+ .help-block You can setup jobs to only use Runners with specific tags. Separate tags with commas.
+ .form-actions
+ = f.submit 'Save changes', class: 'btn btn-save'
diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml
new file mode 100644
index 00000000000..322152cfaca
--- /dev/null
+++ b/app/views/projects/runners/show.html.haml
@@ -0,0 +1,67 @@
+- page_title "#{@runner.description} ##{@runner.id}", "Runners"
+
+%h3.page-title
+ Runner ##{@runner.id}
+ .pull-right
+ - if @runner.shared?
+ %span.runner-state.runner-state-shared
+ Shared
+ - else
+ %span.runner-state.runner-state-specific
+ Specific
+
+.table-holder
+ %table.table
+ %thead
+ %tr
+ %th Property Name
+ %th Value
+ %tr
+ %td Active
+ %td= @runner.active? ? 'Yes' : 'No'
+ %tr
+ %td Protected
+ %td= @runner.ref_protected? ? 'Yes' : 'No'
+ %tr
+ %td Can run untagged jobs
+ %td= @runner.run_untagged? ? 'Yes' : 'No'
+ %tr
+ %td Locked to this project
+ %td= @runner.locked? ? 'Yes' : 'No'
+ %tr
+ %td Tags
+ %td
+ - @runner.tag_list.sort.each do |tag|
+ %span.label.label-primary
+ = tag
+ %tr
+ %td Name
+ %td= @runner.name
+ %tr
+ %td Version
+ %td= @runner.version
+ %tr
+ %td IP Address
+ %td= @runner.ip_address
+ %tr
+ %td Revision
+ %td= @runner.revision
+ %tr
+ %td Platform
+ %td= @runner.platform
+ %tr
+ %td Architecture
+ %td= @runner.architecture
+ %tr
+ %td Description
+ %td= @runner.description
+ %tr
+ %td Maximum job timeout
+ %td= @runner.maximum_timeout_human_readable
+ %tr
+ %td Last contact
+ %td
+ - if @runner.contacted_at
+ = time_ago_with_tooltip @runner.contacted_at
+ - else
+ Never
diff --git a/bin/spinach b/bin/spinach
new file mode 100755
index 00000000000..eda81c9ed8a
--- /dev/null
+++ b/bin/spinach
@@ -0,0 +1,13 @@
+#!/usr/bin/env ruby
+
+# Remove this block when removing rails5? code.
+gemfile = %w[1 true].include?(ENV["RAILS5"]) ? "Gemfile.rails5" : "Gemfile"
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../#{gemfile}", __dir__)
+
+begin
+ load File.expand_path('../spring', __FILE__)
+rescue LoadError => e
+ raise unless e.message.include?('spring')
+end
+require 'bundler/setup'
+load Gem.bin_path('spinach', 'spinach')
diff --git a/changelogs/unreleased/44775-avatar-on-os-fails-with-cdn.yml b/changelogs/unreleased/44775-avatar-on-os-fails-with-cdn.yml
new file mode 100644
index 00000000000..80b5b4a8abe
--- /dev/null
+++ b/changelogs/unreleased/44775-avatar-on-os-fails-with-cdn.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed wrong avatar URL when the avatar is on object storage.
+merge_request: 18092
+author:
+type: fixed
diff --git a/changelogs/unreleased/add-jwt-strategy-to-gitlab-suite.yml b/changelogs/unreleased/add-jwt-strategy-to-gitlab-suite.yml
new file mode 100644
index 00000000000..22a839cef56
--- /dev/null
+++ b/changelogs/unreleased/add-jwt-strategy-to-gitlab-suite.yml
@@ -0,0 +1,5 @@
+---
+title: Ports omniauth-jwt gem onto GitLab OmniAuth Strategies suite
+merge_request: 18580
+author:
+type: fixed
diff --git a/changelogs/unreleased/bvl-fix-maintainer-push-error.yml b/changelogs/unreleased/bvl-fix-maintainer-push-error.yml
new file mode 100644
index 00000000000..66ab8fbf884
--- /dev/null
+++ b/changelogs/unreleased/bvl-fix-maintainer-push-error.yml
@@ -0,0 +1,5 @@
+---
+title: Fix errors on pushing to an empty repository
+merge_request: 18462
+author:
+type: fixed
diff --git a/changelogs/unreleased/bvl-fix-openid-redirect.yml b/changelogs/unreleased/bvl-fix-openid-redirect.yml
new file mode 100644
index 00000000000..83ee6d953e4
--- /dev/null
+++ b/changelogs/unreleased/bvl-fix-openid-redirect.yml
@@ -0,0 +1,5 @@
+---
+title: Fix redirection error for applications using OpenID
+merge_request: 18599
+author:
+type: fixed
diff --git a/changelogs/unreleased/dm-commit-trailer-without-gravatar.yml b/changelogs/unreleased/dm-commit-trailer-without-gravatar.yml
new file mode 100644
index 00000000000..9f057c67122
--- /dev/null
+++ b/changelogs/unreleased/dm-commit-trailer-without-gravatar.yml
@@ -0,0 +1,5 @@
+---
+title: Fix commit trailer rendering when Gravatar is disabled
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-file-store-artifacts-and-lfs.yml b/changelogs/unreleased/fix-file-store-artifacts-and-lfs.yml
new file mode 100644
index 00000000000..7e97f245e66
--- /dev/null
+++ b/changelogs/unreleased/fix-file-store-artifacts-and-lfs.yml
@@ -0,0 +1,5 @@
+---
+title: Fix file_store for artifacts and lfs when saving
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/issue_45463.yml b/changelogs/unreleased/issue_45463.yml
new file mode 100644
index 00000000000..a350568d04b
--- /dev/null
+++ b/changelogs/unreleased/issue_45463.yml
@@ -0,0 +1,5 @@
+---
+title: Fix users not seeing labels from private groups when being a member of a child project
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/update-doorkeeper-changelog.yml b/changelogs/unreleased/update-doorkeeper-changelog.yml
new file mode 100644
index 00000000000..b47bdf4a28d
--- /dev/null
+++ b/changelogs/unreleased/update-doorkeeper-changelog.yml
@@ -0,0 +1,5 @@
+---
+title: Update doorkeeper to 4.3.2 to fix GitLab OAuth authentication
+merge_request: 18543
+author:
+type: fixed
diff --git a/ee/app/controllers/ee/ldap/omniauth_callbacks_controller.rb b/ee/app/controllers/ee/ldap/omniauth_callbacks_controller.rb
new file mode 100644
index 00000000000..f1e851a210b
--- /dev/null
+++ b/ee/app/controllers/ee/ldap/omniauth_callbacks_controller.rb
@@ -0,0 +1,22 @@
+module EE
+ module Ldap
+ module OmniauthCallbacksController
+ extend ::Gitlab::Utils::Override
+
+ override :sign_in_and_redirect
+ def sign_in_and_redirect(user)
+ # The counter gets incremented in `sign_in_and_redirect`
+ show_ldap_sync_flash if user.sign_in_count == 0
+
+ super
+ end
+
+ private
+
+ def show_ldap_sync_flash
+ flash[:notice] = 'LDAP sync in progress. This could take a few minutes. '\
+ 'Refresh the page to see the changes.'
+ end
+ end
+ end
+end
diff --git a/ee/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb b/ee/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb
new file mode 100644
index 00000000000..0835ff35846
--- /dev/null
+++ b/ee/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe Ldap::OmniauthCallbacksController do
+ include_context 'Ldap::OmniauthCallbacksController'
+
+ it "displays LDAP sync flash on first sign in" do
+ post provider
+
+ expect(flash[:notice]).to match(/LDAP sync in progress*/)
+ end
+
+ it "skips LDAP sync flash on subsequent sign ins" do
+ user.update!(sign_in_count: 1)
+
+ post provider
+
+ expect(flash[:notice]).to eq nil
+ end
+
+ context 'access denied' do
+ let(:valid_login?) { false }
+
+ it 'logs a failure event' do
+ stub_licensed_features(extended_audit_events: true)
+
+ expect { post provider }.to change(SecurityEvent, :count).by(1)
+ end
+ end
+end
diff --git a/features/project/builds/artifacts.feature b/features/project/builds/artifacts.feature
new file mode 100644
index 00000000000..5abc24949cf
--- /dev/null
+++ b/features/project/builds/artifacts.feature
@@ -0,0 +1,65 @@
+Feature: Project Builds Artifacts
+ Background:
+ Given I sign in as a user
+ And I own a project
+ And project has CI enabled
+ And project has a recent build
+
+ Scenario: I download build artifacts
+ Given recent build has artifacts available
+ When I visit recent build details page
+ And I click artifacts download button
+ Then download of build artifacts archive starts
+
+ Scenario: I browse build artifacts
+ Given recent build has artifacts available
+ And recent build has artifacts metadata available
+ When I visit recent build details page
+ And I click artifacts browse button
+ Then I should see content of artifacts archive
+ And I should see the build header
+
+ Scenario: I browse subdirectory of build artifacts
+ Given recent build has artifacts available
+ And recent build has artifacts metadata available
+ When I visit recent build details page
+ And I click artifacts browse button
+ And I click link to subdirectory within build artifacts
+ Then I should see content of subdirectory within artifacts archive
+ And I should see the directory name in the breadcrumb
+
+ Scenario: I browse directory with UTF-8 characters in name
+ Given recent build has artifacts available
+ And recent build has artifacts metadata available
+ And recent build artifacts contain directory with UTF-8 characters
+ When I visit recent build details page
+ And I click artifacts browse button
+ And I navigate to directory with UTF-8 characters in name
+ Then I should see content of directory with UTF-8 characters in name
+
+ Scenario: I try to browse directory with invalid UTF-8 characters in name
+ Given recent build has artifacts available
+ And recent build has artifacts metadata available
+ And recent build artifacts contain directory with invalid UTF-8 characters
+ When I visit recent build details page
+ And I click artifacts browse button
+ And I navigate to parent directory of directory with invalid name
+ Then I should not see directory with invalid name on the list
+
+ @javascript
+ Scenario: I download a single file from build artifacts
+ Given recent build has artifacts available
+ And recent build has artifacts metadata available
+ When I visit recent build details page
+ And I click artifacts browse button
+ And I click a link to file within build artifacts
+ Then I see a download link
+
+ @javascript
+ Scenario: I click on a row in an artifacts table
+ Given recent build has artifacts available
+ And recent build has artifacts metadata available
+ When I visit recent build details page
+ And I click artifacts browse button
+ And I click a first row within build artifacts table
+ Then page with a coresponding path is loading
diff --git a/features/project/commits/commits.feature b/features/project/commits/commits.feature
new file mode 100644
index 00000000000..3459cce03f9
--- /dev/null
+++ b/features/project/commits/commits.feature
@@ -0,0 +1,96 @@
+@project_commits
+Feature: Project Commits
+ Background:
+ Given I sign in as a user
+ And I own a project
+ And I visit my project's commits page
+
+ Scenario: I browse commits list for master branch
+ Then I see project commits
+ And I should not see button to create a new merge request
+ Then I click the "Compare" tab
+ And I should not see button to create a new merge request
+
+ Scenario: I browse commits list for feature branch without a merge request
+ Given I visit commits list page for feature branch
+ Then I see feature branch commits
+ And I see button to create a new merge request
+ Then I click the "Compare" tab
+ And I see button to create a new merge request
+
+ Scenario: I browse commits list for feature branch with an open merge request
+ Given project have an open merge request
+ And I visit commits list page for feature branch
+ Then I see feature branch commits
+ And I should not see button to create a new merge request
+ And I should see button to the merge request
+ Then I click the "Compare" tab
+ And I should not see button to create a new merge request
+ And I should see button to the merge request
+
+ Scenario: I browse atom feed of commits list for master branch
+ Given I click atom feed link
+ Then I see commits atom feed
+
+ Scenario: I browse commit from list
+ Given I click on commit link
+ Then I see commit info
+ And I see side-by-side diff button
+
+ Scenario: I browse commit from list and create a new tag
+ Given I click on commit link
+ And I click on tag link
+ Then I see commit SHA pre-filled
+
+ Scenario: I browse commit with ci from list
+ Given commit has ci status
+ And repository contains ".gitlab-ci.yml" file
+ When I click on commit link
+ Then I see commit ci info
+
+ Scenario: I browse commit with side-by-side diff view
+ Given I click on commit link
+ And I click side-by-side diff button
+ Then I see inline diff button
+
+ @javascript
+ Scenario: I compare branches without a merge request
+ Given I visit compare refs page
+ And I fill compare fields with branches
+ Then I see compared branches
+ And I see button to create a new merge request
+
+ @javascript
+ Scenario: I compare branches with an open merge request
+ Given project have an open merge request
+ And I visit compare refs page
+ And I fill compare fields with branches
+ Then I see compared branches
+ And I should not see button to create a new merge request
+ And I should see button to the merge request
+
+ @javascript
+ Scenario: I compare refs
+ Given I visit compare refs page
+ And I fill compare fields with refs
+ Then I see compared refs
+ And I unfold diff
+ Then I should see additional file lines
+
+ Scenario: I browse commits for a specific path
+ Given I visit my project's commits page for a specific path
+ Then I see breadcrumb links
+
+ # TODO: Implement feature in graphs
+ #Scenario: I browse commits stats
+ #Given I visit my project's commits stats page
+ #Then I see commits stats
+
+ Scenario: I browse a commit with an image
+ Given I visit a commit with an image that changed
+ Then The diff links to both the previous and current image
+
+ @javascript
+ Scenario: I filter commits by message
+ When I search "submodules" commits
+ Then I should see only "submodules" commits
diff --git a/features/project/commits/diff_comments.feature b/features/project/commits/diff_comments.feature
new file mode 100644
index 00000000000..35687aac9ea
--- /dev/null
+++ b/features/project/commits/diff_comments.feature
@@ -0,0 +1,96 @@
+@project_commits
+Feature: Project Commits Diff Comments
+ Background:
+ Given I sign in as a user
+ And I own project "Shop"
+ And I visit project commit page
+
+ @javascript
+ Scenario: I can comment on a commit diff
+ Given I leave a diff comment like "Typo, please fix"
+ Then I should see a diff comment saying "Typo, please fix"
+
+ @javascript
+ Scenario: I can add a diff comment with a single emoji
+ Given I open a diff comment form
+ And I write a diff comment like ":smile:"
+ Then I should see a diff comment with an emoji image
+
+ @javascript
+ Scenario: I get a temporary form for the first comment on a diff line
+ Given I open a diff comment form
+ Then I should see a temporary diff comment form
+
+ @javascript
+ Scenario: I have a cancel button on the diff form
+ Given I open a diff comment form
+ Then I should see the cancel comment button
+
+ @javascript
+ Scenario: I can cancel a diff form
+ Given I open a diff comment form
+ And I cancel the diff comment
+ Then I should not see the diff comment form
+
+ @javascript
+ Scenario: I can't open a second form for a diff line
+ Given I open a diff comment form
+ And I open a diff comment form
+ Then I should only see one diff form
+
+ @javascript
+ Scenario: I can have multiple forms
+ Given I open a diff comment form
+ And I write a diff comment like ":-1: I don't like this"
+ And I open another diff comment form
+ Then I should see a diff comment form with ":-1: I don't like this"
+ And I should see an empty diff comment form
+
+ @javascript
+ Scenario: I can preview multiple forms separately
+ Given I preview a diff comment text like "Should fix it :smile:"
+ And I preview another diff comment text like "DRY this up"
+ Then I should see two separate previews
+
+ @javascript
+ Scenario: I have a reply button in discussions
+ Given I leave a diff comment like "Typo, please fix"
+ Then I should see a discussion reply button
+
+ @javascript
+ Scenario: I can preview with text
+ Given I open a diff comment form
+ And I write a diff comment like ":-1: I don't like this"
+ Then The diff comment preview tab should display rendered Markdown
+
+ @javascript
+ Scenario: I preview a diff comment
+ Given I preview a diff comment text like "Should fix it :smile:"
+ Then I should see the diff comment preview
+ And I should not see the diff comment text field
+
+ @javascript
+ Scenario: I can edit after preview
+ Given I preview a diff comment text like "Should fix it :smile:"
+ Then I should see the diff comment write tab
+
+ @javascript
+ Scenario: The form gets removed after posting
+ Given I preview a diff comment text like "Should fix it :smile:"
+ And I submit the diff comment
+ Then I should not see the diff comment form
+ And I should see a discussion reply button
+
+ @javascript
+ Scenario: I can add a comment on a side-by-side commit diff (left side)
+ Given I open a diff comment form
+ And I click side-by-side diff button
+ When I leave a diff comment in a parallel view on the left side like "Old comment"
+ Then I should see a diff comment on the left side saying "Old comment"
+
+ @javascript
+ Scenario: I can add a comment on a side-by-side commit diff (right side)
+ Given I open a diff comment form
+ And I click side-by-side diff button
+ When I leave a diff comment in a parallel view on the right side like "New comment"
+ Then I should see a diff comment on the right side saying "New comment"
diff --git a/features/project/deploy_keys.feature b/features/project/deploy_keys.feature
new file mode 100644
index 00000000000..6f1ed9ff5b6
--- /dev/null
+++ b/features/project/deploy_keys.feature
@@ -0,0 +1,46 @@
+Feature: Project Deploy Keys
+ Background:
+ Given I sign in as a user
+ And I own project "Shop"
+
+ @javascript
+ Scenario: I should see deploy keys list
+ Given project has deploy key
+ When I visit project deploy keys page
+ Then I should see project deploy key
+
+ @javascript
+ Scenario: I should see project deploy keys
+ Given other projects have deploy keys
+ When I visit project deploy keys page
+ Then I should see other project deploy key
+ And I should only see the same deploy key once
+
+ @javascript
+ Scenario: I should see public deploy keys
+ Given public deploy key exists
+ When I visit project deploy keys page
+ Then I should see public deploy key
+
+ @javascript
+ Scenario: I add new deploy key
+ Given I visit project deploy keys page
+ And I submit new deploy key
+ Then I should be on deploy keys page
+ And I should see newly created deploy key
+
+ @javascript
+ Scenario: I attach other project deploy key to project
+ Given other projects have deploy keys
+ And I visit project deploy keys page
+ When I click attach deploy key
+ Then I should be on deploy keys page
+ And I should see newly created deploy key
+
+ @javascript
+ Scenario: I attach public deploy key to project
+ Given public deploy key exists
+ And I visit project deploy keys page
+ When I click attach deploy key
+ Then I should be on deploy keys page
+ And I should see newly created deploy key
diff --git a/features/project/ff_merge_requests.feature b/features/project/ff_merge_requests.feature
new file mode 100644
index 00000000000..39035d551d1
--- /dev/null
+++ b/features/project/ff_merge_requests.feature
@@ -0,0 +1,41 @@
+Feature: Project Ff Merge Requests
+ Background:
+ Given I sign in as a user
+ And I own project "Shop"
+ And project "Shop" have "Bug NS-05" open merge request with diffs inside
+ And merge request "Bug NS-05" is mergeable
+
+ @javascript
+ Scenario: I do ff-only merge for rebased branch
+ Given ff merge enabled
+ And merge request "Bug NS-05" is rebased
+ When I visit merge request page "Bug NS-05"
+ Then I should see ff-only merge button
+ When I accept this merge request
+ Then I should see merged request
+
+ @javascript
+ Scenario: I do ff-only merge for merged branch
+ Given ff merge enabled
+ And merge request "Bug NS-05" merged target
+ When I visit merge request page "Bug NS-05"
+ Then I should see ff-only merge button
+ When I accept this merge request
+ Then I should see merged request
+
+ @javascript
+ Scenario: I do rebase before ff-only merge
+ Given ff merge enabled
+ And rebase before merge enabled
+ When I visit merge request page "Bug NS-05"
+ Then I should see rebase button
+ When I press rebase button
+ Then I should see rebase in progress message
+
+ @javascript
+ Scenario: I do rebase before regular merge
+ Given rebase before merge enabled
+ When I visit merge request page "Bug NS-05"
+ Then I should see rebase button
+ When I press rebase button
+ Then I should see rebase in progress message
diff --git a/features/project/forked_merge_requests.feature b/features/project/forked_merge_requests.feature
new file mode 100644
index 00000000000..9809b0ea0fe
--- /dev/null
+++ b/features/project/forked_merge_requests.feature
@@ -0,0 +1,51 @@
+Feature: Project Forked Merge Requests
+ Background:
+ Given I sign in as a user
+ And I am a member of project "Shop"
+ And I have a project forked off of "Shop" called "Forked Shop"
+
+ @javascript
+ Scenario: I submit new unassigned merge request to a forked project
+ Given I visit project "Forked Shop" merge requests page
+ And I click link "New Merge Request"
+ And I fill out a "Merge Request On Forked Project" merge request
+ And I submit the merge request
+ Then I should see merge request "Merge Request On Forked Project"
+
+ # TODO: Improve it so it does not fail randomly
+ #
+ #@javascript
+ #Scenario: I can edit a forked merge request
+ #Given I visit project "Forked Shop" merge requests page
+ #And I click link "New Merge Request"
+ #And I fill out a "Merge Request On Forked Project" merge request
+ #And I submit the merge request
+ #And I should see merge request "Merge Request On Forked Project"
+ #And I click link edit "Merge Request On Forked Project"
+ #Then I see the edit page prefilled for "Merge Request On Forked Project"
+ #And I update the merge request title
+ #And I save the merge request
+ #Then I should see the edited merge request
+
+ Scenario: I cannot submit an invalid merge request
+ Given I visit project "Forked Shop" merge requests page
+ And I click link "New Merge Request"
+ And I fill out an invalid "Merge Request On Forked Project" merge request
+ Then I should see validation errors
+
+ @javascript
+ Scenario: Merge request should target fork repository by default
+ Given I visit project "Forked Shop" merge requests page
+ And I click link "New Merge Request"
+ Then the target repository should be the original repository
+
+ @javascript
+ Scenario: I see the users in the target project for a new merge request
+ Given I sign in as an admin
+ And I have a project forked off of "Shop" called "Forked Shop"
+ Then I visit project "Forked Shop" merge requests page
+ And I click link "New Merge Request"
+ And I fill out a "Merge Request On Forked Project" merge request
+ When I click "Assign to" dropdown"
+ Then I should see the target project ID in the input selector
+ And I should see the users from the target project ID
diff --git a/features/project/issues/references.feature b/features/project/issues/references.feature
new file mode 100644
index 00000000000..4ae2d653337
--- /dev/null
+++ b/features/project/issues/references.feature
@@ -0,0 +1,33 @@
+@project_issues
+Feature: Project Issues References
+ Background:
+ Given I sign in as "John Doe"
+ And public project "Community"
+ And "John Doe" owns public project "Community"
+ And project "Community" has "Community issue" open issue
+ And I logout
+ And I sign in as "Mary Jane"
+ And private project "Enterprise"
+ And "Mary Jane" owns private project "Enterprise"
+ And project "Enterprise" has "Enterprise issue" open issue
+ And project "Enterprise" has "Enterprise fix" open merge request
+ And I visit issue page "Enterprise issue"
+ And I leave a comment referencing issue "Community issue"
+ And I visit merge request page "Enterprise fix"
+ And I leave a comment referencing issue "Community issue"
+ And I logout
+
+ @javascript
+ Scenario: Viewing the public issue as a "John Doe"
+ Given I sign in as "John Doe"
+ When I visit issue page "Community issue"
+ Then I should not see any related merge requests
+ And I should see no notes at all
+
+ @javascript
+ Scenario: Viewing the public issue as "Mary Jane"
+ Given I sign in as "Mary Jane"
+ When I visit issue page "Community issue"
+ Then I should see the "Enterprise fix" related merge request
+ And I should see a note linking to "Enterprise fix" merge request
+ And I should see a note linking to "Enterprise issue" issue
diff --git a/features/project/merge_requests/references.feature b/features/project/merge_requests/references.feature
new file mode 100644
index 00000000000..571612261a9
--- /dev/null
+++ b/features/project/merge_requests/references.feature
@@ -0,0 +1,31 @@
+@project_merge_requests
+Feature: Project Merge Requests References
+ Background:
+ Given I sign in as "John Doe"
+ And public project "Community"
+ And "John Doe" owns public project "Community"
+ And project "Community" has "Community fix" open merge request
+ And I logout
+ And I sign in as "Mary Jane"
+ And private project "Enterprise"
+ And "Mary Jane" owns private project "Enterprise"
+ And project "Enterprise" has "Enterprise issue" open issue
+ And project "Enterprise" has "Enterprise fix" open merge request
+ And I visit issue page "Enterprise issue"
+ And I leave a comment referencing issue "Community fix"
+ And I visit merge request page "Enterprise fix"
+ And I leave a comment referencing issue "Community fix"
+ And I logout
+
+ @javascript
+ Scenario: Viewing the public issue as a "John Doe"
+ Given I sign in as "John Doe"
+ When I visit issue page "Community fix"
+ Then I should see no notes at all
+
+ @javascript
+ Scenario: Viewing the public issue as "Mary Jane"
+ Given I sign in as "Mary Jane"
+ When I visit issue page "Community fix"
+ And I should see a note linking to "Enterprise fix" merge request
+ And I should see a note linking to "Enterprise issue" issue
diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb
new file mode 100644
index 00000000000..97bcca7730b
--- /dev/null
+++ b/features/steps/group/members.rb
@@ -0,0 +1,68 @@
+class Spinach::Features::GroupMembers < Spinach::FeatureSteps
+ include WaitForRequests
+ include SharedAuthentication
+ include SharedPaths
+ include SharedGroup
+ include SharedUser
+
+ step 'I should see user "John Doe" in team list' do
+ expect(group_members_list).to have_content("John Doe")
+ end
+
+ step 'I should not see user "Mary Jane" in team list' do
+ expect(group_members_list).not_to have_content("Mary Jane")
+ end
+
+ step 'I click on the "Remove User From Group" button for "John Doe"' do
+ find(:css, '.project-members-page li', text: "John Doe").find(:css, 'a.btn-remove').click
+ # poltergeist always confirms popups.
+ end
+
+ step 'I click on the "Remove User From Group" button for "Mary Jane"' do
+ find(:css, 'li', text: "Mary Jane").find(:css, 'a.btn-remove').click
+ # poltergeist always confirms popups.
+ end
+
+ step 'I should not see the "Remove User From Group" button for "John Doe"' do
+ expect(find(:css, '.project-members-page li', text: "John Doe")).not_to have_selector(:css, 'a.btn-remove')
+ # poltergeist always confirms popups.
+ end
+
+ step 'I should not see the "Remove User From Group" button for "Mary Jane"' do
+ expect(find(:css, 'li', text: "Mary Jane")).not_to have_selector(:css, 'a.btn-remove')
+ # poltergeist always confirms popups.
+ end
+
+ step 'I change the "Mary Jane" role to "Developer"' do
+ member = mary_jane_member
+
+ page.within "#group_member_#{member.id}" do
+ click_button member.human_access
+
+ page.within '.dropdown-menu' do
+ click_link 'Developer'
+ end
+
+ wait_for_requests
+ end
+ end
+
+ step 'I should see "Mary Jane" as "Developer"' do
+ member = mary_jane_member
+
+ page.within "#group_member_#{member.id}" do
+ expect(page).to have_content "Developer"
+ end
+ end
+
+ private
+
+ def mary_jane_member
+ user = User.find_by(name: "Mary Jane")
+ owned_group.members.find_by(user_id: user.id)
+ end
+
+ def group_members_list
+ find(".panel .content-list")
+ end
+end
diff --git a/features/steps/profile/notifications.rb b/features/steps/profile/notifications.rb
new file mode 100644
index 00000000000..f8eb0f01de8
--- /dev/null
+++ b/features/steps/profile/notifications.rb
@@ -0,0 +1,20 @@
+class Spinach::Features::ProfileNotifications < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedProject
+
+ step 'I visit profile notifications page' do
+ visit profile_notifications_path
+ end
+
+ step 'I should see global notifications settings' do
+ expect(page).to have_content "Notifications"
+ end
+
+ step 'I select Mention setting from dropdown' do
+ first(:link, "On mention").click
+ end
+
+ step 'I should see Notification saved message' do
+ expect(page).to have_content 'On mention'
+ end
+end
diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb
new file mode 100644
index 00000000000..4b72355b125
--- /dev/null
+++ b/features/steps/project/builds/artifacts.rb
@@ -0,0 +1,98 @@
+class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedProject
+ include SharedBuilds
+ include RepoHelpers
+ include WaitForRequests
+
+ step 'I click artifacts download button' do
+ click_link 'Download'
+ end
+
+ step 'I click artifacts browse button' do
+ click_link 'Browse'
+ expect(page).not_to have_selector('.build-sidebar')
+ end
+
+ step 'I should see content of artifacts archive' do
+ page.within('.tree-table') do
+ expect(page).to have_no_content '..'
+ expect(page).to have_content 'other_artifacts_0.1.2'
+ expect(page).to have_content 'ci_artifacts.txt'
+ expect(page).to have_content 'rails_sample.jpg'
+ end
+ end
+
+ step 'I should see the build header' do
+ page.within('.build-header') do
+ expect(page).to have_content "Job ##{@build.id} in pipeline ##{@pipeline.id} for #{@pipeline.short_sha}"
+ end
+ end
+
+ step 'I click link to subdirectory within build artifacts' do
+ page.within('.tree-table') { click_link 'other_artifacts_0.1.2' }
+ end
+
+ step 'I should see content of subdirectory within artifacts archive' do
+ page.within('.tree-table') do
+ expect(page).to have_content '..'
+ expect(page).to have_content 'another-subdirectory'
+ expect(page).to have_content 'doc_sample.txt'
+ end
+ end
+
+ step 'I should see the directory name in the breadcrumb' do
+ page.within('.repo-breadcrumb') do
+ expect(page).to have_content 'other_artifacts_0.1.2'
+ end
+ end
+
+ step 'recent build artifacts contain directory with UTF-8 characters' do
+ # metadata fixture contains relevant directory
+ end
+
+ step 'I navigate to directory with UTF-8 characters in name' do
+ page.within('.tree-table') { click_link 'tests_encoding' }
+ page.within('.tree-table') { click_link 'utf8 test dir ✓' }
+ end
+
+ step 'I should see content of directory with UTF-8 characters in name' do
+ page.within('.tree-table') do
+ expect(page).to have_content '..'
+ expect(page).to have_content 'regular_file_2'
+ end
+ end
+
+ step 'recent build artifacts contain directory with invalid UTF-8 characters' do
+ # metadata fixture contains relevant directory
+ end
+
+ step 'I navigate to parent directory of directory with invalid name' do
+ page.within('.tree-table') { click_link 'tests_encoding' }
+ end
+
+ step 'I should not see directory with invalid name on the list' do
+ page.within('.tree-table') do
+ expect(page).to have_no_content('non-utf8-dir')
+ end
+ end
+
+ step 'I click a link to file within build artifacts' do
+ page.within('.tree-table') { find_link('ci_artifacts.txt').click }
+ wait_for_requests
+ end
+
+ step 'I see a download link' do
+ expect(page).to have_link 'download it'
+ end
+
+ step 'I click a first row within build artifacts table' do
+ row = first('tr[data-link]')
+ @row_path = row['data-link']
+ row.click
+ end
+
+ step 'page with a coresponding path is loading' do
+ expect(current_path).to eq @row_path
+ end
+end
diff --git a/features/steps/project/commits/branches.rb b/features/steps/project/commits/branches.rb
new file mode 100644
index 00000000000..3ecd4c8b672
--- /dev/null
+++ b/features/steps/project/commits/branches.rb
@@ -0,0 +1,32 @@
+class Spinach::Features::ProjectCommitsBranches < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedProject
+ include SharedPaths
+
+ step 'I click link "All"' do
+ click_link "All"
+ end
+
+ step 'I click link "Protected"' do
+ click_link "Protected"
+ end
+
+ step 'I click new branch link' do
+ click_link "New branch"
+ end
+
+ step 'I submit new branch form with invalid name' do
+ fill_in 'branch_name', with: '1.0 stable'
+ page.find("body").click # defocus the branch_name input
+ select_branch('master')
+ click_button 'Create branch'
+ end
+
+ def select_branch(branch_name)
+ find('.git-revision-dropdown-toggle').click
+
+ page.within '#new-branch-form .dropdown-menu' do
+ click_link branch_name
+ end
+ end
+end
diff --git a/features/steps/project/commits/comments.rb b/features/steps/project/commits/comments.rb
new file mode 100644
index 00000000000..3d4d8ad6368
--- /dev/null
+++ b/features/steps/project/commits/comments.rb
@@ -0,0 +1,6 @@
+class Spinach::Features::ProjectCommitsComments < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedNote
+ include SharedPaths
+ include SharedProject
+end
diff --git a/features/steps/project/commits/commits.rb b/features/steps/project/commits/commits.rb
new file mode 100644
index 00000000000..959cf7d3e54
--- /dev/null
+++ b/features/steps/project/commits/commits.rb
@@ -0,0 +1,192 @@
+class Spinach::Features::ProjectCommits < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedProject
+ include SharedPaths
+ include SharedDiffNote
+ include RepoHelpers
+
+ step 'I see project commits' do
+ commit = @project.repository.commit
+ expect(page).to have_content(@project.name)
+ expect(page).to have_content(commit.message[0..20])
+ expect(page).to have_content(commit.short_id)
+ end
+
+ step 'I click atom feed link' do
+ click_link "Commits feed"
+ end
+
+ step 'I see commits atom feed' do
+ commit = @project.repository.commit
+ expect(response_headers['Content-Type']).to have_content("application/atom+xml")
+ expect(body).to have_selector("title", text: "#{@project.name}:master commits")
+ expect(body).to have_selector("author email", text: commit.author_email)
+ expect(body).to have_selector("entry summary", text: commit.description[0..10].delete("\r\n"))
+ end
+
+ step 'I click on tag link' do
+ click_link "Tag"
+ end
+
+ step 'I see commit SHA pre-filled' do
+ expect(page).to have_selector("input[value='#{sample_commit.id}']")
+ end
+
+ step 'I click on commit link' do
+ visit project_commit_path(@project, sample_commit.id)
+ end
+
+ step 'I see commit info' do
+ expect(page).to have_content sample_commit.message
+ expect(page).to have_content "Showing #{sample_commit.files_changed_count} changed files"
+ end
+
+ step 'I fill compare fields with branches' do
+ select_using_dropdown('from', 'feature')
+ select_using_dropdown('to', 'master')
+
+ click_button 'Compare'
+ end
+
+ step 'I fill compare fields with refs' do
+ select_using_dropdown('from', sample_commit.parent_id, true)
+ select_using_dropdown('to', sample_commit.id, true)
+
+ click_button "Compare"
+ end
+
+ step 'I unfold diff' do
+ @diff = first('.js-unfold')
+ @diff.click
+ sleep 2
+ end
+
+ step 'I should see additional file lines' do
+ page.within @diff.query_scope do
+ expect(first('.new_line').text).not_to have_content "..."
+ end
+ end
+
+ step 'I see compared refs' do
+ expect(page).to have_content "Commits (1)"
+ expect(page).to have_content "Showing 2 changed files"
+ end
+
+ step 'I visit commits list page for feature branch' do
+ visit project_commits_path(@project, 'feature', { limit: 5 })
+ end
+
+ step 'I see feature branch commits' do
+ commit = @project.repository.commit('0b4bc9a')
+ expect(page).to have_content(@project.name)
+ expect(page).to have_content(commit.message[0..12])
+ expect(page).to have_content(commit.short_id)
+ end
+
+ step 'project have an open merge request' do
+ create(:merge_request,
+ title: 'Feature',
+ source_project: @project,
+ source_branch: 'feature',
+ target_branch: 'master',
+ author: @project.users.first
+ )
+ end
+
+ step 'I click the "Compare" tab' do
+ click_link('Compare')
+ end
+
+ step 'I fill compare fields with branches' do
+ select_using_dropdown('from', 'master')
+ select_using_dropdown('to', 'feature')
+
+ click_button 'Compare'
+ end
+
+ step 'I see compared branches' do
+ expect(page).to have_content 'Commits (1)'
+ expect(page).to have_content 'Showing 1 changed file with 5 additions and 0 deletions'
+ end
+
+ step 'I see button to create a new merge request' do
+ expect(page).to have_link 'Create merge request'
+ end
+
+ step 'I should not see button to create a new merge request' do
+ expect(page).not_to have_link 'Create merge request'
+ end
+
+ step 'I should see button to the merge request' do
+ merge_request = MergeRequest.find_by(title: 'Feature')
+ expect(page).to have_link "View open merge request", href: project_merge_request_path(@project, merge_request)
+ end
+
+ step 'I see breadcrumb links' do
+ expect(page).to have_selector('ul.breadcrumb')
+ expect(page).to have_selector('ul.breadcrumb a', count: 4)
+ end
+
+ step 'I see commits stats' do
+ expect(page).to have_content 'Top 50 Committers'
+ expect(page).to have_content 'Committers'
+ expect(page).to have_content 'Total commits'
+ expect(page).to have_content 'Authors'
+ end
+
+ step 'I visit a commit with an image that changed' do
+ visit project_commit_path(@project, sample_image_commit.id)
+ end
+
+ step 'The diff links to both the previous and current image' do
+ links = page.all('.file-actions a')
+ expect(links[0]['href']).to match %r{blob/#{sample_image_commit.old_blob_id}}
+ expect(links[1]['href']).to match %r{blob/#{sample_image_commit.new_blob_id}}
+ end
+
+ step 'I see inline diff button' do
+ expect(page).to have_content "Inline"
+ end
+
+ step 'I click side-by-side diff button' do
+ find('#parallel-diff-btn').click
+ end
+
+ step 'commit has ci status' do
+ @project.enable_ci
+ @pipeline = create(:ci_pipeline, project: @project, sha: sample_commit.id)
+ create(:ci_build, pipeline: @pipeline)
+ end
+
+ step 'repository contains ".gitlab-ci.yml" file' do
+ allow_any_instance_of(Ci::Pipeline).to receive(:ci_yaml_file).and_return(String.new)
+ end
+
+ step 'I see commit ci info' do
+ expect(page).to have_content "Pipeline ##{@pipeline.id} pending"
+ end
+
+ step 'I search "submodules" commits' do
+ fill_in 'commits-search', with: 'submodules'
+ end
+
+ step 'I should see only "submodules" commits' do
+ expect(page).to have_content "More submodules"
+ expect(page).not_to have_content "Change some files"
+ end
+
+ def select_using_dropdown(dropdown_type, selection, is_commit = false)
+ dropdown = find(".js-compare-#{dropdown_type}-dropdown")
+ dropdown.find(".compare-dropdown-toggle").click
+ dropdown.find('.dropdown-menu', visible: true)
+ dropdown.fill_in("Filter by Git revision", with: selection)
+
+ if is_commit
+ dropdown.find('input[type="search"]').send_keys(:return)
+ else
+ find_link(selection, visible: true).click
+ end
+
+ dropdown.find('.dropdown-menu', visible: false)
+ end
+end
diff --git a/features/steps/project/commits/diff_comments.rb b/features/steps/project/commits/diff_comments.rb
new file mode 100644
index 00000000000..b9d8cf2c5a5
--- /dev/null
+++ b/features/steps/project/commits/diff_comments.rb
@@ -0,0 +1,6 @@
+class Spinach::Features::ProjectCommitsDiffComments < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedDiffNote
+ include SharedPaths
+ include SharedProject
+end
diff --git a/features/steps/project/create.rb b/features/steps/project/create.rb
new file mode 100644
index 00000000000..60fa232672e
--- /dev/null
+++ b/features/steps/project/create.rb
@@ -0,0 +1,23 @@
+class Spinach::Features::ProjectCreate < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedPaths
+ include SharedUser
+
+ step 'fill project form with valid data' do
+ fill_in 'project_path', with: 'Empty'
+ page.within '#content-body' do
+ click_button "Create project"
+ end
+ end
+
+ step 'I should see project page' do
+ expect(page).to have_content "Empty"
+ expect(current_path).to eq project_path(Project.last)
+ end
+
+ step 'I should see empty project instructions' do
+ expect(page).to have_content "git init"
+ expect(page).to have_content "git remote"
+ expect(page).to have_content Project.last.url_to_repo
+ end
+end
diff --git a/features/steps/project/deploy_keys.rb b/features/steps/project/deploy_keys.rb
new file mode 100644
index 00000000000..9db31522c5c
--- /dev/null
+++ b/features/steps/project/deploy_keys.rb
@@ -0,0 +1,89 @@
+class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedProject
+ include SharedPaths
+
+ step 'project has deploy key' do
+ create(:deploy_keys_project, project: @project)
+ end
+
+ step 'I should see project deploy key' do
+ page.within(find('.deploy-keys')) do
+ expect(page).to have_content deploy_key.title
+ end
+ end
+
+ step 'I should see other project deploy key' do
+ page.within(find('.deploy-keys')) do
+ expect(page).to have_content other_deploy_key.title
+ end
+ end
+
+ step 'I should see public deploy key' do
+ page.within(find('.deploy-keys')) do
+ expect(page).to have_content public_deploy_key.title
+ end
+ end
+
+ step 'I click \'New Deploy Key\'' do
+ click_link 'New deploy key'
+ end
+
+ step 'I submit new deploy key' do
+ fill_in "deploy_key_title", with: "laptop"
+ fill_in "deploy_key_key", with: "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAzrEJUIR6Y03TCE9rIJ+GqTBvgb8t1jI9h5UBzCLuK4VawOmkLornPqLDrGbm6tcwM/wBrrLvVOqi2HwmkKEIecVO0a64A4rIYScVsXIniHRS6w5twyn1MD3sIbN+socBDcaldECQa2u1dI3tnNVcs8wi77fiRe7RSxePsJceGoheRQgC8AZ510UdIlO+9rjIHUdVN7LLyz512auAfYsgx1OfablkQ/XJcdEwDNgi9imI6nAXhmoKUm1IPLT2yKajTIC64AjLOnE0YyCh6+7RFMpiMyu1qiOCpdjYwTgBRiciNRZCH8xIedyCoAmiUgkUT40XYHwLuwiPJICpkAzp7Q== user@laptop"
+ click_button "Add key"
+ end
+
+ step 'I should be on deploy keys page' do
+ expect(current_path).to eq project_settings_repository_path(@project)
+ end
+
+ step 'I should see newly created deploy key' do
+ @project.reload
+ page.within(find('.deploy-keys')) do
+ expect(page).to have_content(deploy_key.title)
+ end
+ end
+
+ step 'other projects have deploy keys' do
+ @second_project = create(:project, namespace: create(:group))
+ @second_project.add_master(current_user)
+ create(:deploy_keys_project, project: @second_project)
+
+ @third_project = create(:project, namespace: create(:group))
+ @third_project.add_master(current_user)
+ create(:deploy_keys_project, project: @third_project, deploy_key: @second_project.deploy_keys.first)
+ end
+
+ step 'I should only see the same deploy key once' do
+ page.within(find('.deploy-keys')) do
+ expect(page).to have_selector('ul li', count: 1)
+ end
+ end
+
+ step 'public deploy key exists' do
+ create(:deploy_key, public: true)
+ end
+
+ step 'I click attach deploy key' do
+ page.within(find('.deploy-keys')) do
+ click_button 'Enable'
+ expect(page).not_to have_selector('.fa-spinner')
+ end
+ end
+
+ protected
+
+ def deploy_key
+ @project.deploy_keys.last
+ end
+
+ def other_deploy_key
+ @second_project.deploy_keys.last
+ end
+
+ def public_deploy_key
+ DeployKey.are_public.last
+ end
+end
diff --git a/features/steps/project/ff_merge_requests.rb b/features/steps/project/ff_merge_requests.rb
new file mode 100644
index 00000000000..27efcfd65b6
--- /dev/null
+++ b/features/steps/project/ff_merge_requests.rb
@@ -0,0 +1,87 @@
+class Spinach::Features::ProjectFfMergeRequests < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedIssuable
+ include SharedProject
+ include SharedNote
+ include SharedPaths
+ include SharedMarkdown
+ include SharedDiffNote
+ include SharedUser
+ include WaitForRequests
+
+ step 'project "Shop" have "Bug NS-05" open merge request with diffs inside' do
+ create(:merge_request_with_diffs,
+ title: "Bug NS-05",
+ source_project: project,
+ target_project: project,
+ author: project.users.first)
+ end
+
+ step 'merge request is mergeable' do
+ expect(page).to have_button 'Merge'
+ end
+
+ step 'I should see ff-only merge button' do
+ expect(page).to have_content "Fast-forward merge without a merge commit"
+ expect(page).to have_button 'Merge'
+ end
+
+ step 'merge request "Bug NS-05" is mergeable' do
+ merge_request.mark_as_mergeable
+ end
+
+ step 'I accept this merge request' do
+ page.within '.mr-state-widget' do
+ click_button "Merge"
+ end
+ end
+
+ step 'I should see merged request' do
+ page.within '.status-box' do
+ expect(page).to have_content "Merged"
+ wait_for_requests
+ end
+ end
+
+ step 'ff merge enabled' do
+ project = merge_request.target_project
+ project.merge_requests_ff_only_enabled = true
+ project.save!
+ end
+
+ step 'I should see rebase button' do
+ expect(page).to have_button "Rebase"
+ end
+
+ step 'merge request "Bug NS-05" is rebased' do
+ merge_request.source_branch = 'flatten-dir'
+ merge_request.target_branch = 'improve/awesome'
+ merge_request.reload_diff
+ merge_request.save!
+ end
+
+ step 'merge request "Bug NS-05" merged target' do
+ merge_request.source_branch = 'merged-target'
+ merge_request.target_branch = 'improve/awesome'
+ merge_request.reload_diff
+ merge_request.save!
+ end
+
+ step 'rebase before merge enabled' do
+ project = merge_request.target_project
+ project.merge_requests_rebase_enabled = true
+ project.save!
+ end
+
+ step 'I press rebase button' do
+ click_button "Rebase"
+ end
+
+ step "I should see rebase in progress message" do
+ expect(page).to have_content("Rebase in progress")
+ end
+
+ def merge_request
+ @merge_request ||= MergeRequest.find_by!(title: "Bug NS-05")
+ end
+end
diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb
new file mode 100644
index 00000000000..fd51ee1a316
--- /dev/null
+++ b/features/steps/project/forked_merge_requests.rb
@@ -0,0 +1,139 @@
+class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedProject
+ include SharedNote
+ include SharedPaths
+ include Select2Helper
+ include WaitForRequests
+ include ProjectForksHelper
+
+ step 'I am a member of project "Shop"' do
+ @project = ::Project.find_by(name: "Shop")
+ @project ||= create(:project, :repository, name: "Shop")
+ @project.add_reporter(@user)
+ end
+
+ step 'I have a project forked off of "Shop" called "Forked Shop"' do
+ @forked_project = fork_project(@project, @user,
+ namespace: @user.namespace,
+ repository: true)
+ end
+
+ step 'I click link "New Merge Request"' do
+ page.within '#content-body' do
+ page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ end
+ end
+
+ step 'I should see merge request "Merge Request On Forked Project"' do
+ expect(@project.merge_requests.size).to be >= 1
+ @merge_request = @project.merge_requests.last
+ expect(current_path).to eq project_merge_request_path(@project, @merge_request)
+ expect(@merge_request.title).to eq "Merge Request On Forked Project"
+ expect(@merge_request.source_project).to eq @forked_project
+ expect(@merge_request.source_branch).to eq "fix"
+ expect(@merge_request.target_branch).to eq "master"
+ expect(page).to have_content @forked_project.full_path
+ expect(page).to have_content @project.full_path
+ expect(page).to have_content @merge_request.source_branch
+ expect(page).to have_content @merge_request.target_branch
+
+ wait_for_requests
+ end
+
+ step 'I fill out a "Merge Request On Forked Project" merge request' do
+ expect(page).to have_content('Source branch')
+ expect(page).to have_content('Target branch')
+
+ first('.js-source-project').click
+ first('.dropdown-source-project a', text: @forked_project.full_path)
+
+ first('.js-target-project').click
+ first('.dropdown-target-project a', text: @project.full_path)
+
+ first('.js-source-branch').click
+ wait_for_requests
+ first('.dropdown-source-branch .dropdown-content a', text: 'fix').click
+
+ click_button "Compare branches and continue"
+
+ expect(page).to have_css("h3.page-title", text: "New Merge Request")
+
+ page.within 'form#new_merge_request' do
+ fill_in "merge_request_title", with: "Merge Request On Forked Project"
+ end
+ end
+
+ step 'I submit the merge request' do
+ click_button "Submit merge request"
+ end
+
+ step 'I update the merge request title' do
+ fill_in "merge_request_title", with: "An Edited Forked Merge Request"
+ end
+
+ step 'I save the merge request' do
+ click_button "Save changes"
+ end
+
+ step 'I should see the edited merge request' do
+ expect(page).to have_content "An Edited Forked Merge Request"
+ expect(@project.merge_requests.size).to be >= 1
+ @merge_request = @project.merge_requests.last
+ expect(current_path).to eq project_merge_request_path(@project, @merge_request)
+ expect(@merge_request.source_project).to eq @forked_project
+ expect(@merge_request.source_branch).to eq "fix"
+ expect(@merge_request.target_branch).to eq "master"
+ expect(page).to have_content @forked_project.full_path
+ expect(page).to have_content @project.full_path
+ expect(page).to have_content @merge_request.source_branch
+ expect(page).to have_content @merge_request.target_branch
+ end
+
+ step 'I should see last push widget' do
+ expect(page).to have_content "You pushed to new_design"
+ expect(page).to have_link "Create Merge Request"
+ end
+
+ step 'I click link edit "Merge Request On Forked Project"' do
+ find("#edit_merge_request").click
+ end
+
+ step 'I see the edit page prefilled for "Merge Request On Forked Project"' do
+ expect(current_path).to eq edit_project_merge_request_path(@project, @merge_request)
+ expect(page).to have_content "Edit merge request #{@merge_request.to_reference}"
+ expect(find("#merge_request_title").value).to eq "Merge Request On Forked Project"
+ end
+
+ step 'I fill out an invalid "Merge Request On Forked Project" merge request' do
+ expect(find_by_id("merge_request_source_project_id", visible: false).value).to eq @forked_project.id.to_s
+ expect(find_by_id("merge_request_target_project_id", visible: false).value).to eq @project.id.to_s
+ expect(find_by_id("merge_request_source_branch", visible: false).value).to eq nil
+ expect(find_by_id("merge_request_target_branch", visible: false).value).to eq "master"
+ click_button "Compare branches"
+ end
+
+ step 'I should see validation errors' do
+ expect(page).to have_content "You must select source and target branch"
+ end
+
+ step 'the target repository should be the original repository' do
+ expect(find_by_id("merge_request_target_project_id").value).to eq "#{@project.id}"
+ end
+
+ step 'I click "Assign to" dropdown"' do
+ click_button 'Assignee'
+ end
+
+ step 'I should see the target project ID in the input selector' do
+ expect(find('.js-assignee-search')["data-project-id"]).to eq "#{@project.id}"
+ end
+
+ step 'I should see the users from the target project ID' do
+ page.within '.dropdown-menu-user' do
+ expect(page).to have_content 'Unassigned'
+ expect(page).to have_content current_user.name
+ expect(page).to have_content @project.users.first.name
+ end
+ end
+end
diff --git a/features/steps/project/issues/filter_labels.rb b/features/steps/project/issues/filter_labels.rb
new file mode 100644
index 00000000000..b467af53c98
--- /dev/null
+++ b/features/steps/project/issues/filter_labels.rb
@@ -0,0 +1,61 @@
+class Spinach::Features::ProjectIssuesFilterLabels < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedProject
+ include SharedPaths
+ include Select2Helper
+
+ step 'I should see "Bugfix1" in issues list' do
+ page.within ".issues-list" do
+ expect(page).to have_content "Bugfix1"
+ end
+ end
+
+ step 'I should see "Bugfix2" in issues list' do
+ page.within ".issues-list" do
+ expect(page).to have_content "Bugfix2"
+ end
+ end
+
+ step 'I should not see "Bugfix2" in issues list' do
+ page.within ".issues-list" do
+ expect(page).not_to have_content "Bugfix2"
+ end
+ end
+
+ step 'I should not see "Feature1" in issues list' do
+ page.within ".issues-list" do
+ expect(page).not_to have_content "Feature1"
+ end
+ end
+
+ step 'I click "dropdown close button"' do
+ page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click
+ sleep 2
+ end
+
+ step 'I click link "feature"' do
+ page.within ".labels-filter" do
+ click_link "feature"
+ end
+ end
+
+ step 'project "Shop" has issue "Bugfix1" with labels: "bug", "feature"' do
+ project = Project.find_by(name: "Shop")
+ issue = create(:issue, title: "Bugfix1", project: project)
+ issue.labels << project.labels.find_by(title: 'bug')
+ issue.labels << project.labels.find_by(title: 'feature')
+ end
+
+ step 'project "Shop" has issue "Bugfix2" with labels: "bug", "enhancement"' do
+ project = Project.find_by(name: "Shop")
+ issue = create(:issue, title: "Bugfix2", project: project)
+ issue.labels << project.labels.find_by(title: 'bug')
+ issue.labels << project.labels.find_by(title: 'enhancement')
+ end
+
+ step 'project "Shop" has issue "Feature1" with labels: "feature"' do
+ project = Project.find_by(name: "Shop")
+ issue = create(:issue, title: "Feature1", project: project)
+ issue.labels << project.labels.find_by(title: 'feature')
+ end
+end
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
new file mode 100644
index 00000000000..baa78c23203
--- /dev/null
+++ b/features/steps/project/issues/issues.rb
@@ -0,0 +1,175 @@
+class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedIssuable
+ include SharedProject
+ include SharedNote
+ include SharedPaths
+ include SharedMarkdown
+ include SharedUser
+
+ step 'I should not see "Release 0.3" in issues' do
+ expect(page).not_to have_content "Release 0.3"
+ end
+
+ step 'I click link "Closed"' do
+ find('.issues-state-filters [data-state="closed"] span', text: 'Closed').click
+ end
+
+ step 'I should see "Release 0.3" in issues' do
+ expect(page).to have_content "Release 0.3"
+ end
+
+ step 'I should not see "Release 0.4" in issues' do
+ expect(page).not_to have_content "Release 0.4"
+ end
+
+ step 'I click link "All"' do
+ find('.issues-state-filters [data-state="all"] span', text: 'All').click
+ # Waits for load
+ expect(find('.issues-state-filters > .active')).to have_content 'All'
+ end
+
+ step 'I should see issue "Tweet control"' do
+ expect(page).to have_content "Tweet control"
+ end
+
+ step 'I click "author" dropdown' do
+ page.find('.js-author-search').click
+ sleep 1
+ end
+
+ step 'I see current user as the first user' do
+ expect(page).to have_selector('.dropdown-content', visible: true)
+ users = page.all('.dropdown-menu-author .dropdown-content li a')
+ expect(users[0].text).to eq 'Any Author'
+ expect(users[1].text).to eq "#{current_user.name} #{current_user.to_reference}"
+ end
+
+ step 'I click link "500 error on profile"' do
+ click_link "500 error on profile"
+ end
+
+ step 'I should see label \'bug\' with issue' do
+ page.within '.issuable-show-labels' do
+ expect(page).to have_content 'bug'
+ end
+ end
+
+ step 'I fill in issue search with "Re"' do
+ filter_issue "Re"
+ end
+
+ step 'I fill in issue search with "Bu"' do
+ filter_issue "Bu"
+ end
+
+ step 'I fill in issue search with ".3"' do
+ filter_issue ".3"
+ end
+
+ step 'I fill in issue search with "Something"' do
+ filter_issue "Something"
+ end
+
+ step 'I fill in issue search with ""' do
+ filter_issue ""
+ end
+
+ step 'project "Shop" has milestone "v2.2"' do
+ milestone = create(:milestone, title: "v2.2", project: project)
+
+ 3.times { create(:issue, project: project, milestone: milestone) }
+ end
+
+ step 'project "Shop" has milestone "v3.0"' do
+ milestone = create(:milestone, title: "v3.0", project: project)
+
+ 3.times { create(:issue, project: project, milestone: milestone) }
+ end
+
+ When 'I select milestone "v3.0"' do
+ select "v3.0", from: "milestone_id"
+ end
+
+ step 'I should see selected milestone with title "v3.0"' do
+ issues_milestone_selector = "#issue_milestone_id_chzn > a"
+ expect(find(issues_milestone_selector)).to have_content("v3.0")
+ end
+
+ When 'I select first assignee from "Shop" project' do
+ first_assignee = project.users.first
+ select first_assignee.name, from: "assignee_id"
+ end
+
+ step 'I should see first assignee from "Shop" as selected assignee' do
+ issues_assignee_selector = "#issue_assignee_id_chzn > a"
+
+ assignee_name = project.users.first.name
+ expect(find(issues_assignee_selector)).to have_content(assignee_name)
+ end
+
+ step 'The list should be sorted by "Least popular"' do
+ page.within '.issues-list' do
+ page.within 'li.issue:nth-child(1)' do
+ expect(page).to have_content 'Tweet control'
+ expect(page).to have_content '1 2'
+ end
+
+ page.within 'li.issue:nth-child(2)' do
+ expect(page).to have_content 'Release 0.4'
+ expect(page).to have_content '2 1'
+ end
+
+ page.within 'li.issue:nth-child(3)' do
+ expect(page).to have_content 'Bugfix'
+ expect(page).not_to have_content '0 0'
+ end
+ end
+ end
+
+ When 'I visit empty project page' do
+ project = Project.find_by(name: 'Empty Project')
+ visit project_path(project)
+ end
+
+ When "I visit project \"Community\" issues page" do
+ project = Project.find_by(name: 'Community')
+ visit project_issues_path(project)
+ end
+
+ step 'project \'Shop\' has issue \'Bugfix1\' with description: \'Description for issue1\'' do
+ create(:issue, title: 'Bugfix1', description: 'Description for issue1', project: project)
+ end
+
+ step 'project \'Shop\' has issue \'Feature1\' with description: \'Feature submitted for issue1\'' do
+ create(:issue, title: 'Feature1', description: 'Feature submitted for issue1', project: project)
+ end
+
+ step 'I fill in issue search with \'Description for issue1\'' do
+ filter_issue 'Description for issue'
+ end
+
+ step 'I fill in issue search with \'issue1\'' do
+ filter_issue 'issue1'
+ end
+
+ step 'I fill in issue search with \'Rock and roll\'' do
+ filter_issue 'Rock and roll'
+ end
+
+ step 'I should see \'Bugfix1\' in issues' do
+ expect(page).to have_content 'Bugfix1'
+ end
+
+ step 'I should see \'Feature1\' in issues' do
+ expect(page).to have_content 'Feature1'
+ end
+
+ step 'I should not see \'Bugfix1\' in issues' do
+ expect(page).not_to have_content 'Bugfix1'
+ end
+
+ def filter_issue(text)
+ fill_in 'issuable_search', with: text
+ end
+end
diff --git a/features/steps/project/issues/milestones.rb b/features/steps/project/issues/milestones.rb
new file mode 100644
index 00000000000..30927306a4f
--- /dev/null
+++ b/features/steps/project/issues/milestones.rb
@@ -0,0 +1,20 @@
+class Spinach::Features::ProjectIssuesMilestones < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedProject
+ include SharedPaths
+ include SharedMarkdown
+
+ step 'project "Shop" has milestone "v2.2"' do
+ project = Project.find_by(name: "Shop")
+ milestone = create(:milestone,
+ title: "v2.2",
+ project: project,
+ description: "# Description header"
+ )
+ 3.times { create(:issue, project: project, milestone: milestone) }
+ end
+
+ When 'I click link "All Issues"' do
+ click_link 'All Issues'
+ end
+end
diff --git a/features/steps/project/issues/references.rb b/features/steps/project/issues/references.rb
new file mode 100644
index 00000000000..69e8b5cbde5
--- /dev/null
+++ b/features/steps/project/issues/references.rb
@@ -0,0 +1,7 @@
+class Spinach::Features::ProjectIssuesReferences < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedIssuable
+ include SharedNote
+ include SharedProject
+ include SharedUser
+end
diff --git a/features/steps/project/merge_requests/references.rb b/features/steps/project/merge_requests/references.rb
new file mode 100644
index 00000000000..ab2ae6847a2
--- /dev/null
+++ b/features/steps/project/merge_requests/references.rb
@@ -0,0 +1,7 @@
+class Spinach::Features::ProjectMergeRequestsReferences < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedIssuable
+ include SharedNote
+ include SharedProject
+ include SharedUser
+end
diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb
new file mode 100644
index 00000000000..afaad4b255e
--- /dev/null
+++ b/features/steps/project/source/browse_files.rb
@@ -0,0 +1,435 @@
+# coding: utf-8
+class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
+ include SharedAuthentication
+ include SharedProject
+ include SharedPaths
+ include RepoHelpers
+ include WaitForRequests
+
+ step "I don't have write access" do
+ @project = create(:project, :repository, name: "Other Project", path: "other-project")
+ @project.add_reporter(@user)
+ visit project_tree_path(@project, root_ref)
+ end
+
+ step 'I should see files from repository' do
+ expect(page).to have_content "VERSION"
+ expect(page).to have_content ".gitignore"
+ expect(page).to have_content "LICENSE"
+ end
+
+ step 'I should see files from repository for "6d39438"' do
+ expect(current_path).to eq project_tree_path(@project, "6d39438")
+ expect(page).to have_content ".gitignore"
+ expect(page).to have_content "LICENSE"
+ end
+
+ step 'I see the ".gitignore"' do
+ expect(page).to have_content '.gitignore'
+ end
+
+ step 'I don\'t see the ".gitignore"' do
+ expect(page).not_to have_content '.gitignore'
+ end
+
+ step 'I click on ".gitignore" file in repo' do
+ click_link ".gitignore"
+ end
+
+ step 'I should see its content' do
+ wait_for_requests
+ expect(page).to have_content old_gitignore_content
+ end
+
+ step 'I should see its new content' do
+ wait_for_requests
+ expect(page).to have_content new_gitignore_content
+ end
+
+ step 'I click link "Raw"' do
+ click_link 'Open raw'
+ end
+
+ step 'I should see raw file content' do
+ expect(source).to eq '' # Body is filled in by gitlab-workhorse
+ end
+
+ step 'I click button "Edit"' do
+ find('.js-edit-blob').click
+ end
+
+ step 'I cannot see the edit button' do
+ expect(page).not_to have_link 'edit'
+ end
+
+ step 'I click button "Fork"' do
+ click_link 'Fork'
+ end
+
+ step 'I edit code' do
+ expect(page).to have_selector('.file-editor')
+ set_new_content
+ end
+
+ step 'I fill the new file name' do
+ fill_in :file_name, with: new_file_name
+ end
+
+ step 'I fill the new branch name' do
+ fill_in :branch_name, with: 'new_branch_name', visible: true
+ end
+
+ step 'I fill the new file name with a new directory' do
+ fill_in :file_name, with: new_file_name_with_directory
+ end
+
+ step 'I fill the commit message' do
+ fill_in :commit_message, with: 'New commit message', visible: true
+ end
+
+ step 'I click link "Diff"' do
+ click_link 'Preview changes'
+ end
+
+ step 'I click on "Commit changes"' do
+ click_button 'Commit changes'
+ end
+
+ step 'I click on "Changes" tab' do
+ click_link 'Changes'
+ end
+
+ step 'I click on "Create directory"' do
+ click_button 'Create directory'
+ end
+
+ step 'I click on "Delete"' do
+ click_on 'Delete'
+ end
+
+ step 'I click on "Delete file"' do
+ click_button 'Delete file'
+ end
+
+ step 'I click on "Replace"' do
+ click_on "Replace"
+ end
+
+ step 'I click on "Replace file"' do
+ click_button 'Replace file'
+ end
+
+ step 'I see diff' do
+ expect(page).to have_css '.line_holder.new'
+ end
+
+ step 'I click on "New file" link in repo' do
+ find('.add-to-tree').click
+ click_link 'New file'
+ expect(page).to have_selector('.file-editor')
+ end
+
+ step 'I click on "Upload file" link in repo' do
+ find('.add-to-tree').click
+ click_link 'Upload file'
+ end
+
+ step 'I click on "New directory" link in repo' do
+ find('.add-to-tree').click
+ click_link 'New directory'
+ end
+
+ step 'I fill the new directory name' do
+ fill_in :dir_name, with: new_dir_name
+ end
+
+ step 'I fill an existing directory name' do
+ fill_in :dir_name, with: 'files'
+ end
+
+ step 'I can see new file page' do
+ expect(page).to have_content "New File"
+ expect(page).to have_content "Commit message"
+ end
+
+ step 'I click on "Upload file"' do
+ click_button 'Upload file'
+ end
+
+ step 'I can see the new commit message' do
+ expect(page).to have_content "New commit message"
+ end
+
+ step 'I upload a new text file' do
+ drop_in_dropzone test_text_file
+ end
+
+ step 'I fill the upload file commit message' do
+ page.within('#modal-upload-blob') do
+ fill_in :commit_message, with: 'New commit message'
+ end
+ end
+
+ step 'I replace it with a text file' do
+ drop_in_dropzone test_text_file
+ end
+
+ step 'I fill the replace file commit message' do
+ page.within('#modal-upload-blob') do
+ fill_in :commit_message, with: 'Replacement file commit message'
+ end
+ end
+
+ step 'I can see the replacement commit message' do
+ expect(page).to have_content "Replacement file commit message"
+ end
+
+ step 'I can see the new text file' do
+ expect(page).to have_content "Lorem ipsum dolor sit amet"
+ expect(page).to have_content "Sed ut perspiciatis unde omnis"
+ end
+
+ step 'I click on files directory' do
+ click_link 'files'
+ end
+
+ step 'I click on History link' do
+ click_link 'History'
+ end
+
+ step 'I see Browse dir link' do
+ expect(page).to have_link 'Browse Directory'
+ expect(page).not_to have_link 'Browse Code'
+ end
+
+ step 'I click on readme file' do
+ page.within '.tree-table' do
+ click_link 'README.md'
+ end
+ end
+
+ step 'I see Browse file link' do
+ expect(page).to have_link 'Browse File'
+ expect(page).not_to have_link 'Browse Files'
+ end
+
+ step 'I see Browse code link' do
+ expect(page).to have_link 'Browse Files'
+ expect(page).not_to have_link 'Browse Directory'
+ end
+
+ step 'I click on Permalink' do
+ click_link 'Permalink'
+ end
+
+ step 'I am redirected to the files URL' do
+ expect(current_path).to eq project_tree_path(@project, 'master')
+ end
+
+ step 'I am redirected to the ".gitignore"' do
+ expect(current_path).to eq(project_blob_path(@project, 'master/.gitignore'))
+ end
+
+ step 'I am redirected to the permalink URL' do
+ expect(current_path).to(
+ eq(project_blob_path(@project,
+ @project.repository.commit.sha +
+ '/.gitignore'))
+ )
+ end
+
+ step 'I am redirected to the new file' do
+ expect(current_path).to eq(
+ project_blob_path(@project, 'master/' + new_file_name))
+ end
+
+ step 'I am redirected to the new file with directory' do
+ expect(current_path).to eq(
+ project_blob_path(@project, 'master/' + new_file_name_with_directory))
+ end
+
+ step 'I am redirected to the new merge request page' do
+ expect(current_path).to eq(project_new_merge_request_path(@project))
+ end
+
+ step "I am redirected to the fork's new merge request page" do
+ fork = @user.fork_of(@project)
+ expect(current_path).to eq(project_new_merge_request_path(fork))
+ end
+
+ step 'I am redirected to the root directory' do
+ expect(current_path).to eq(
+ project_tree_path(@project, 'master'))
+ end
+
+ step "I don't see the permalink link" do
+ expect(page).not_to have_link('permalink')
+ end
+
+ step 'I see "Unable to create directory"' do
+ expect(page).to have_content('A directory with this name already exists')
+ end
+
+ step 'I see "Path can contain only..."' do
+ expect(page).to have_content('Path can contain only')
+ end
+
+ step 'I see a commit error message' do
+ expect(page).to have_content('Your changes could not be committed')
+ end
+
+ step "I switch ref to 'test'" do
+ first('.js-project-refs-dropdown').click
+
+ page.within '.project-refs-form' do
+ click_link "'test'"
+ end
+ end
+
+ step "I switch ref to fix" do
+ first('.js-project-refs-dropdown').click
+
+ page.within '.project-refs-form' do
+ click_link 'fix'
+ end
+ end
+
+ step "I see the ref 'test' has been selected" do
+ expect(page).to have_selector '.dropdown-toggle-text', text: "'test'"
+ end
+
+ step "I visit the 'test' tree" do
+ visit project_tree_path(@project, "'test'")
+ end
+
+ step "I visit the fix tree" do
+ visit project_tree_path(@project, "fix/.testdir")
+ end
+
+ step 'I see the commit data' do
+ expect(page).to have_css('.tree-commit-link', visible: true)
+ expect(page).not_to have_content('Loading commit data...')
+ end
+
+ step 'I see the commit data for a directory with a leading dot' do
+ expect(page).to have_css('.tree-commit-link', visible: true)
+ expect(page).not_to have_content('Loading commit data...')
+ end
+
+ step 'I click on "files/lfs/lfs_object.iso" file in repo' do
+ allow_any_instance_of(Project).to receive(:lfs_enabled?).and_return(true)
+ visit project_tree_path(@project, "lfs")
+ click_link 'files'
+ click_link "lfs"
+ click_link "lfs_object.iso"
+ end
+
+ step 'I should see download link and object size' do
+ expect(page).to have_content 'Download (1.5 MB)'
+ end
+
+ step 'I should not see lfs pointer details' do
+ expect(page).not_to have_content 'version https://git-lfs.github.com/spec/v1'
+ expect(page).not_to have_content 'oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897'
+ expect(page).not_to have_content 'size 1575078'
+ end
+
+ step 'I should see buttons for allowed commands' do
+ page.within '.content' do
+ expect(page).to have_link 'Download'
+ expect(page).to have_content 'History'
+ expect(page).to have_content 'Permalink'
+ expect(page).not_to have_content 'Edit'
+ expect(page).not_to have_content 'Blame'
+ expect(page).to have_content 'Delete'
+ expect(page).to have_content 'Replace'
+ end
+ end
+
+ step 'I should see a Fork/Cancel combo' do
+ expect(page).to have_link 'Fork'
+ expect(page).to have_button 'Cancel'
+ end
+
+ step 'I should see a notice about a new fork having been created' do
+ expect(page).to have_content "You're not allowed to make changes to this project directly. A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ end
+
+ # SVG files
+ step 'I upload a new SVG file' do
+ drop_in_dropzone test_svg_file
+ end
+
+ step 'I visit the SVG file' do
+ visit project_blob_path(@project, 'new_branch_name/logo_sample.svg')
+ end
+
+ step 'I can see the new rendered SVG image' do
+ expect(page).to have_css('.file-content img')
+ end
+
+ private
+
+ def set_new_content
+ find('#editor')
+ execute_script("ace.edit('editor').setValue('#{new_gitignore_content}')")
+ end
+
+ # Content of the gitignore file on the seed repository.
+ def old_gitignore_content
+ '*.rbc'
+ end
+
+ # Constant value that differs from the content
+ # of the gitignore of the seed repository.
+ def new_gitignore_content
+ old_gitignore_content + 'a'
+ end
+
+ # Constant value that is a valid filename and
+ # not a filename present at root of the seed repository.
+ def new_file_name
+ 'not_a_file.md'
+ end
+
+ # Constant value that is a valid filename with directory and
+ # not a filename present at root of the seed repository.
+ def new_file_name_with_directory
+ 'foo/bar/baz.txt'
+ end
+
+ # Constant value that is a valid directory and
+ # not a directory present at root of the seed repository.
+ def new_dir_name
+ 'new_dir/subdir'
+ end
+
+ def drop_in_dropzone(file_path)
+ # Generate a fake input selector
+ page.execute_script <<-JS
+ var fakeFileInput = window.$('<input/>').attr(
+ {id: 'fakeFileInput', type: 'file'}
+ ).appendTo('body');
+ JS
+ # Attach the file to the fake input selector with Capybara
+ attach_file("fakeFileInput", file_path)
+ # Add the file to a fileList array and trigger the fake drop event
+ page.execute_script <<-JS
+ var fileList = [$('#fakeFileInput')[0].files[0]];
+ var e = jQuery.Event('drop', { dataTransfer : { files : fileList } });
+ $('.dropzone')[0].dropzone.listeners[0].events.drop(e);
+ JS
+ end
+
+ def test_text_file
+ File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')
+ end
+
+ def test_image_file
+ File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')
+ end
+
+ def test_svg_file
+ File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg')
+ end
+end
diff --git a/features/steps/shared/active_tab.rb b/features/steps/shared/active_tab.rb
new file mode 100644
index 00000000000..104d024fee2
--- /dev/null
+++ b/features/steps/shared/active_tab.rb
@@ -0,0 +1,32 @@
+module SharedActiveTab
+ include Spinach::DSL
+ include WaitForRequests
+
+ after do
+ wait_for_requests if javascript_test?
+ end
+
+ def ensure_active_main_tab(content)
+ expect(find('.sidebar-top-level-items > li.active')).to have_content(content)
+ end
+
+ def ensure_active_sub_tab(content)
+ expect(find('.sidebar-sub-level-items > li.active:not(.fly-out-top-item)')).to have_content(content)
+ end
+
+ def ensure_active_sub_nav(content)
+ expect(find('.layout-nav .controls li.active')).to have_content(content)
+ end
+
+ step 'no other main tabs should be active' do
+ expect(page).to have_selector('.sidebar-top-level-items > li.active', count: 1)
+ end
+
+ step 'no other sub tabs should be active' do
+ expect(page).to have_selector('.sidebar-sub-level-items > li.active:not(.fly-out-top-item)', count: 1)
+ end
+
+ step 'no other sub navs should be active' do
+ expect(page).to have_selector('.layout-nav .controls li.active', count: 1)
+ end
+end
diff --git a/features/steps/shared/admin.rb b/features/steps/shared/admin.rb
new file mode 100644
index 00000000000..ac0a1764147
--- /dev/null
+++ b/features/steps/shared/admin.rb
@@ -0,0 +1,11 @@
+module SharedAdmin
+ include Spinach::DSL
+
+ step 'there are projects in system' do
+ 2.times { create(:project, :repository) }
+ end
+
+ step 'system has users' do
+ 2.times { create(:user) }
+ end
+end
diff --git a/features/steps/shared/authentication.rb b/features/steps/shared/authentication.rb
new file mode 100644
index 00000000000..97fac595d8e
--- /dev/null
+++ b/features/steps/shared/authentication.rb
@@ -0,0 +1,75 @@
+require Rails.root.join('features', 'support', 'login_helpers')
+
+module SharedAuthentication
+ include Spinach::DSL
+ include LoginHelpers
+
+ step 'I sign in as a user' do
+ sign_out(@user) if @user
+
+ @user = create(:user)
+ sign_in(@user)
+ end
+
+ step 'I sign in via the UI' do
+ gitlab_sign_in(create(:user))
+ end
+
+ step 'I sign in as an admin' do
+ sign_out(@user) if @user
+
+ @user = create(:admin)
+ sign_in(@user)
+ end
+
+ step 'I sign in as "John Doe"' do
+ gitlab_sign_in(user_exists("John Doe"))
+ end
+
+ step 'I sign in as "Mary Jane"' do
+ gitlab_sign_in(user_exists("Mary Jane"))
+ end
+
+ step 'I should be redirected to sign in page' do
+ expect(current_path).to eq new_user_session_path
+ end
+
+ step "I logout" do
+ gitlab_sign_out
+ end
+
+ step "I logout directly" do
+ gitlab_sign_out
+ end
+
+ def current_user
+ @user || User.reorder(nil).first
+ end
+
+ private
+
+ def gitlab_sign_in(user)
+ visit new_user_session_path
+
+ fill_in "user_login", with: user.email
+ fill_in "user_password", with: "12345678"
+ check 'user_remember_me'
+ click_button "Sign in"
+
+ @user = user
+ end
+
+ def gitlab_sign_out
+ return unless @user
+
+ if Capybara.current_driver == Capybara.javascript_driver
+ find('.header-user-dropdown-toggle').click
+ click_link 'Sign out'
+ expect(page).to have_button('Sign in')
+ else
+ sign_out(@user)
+ end
+
+ @user = nil
+ end
+end
diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb
new file mode 100644
index 00000000000..c2197584d8d
--- /dev/null
+++ b/features/steps/shared/builds.rb
@@ -0,0 +1,53 @@
+module SharedBuilds
+ include Spinach::DSL
+
+ step 'project has CI enabled' do
+ @project.enable_ci
+ end
+
+ step 'project has coverage enabled' do
+ @project.update_attribute(:build_coverage_regex, /Coverage (\d+)%/)
+ end
+
+ step 'project has a recent build' do
+ @pipeline = create(:ci_empty_pipeline, project: @project, sha: @project.commit.sha, ref: 'master')
+ @build = create(:ci_build, :running, :coverage, :trace_artifact, pipeline: @pipeline)
+ end
+
+ step 'recent build is successful' do
+ @build.success
+ end
+
+ step 'recent build failed' do
+ @build.drop
+ end
+
+ step 'project has another build that is running' do
+ create(:ci_build, pipeline: @pipeline, name: 'second build', status_event: 'run')
+ end
+
+ step 'I visit recent build details page' do
+ visit project_job_path(@project, @build)
+ end
+
+ step 'recent build has artifacts available' do
+ artifacts = Rails.root + 'spec/fixtures/ci_build_artifacts.zip'
+ archive = fixture_file_upload(artifacts, 'application/zip')
+ @build.update_attributes(legacy_artifacts_file: archive)
+ end
+
+ step 'recent build has artifacts metadata available' do
+ metadata = Rails.root + 'spec/fixtures/ci_build_artifacts_metadata.gz'
+ gzip = fixture_file_upload(metadata, 'application/x-gzip')
+ @build.update_attributes(legacy_artifacts_metadata: gzip)
+ end
+
+ step 'recent build has a build trace' do
+ @build.trace.set('job trace')
+ end
+
+ step 'download of build artifacts archive starts' do
+ expect(page.response_headers['Content-Type']).to eq 'application/zip'
+ expect(page.response_headers['Content-Transfer-Encoding']).to eq 'binary'
+ end
+end
diff --git a/features/steps/shared/diff_note.rb b/features/steps/shared/diff_note.rb
new file mode 100644
index 00000000000..aa32528a7ca
--- /dev/null
+++ b/features/steps/shared/diff_note.rb
@@ -0,0 +1,237 @@
+module SharedDiffNote
+ include Spinach::DSL
+ include RepoHelpers
+ include WaitForRequests
+
+ after do
+ wait_for_requests if javascript_test?
+ end
+
+ step 'I cancel the diff comment' do
+ page.within(diff_file_selector) do
+ find(".js-close-discussion-note-form").click
+ end
+ end
+
+ step 'I delete a diff comment' do
+ find('.note').hover
+ find(".js-note-delete").click
+ end
+
+ step 'I haven\'t written any diff comment text' do
+ page.within(diff_file_selector) do
+ fill_in "note[note]", with: ""
+ end
+ end
+
+ step 'I leave a diff comment like "Typo, please fix"' do
+ page.within(diff_file_selector) do
+ click_diff_line(sample_commit.line_code)
+
+ page.within("form[data-line-code='#{sample_commit.line_code}']") do
+ fill_in "note[note]", with: "Typo, please fix"
+ find(".js-comment-button").click
+ end
+ end
+ end
+
+ step 'I leave a diff comment in a parallel view on the left side like "Old comment"' do
+ click_parallel_diff_line(sample_commit.del_line_code, 'old')
+ page.within("#{diff_file_selector} form[data-line-code='#{sample_commit.del_line_code}']") do
+ fill_in "note[note]", with: "Old comment"
+ find(".js-comment-button").click
+ end
+ end
+
+ step 'I leave a diff comment in a parallel view on the right side like "New comment"' do
+ click_parallel_diff_line(sample_commit.line_code, 'new')
+ page.within("#{diff_file_selector} form[data-line-code='#{sample_commit.line_code}']") do
+ fill_in "note[note]", with: "New comment"
+ find(".js-comment-button").click
+ end
+ end
+
+ step 'I preview a diff comment text like "Should fix it :smile:"' do
+ page.within(diff_file_selector) do
+ click_diff_line(sample_commit.line_code)
+
+ page.within("form[data-line-code='#{sample_commit.line_code}']") do
+ fill_in "note[note]", with: "Should fix it :smile:"
+ find('.js-md-preview-button').click
+ end
+ end
+ end
+
+ step 'I preview another diff comment text like "DRY this up"' do
+ page.within(diff_file_selector) do
+ click_diff_line(sample_commit.del_line_code)
+
+ page.within("form[data-line-code='#{sample_commit.del_line_code}']") do
+ fill_in "note[note]", with: "DRY this up"
+ find('.js-md-preview-button').click
+ end
+ end
+ end
+
+ step 'I open a diff comment form' do
+ page.within(diff_file_selector) do
+ click_diff_line(sample_commit.line_code)
+ end
+ end
+
+ step 'I open another diff comment form' do
+ page.within(diff_file_selector) do
+ click_diff_line(sample_commit.del_line_code)
+ end
+ end
+
+ step 'I write a diff comment like ":-1: I don\'t like this"' do
+ page.within(diff_file_selector) do
+ fill_in "note[note]", with: ":-1: I don\'t like this"
+ end
+ end
+
+ step 'I write a diff comment like ":smile:"' do
+ page.within(diff_file_selector) do
+ click_diff_line(sample_commit.line_code)
+
+ page.within("form[data-line-code='#{sample_commit.line_code}']") do
+ fill_in 'note[note]', with: ':smile:'
+ click_button('Comment')
+ end
+ end
+ end
+
+ step 'I submit the diff comment' do
+ page.within(diff_file_selector) do
+ click_button("Comment")
+ end
+ end
+
+ step 'I should not see the diff comment form' do
+ page.within(diff_file_selector) do
+ expect(page).not_to have_css("form.new_note")
+ end
+ end
+
+ step 'The diff comment preview tab should say there is nothing to do' do
+ page.within(diff_file_selector) do
+ find('.js-md-preview-button').click
+ expect(find('.js-md-preview')).to have_content('Nothing to preview.')
+ end
+ end
+
+ step 'I should not see the diff comment text field' do
+ page.within(diff_file_selector) do
+ expect(find('.js-note-text')).not_to be_visible
+ end
+ end
+
+ step 'I should only see one diff form' do
+ page.within(diff_file_selector) do
+ expect(page).to have_css("form.new-note", count: 1)
+ end
+ end
+
+ step 'I should see a diff comment form with ":-1: I don\'t like this"' do
+ page.within(diff_file_selector) do
+ expect(page).to have_field("note[note]", with: ":-1: I don\'t like this")
+ end
+ end
+
+ step 'I should see a diff comment saying "Typo, please fix"' do
+ page.within("#{diff_file_selector} .note") do
+ expect(page).to have_content("Typo, please fix")
+ end
+ end
+
+ step 'I should see a diff comment on the left side saying "Old comment"' do
+ page.within("#{diff_file_selector} .notes_content.parallel.old") do
+ expect(page).to have_content("Old comment")
+ end
+ end
+
+ step 'I should see a diff comment on the right side saying "New comment"' do
+ page.within("#{diff_file_selector} .notes_content.parallel.new") do
+ expect(page).to have_content("New comment")
+ end
+ end
+
+ step 'I should see a discussion reply button' do
+ page.within(diff_file_selector) do
+ expect(page).to have_button('Reply...')
+ end
+ end
+
+ step 'I should see a temporary diff comment form' do
+ page.within(diff_file_selector) do
+ expect(page).to have_css(".js-temp-notes-holder form.new-note")
+ end
+ end
+
+ step 'I should see an empty diff comment form' do
+ page.within(diff_file_selector) do
+ expect(page).to have_field("note[note]", with: "")
+ end
+ end
+
+ step 'I should see the cancel comment button' do
+ page.within("#{diff_file_selector} form") do
+ expect(page).to have_css(".js-close-discussion-note-form", text: "Cancel")
+ end
+ end
+
+ step 'I should see the diff comment preview' do
+ page.within("#{diff_file_selector} form") do
+ expect(page).to have_css('.js-md-preview', visible: true)
+ end
+ end
+
+ step 'I should see the diff comment write tab' do
+ page.within(diff_file_selector) do
+ expect(page).to have_css('.js-md-write-button', visible: true)
+ end
+ end
+
+ step 'The diff comment preview tab should display rendered Markdown' do
+ page.within(diff_file_selector) do
+ find('.js-md-preview-button').click
+ expect(find('.js-md-preview')).to have_css('gl-emoji', visible: true)
+ end
+ end
+
+ step 'I should see two separate previews' do
+ page.within(diff_file_selector) do
+ expect(page).to have_css('.js-md-preview', visible: true, count: 2)
+ expect(page).to have_content('Should fix it')
+ expect(page).to have_content('DRY this up')
+ end
+ end
+
+ step 'I should see a diff comment with an emoji image' do
+ page.within("#{diff_file_selector} .note") do
+ expect(page).to have_xpath("//gl-emoji[@data-name='smile']")
+ end
+ end
+
+ step 'I click side-by-side diff button' do
+ find('#parallel-diff-btn').click
+ end
+
+ step 'I see side-by-side diff button' do
+ expect(page).to have_content "Side-by-side"
+ end
+
+ def diff_file_selector
+ '.diff-file:nth-of-type(1)'
+ end
+
+ def click_diff_line(code)
+ find(".line_holder[id='#{code}'] button").click
+ end
+
+ def click_parallel_diff_line(code, line_type)
+ find(".line_holder.parallel td[id='#{code}']").find(:xpath, 'preceding-sibling::*[1][self::td]').hover
+ find(".line_holder.parallel button[data-line-code='#{code}']").click
+ end
+end
diff --git a/features/steps/shared/group.rb b/features/steps/shared/group.rb
new file mode 100644
index 00000000000..0a0588346b1
--- /dev/null
+++ b/features/steps/shared/group.rb
@@ -0,0 +1,50 @@
+module SharedGroup
+ include Spinach::DSL
+
+ step 'current user is developer of group "Owned"' do
+ is_member_of(current_user.name, "Owned", Gitlab::Access::DEVELOPER)
+ end
+
+ step '"John Doe" is owner of group "Owned"' do
+ is_member_of("John Doe", "Owned", Gitlab::Access::OWNER)
+ end
+
+ step '"John Doe" is guest of group "Guest"' do
+ is_member_of("John Doe", "Guest", Gitlab::Access::GUEST)
+ end
+
+ step '"Mary Jane" is owner of group "Owned"' do
+ is_member_of("Mary Jane", "Owned", Gitlab::Access::OWNER)
+ end
+
+ step '"Mary Jane" is guest of group "Owned"' do
+ is_member_of("Mary Jane", "Owned", Gitlab::Access::GUEST)
+ end
+
+ step '"Mary Jane" is guest of group "Guest"' do
+ is_member_of("Mary Jane", "Guest", Gitlab::Access::GUEST)
+ end
+
+ step 'I should see group "TestGroup"' do
+ expect(page).to have_content "TestGroup"
+ end
+
+ step 'I should not see group "TestGroup"' do
+ expect(page).not_to have_content "TestGroup"
+ end
+
+ protected
+
+ def is_member_of(username, groupname, role)
+ user = User.find_by(name: username) || create(:user, name: username)
+ group = Group.find_by(name: groupname) || create(:group, name: groupname)
+ group.add_user(user, role)
+ project ||= create(:project, :repository, namespace: group)
+ create(:closed_issue_event, project: project)
+ project.add_master(user)
+ end
+
+ def owned_group
+ @owned_group ||= Group.find_by(name: "Owned")
+ end
+end
diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb
new file mode 100644
index 00000000000..a9174efd334
--- /dev/null
+++ b/features/steps/shared/issuable.rb
@@ -0,0 +1,167 @@
+module SharedIssuable
+ include Spinach::DSL
+
+ def edit_issuable
+ find('.js-issuable-edit', visible: true).click
+ end
+
+ step 'project "Community" has "Community issue" open issue' do
+ create_issuable_for_project(
+ project_name: 'Community',
+ title: 'Community issue'
+ )
+ end
+
+ step 'project "Community" has "Community fix" open merge request' do
+ create_issuable_for_project(
+ project_name: 'Community',
+ type: :merge_request,
+ title: 'Community fix'
+ )
+ end
+
+ step 'project "Enterprise" has "Enterprise issue" open issue' do
+ create_issuable_for_project(
+ project_name: 'Enterprise',
+ title: 'Enterprise issue'
+ )
+ end
+
+ step 'project "Enterprise" has "Enterprise fix" open merge request' do
+ create_issuable_for_project(
+ project_name: 'Enterprise',
+ type: :merge_request,
+ title: 'Enterprise fix'
+ )
+ end
+
+ step 'I leave a comment referencing issue "Community issue"' do
+ leave_reference_comment(
+ issuable: Issue.find_by(title: 'Community issue'),
+ from_project_name: 'Enterprise'
+ )
+ end
+
+ step 'I leave a comment referencing issue "Community fix"' do
+ leave_reference_comment(
+ issuable: MergeRequest.find_by(title: 'Community fix'),
+ from_project_name: 'Enterprise'
+ )
+ end
+
+ step 'I visit issue page "Enterprise issue"' do
+ issue = Issue.find_by(title: 'Enterprise issue')
+ visit project_issue_path(issue.project, issue)
+ end
+
+ step 'I visit merge request page "Enterprise fix"' do
+ mr = MergeRequest.find_by(title: 'Enterprise fix')
+ visit project_merge_request_path(mr.target_project, mr)
+ end
+
+ step 'I visit issue page "Community issue"' do
+ issue = Issue.find_by(title: 'Community issue')
+ visit project_issue_path(issue.project, issue)
+ end
+
+ step 'I visit issue page "Community fix"' do
+ mr = MergeRequest.find_by(title: 'Community fix')
+ visit project_merge_request_path(mr.target_project, mr)
+ end
+
+ step 'I should not see any related merge requests' do
+ page.within '.issue-details' do
+ expect(page).not_to have_content('#merge-requests .merge-requests-title')
+ end
+ end
+
+ step 'I should see the "Enterprise fix" related merge request' do
+ page.within '#merge-requests .merge-requests-title' do
+ expect(page).to have_content('1 Related Merge Request')
+ end
+
+ page.within '#merge-requests ul' do
+ expect(page).to have_content('Enterprise fix')
+ end
+ end
+
+ step 'I should see a note linking to "Enterprise fix" merge request' do
+ visible_note(
+ issuable: MergeRequest.find_by(title: 'Enterprise fix'),
+ from_project_name: 'Community',
+ user_name: 'Mary Jane'
+ )
+ end
+
+ step 'I should see a note linking to "Enterprise issue" issue' do
+ visible_note(
+ issuable: Issue.find_by(title: 'Enterprise issue'),
+ from_project_name: 'Community',
+ user_name: 'Mary Jane'
+ )
+ end
+
+ step 'I click link "Edit" for the merge request' do
+ edit_issuable
+ end
+
+ step 'I sort the list by "Least popular"' do
+ find('button.dropdown-toggle').click
+
+ page.within('.content ul.dropdown-menu.dropdown-menu-align-right li') do
+ click_link 'Least popular'
+ end
+ end
+
+ step 'I click link "Next" in the sidebar' do
+ page.within '.issuable-sidebar' do
+ click_link 'Next'
+ end
+ end
+
+ def create_issuable_for_project(project_name:, title:, type: :issue)
+ project = Project.find_by(name: project_name)
+
+ attrs = {
+ title: title,
+ author: project.users.first,
+ description: '# Description header'
+ }
+
+ case type
+ when :issue
+ attrs[:project] = project
+ when :merge_request
+ attrs.merge!(
+ source_project: project,
+ target_project: project,
+ source_branch: 'fix',
+ target_branch: 'master'
+ )
+ end
+
+ create(type, attrs)
+ end
+
+ def leave_reference_comment(issuable:, from_project_name:)
+ project = Project.find_by(name: from_project_name)
+
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "##{issuable.to_reference(project)}"
+ click_button 'Comment'
+ end
+ end
+
+ def visible_note(issuable:, from_project_name:, user_name:)
+ project = Project.find_by(name: from_project_name)
+
+ expect(page).to have_content(user_name)
+ expect(page).to have_content("mentioned in #{issuable.class.to_s.titleize.downcase} #{issuable.to_reference(project)}")
+ end
+
+ def expect_sidebar_content(content)
+ page.within '.issuable-sidebar' do
+ expect(page).to have_content content
+ end
+ end
+end
diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb
new file mode 100644
index 00000000000..65118f07ca2
--- /dev/null
+++ b/features/steps/shared/markdown.rb
@@ -0,0 +1,11 @@
+module SharedMarkdown
+ include Spinach::DSL
+
+ step 'I should not see the Markdown preview' do
+ expect(find('.gfm-form .js-md-preview')).not_to be_visible
+ end
+
+ step 'I haven\'t written any description text' do
+ find('.gfm-form').fill_in 'Description', with: ''
+ end
+end
diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb
new file mode 100644
index 00000000000..bf1b88c60d7
--- /dev/null
+++ b/features/steps/shared/note.rb
@@ -0,0 +1,25 @@
+module SharedNote
+ include Spinach::DSL
+ include WaitForRequests
+
+ after do
+ wait_for_requests if javascript_test?
+ end
+
+ step 'I haven\'t written any comment text' do
+ page.within(".js-main-target-form") do
+ fill_in "note[note]", with: ""
+ end
+ end
+
+ step 'The comment preview tab should say there is nothing to do' do
+ page.within(".js-main-target-form") do
+ find('.js-md-preview-button').click
+ expect(find('.js-md-preview')).to have_content('Nothing to preview.')
+ end
+ end
+
+ step 'I should see no notes at all' do
+ expect(page).not_to have_css('.note')
+ end
+end
diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb
new file mode 100644
index 00000000000..014e6ad625b
--- /dev/null
+++ b/features/steps/shared/paths.rb
@@ -0,0 +1,434 @@
+module SharedPaths
+ include Spinach::DSL
+ include RepoHelpers
+ include DashboardHelper
+ include WaitForRequests
+
+ step 'I visit new project page' do
+ visit new_project_path
+ end
+
+ step 'I visit login page' do
+ visit new_user_session_path
+ end
+
+ # ----------------------------------------
+ # User
+ # ----------------------------------------
+
+ step 'I visit user "John Doe" page' do
+ visit user_path("john_doe")
+ end
+
+ # ----------------------------------------
+ # Group
+ # ----------------------------------------
+
+ step 'I visit group "Owned" page' do
+ visit group_path(Group.find_by(name: "Owned"))
+ end
+
+ step 'I visit group "Owned" activity page' do
+ visit activity_group_path(Group.find_by(name: "Owned"))
+ end
+
+ step 'I visit group "Owned" issues page' do
+ visit issues_group_path(Group.find_by(name: "Owned"))
+ end
+
+ step 'I visit group "Owned" merge requests page' do
+ visit merge_requests_group_path(Group.find_by(name: "Owned"))
+ end
+
+ step 'I visit group "Owned" milestones page' do
+ visit group_milestones_path(Group.find_by(name: "Owned"))
+ end
+
+ step 'I visit group "Owned" members page' do
+ visit group_group_members_path(Group.find_by(name: "Owned"))
+ end
+
+ step 'I visit group "Owned" settings page' do
+ visit edit_group_path(Group.find_by(name: "Owned"))
+ end
+
+ step 'I visit group "Owned" projects page' do
+ visit projects_group_path(Group.find_by(name: "Owned"))
+ end
+
+ step 'I visit group "Guest" page' do
+ visit group_path(Group.find_by(name: "Guest"))
+ end
+
+ step 'I visit group "Guest" issues page' do
+ visit issues_group_path(Group.find_by(name: "Guest"))
+ end
+
+ step 'I visit group "Guest" merge requests page' do
+ visit merge_requests_group_path(Group.find_by(name: "Guest"))
+ end
+
+ step 'I visit group "Guest" members page' do
+ visit group_group_members_path(Group.find_by(name: "Guest"))
+ end
+
+ step 'I visit group "Guest" settings page' do
+ visit edit_group_path(Group.find_by(name: "Guest"))
+ end
+
+ # ----------------------------------------
+ # Dashboard
+ # ----------------------------------------
+
+ step 'I visit dashboard page' do
+ visit dashboard_projects_path
+ end
+
+ step 'I visit dashboard activity page' do
+ visit activity_dashboard_path
+ end
+
+ step 'I visit dashboard projects page' do
+ visit projects_dashboard_path
+ end
+
+ step 'I visit dashboard issues page' do
+ visit assigned_issues_dashboard_path
+ end
+
+ step 'I visit dashboard search page' do
+ visit search_path
+ end
+
+ step 'I visit dashboard help page' do
+ visit help_path
+ end
+
+ step 'I visit dashboard groups page' do
+ visit dashboard_groups_path
+ end
+
+ step 'I should be redirected to the dashboard groups page' do
+ expect(current_path).to eq dashboard_groups_path
+ end
+
+ step 'I visit dashboard starred projects page' do
+ visit starred_dashboard_projects_path
+ end
+
+ # ----------------------------------------
+ # Profile
+ # ----------------------------------------
+
+ step 'I visit profile page' do
+ visit profile_path
+ end
+
+ step 'I visit profile applications page' do
+ visit applications_profile_path
+ end
+
+ step 'I visit profile password page' do
+ visit edit_profile_password_path
+ end
+
+ step 'I visit profile account page' do
+ visit profile_account_path
+ end
+
+ step 'I visit profile SSH keys page' do
+ visit profile_keys_path
+ end
+
+ step 'I visit profile preferences page' do
+ visit profile_preferences_path
+ end
+
+ step 'I visit Authentication log page' do
+ visit audit_log_profile_path
+ end
+
+ # ----------------------------------------
+ # Admin
+ # ----------------------------------------
+
+ step 'I visit admin page' do
+ visit admin_root_path
+ end
+
+ step 'I visit abuse reports page' do
+ visit admin_abuse_reports_path
+ end
+
+ step 'I visit admin projects page' do
+ visit admin_projects_path
+ end
+
+ step 'I visit admin users page' do
+ visit admin_users_path
+ end
+
+ step 'I visit admin logs page' do
+ visit admin_logs_path
+ end
+
+ step 'I visit admin messages page' do
+ visit admin_broadcast_messages_path
+ end
+
+ step 'I visit admin hooks page' do
+ visit admin_hooks_path
+ end
+
+ step 'I visit admin Resque page' do
+ visit admin_background_jobs_path
+ end
+
+ step 'I visit admin teams page' do
+ visit admin_teams_path
+ end
+
+ step 'I visit spam logs page' do
+ visit admin_spam_logs_path
+ end
+
+ # ----------------------------------------
+ # Generic Project
+ # ----------------------------------------
+
+ step "I visit my project's settings page" do
+ visit edit_project_path(@project)
+ end
+
+ step 'I visit a binary file in the repo' do
+ visit project_blob_path(@project,
+ File.join(root_ref, 'files/images/logo-black.png'))
+ end
+
+ step "I visit my project's commits page" do
+ visit project_commits_path(@project, root_ref, { limit: 5 })
+ end
+
+ step "I visit my project's commits page for a specific path" do
+ visit project_commits_path(@project, root_ref + "/files/ruby/regex.rb", { limit: 5 })
+ end
+
+ step 'I visit my project\'s commits stats page' do
+ visit stats_project_repository_path(@project)
+ end
+
+ step "I visit my project's graph page" do
+ # Stub Graph max_size to speed up test (10 commits vs. 650)
+ Network::Graph.stub(max_count: 10)
+
+ visit project_network_path(@project, root_ref)
+ end
+
+ step "I visit my project's issues page" do
+ visit project_issues_path(@project)
+ end
+
+ step "I visit my project's merge requests page" do
+ visit project_merge_requests_path(@project)
+ end
+
+ step "I visit my project's members page" do
+ visit project_project_members_path(@project)
+ end
+
+ step "I visit my project's wiki page" do
+ visit project_wiki_path(@project, :home)
+ end
+
+ step 'I visit project hooks page' do
+ visit project_settings_integrations_path(@project)
+ end
+
+ step 'I visit project deploy keys page' do
+ visit project_deploy_keys_path(@project)
+ end
+
+ step 'I visit project find file page' do
+ visit project_find_file_path(@project, root_ref)
+ end
+
+ # ----------------------------------------
+ # "Shop" Project
+ # ----------------------------------------
+
+ step 'I visit project "Shop" page' do
+ visit project_path(project)
+ end
+
+ step 'I visit project "Forked Shop" merge requests page' do
+ visit project_merge_requests_path(@forked_project)
+ end
+
+ step 'I visit edit project "Shop" page' do
+ visit edit_project_path(project)
+ end
+
+ step 'I visit compare refs page' do
+ visit project_compare_index_path(@project)
+ end
+
+ step 'I visit project commits page' do
+ visit project_commits_path(@project, root_ref, { limit: 5 })
+ end
+
+ step 'I visit project commits page for stable branch' do
+ visit project_commits_path(@project, 'stable', { limit: 5 })
+ end
+
+ step 'I visit blob file from repo' do
+ visit project_blob_path(@project, File.join(sample_commit.id, sample_blob.path))
+ end
+
+ step 'I visit ".gitignore" file in repo' do
+ visit project_blob_path(@project, File.join(root_ref, '.gitignore'))
+ end
+
+ step 'I am on the new file page' do
+ expect(current_path).to eq(project_create_blob_path(@project, root_ref))
+ end
+
+ step 'I am on the ".gitignore" edit file page' do
+ expect(current_path).to eq(
+ project_edit_blob_path(@project, File.join(root_ref, '.gitignore')))
+ end
+
+ step 'I visit project source page for "6d39438"' do
+ visit project_tree_path(@project, "6d39438")
+ end
+
+ step 'I visit project source page for' \
+ ' "6d394385cf567f80a8fd85055db1ab4c5295806f"' do
+ visit project_tree_path(@project,
+ '6d394385cf567f80a8fd85055db1ab4c5295806f')
+ end
+
+ step 'I visit project tags page' do
+ visit project_tags_path(@project)
+ end
+
+ step 'I visit project commit page' do
+ visit project_commit_path(@project, sample_commit.id)
+ end
+
+ step 'I visit issue page "Release 0.4"' do
+ issue = Issue.find_by(title: "Release 0.4")
+ visit project_issue_path(issue.project, issue)
+ end
+
+ step 'I visit project "Forum" labels page' do
+ project = Project.find_by(name: 'Forum')
+ visit project_labels_path(project)
+ end
+
+ step 'I visit project "Shop" new label page' do
+ project = Project.find_by(name: 'Shop')
+ visit new_project_label_path(project)
+ end
+
+ step 'I visit project "Forum" new label page' do
+ project = Project.find_by(name: 'Forum')
+ visit new_project_label_path(project)
+ end
+
+ step 'I visit merge request page "Bug NS-04"' do
+ visit merge_request_path("Bug NS-04")
+ wait_for_requests
+ end
+
+ step 'I visit merge request page "Bug NS-05"' do
+ visit merge_request_path("Bug NS-05")
+ wait_for_requests
+ end
+
+ step 'I visit merge request page "Bug NS-07"' do
+ visit merge_request_path("Bug NS-07")
+ wait_for_requests
+ end
+
+ step 'I visit merge request page "Bug NS-08"' do
+ visit merge_request_path("Bug NS-08")
+ wait_for_requests
+ end
+
+ step 'I visit merge request page "Bug CO-01"' do
+ mr = MergeRequest.find_by(title: "Bug CO-01")
+ visit project_merge_request_path(mr.target_project, mr)
+ wait_for_requests
+ end
+
+ step 'I visit forked project "Shop" merge requests page' do
+ visit project_merge_requests_path(project)
+ end
+
+ step 'I visit project "Shop" team page' do
+ visit project_project_members_path(project)
+ end
+
+ step 'I visit project wiki page' do
+ visit project_wiki_path(@project, :home)
+ end
+
+ # ----------------------------------------
+ # Visibility Projects
+ # ----------------------------------------
+
+ step 'I visit project "Community" source page' do
+ project = Project.find_by(name: 'Community')
+ visit project_tree_path(project, root_ref)
+ end
+
+ step 'I visit project "Internal" page' do
+ project = Project.find_by(name: "Internal")
+ visit project_path(project)
+ end
+
+ step 'I visit project "Enterprise" page' do
+ project = Project.find_by(name: "Enterprise")
+ visit project_path(project)
+ end
+
+ # ----------------------------------------
+ # Empty Projects
+ # ----------------------------------------
+
+ step "I should not see command line instructions" do
+ expect(page).not_to have_css('.empty_wrapper')
+ end
+
+ # ----------------------------------------
+ # Public Projects
+ # ----------------------------------------
+ step 'I visit the public groups area' do
+ visit explore_groups_path
+ end
+
+ # ----------------------------------------
+ # Snippets
+ # ----------------------------------------
+
+ step 'I visit project "Shop" snippets page' do
+ visit project_snippets_path(project)
+ end
+
+ step 'I visit snippets page' do
+ visit explore_snippets_path
+ end
+
+ def root_ref
+ @project.repository.root_ref
+ end
+
+ def project
+ Project.find_by!(name: 'Shop')
+ end
+
+ def merge_request_path(title)
+ mr = MergeRequest.find_by(title: title)
+ project_merge_request_path(mr.target_project, mr)
+ end
+end
diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb
new file mode 100644
index 00000000000..a1945cf5f3d
--- /dev/null
+++ b/features/steps/shared/project.rb
@@ -0,0 +1,132 @@
+module SharedProject
+ include Spinach::DSL
+
+ # Create a project without caring about what it's called
+ step "I own a project" do
+ @project = create(:project, :repository, namespace: @user.namespace)
+ @project.add_master(@user)
+ end
+
+ step "I own a project in some group namespace" do
+ @group = create(:group, name: 'some group')
+ @project = create(:project, namespace: @group)
+ @project.add_master(@user)
+ end
+
+ # Create a specific project called "Shop"
+ step 'I own project "Shop"' do
+ @project = Project.find_by(name: "Shop")
+ @project ||= create(:project, :repository, name: "Shop", namespace: @user.namespace)
+ @project.add_master(@user)
+ end
+
+ def current_project
+ @project ||= Project.first
+ end
+
+ # ----------------------------------------
+ # Visibility of archived project
+ # ----------------------------------------
+
+ step 'I should not see project "Archive"' do
+ project = Project.find_by(name: "Archive")
+ expect(page).not_to have_content project.full_name
+ end
+
+ step 'I should see project "Archive"' do
+ project = Project.find_by(name: "Archive")
+ expect(page).to have_content project.full_name
+ end
+
+ # ----------------------------------------
+ # Visibility level
+ # ----------------------------------------
+
+ step 'private project "Enterprise"' do
+ create(:project, :private, :repository, name: 'Enterprise')
+ end
+
+ step 'I should see project "Enterprise"' do
+ expect(page).to have_content "Enterprise"
+ end
+
+ step 'I should not see project "Enterprise"' do
+ expect(page).not_to have_content "Enterprise"
+ end
+
+ step 'internal project "Internal"' do
+ create(:project, :internal, :repository, name: 'Internal')
+ end
+
+ step 'I should see project "Internal"' do
+ page.within '.js-projects-list-holder' do
+ expect(page).to have_content "Internal"
+ end
+ end
+
+ step 'I should not see project "Internal"' do
+ page.within '.js-projects-list-holder' do
+ expect(page).not_to have_content "Internal"
+ end
+ end
+
+ step 'public project "Community"' do
+ create(:project, :public, :repository, name: 'Community')
+ end
+
+ step 'I should see project "Community"' do
+ expect(page).to have_content "Community"
+ end
+
+ step 'I should not see project "Community"' do
+ expect(page).not_to have_content "Community"
+ end
+
+ step '"John Doe" owns private project "Enterprise"' do
+ user_owns_project(
+ user_name: 'John Doe',
+ project_name: 'Enterprise'
+ )
+ end
+
+ step '"Mary Jane" owns private project "Enterprise"' do
+ user_owns_project(
+ user_name: 'Mary Jane',
+ project_name: 'Enterprise'
+ )
+ end
+
+ step '"John Doe" owns internal project "Internal"' do
+ user_owns_project(
+ user_name: 'John Doe',
+ project_name: 'Internal',
+ visibility: :internal
+ )
+ end
+
+ step '"John Doe" owns public project "Community"' do
+ user_owns_project(
+ user_name: 'John Doe',
+ project_name: 'Community',
+ visibility: :public
+ )
+ end
+
+ step 'public empty project "Empty Public Project"' do
+ create :project_empty_repo, :public, name: "Empty Public Project"
+ end
+
+ step 'project "Shop" has labels: "bug", "feature", "enhancement"' do
+ project = Project.find_by(name: "Shop")
+ create(:label, project: project, title: 'bug')
+ create(:label, project: project, title: 'feature')
+ create(:label, project: project, title: 'enhancement')
+ end
+
+ def user_owns_project(user_name:, project_name:, visibility: :private)
+ user = user_exists(user_name, username: user_name.gsub(/\s/, '').underscore)
+ project = Project.find_by(name: project_name)
+ project ||= create(:project, visibility, name: project_name, namespace: user.namespace)
+ project.add_master(user)
+ end
+end
diff --git a/features/steps/shared/project_tab.rb b/features/steps/shared/project_tab.rb
new file mode 100644
index 00000000000..5a516ee33bc
--- /dev/null
+++ b/features/steps/shared/project_tab.rb
@@ -0,0 +1,66 @@
+require_relative 'active_tab'
+
+module SharedProjectTab
+ include Spinach::DSL
+ include SharedActiveTab
+
+ step 'the active main tab should be Project' do
+ ensure_active_main_tab('Overview')
+ end
+
+ step 'the active main tab should be Repository' do
+ ensure_active_main_tab('Repository')
+ end
+
+ step 'the active main tab should be Issues' do
+ ensure_active_main_tab('Issues')
+ end
+
+ step 'the active sub tab should be Members' do
+ ensure_active_sub_tab('Members')
+ end
+
+ step 'the active main tab should be Merge Requests' do
+ ensure_active_main_tab('Merge Requests')
+ end
+
+ step 'the active main tab should be Snippets' do
+ ensure_active_main_tab('Snippets')
+ end
+
+ step 'the active main tab should be Wiki' do
+ ensure_active_main_tab('Wiki')
+ end
+
+ step 'the active main tab should be Members' do
+ ensure_active_main_tab('Members')
+ end
+
+ step 'the active main tab should be Settings' do
+ ensure_active_main_tab('Settings')
+ end
+
+ step 'the active sub tab should be Graph' do
+ ensure_active_sub_tab('Graph')
+ end
+
+ step 'the active sub tab should be Files' do
+ ensure_active_sub_tab('Files')
+ end
+
+ step 'the active sub tab should be Commits' do
+ ensure_active_sub_tab('Commits')
+ end
+
+ step 'the active sub tab should be Home' do
+ ensure_active_sub_tab('Details')
+ end
+
+ step 'the active sub tab should be Activity' do
+ ensure_active_sub_tab('Activity')
+ end
+
+ step 'the active sub tab should be Charts' do
+ ensure_active_sub_tab('Charts')
+ end
+end
diff --git a/features/steps/shared/shortcuts.rb b/features/steps/shared/shortcuts.rb
new file mode 100644
index 00000000000..a75a8474d26
--- /dev/null
+++ b/features/steps/shared/shortcuts.rb
@@ -0,0 +1,18 @@
+module SharedShortcuts
+ include Spinach::DSL
+
+ step 'I press "g" and "p"' do
+ find('body').native.send_key('g')
+ find('body').native.send_key('p')
+ end
+
+ step 'I press "g" and "i"' do
+ find('body').native.send_key('g')
+ find('body').native.send_key('i')
+ end
+
+ step 'I press "g" and "m"' do
+ find('body').native.send_key('g')
+ find('body').native.send_key('m')
+ end
+end
diff --git a/features/steps/shared/sidebar_active_tab.rb b/features/steps/shared/sidebar_active_tab.rb
new file mode 100644
index 00000000000..07fff16e867
--- /dev/null
+++ b/features/steps/shared/sidebar_active_tab.rb
@@ -0,0 +1,31 @@
+module SharedSidebarActiveTab
+ include Spinach::DSL
+
+ step 'no other main tabs should be active' do
+ expect(page).to have_selector('.nav-sidebar li.active', count: 1)
+ end
+
+ def ensure_active_main_tab(content)
+ expect(find('.nav-sidebar li.active')).to have_content(content)
+ end
+
+ step 'the active main tab should be Home' do
+ ensure_active_main_tab('Projects')
+ end
+
+ step 'the active main tab should be Groups' do
+ ensure_active_main_tab('Groups')
+ end
+
+ step 'the active main tab should be Projects' do
+ ensure_active_main_tab('Projects')
+ end
+
+ step 'the active main tab should be Issues' do
+ ensure_active_main_tab('Issues')
+ end
+
+ step 'the active main tab should be Merge Requests' do
+ ensure_active_main_tab('Merge Requests')
+ end
+end
diff --git a/features/steps/shared/user.rb b/features/steps/shared/user.rb
new file mode 100644
index 00000000000..9cadc91769d
--- /dev/null
+++ b/features/steps/shared/user.rb
@@ -0,0 +1,41 @@
+module SharedUser
+ include Spinach::DSL
+
+ step 'User "John Doe" exists' do
+ user_exists("John Doe", { username: "john_doe" })
+ end
+
+ step 'User "Mary Jane" exists' do
+ user_exists("Mary Jane", { username: "mary_jane" })
+ end
+
+ step 'gitlab user "Mike"' do
+ create(:user, name: "Mike")
+ end
+
+ protected
+
+ def user_exists(name, options = {})
+ User.find_by(name: name) || create(:user, { name: name, admin: false }.merge(options))
+ end
+
+ step 'I have no ssh keys' do
+ @user.keys.delete_all
+ end
+
+ step 'I click on "Personal projects" tab' do
+ page.within '.nav-links' do
+ click_link 'Personal projects'
+ end
+
+ expect(page).to have_css('.tab-content #projects.active')
+ end
+
+ step 'I click on "Contributed projects" tab' do
+ page.within '.nav-links' do
+ click_link 'Contributed projects'
+ end
+
+ expect(page).to have_css('.tab-content #contributed.active')
+ end
+end
diff --git a/features/support/capybara.rb b/features/support/capybara.rb
new file mode 100644
index 00000000000..8879c9ab650
--- /dev/null
+++ b/features/support/capybara.rb
@@ -0,0 +1,50 @@
+require 'capybara-screenshot/spinach'
+
+# Give CI some extra time
+timeout = (ENV['CI'] || ENV['CI_SERVER']) ? 60 : 30
+
+Capybara.register_driver :chrome do |app|
+ capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(
+ # This enables access to logs with `page.driver.manage.get_log(:browser)`
+ loggingPrefs: {
+ browser: "ALL",
+ client: "ALL",
+ driver: "ALL",
+ server: "ALL"
+ }
+ )
+
+ options = Selenium::WebDriver::Chrome::Options.new
+ options.add_argument("window-size=1240,1400")
+
+ # Chrome won't work properly in a Docker container in sandbox mode
+ options.add_argument("no-sandbox")
+
+ # Run headless by default unless CHROME_HEADLESS specified
+ options.add_argument("headless") unless ENV['CHROME_HEADLESS'] =~ /^(false|no|0)$/i
+
+ # Disable /dev/shm use in CI. See https://gitlab.com/gitlab-org/gitlab-ee/issues/4252
+ options.add_argument("disable-dev-shm-usage") if ENV['CI'] || ENV['CI_SERVER']
+
+ Capybara::Selenium::Driver.new(
+ app,
+ browser: :chrome,
+ desired_capabilities: capabilities,
+ options: options
+ )
+end
+
+Capybara.javascript_driver = :chrome
+Capybara.default_max_wait_time = timeout
+Capybara.ignore_hidden_elements = false
+
+# Keep only the screenshots generated from the last failing test suite
+Capybara::Screenshot.prune_strategy = :keep_last_run
+# From https://github.com/mattheworiordan/capybara-screenshot/issues/84#issuecomment-41219326
+Capybara::Screenshot.register_driver(:chrome) do |driver, path|
+ driver.browser.save_screenshot(path)
+end
+
+Spinach.hooks.before_run do
+ TestEnv.eager_load_driver_server
+end
diff --git a/features/support/db_cleaner.rb b/features/support/db_cleaner.rb
new file mode 100644
index 00000000000..31c922d23c3
--- /dev/null
+++ b/features/support/db_cleaner.rb
@@ -0,0 +1,11 @@
+require 'database_cleaner'
+
+DatabaseCleaner[:active_record].strategy = :deletion
+
+Spinach.hooks.before_scenario do
+ DatabaseCleaner.start
+end
+
+Spinach.hooks.after_scenario do
+ DatabaseCleaner.clean
+end
diff --git a/features/support/env.rb b/features/support/env.rb
new file mode 100644
index 00000000000..8fa2fcb6e3e
--- /dev/null
+++ b/features/support/env.rb
@@ -0,0 +1,60 @@
+require './spec/simplecov_env'
+SimpleCovEnv.start!
+
+ENV['RAILS_ENV'] = 'test'
+require './config/environment'
+require 'rspec/expectations'
+
+if ENV['CI']
+ require 'knapsack'
+ Knapsack::Adapters::SpinachAdapter.bind
+end
+
+WebMock.enable!
+
+%w(select2_helper test_env repo_helpers wait_for_requests project_forks_helper).each do |f|
+ require Rails.root.join('spec', 'support', 'helpers', f)
+end
+
+%w(sidekiq webmock).each do |f|
+ require Rails.root.join('spec', 'support', f)
+end
+
+Dir["#{Rails.root}/features/steps/shared/*.rb"].each { |file| require file }
+
+Spinach.hooks.before_run do
+ include RSpec::Mocks::ExampleMethods
+ include ActiveJob::TestHelper
+ include FactoryBot::Syntax::Methods
+ include GitlabRoutingHelper
+
+ RSpec::Mocks.setup
+ TestEnv.init(mailer: false)
+
+ # skip pre-receive hook check so we can use
+ # web editor and merge
+ TestEnv.disable_pre_receive
+end
+
+Spinach.hooks.after_scenario do |scenario_data, step_definitions|
+ if scenario_data.tags.include?('javascript')
+ include WaitForRequests
+ block_and_wait_for_requests_complete
+ end
+end
+
+module StdoutReporterWithScenarioLocation
+ # Override the standard reporter to show filename and line number next to each
+ # scenario for easy, focused re-runs
+ def before_scenario_run(scenario, step_definitions = nil)
+ @max_step_name_length = scenario.steps.map(&:name).map(&:length).max if scenario.steps.any? # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ name = scenario.name
+
+ # This number has no significance, it's just to line things up
+ max_length = @max_step_name_length + 19 # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ out.puts "\n #{'Scenario:'.green} #{name.light_green.ljust(max_length)}" \
+ " # #{scenario.feature.filename}:#{scenario.line}"
+ end
+end
+
+Spinach::Reporter::Stdout.prepend(StdoutReporterWithScenarioLocation)
diff --git a/features/support/gitaly.rb b/features/support/gitaly.rb
new file mode 100644
index 00000000000..3cd5f4ce497
--- /dev/null
+++ b/features/support/gitaly.rb
@@ -0,0 +1,3 @@
+Spinach.hooks.before_scenario do
+ allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true)
+end
diff --git a/features/support/login_helpers.rb b/features/support/login_helpers.rb
new file mode 100644
index 00000000000..540ff25a4f2
--- /dev/null
+++ b/features/support/login_helpers.rb
@@ -0,0 +1,19 @@
+module LoginHelpers
+ # After inclusion, IntegrationHelpers calls these two methods that aren't
+ # supported by Spinach, so we perform the end results ourselves
+ class << self
+ def setup(*args)
+ Spinach.hooks.before_scenario do
+ Warden.test_mode!
+ end
+ end
+
+ def teardown(*args)
+ Spinach.hooks.after_scenario do
+ Warden.test_reset!
+ end
+ end
+ end
+
+ include Devise::Test::IntegrationHelpers
+end
diff --git a/features/support/rerun.rb b/features/support/rerun.rb
new file mode 100644
index 00000000000..60b78f9d050
--- /dev/null
+++ b/features/support/rerun.rb
@@ -0,0 +1,16 @@
+# The spinach-rerun-reporter doesn't define the on_undefined_step
+# See it here: https://github.com/javierav/spinach-rerun-reporter/blob/master/lib/spinach/reporter/rerun.rb
+require 'spinach-rerun-reporter'
+
+module Spinach
+ class Reporter
+ class Rerun
+ def on_undefined_step(step_data, failure, step_definitions = nil)
+ super step_data, failure, step_definitions
+
+ # save feature file and scenario line
+ @rerun << "#{current_feature.filename}:#{current_scenario.line}"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/middleware/webpack_proxy.rb b/lib/gitlab/middleware/webpack_proxy.rb
new file mode 100644
index 00000000000..6aecf63231f
--- /dev/null
+++ b/lib/gitlab/middleware/webpack_proxy.rb
@@ -0,0 +1,26 @@
+# This Rack middleware is intended to proxy the webpack assets directory to the
+# webpack-dev-server. It is only intended for use in development.
+
+# :nocov:
+module Gitlab
+ module Middleware
+ class WebpackProxy < Rack::Proxy
+ def initialize(app = nil, opts = {})
+ @proxy_host = opts.fetch(:proxy_host, 'localhost')
+ @proxy_port = opts.fetch(:proxy_port, 3808)
+ @proxy_path = opts[:proxy_path] if opts[:proxy_path]
+
+ super(app, backend: "http://#{@proxy_host}:#{@proxy_port}", **opts)
+ end
+
+ def perform_request(env)
+ if @proxy_path && env['PATH_INFO'].start_with?("/#{@proxy_path}")
+ super(env)
+ else
+ @app.call(env)
+ end
+ end
+ end
+ end
+end
+# :nocov:
diff --git a/lib/tasks/spinach.rake b/lib/tasks/spinach.rake
new file mode 100644
index 00000000000..19ff13f06c0
--- /dev/null
+++ b/lib/tasks/spinach.rake
@@ -0,0 +1,60 @@
+Rake::Task["spinach"].clear if Rake::Task.task_defined?('spinach')
+
+namespace :spinach do
+ namespace :project do
+ desc "GitLab | Spinach | Run project commits, issues and merge requests spinach features"
+ task :half do
+ run_spinach_tests('@project_commits,@project_issues,@project_merge_requests')
+ end
+
+ desc "GitLab | Spinach | Run remaining project spinach features"
+ task :rest do
+ run_spinach_tests('~@admin,~@dashboard,~@profile,~@public,~@snippets,~@project_commits,~@project_issues,~@project_merge_requests')
+ end
+ end
+
+ desc "GitLab | Spinach | Run project spinach features"
+ task :project do
+ run_spinach_tests('~@admin,~@dashboard,~@profile,~@public,~@snippets')
+ end
+
+ desc "GitLab | Spinach | Run other spinach features"
+ task :other do
+ run_spinach_tests('@admin,@dashboard,@profile,@public,@snippets')
+ end
+
+ desc "GitLab | Spinach | Run other spinach features"
+ task :builds do
+ run_spinach_tests('@builds')
+ end
+end
+
+desc "GitLab | Run spinach"
+task :spinach do
+ run_spinach_tests(nil)
+end
+
+def run_system_command(cmd)
+ system({ 'RAILS_ENV' => 'test', 'force' => 'yes' }, *cmd)
+end
+
+def run_spinach_command(args)
+ run_system_command(%w(spinach -r rerun) + args)
+end
+
+def run_spinach_tests(tags)
+ success = run_spinach_command(%W(--tags #{tags}))
+ 3.times do |_|
+ break if success
+ break unless File.exist?('tmp/spinach-rerun.txt')
+
+ tests = File.foreach('tmp/spinach-rerun.txt').map(&:chomp)
+ puts ''
+ puts "Spinach tests for #{tags}: Retrying tests... #{tests}".color(:red)
+ puts ''
+ sleep(3)
+ success = run_spinach_command(tests)
+ end
+
+ raise("spinach tests for #{tags} failed!") unless success
+end
diff --git a/spec/features/projects/artifacts/browse_spec.rb b/spec/features/projects/artifacts/browse_spec.rb
new file mode 100644
index 00000000000..cb69aff8d5f
--- /dev/null
+++ b/spec/features/projects/artifacts/browse_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+feature 'Browse artifact', :js do
+ let(:project) { create(:project, :public) }
+ let(:pipeline) { create(:ci_empty_pipeline, project: project) }
+ let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
+ let(:browse_url) do
+ browse_path('other_artifacts_0.1.2')
+ end
+
+ def browse_path(path)
+ browse_project_job_artifacts_path(project, job, path)
+ end
+
+ context 'when visiting old URL' do
+ before do
+ visit browse_url.sub('/-/jobs', '/builds')
+ end
+
+ it "redirects to new URL" do
+ expect(page.current_path).to eq(browse_url)
+ end
+ end
+
+ context 'when browsing a directory with an text file' do
+ let(:txt_entry) { job.artifacts_metadata_entry('other_artifacts_0.1.2/doc_sample.txt') }
+
+ before do
+ allow(Gitlab.config.pages).to receive(:enabled).and_return(true)
+ allow(Gitlab.config.pages).to receive(:artifacts_server).and_return(true)
+ end
+
+ context 'when the project is public' do
+ it "shows external link icon and styles" do
+ visit browse_url
+
+ link = first('.tree-item-file-external-link')
+
+ expect(page).to have_link('doc_sample.txt', href: file_project_job_artifacts_path(project, job, path: txt_entry.blob.path))
+ expect(link[:target]).to eq('_blank')
+ expect(link[:rel]).to include('noopener')
+ expect(link[:rel]).to include('noreferrer')
+ expect(page).to have_selector('.js-artifact-tree-external-icon')
+ end
+ end
+
+ context 'when the project is private' do
+ let!(:private_project) { create(:project, :private) }
+ let(:pipeline) { create(:ci_empty_pipeline, project: private_project) }
+ let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) }
+ let(:user) { create(:user) }
+
+ before do
+ private_project.add_developer(user)
+
+ sign_in(user)
+ end
+
+ it 'shows internal link styles' do
+ visit browse_project_job_artifacts_path(private_project, job, 'other_artifacts_0.1.2')
+
+ expect(page).to have_link('doc_sample.txt')
+ expect(page).not_to have_selector('.js-artifact-tree-external-icon')
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/artifacts/download_spec.rb b/spec/features/projects/artifacts/download_spec.rb
new file mode 100644
index 00000000000..6f76c14910b
--- /dev/null
+++ b/spec/features/projects/artifacts/download_spec.rb
@@ -0,0 +1,61 @@
+require 'spec_helper'
+
+feature 'Download artifact' do
+ let(:project) { create(:project, :public) }
+ let(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project) }
+ let(:job) { create(:ci_build, :artifacts, :success, pipeline: pipeline) }
+
+ shared_examples 'downloading' do
+ it 'downloads the zip' do
+ expect(page.response_headers['Content-Disposition'])
+ .to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"})
+
+ # Check the content does match, but don't print this as error message
+ expect(page.source.b == job.artifacts_file.file.read.b)
+ end
+ end
+
+ context 'when downloading' do
+ before do
+ visit download_url
+ end
+
+ context 'via job id' do
+ let(:download_url) do
+ download_project_job_artifacts_path(project, job)
+ end
+
+ it_behaves_like 'downloading'
+ end
+
+ context 'via branch name and job name' do
+ let(:download_url) do
+ latest_succeeded_project_artifacts_path(project, "#{pipeline.ref}/download", job: job.name)
+ end
+
+ it_behaves_like 'downloading'
+ end
+ end
+
+ context 'when visiting old URL' do
+ before do
+ visit download_url.sub('/-/jobs', '/builds')
+ end
+
+ context 'via job id' do
+ let(:download_url) do
+ download_project_job_artifacts_path(project, job)
+ end
+
+ it_behaves_like 'downloading'
+ end
+
+ context 'via branch name and job name' do
+ let(:download_url) do
+ latest_succeeded_project_artifacts_path(project, "#{pipeline.ref}/download", job: job.name)
+ end
+
+ it_behaves_like 'downloading'
+ end
+ end
+end
diff --git a/spec/features/projects/commit/user_comments_on_commit_spec.rb b/spec/features/projects/commit/user_comments_on_commit_spec.rb
new file mode 100644
index 00000000000..5174f793367
--- /dev/null
+++ b/spec/features/projects/commit/user_comments_on_commit_spec.rb
@@ -0,0 +1,110 @@
+require "spec_helper"
+
+describe "User comments on commit", :js do
+ include Spec::Support::Helpers::Features::NotesHelpers
+ include RepoHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ COMMENT_TEXT = "XML attached".freeze
+
+ before do
+ sign_in(user)
+ project.add_developer(user)
+
+ visit(project_commit_path(project, sample_commit.id))
+ end
+
+ context "when adding new comment" do
+ it "adds comment" do
+ EMOJI = ":+1:".freeze
+
+ page.within(".js-main-target-form") do
+ expect(page).not_to have_link("Cancel")
+
+ fill_in("note[note]", with: "#{COMMENT_TEXT} #{EMOJI}")
+
+ # Check on `Preview` tab
+ click_link("Preview")
+
+ expect(find(".js-md-preview")).to have_content(COMMENT_TEXT).and have_css("gl-emoji")
+ expect(page).not_to have_css(".js-note-text")
+
+ # Check on `Write` tab
+ click_link("Write")
+
+ expect(page).to have_field("note[note]", with: "#{COMMENT_TEXT} #{EMOJI}")
+
+ # Submit comment from the `Preview` tab to get rid of a separate `it` block
+ # which would specially tests if everything gets cleared from the note form.
+ click_link("Preview")
+ click_button("Comment")
+ end
+
+ wait_for_requests
+
+ page.within(".note") do
+ expect(page).to have_content(COMMENT_TEXT).and have_css("gl-emoji")
+ end
+
+ page.within(".js-main-target-form") do
+ expect(page).to have_field("note[note]", with: "").and have_no_css(".js-md-preview")
+ end
+ end
+ end
+
+ context "when editing comment" do
+ before do
+ add_note(COMMENT_TEXT)
+ end
+
+ it "edits comment" do
+ NEW_COMMENT_TEXT = "+1 Awesome!".freeze
+
+ page.within(".main-notes-list") do
+ note = find(".note")
+ note.hover
+
+ note.find(".js-note-edit").click
+ end
+
+ page.find(".current-note-edit-form textarea")
+
+ page.within(".current-note-edit-form") do
+ fill_in("note[note]", with: NEW_COMMENT_TEXT)
+ click_button("Save comment")
+ end
+
+ wait_for_requests
+
+ page.within(".note") do
+ expect(page).to have_content(NEW_COMMENT_TEXT)
+ end
+ end
+ end
+
+ context "when deleting comment" do
+ before do
+ add_note(COMMENT_TEXT)
+ end
+
+ it "deletes comment" do
+ page.within(".note") do
+ expect(page).to have_content(COMMENT_TEXT)
+ end
+
+ page.within(".main-notes-list") do
+ note = find(".note")
+ note.hover
+
+ find(".more-actions").click
+ find(".more-actions .dropdown-menu li", match: :first)
+
+ accept_confirm { find(".js-note-delete").click }
+ end
+
+ expect(page).not_to have_css(".note")
+ end
+ end
+end
diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js
new file mode 100644
index 00000000000..e17b051f137
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_context_bar_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import ideContextBar from '~/ide/components/ide_context_bar.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+
+describe('Multi-file editor right context bar', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ideContextBar);
+
+ vm = createComponentWithStore(Component, store, {
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'svg',
+ });
+
+ vm.$store.state.rightPanelCollapsed = false;
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('collapsed', () => {
+ beforeEach(done => {
+ vm.$store.state.rightPanelCollapsed = true;
+
+ Vue.nextTick(done);
+ });
+
+ it('adds collapsed class', () => {
+ expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_external_links_spec.js b/spec/javascripts/ide/components/ide_external_links_spec.js
new file mode 100644
index 00000000000..9f6cb459f3b
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_external_links_spec.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import ideExternalLinks from '~/ide/components/ide_external_links.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('ide external links component', () => {
+ let vm;
+ let fakeReferrer;
+ let Component;
+
+ const fakeProjectUrl = '/project/';
+
+ beforeEach(() => {
+ Component = Vue.extend(ideExternalLinks);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('goBackUrl', () => {
+ it('renders the Go Back link with the referrer when present', () => {
+ fakeReferrer = '/example/README.md';
+ spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
+
+ vm = createComponent(Component, {
+ projectUrl: fakeProjectUrl,
+ }).$mount();
+
+ expect(vm.goBackUrl).toEqual(fakeReferrer);
+ });
+
+ it('renders the Go Back link with the project url when referrer is not present', () => {
+ fakeReferrer = '';
+ spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
+
+ vm = createComponent(Component, {
+ projectUrl: fakeProjectUrl,
+ }).$mount();
+
+ expect(vm.goBackUrl).toEqual(fakeProjectUrl);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_project_tree_spec.js b/spec/javascripts/ide/components/ide_project_tree_spec.js
new file mode 100644
index 00000000000..657682cb39c
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_project_tree_spec.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import ProjectTree from '~/ide/components/ide_project_tree.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('IDE project tree', () => {
+ const Component = Vue.extend(ProjectTree);
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent(Component, {
+ project: {
+ id: 1,
+ name: 'test',
+ web_url: gl.TEST_HOST,
+ avatar_url: '',
+ branches: [],
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders identicon when projct has no avatar', () => {
+ expect(vm.$el.querySelector('.identicon')).not.toBeNull();
+ });
+
+ it('renders avatar image if project has avatar', done => {
+ vm.project.avatar_url = gl.TEST_HOST;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.identicon')).toBeNull();
+ expect(vm.$el.querySelector('img.avatar')).not.toBeNull();
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_repo_tree_spec.js b/spec/javascripts/ide/components/ide_repo_tree_spec.js
new file mode 100644
index 00000000000..e0fbc90ca61
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_repo_tree_spec.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import ideRepoTree from '~/ide/components/ide_repo_tree.vue';
+import createComponent from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('IdeRepoTree', () => {
+ let vm;
+ let tree;
+
+ beforeEach(() => {
+ const IdeRepoTree = Vue.extend(ideRepoTree);
+
+ tree = {
+ tree: [file()],
+ loading: false,
+ };
+
+ vm = createComponent(IdeRepoTree, {
+ tree,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a sidebar', () => {
+ expect(vm.$el.querySelector('.loading-file')).toBeNull();
+ expect(vm.$el.querySelector('.file')).not.toBeNull();
+ });
+
+ it('renders 3 loading files if tree is loading', done => {
+ tree.loading = true;
+
+ vm.$nextTick(() => {
+ expect(
+ vm.$el.querySelectorAll('.multi-file-loading-container').length,
+ ).toEqual(3);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/pipelines/async_button_spec.js b/spec/javascripts/pipelines/async_button_spec.js
new file mode 100644
index 00000000000..e0ea3649646
--- /dev/null
+++ b/spec/javascripts/pipelines/async_button_spec.js
@@ -0,0 +1,62 @@
+import Vue from 'vue';
+import asyncButtonComp from '~/pipelines/components/async_button.vue';
+import eventHub from '~/pipelines/event_hub';
+
+describe('Pipelines Async Button', () => {
+ let component;
+ let AsyncButtonComponent;
+
+ beforeEach(() => {
+ AsyncButtonComponent = Vue.extend(asyncButtonComp);
+
+ component = new AsyncButtonComponent({
+ propsData: {
+ endpoint: '/foo',
+ title: 'Foo',
+ icon: 'repeat',
+ cssClass: 'bar',
+ pipelineId: 123,
+ type: 'explode',
+ },
+ }).$mount();
+ });
+
+ it('should render a button', () => {
+ expect(component.$el.tagName).toEqual('BUTTON');
+ });
+
+ it('should render svg icon', () => {
+ expect(component.$el.querySelector('svg')).not.toBeNull();
+ });
+
+ it('should render the provided title', () => {
+ expect(component.$el.getAttribute('data-original-title')).toContain('Foo');
+ expect(component.$el.getAttribute('aria-label')).toContain('Foo');
+ });
+
+ it('should render the provided cssClass', () => {
+ expect(component.$el.getAttribute('class')).toContain('bar');
+ });
+
+ describe('With confirm dialog', () => {
+ it('should call the service when confimation is positive', () => {
+ eventHub.$on('openConfirmationModal', (data) => {
+ expect(data.pipelineId).toEqual(123);
+ expect(data.type).toEqual('explode');
+ });
+
+ component = new AsyncButtonComponent({
+ propsData: {
+ endpoint: '/foo',
+ title: 'Foo',
+ icon: 'fa fa-foo',
+ cssClass: 'bar',
+ pipelineId: 123,
+ type: 'explode',
+ },
+ }).$mount();
+
+ component.$el.click();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js
new file mode 100644
index 00000000000..cee22d5342a
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_maintainer_edit_spec.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+import maintainerEditComponent from '~/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('RWidgetMaintainerEdit', () => {
+ let Component;
+ let vm;
+
+ beforeEach(() => {
+ Component = Vue.extend(maintainerEditComponent);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('when a maintainer is allowed to edit', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ maintainerEditAllowed: true,
+ });
+ });
+
+ it('it renders the message', () => {
+ expect(vm.$el.textContent.trim()).toEqual('Allows edits from maintainers');
+ });
+ });
+
+ describe('when a maintainer is not allowed to edit', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ maintainerEditAllowed: false,
+ });
+ });
+
+ it('hides the message', () => {
+ expect(vm.$el.textContent.trim()).toEqual('');
+ });
+ });
+});
diff --git a/spec/lib/gitlab/email/email_shared_blocks.rb b/spec/lib/gitlab/email/email_shared_blocks.rb
new file mode 100644
index 00000000000..9d806fc524d
--- /dev/null
+++ b/spec/lib/gitlab/email/email_shared_blocks.rb
@@ -0,0 +1,41 @@
+require 'gitlab/email/receiver'
+
+shared_context :email_shared_context do
+ let(:mail_key) { "59d8df8370b7e95c5a49fbf86aeb2c93" }
+ let(:receiver) { Gitlab::Email::Receiver.new(email_raw) }
+ let(:markdown) { "![image](uploads/image.png)" }
+
+ def setup_attachment
+ allow_any_instance_of(Gitlab::Email::AttachmentUploader).to receive(:execute).and_return(
+ [
+ {
+ url: "uploads/image.png",
+ alt: "image",
+ markdown: markdown
+ }
+ ]
+ )
+ end
+end
+
+shared_examples :reply_processing_shared_examples do
+ context "when the user could not be found" do
+ before do
+ user.destroy
+ end
+
+ it "raises a UserNotFoundError" do
+ expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotFoundError)
+ end
+ end
+
+ context "when the user is not authorized to the project" do
+ before do
+ project.update_attribute(:visibility_level, Project::PRIVATE)
+ end
+
+ it "raises a ProjectNotFound" do
+ expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound)
+ end
+ end
+end