Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
5506a7c09b
commit
270e08fdb1
|
|
@ -1 +1 @@
|
|||
6119a6d998d6c8a7f2bdfcf113068c7a00feef08
|
||||
1ed908c7dd2f4daef8f29d9f88461b81d16559ea
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
<script>
|
||||
import PageHeading from '~/vue_shared/components/page_heading.vue';
|
||||
import AiCatalogNavTabs from './components/ai_catalog_nav_tabs.vue';
|
||||
|
||||
export default {
|
||||
name: 'AiCatalogApp',
|
||||
components: {
|
||||
AiCatalogNavTabs,
|
||||
PageHeading,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<page-heading :heading="s__('AICatalog|AI Catalog')" />
|
||||
<ai-catalog-nav-tabs />
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<script>
|
||||
import { GlSkeletonLoader } from '@gitlab/ui';
|
||||
import AiCatalogListItem from './ai_catalog_list_item.vue';
|
||||
|
||||
export default {
|
||||
name: 'AiCatalogList',
|
||||
components: {
|
||||
AiCatalogListItem,
|
||||
GlSkeletonLoader,
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="ai-catalog-list">
|
||||
<gl-skeleton-loader v-if="isLoading" :lines="2" />
|
||||
|
||||
<ul v-else class="gl-list-style-none gl-m-0 gl-p-0">
|
||||
<ai-catalog-list-item v-for="item in items" :key="item.id" :item="item" />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
<script>
|
||||
import { GlIcon, GlBadge, GlMarkdown, GlLink, GlAvatar, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import { sprintf, s__ } from '~/locale';
|
||||
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
|
||||
import { AI_CATALOG_AGENTS_SHOW_ROUTE } from '../router/constants';
|
||||
|
||||
export default {
|
||||
name: 'AiCatalogListItem',
|
||||
components: {
|
||||
GlIcon,
|
||||
GlBadge,
|
||||
GlMarkdown,
|
||||
GlLink,
|
||||
GlAvatar,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
fullDate() {
|
||||
if (!this.item.releasedAt) return undefined;
|
||||
const date = new Date(this.item.releasedAt);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return formatDate(date);
|
||||
},
|
||||
releasedTooltipTitle() {
|
||||
if (!this.fullDate) return undefined;
|
||||
return sprintf(s__('AiCatalog|Released %{fullDate}'), {
|
||||
fullDate: this.fullDate,
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatId(id) {
|
||||
return getIdFromGraphQLId(id);
|
||||
},
|
||||
},
|
||||
showRoute: AI_CATALOG_AGENTS_SHOW_ROUTE,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-testid="ai-catalog-list-item"
|
||||
class="gl-flex gl-items-center gl-border-b-1 gl-border-default gl-py-3 gl-text-subtle gl-border-b-solid"
|
||||
>
|
||||
<gl-avatar
|
||||
:alt="`${item.name} avatar`"
|
||||
:entity-name="item.name"
|
||||
:size="48"
|
||||
class="gl-mr-4 gl-self-start"
|
||||
/>
|
||||
<div class="gl-flex gl-grow gl-flex-col gl-gap-1">
|
||||
<div>
|
||||
<span class="gl-text-sm">{{ item.model }}</span>
|
||||
<gl-icon
|
||||
v-if="item.verified"
|
||||
name="tanuki-verified"
|
||||
class="gl-ml-1 gl-text-status-info"
|
||||
:size="16"
|
||||
data-testid="tanuki-verified-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="gl-mb-1 gl-flex gl-flex-wrap gl-items-center gl-gap-2">
|
||||
<gl-link :to="{ name: $options.showRoute, params: { id: formatId(item.id) } }">
|
||||
{{ item.name }}
|
||||
</gl-link>
|
||||
<gl-badge variant="neutral" class="gl-self-center">{{ item.type }}</gl-badge>
|
||||
<gl-badge v-gl-tooltip="releasedTooltipTitle" variant="info" class="gl-self-center">
|
||||
{{ item.version }}
|
||||
</gl-badge>
|
||||
</div>
|
||||
|
||||
<div v-if="item.description" class="gl-line-clamp-2 gl-break-words gl-text-default">
|
||||
<gl-markdown compact class="gl-text-sm">{{ item.description }}</gl-markdown>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<script>
|
||||
import { GlTab, GlTabs } from '@gitlab/ui';
|
||||
import { s__ } from '~/locale';
|
||||
import { AI_CATALOG_AGENTS_ROUTE } from '../router/constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlTab,
|
||||
GlTabs,
|
||||
},
|
||||
computed: {
|
||||
tabs() {
|
||||
return [
|
||||
{
|
||||
text: s__('AICatalog|Agents'),
|
||||
route: AI_CATALOG_AGENTS_ROUTE,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
navigateTo(route) {
|
||||
if (this.$route.path !== route) {
|
||||
this.$router.push({ name: route });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gl-mb-4 gl-flex lg:gl-items-center">
|
||||
<gl-tabs content-class="gl-py-0" class="gl-w-full">
|
||||
<gl-tab
|
||||
v-for="tab in tabs"
|
||||
:key="tab.text"
|
||||
:title="tab.text"
|
||||
@click="navigateTo(tab.route)"
|
||||
/>
|
||||
</gl-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
query aiCatalogAgents {
|
||||
aiCatalogAgents @client {
|
||||
nodes {
|
||||
id
|
||||
type
|
||||
name
|
||||
description
|
||||
model
|
||||
verified
|
||||
version
|
||||
releasedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
|
||||
import createDefaultClient from '~/lib/graphql';
|
||||
|
||||
import AiCatalogApp from './ai_catalog_app.vue';
|
||||
import { createRouter } from './router';
|
||||
|
||||
import aiCatalogAgentsQuery from './graphql/ai_catalog_agents.query.graphql';
|
||||
|
||||
export const initAiCatalog = (selector = '#js-ai-catalog') => {
|
||||
const el = document.querySelector(selector);
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { dataset } = el;
|
||||
const { aiCatalogIndexPath } = dataset;
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: createDefaultClient(),
|
||||
});
|
||||
|
||||
/* eslint-disable @gitlab/require-i18n-strings */
|
||||
apolloProvider.clients.defaultClient.cache.writeQuery({
|
||||
query: aiCatalogAgentsQuery,
|
||||
data: {
|
||||
aiCatalogAgents: {
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'agent',
|
||||
name: 'Claude Sonnet 4',
|
||||
description: 'Smart, efficient model for everyday user',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
verified: true,
|
||||
version: 'v4.2',
|
||||
releasedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'agent',
|
||||
name: 'Claude Opus 4',
|
||||
description: 'Powerful, large model for complex challenges',
|
||||
model: 'claude-opus-4-20250514',
|
||||
verified: true,
|
||||
version: 'v4.2',
|
||||
releasedAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
/* eslint-enable @gitlab/require-i18n-strings */
|
||||
|
||||
return new Vue({
|
||||
el,
|
||||
name: 'AiCatalogRoot',
|
||||
router: createRouter(aiCatalogIndexPath),
|
||||
apolloProvider,
|
||||
render(h) {
|
||||
return h(AiCatalogApp);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<script>
|
||||
import aiCatalogAgentsQuery from '../graphql/ai_catalog_agents.query.graphql';
|
||||
import AiCatalogList from '../components/ai_catalog_list.vue';
|
||||
|
||||
export default {
|
||||
name: 'AiCatalogAgents',
|
||||
components: {
|
||||
AiCatalogList,
|
||||
},
|
||||
apollo: {
|
||||
aiCatalogAgents: {
|
||||
query: aiCatalogAgentsQuery,
|
||||
update: (data) => data.aiCatalogAgents.nodes,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
aiCatalogAgents: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isLoading() {
|
||||
return this.$apollo.queries.aiCatalogAgents.loading;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ai-catalog-list :is-loading="isLoading" :items="aiCatalogAgents" />
|
||||
</template>
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<script>
|
||||
import { s__ } from '~/locale';
|
||||
import PageHeading from '~/vue_shared/components/page_heading.vue';
|
||||
|
||||
export default {
|
||||
name: 'AiCatalogAgentsShow',
|
||||
components: {
|
||||
PageHeading,
|
||||
},
|
||||
computed: {
|
||||
pageTitle() {
|
||||
return `${s__('AICatalog|Edit agent')}: ${this.$route.params.id}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<page-heading :heading="pageTitle" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const AI_CATALOG_INDEX_ROUTE = 'ai-catalog';
|
||||
export const AI_CATALOG_AGENTS_ROUTE = '/agents';
|
||||
export const AI_CATALOG_AGENTS_SHOW_ROUTE = '/agents/:id';
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import VueRouter from 'vue-router';
|
||||
import AiCatalogAgents from '../pages/ai_catalog_agents.vue';
|
||||
import AiCatalogAgentsShow from '../pages/ai_catalog_agents_show.vue';
|
||||
import {
|
||||
AI_CATALOG_INDEX_ROUTE,
|
||||
AI_CATALOG_AGENTS_ROUTE,
|
||||
AI_CATALOG_AGENTS_SHOW_ROUTE,
|
||||
} from './constants';
|
||||
|
||||
Vue.use(VueRouter);
|
||||
|
||||
export const createRouter = (base) => {
|
||||
return new VueRouter({
|
||||
base,
|
||||
mode: 'history',
|
||||
routes: [
|
||||
{
|
||||
name: AI_CATALOG_INDEX_ROUTE,
|
||||
path: '',
|
||||
component: AiCatalogAgents,
|
||||
},
|
||||
{
|
||||
name: AI_CATALOG_AGENTS_ROUTE,
|
||||
path: '/agents',
|
||||
component: AiCatalogAgents,
|
||||
},
|
||||
{
|
||||
name: AI_CATALOG_AGENTS_SHOW_ROUTE,
|
||||
path: '/agents/:id',
|
||||
component: AiCatalogAgentsShow,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
|
@ -22,6 +22,7 @@ export const TYPENAME_EPIC_BOARD = 'Boards::EpicBoard';
|
|||
export const TYPENAME_FEATURE_FLAG = 'FeatureFlag';
|
||||
export const TYPENAME_GROUP = 'Group';
|
||||
export const TYPENAME_ISSUE = 'Issue';
|
||||
export const TYPENAME_TASK = 'Task';
|
||||
export const TYPENAME_ITERATION = 'Iteration';
|
||||
export const TYPENAME_ITERATIONS_CADENCE = 'Iterations::Cadence';
|
||||
export const TYPENAME_LABEL = 'Label';
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
import { initAiCatalog } from '~/ai/catalog/';
|
||||
|
||||
initAiCatalog();
|
||||
|
|
@ -72,7 +72,7 @@ export default {
|
|||
},
|
||||
{
|
||||
text: this.$options.i18n.plans,
|
||||
href: `${PROMO_URL}/pricing`,
|
||||
href: this.sidebarData.compare_plans_url,
|
||||
extraAttrs: {
|
||||
...this.trackingAttrs('compare_gitlab_plans'),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Explore
|
||||
class AiCatalogController < Explore::ApplicationController
|
||||
feature_category :workflow_catalog
|
||||
before_action :check_feature_flag
|
||||
|
||||
private
|
||||
|
||||
def check_feature_flag
|
||||
render_404 unless Feature.enabled?(:global_ai_catalog, current_user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -45,6 +45,7 @@ module SidebarsHelper
|
|||
def super_sidebar_logged_out_context(panel:, panel_type:)
|
||||
super_sidebar_instance_version_data.merge(super_sidebar_whats_new_data).merge({
|
||||
is_logged_in: false,
|
||||
compare_plans_url: compare_plans_url,
|
||||
context_switcher_links: context_switcher_links,
|
||||
current_menu_items: panel.super_sidebar_menu_items,
|
||||
current_context_header: panel.super_sidebar_context_header,
|
||||
|
|
@ -94,9 +95,8 @@ module SidebarsHelper
|
|||
sign_out_link: destroy_user_session_path,
|
||||
issues_dashboard_path: issues_dashboard_path(assignee_username: user.username),
|
||||
merge_request_dashboard_path: user.merge_request_dashboard_enabled? ? merge_requests_dashboard_path : nil,
|
||||
|
||||
todos_dashboard_path: dashboard_todos_path,
|
||||
|
||||
compare_plans_url: compare_plans_url(user: user, project: project, group: group),
|
||||
create_new_menu_groups: create_new_menu_groups(group: group, project: project),
|
||||
merge_request_menu: create_merge_request_menu(user),
|
||||
projects_path: dashboard_projects_path,
|
||||
|
|
@ -228,6 +228,10 @@ module SidebarsHelper
|
|||
}
|
||||
end
|
||||
|
||||
def compare_plans_url(*)
|
||||
"#{promo_url}/pricing"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_data
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ class Namespace < ApplicationRecord
|
|||
include Namespaces::Traversal::Recursive
|
||||
include Namespaces::Traversal::Linear
|
||||
include Namespaces::Traversal::Cached
|
||||
include Namespaces::Traversal::Traversable
|
||||
include Namespaces::AdjournedDeletable
|
||||
include EachBatch
|
||||
include BlocksUnsafeSerialization
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Namespaces
|
||||
module Traversal
|
||||
module Traversable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :within, ->(traversal_ids) do
|
||||
validated_ids = traversal_ids.map { |id| Integer(id) }
|
||||
|
||||
where(
|
||||
"#{arel_table.name}.traversal_ids >= ARRAY[?]::bigint[] " \
|
||||
"AND next_traversal_ids_sibling(ARRAY[?]::bigint[]) > #{arel_table.name}.traversal_ids",
|
||||
validated_ids, validated_ids
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
# Create a json schema within the json_schemas directory
|
||||
#
|
||||
# class Project < ActiveRecord::Base
|
||||
# validates :data, json_schema: { filename: "file" }
|
||||
# validates :data, json_schema: { filename: "file", size_limit: 64.kilobytes }
|
||||
# end
|
||||
#
|
||||
class JsonSchemaValidator < ActiveModel::EachValidator
|
||||
|
|
@ -20,6 +20,7 @@ class JsonSchemaValidator < ActiveModel::EachValidator
|
|||
raise FilenameError, "Must be a valid 'filename'" unless options[:filename].match?(FILENAME_ALLOWED)
|
||||
|
||||
@base_directory = options.delete(:base_directory) || BASE_DIRECTORY
|
||||
@size_limit = options.delete(:size_limit)
|
||||
|
||||
super(options)
|
||||
end
|
||||
|
|
@ -28,6 +29,11 @@ class JsonSchemaValidator < ActiveModel::EachValidator
|
|||
value = Gitlab::Json.parse(Gitlab::Json.dump(value)) if options[:hash_conversion] == true
|
||||
value = Gitlab::Json.parse(value.to_s) if options[:parse_json] == true && !value.nil?
|
||||
|
||||
if size_limit && !valid_size?(value)
|
||||
record.errors.add(attribute, size_error_message)
|
||||
return
|
||||
end
|
||||
|
||||
if options[:detail_errors]
|
||||
schema.validate(value).each do |error|
|
||||
message = format_error_message(error)
|
||||
|
|
@ -44,7 +50,20 @@ class JsonSchemaValidator < ActiveModel::EachValidator
|
|||
|
||||
private
|
||||
|
||||
attr_reader :base_directory
|
||||
attr_reader :base_directory, :size_limit
|
||||
|
||||
def valid_size?(value)
|
||||
json_size(value) <= size_limit
|
||||
end
|
||||
|
||||
def json_size(value)
|
||||
Gitlab::Json.dump(value).bytesize
|
||||
end
|
||||
|
||||
def size_error_message
|
||||
human_size = ActiveSupport::NumberHelper.number_to_human_size(size_limit)
|
||||
format(_("is too large. Maximum size allowed is %{size}"), size: human_size)
|
||||
end
|
||||
|
||||
def format_error_message(error)
|
||||
case error['type']
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
- page_title s_('AICatalog|AI Catalog')
|
||||
|
||||
#js-ai-catalog{ data: { ai_catalog_index_path: explore_ai_catalog_path } }
|
||||
|
|
@ -15,7 +15,6 @@ namespace :explore do
|
|||
get '/' => 'catalog#index', as: :catalog_index
|
||||
get '/*full_path' => 'catalog#show', as: :catalog, constraints: { full_path: /.*/ }
|
||||
end
|
||||
get '/ai-catalog/(*vueroute)' => 'ai_catalog#index', as: :ai_catalog, format: false
|
||||
resources :snippets, only: [:index]
|
||||
root to: 'projects#index'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ When running Gitaly in Kubernetes, you must:
|
|||
- [Address pod disruption](#address-pod-disruption).
|
||||
- [Address resource contention and saturation](#address-resource-contention-and-saturation).
|
||||
- [Optimize pod rotation time](#optimize-pod-rotation-time).
|
||||
- [Monitor disk usage](#monitor-disk-usage)
|
||||
|
||||
### Enable cgroup_writable field in Containerd
|
||||
|
||||
|
|
@ -295,3 +296,8 @@ gitlab:
|
|||
gitaly:
|
||||
gracefulRestartTimeout: 1
|
||||
```
|
||||
|
||||
### Monitor disk usage
|
||||
|
||||
Monitor disk usage regularly for long-running Gitaly containers because log file growth can cause storage issues if
|
||||
[log rotation is not enabled](https://docs.gitlab.com/charts/charts/globals/#log-rotation).
|
||||
|
|
|
|||
|
|
@ -101,6 +101,15 @@ content in the `/doc-locale/` or `/docs-locale/` directories.
|
|||
| Charts | [`/doc`](https://gitlab.com/gitlab-org/charts/gitlab/-/tree/master/doc) | [`/doc-locale`](https://gitlab.com/gitlab-org/charts/gitlab/-/tree/master/doc-locale) | `check_docs_i18n_content` <br/> `check_docs_i18n_markdown` |
|
||||
| Operator | [`/doc`](https://gitlab.com/gitlab-org/cloud-native/gitlab-operator/-/tree/master/doc) | [`/doc-locale`](https://gitlab.com/gitlab-org/cloud-native/gitlab-operator/-/tree/master/doc-locale) | `docs-i18n-lint content` <br/> `docs-i18n-lint markdown` |
|
||||
|
||||
### Path verification of orphaned translation Files
|
||||
|
||||
The `docs-i18n-lint paths` job fails if translated files in `/doc-locale` have no corresponding English source files. The job runs when:
|
||||
|
||||
- Files in `/doc-locale` are modified
|
||||
- The path verification script changes
|
||||
|
||||
When orphaned translation files are detected, localization team members handle the necessary deletions. English fallback content provides coverage until new translations are available.
|
||||
|
||||
## Install documentation linters
|
||||
|
||||
To help adhere to the [documentation style guidelines](../styleguide/_index.md), and
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Sidebars # rubocop: disable Gitlab/BoundedContexts -- unknown
|
||||
module Explore
|
||||
module Menus
|
||||
class AiCatalogMenu < ::Sidebars::Menu
|
||||
override :link
|
||||
def link
|
||||
explore_ai_catalog_path
|
||||
end
|
||||
|
||||
override :title
|
||||
def title
|
||||
s_('AICatalog|AI Catalog')
|
||||
end
|
||||
|
||||
override :sprite_icon
|
||||
def sprite_icon
|
||||
'tanuki-ai'
|
||||
end
|
||||
|
||||
override :render?
|
||||
def render?
|
||||
Feature.enabled?(:global_ai_catalog, current_user)
|
||||
end
|
||||
|
||||
override :active_routes
|
||||
def active_routes
|
||||
{ controller: ['explore/ai_catalog'] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -24,7 +24,6 @@ module Sidebars
|
|||
add_menu(Sidebars::Explore::Menus::ProjectsMenu.new(context))
|
||||
add_menu(Sidebars::Explore::Menus::GroupsMenu.new(context))
|
||||
add_menu(Sidebars::Explore::Menus::CatalogMenu.new(context))
|
||||
add_menu(Sidebars::Explore::Menus::AiCatalogMenu.new(context))
|
||||
add_menu(Sidebars::Explore::Menus::TopicsMenu.new(context))
|
||||
add_menu(Sidebars::Explore::Menus::SnippetsMenu.new(context))
|
||||
end
|
||||
|
|
|
|||
|
|
@ -73881,6 +73881,9 @@ msgstr ""
|
|||
msgid "is read-only"
|
||||
msgstr ""
|
||||
|
||||
msgid "is too large. Maximum size allowed is %{size}"
|
||||
msgstr ""
|
||||
|
||||
msgid "is too long (%{current_value}). The maximum size is %{max_size}."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
import { GlBadge, GlMarkdown, GlLink, GlAvatar } from '@gitlab/ui';
|
||||
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
|
||||
import AiCatalogListItem from '~/ai/catalog/components/ai_catalog_list_item.vue';
|
||||
|
||||
describe('AiCatalogListItem', () => {
|
||||
let wrapper;
|
||||
|
||||
const mockItem = {
|
||||
id: 1,
|
||||
name: 'Test AI Agent',
|
||||
model: 'gpt-4',
|
||||
type: 'Assistant',
|
||||
version: 'v1.2.0',
|
||||
description: 'A helpful AI assistant for testing purposes',
|
||||
releasedAt: '2024-01-15T10:30:00Z',
|
||||
verified: true,
|
||||
};
|
||||
|
||||
const mockItemWithoutOptionalFields = {
|
||||
id: 2,
|
||||
name: 'Basic Agent',
|
||||
model: 'claude-3',
|
||||
type: 'Chatbot',
|
||||
version: 'v1.0.0',
|
||||
verified: false,
|
||||
};
|
||||
|
||||
const createComponent = (item = mockItem) => {
|
||||
wrapper = shallowMountExtended(AiCatalogListItem, {
|
||||
propsData: {
|
||||
item,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const findAvatar = () => wrapper.findComponent(GlAvatar);
|
||||
const findLink = () => wrapper.findComponent(GlLink);
|
||||
const findBadges = () => wrapper.findAllComponents(GlBadge);
|
||||
const findTypeBadge = () => findBadges().at(0);
|
||||
const findVersionBadge = () => findBadges().at(1);
|
||||
const findMarkdown = () => wrapper.findComponent(GlMarkdown);
|
||||
const findVerifiedIcon = () => wrapper.findByTestId('tanuki-verified-icon');
|
||||
|
||||
describe('component rendering', () => {
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('renders the list item container with correct attributes', () => {
|
||||
const listItem = wrapper.findByTestId('ai-catalog-list-item');
|
||||
|
||||
expect(listItem.exists()).toBe(true);
|
||||
expect(listItem.element.tagName).toBe('LI');
|
||||
});
|
||||
|
||||
it('renders avatar with correct props', () => {
|
||||
const avatar = findAvatar();
|
||||
|
||||
expect(avatar.exists()).toBe(true);
|
||||
expect(avatar.props('alt')).toBe('Test AI Agent avatar');
|
||||
expect(avatar.props('entityName')).toBe('Test AI Agent');
|
||||
expect(avatar.props('size')).toBe(48);
|
||||
});
|
||||
|
||||
it('displays the model name', () => {
|
||||
expect(wrapper.text()).toContain('gpt-4');
|
||||
});
|
||||
|
||||
it('displays the agent name as a button', () => {
|
||||
const link = findLink();
|
||||
|
||||
expect(link.exists()).toBe(true);
|
||||
expect(link.text()).toBe('Test AI Agent');
|
||||
});
|
||||
|
||||
it('displays type badge with correct variant and text', () => {
|
||||
const typeBadge = findTypeBadge();
|
||||
|
||||
expect(typeBadge.exists()).toBe(true);
|
||||
expect(typeBadge.props('variant')).toBe('neutral');
|
||||
expect(typeBadge.text()).toBe('Assistant');
|
||||
});
|
||||
|
||||
it('displays version badge with correct variant and text', () => {
|
||||
const versionBadge = findVersionBadge();
|
||||
|
||||
expect(versionBadge.exists()).toBe(true);
|
||||
expect(versionBadge.props('variant')).toBe('info');
|
||||
expect(versionBadge.text()).toBe('v1.2.0');
|
||||
});
|
||||
|
||||
it('displays description when provided', () => {
|
||||
const markdown = findMarkdown();
|
||||
|
||||
expect(markdown.exists()).toBe(true);
|
||||
expect(markdown.text()).toBe('A helpful AI assistant for testing purposes');
|
||||
expect(markdown.props('compact')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verified icon', () => {
|
||||
it('shows verified icon when item is verified', () => {
|
||||
createComponent();
|
||||
|
||||
const verifiedIcon = findVerifiedIcon();
|
||||
expect(verifiedIcon.exists()).toBe(true);
|
||||
expect(verifiedIcon.props('name')).toBe('tanuki-verified');
|
||||
expect(verifiedIcon.props('size')).toBe(16);
|
||||
});
|
||||
|
||||
it('does not show verified icon when item is not verified', () => {
|
||||
createComponent(mockItemWithoutOptionalFields);
|
||||
|
||||
const verifiedIcon = findVerifiedIcon();
|
||||
expect(verifiedIcon.exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('description handling', () => {
|
||||
it('shows description when provided', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findMarkdown().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show description when not provided', () => {
|
||||
createComponent(mockItemWithoutOptionalFields);
|
||||
|
||||
expect(findMarkdown().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { GlSkeletonLoader } from '@gitlab/ui';
|
||||
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
|
||||
import AiCatalogList from '~/ai/catalog/components/ai_catalog_list.vue';
|
||||
import AiCatalogListItem from '~/ai/catalog/components/ai_catalog_list_item.vue';
|
||||
|
||||
describe('AiCatalogList', () => {
|
||||
let wrapper;
|
||||
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test AI Agent 1',
|
||||
model: 'gpt-4',
|
||||
type: 'Assistant',
|
||||
version: 'v1.2.0',
|
||||
description: 'A helpful AI assistant for testing purposes',
|
||||
releasedAt: '2024-01-15T10:30:00Z',
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Test AI Agent 2',
|
||||
model: 'claude-3',
|
||||
type: 'Chatbot',
|
||||
version: 'v1.0.0',
|
||||
description: 'Another AI assistant',
|
||||
releasedAt: '2024-02-10T14:20:00Z',
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Test AI Agent 3',
|
||||
model: 'gemini-pro',
|
||||
type: 'Helper',
|
||||
version: 'v2.1.0',
|
||||
verified: true,
|
||||
},
|
||||
];
|
||||
|
||||
const createComponent = (props = {}) => {
|
||||
wrapper = shallowMountExtended(AiCatalogList, {
|
||||
propsData: {
|
||||
items: mockItems,
|
||||
isLoading: false,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
|
||||
const findList = () => wrapper.find('ul');
|
||||
const findListItems = () => wrapper.findAllComponents(AiCatalogListItem);
|
||||
const findContainer = () => wrapper.findByTestId('ai-catalog-list');
|
||||
|
||||
describe('component rendering', () => {
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('renders the container with correct test id', () => {
|
||||
const container = findContainer();
|
||||
|
||||
expect(container.exists()).toBe(true);
|
||||
expect(container.element.tagName).toBe('DIV');
|
||||
});
|
||||
|
||||
it('renders list when not loading', () => {
|
||||
const list = findList();
|
||||
|
||||
expect(list.exists()).toBe(true);
|
||||
expect(list.classes()).toContain('gl-list-style-none');
|
||||
expect(list.classes()).toContain('gl-m-0');
|
||||
expect(list.classes()).toContain('gl-p-0');
|
||||
});
|
||||
|
||||
it('does not render skeleton loader when not loading', () => {
|
||||
expect(findSkeletonLoader().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton loader and hides list when loading is true', () => {
|
||||
createComponent({ isLoading: true });
|
||||
|
||||
expect(findSkeletonLoader().exists()).toBe(true);
|
||||
expect(findList().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows list and hides skeleton loader when loading is false', () => {
|
||||
createComponent({ isLoading: false });
|
||||
|
||||
expect(findSkeletonLoader().exists()).toBe(false);
|
||||
expect(findList().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list items rendering', () => {
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('renders correct number of list items', () => {
|
||||
const listItems = findListItems();
|
||||
|
||||
expect(listItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('passes correct props to each list item', () => {
|
||||
const listItems = findListItems();
|
||||
|
||||
listItems.wrappers.forEach((listItem, index) => {
|
||||
expect(listItem.props('item')).toEqual(mockItems[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty items', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ items: [] });
|
||||
});
|
||||
|
||||
it('renders empty list when no items provided', () => {
|
||||
expect(findList().exists()).toBe(true);
|
||||
expect(findListItems()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not render skeleton loader when not loading with empty items', () => {
|
||||
expect(findSkeletonLoader().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { GlTab, GlTabs } from '@gitlab/ui';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
|
||||
import AiCatalogNavTabs from '~/ai/catalog/components/ai_catalog_nav_tabs.vue';
|
||||
import { AI_CATALOG_AGENTS_ROUTE } from '~/ai/catalog/router/constants';
|
||||
|
||||
describe('AiCatalogNavTabs', () => {
|
||||
let wrapper;
|
||||
|
||||
const mockRouter = {
|
||||
push: jest.fn(),
|
||||
};
|
||||
|
||||
const createComponent = ({ routePath = '/ai/catalog' } = {}) => {
|
||||
wrapper = shallowMountExtended(AiCatalogNavTabs, {
|
||||
mocks: {
|
||||
$route: {
|
||||
path: routePath,
|
||||
},
|
||||
$router: mockRouter,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const findTabs = () => wrapper.findComponent(GlTabs);
|
||||
const findAllTabs = () => wrapper.findAllComponents(GlTab);
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('renders tabs', () => {
|
||||
expect(findTabs().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the correct number of tabs', () => {
|
||||
expect(findAllTabs()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders the Agents tab', () => {
|
||||
const agentsTab = findAllTabs().at(0);
|
||||
|
||||
expect(agentsTab.attributes('title')).toBe('Agents');
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('navigates to the correct route when tab is clicked', () => {
|
||||
const agentsTab = findAllTabs().at(0);
|
||||
|
||||
agentsTab.vm.$emit('click');
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({ name: AI_CATALOG_AGENTS_ROUTE });
|
||||
});
|
||||
|
||||
it('does not navigate if already on the same route', () => {
|
||||
createComponent({ routePath: AI_CATALOG_AGENTS_ROUTE });
|
||||
|
||||
const agentsTab = findAllTabs().at(0);
|
||||
|
||||
agentsTab.vm.$emit('click');
|
||||
|
||||
expect(mockRouter.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { createWrapper } from '@vue/test-utils';
|
||||
|
||||
import { initAiCatalog } from '~/ai/catalog/index';
|
||||
import AiCatalogApp from '~/ai/catalog/ai_catalog_app.vue';
|
||||
import * as Router from '~/ai/catalog/router';
|
||||
|
||||
describe('AI Catalog Index', () => {
|
||||
let mockElement;
|
||||
let wrapper;
|
||||
|
||||
const findAiCatalog = () => wrapper.findComponent(AiCatalogApp);
|
||||
|
||||
afterEach(() => {
|
||||
mockElement = null;
|
||||
});
|
||||
|
||||
describe('initAiCatalog', () => {
|
||||
beforeEach(() => {
|
||||
mockElement = document.createElement('div');
|
||||
mockElement.id = 'js-ai-catalog';
|
||||
mockElement.dataset.aiCatalogIndexPath = '/ai/catalog';
|
||||
document.body.appendChild(mockElement);
|
||||
|
||||
jest.spyOn(Router, 'createRouter');
|
||||
|
||||
wrapper = createWrapper(initAiCatalog(`#${mockElement.id}`));
|
||||
});
|
||||
|
||||
it('renders the AiCatalog component', () => {
|
||||
expect(findAiCatalog().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('creates router with correct base path', () => {
|
||||
initAiCatalog();
|
||||
|
||||
expect(Router.createRouter).toHaveBeenCalledWith('/ai/catalog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the element does not exist', () => {
|
||||
it('returns `null`', () => {
|
||||
expect(initAiCatalog('foo')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import PageHeading from '~/vue_shared/components/page_heading.vue';
|
||||
import AiCatalogAgentsShow from '~/ai/catalog/pages/ai_catalog_agents_show.vue';
|
||||
|
||||
describe('AiCatalogAgentsShow', () => {
|
||||
let wrapper;
|
||||
|
||||
const agentId = 732;
|
||||
|
||||
const mockRouter = {
|
||||
push: jest.fn(),
|
||||
};
|
||||
|
||||
const createComponent = () => {
|
||||
wrapper = shallowMount(AiCatalogAgentsShow, {
|
||||
mocks: {
|
||||
$route: {
|
||||
params: { id: agentId },
|
||||
},
|
||||
$router: mockRouter,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const findHeader = () => wrapper.findComponent(PageHeading);
|
||||
|
||||
describe('component initialization', () => {
|
||||
it('renders the page heading', async () => {
|
||||
await createComponent();
|
||||
|
||||
expect(findHeader().props('heading')).toBe(`Edit agent: ${agentId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
|
||||
import AiCatalogAgents from '~/ai/catalog/pages/ai_catalog_agents.vue';
|
||||
import AiCatalogList from '~/ai/catalog/components/ai_catalog_list.vue';
|
||||
|
||||
describe('AiCatalogAgents', () => {
|
||||
let wrapper;
|
||||
|
||||
const mockAgentsData = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test AI Agent 1',
|
||||
model: 'gpt-4',
|
||||
type: 'Assistant',
|
||||
version: 'v1.2.0',
|
||||
description: 'A helpful AI assistant for testing purposes',
|
||||
releasedAt: '2024-01-15T10:30:00Z',
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Test AI Agent 2',
|
||||
model: 'claude-3',
|
||||
type: 'Chatbot',
|
||||
version: 'v1.0.0',
|
||||
description: 'Another AI assistant',
|
||||
releasedAt: '2024-02-10T14:20:00Z',
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Test AI Agent 3',
|
||||
model: 'gemini-pro',
|
||||
type: 'Helper',
|
||||
version: 'v2.1.0',
|
||||
verified: true,
|
||||
},
|
||||
];
|
||||
|
||||
const createComponent = ({ loading = false, mockData = mockAgentsData } = {}) => {
|
||||
wrapper = shallowMountExtended(AiCatalogAgents, {
|
||||
data() {
|
||||
return { aiCatalogAgents: mockData };
|
||||
},
|
||||
mocks: {
|
||||
$apollo: {
|
||||
queries: {
|
||||
aiCatalogAgents: {
|
||||
loading,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return waitForPromises();
|
||||
};
|
||||
|
||||
const findAiCatalogList = () => wrapper.findComponent(AiCatalogList);
|
||||
|
||||
describe('component rendering', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
});
|
||||
|
||||
it('renders AiCatalogList component', () => {
|
||||
const catalogList = findAiCatalogList();
|
||||
|
||||
expect(catalogList.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('passes correct props to AiCatalogList', () => {
|
||||
const catalogList = findAiCatalogList();
|
||||
|
||||
expect(catalogList.props('items')).toEqual(mockAgentsData);
|
||||
expect(catalogList.props('isLoading')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({ loading: true });
|
||||
});
|
||||
|
||||
it('passes loading state to AiCatalogList', () => {
|
||||
const catalogList = findAiCatalogList();
|
||||
|
||||
expect(catalogList.props('isLoading')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with agent data', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
});
|
||||
|
||||
it('passes agent data to AiCatalogList', () => {
|
||||
const catalogList = findAiCatalogList();
|
||||
|
||||
expect(catalogList.props('items')).toEqual(mockAgentsData);
|
||||
expect(catalogList.props('items')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('passes isLoading as false when not loading', () => {
|
||||
const catalogList = findAiCatalogList();
|
||||
|
||||
expect(catalogList.props('isLoading')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -26,10 +26,9 @@ describe('HelpCenter component', () => {
|
|||
const findButton = (name) => withinComponent().getByRole('button', { name });
|
||||
const findNotificationDot = () => wrapper.findByTestId('notification-dot');
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
const createWrapper = (sidebarData, provide = {}) => {
|
||||
const createWrapper = (sidebarDataOverride = sidebarData, provide = {}) => {
|
||||
wrapper = mountExtended(HelpCenter, {
|
||||
propsData: { sidebarData },
|
||||
propsData: { sidebarData: sidebarDataOverride },
|
||||
stubs: { GlEmoji },
|
||||
provide: {
|
||||
isSaas: false,
|
||||
|
|
@ -53,21 +52,21 @@ describe('HelpCenter component', () => {
|
|||
extraAttrs: trackingAttrs('privacy'),
|
||||
};
|
||||
|
||||
const DEFAULT_HELP_ITEMS = [
|
||||
const getDefaultHelpItems = (customSidebarData = sidebarData) => [
|
||||
{ text: HelpCenter.i18n.help, href: helpPagePath(), extraAttrs: trackingAttrs('help') },
|
||||
{
|
||||
text: HelpCenter.i18n.support,
|
||||
href: sidebarData.support_path,
|
||||
href: customSidebarData.support_path,
|
||||
extraAttrs: trackingAttrs('support'),
|
||||
},
|
||||
{
|
||||
text: HelpCenter.i18n.docs,
|
||||
href: sidebarData.docs_path,
|
||||
href: customSidebarData.docs_path,
|
||||
extraAttrs: trackingAttrs('gitlab_documentation'),
|
||||
},
|
||||
{
|
||||
text: HelpCenter.i18n.plans,
|
||||
href: `${PROMO_URL}/pricing`,
|
||||
href: customSidebarData.compare_plans_url,
|
||||
extraAttrs: trackingAttrs('compare_gitlab_plans'),
|
||||
},
|
||||
{
|
||||
|
|
@ -87,15 +86,13 @@ describe('HelpCenter component', () => {
|
|||
},
|
||||
];
|
||||
|
||||
const ALL_HELP_ITEMS = [...DEFAULT_HELP_ITEMS, PRIVACY_HELP_ITEM];
|
||||
|
||||
describe('default', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper(sidebarData);
|
||||
createWrapper();
|
||||
});
|
||||
|
||||
it('renders menu items', () => {
|
||||
expect(findDropdownGroup(0).props('group').items).toEqual(DEFAULT_HELP_ITEMS);
|
||||
expect(findDropdownGroup(0).props('group').items).toEqual(getDefaultHelpItems());
|
||||
|
||||
expect(findDropdownGroup(1).props('group').items).toEqual([
|
||||
expect.objectContaining({ text: HelpCenter.i18n.shortcuts }),
|
||||
|
|
@ -103,16 +100,44 @@ describe('HelpCenter component', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('doesn`t render privacy item if not in `SaaS` mode', () => {
|
||||
createWrapper({ ...sidebarData }, { isSaas: false });
|
||||
it('does not render privacy item if not in SaaS mode', () => {
|
||||
createWrapper(sidebarData, { isSaas: false });
|
||||
|
||||
expect(findDropdownGroup(0).props('group').items).toEqual(DEFAULT_HELP_ITEMS);
|
||||
expect(findDropdownGroup(0).props('group').items).toEqual(getDefaultHelpItems());
|
||||
});
|
||||
|
||||
it('renders privacy item if in `SaaS` mode', () => {
|
||||
createWrapper({ ...sidebarData }, { isSaas: true });
|
||||
it('renders privacy item if in SaaS mode', () => {
|
||||
createWrapper(sidebarData, { isSaas: true });
|
||||
|
||||
expect(findDropdownGroup(0).props('group').items).toEqual(ALL_HELP_ITEMS);
|
||||
expect(findDropdownGroup(0).props('group').items).toEqual([
|
||||
...getDefaultHelpItems(),
|
||||
PRIVACY_HELP_ITEM,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('compare plans URL', () => {
|
||||
it('uses the compare_plans_url provided in sidebarData', () => {
|
||||
const customSidebarData = {
|
||||
...sidebarData,
|
||||
compare_plans_url: '/custom/billing/path',
|
||||
};
|
||||
|
||||
createWrapper(customSidebarData);
|
||||
|
||||
const helpItems = findDropdownGroup(0).props('group').items;
|
||||
const plansItem = helpItems.find((item) => item.text === HelpCenter.i18n.plans);
|
||||
|
||||
expect(plansItem.href).toBe('/custom/billing/path');
|
||||
});
|
||||
|
||||
it('uses the compare_plans_url from sidebarData', () => {
|
||||
createWrapper();
|
||||
|
||||
const helpItems = findDropdownGroup(0).props('group').items;
|
||||
const plansItem = helpItems.find((item) => item.text === HelpCenter.i18n.plans);
|
||||
|
||||
expect(plansItem.href).toBe(sidebarData.compare_plans_url);
|
||||
});
|
||||
});
|
||||
|
||||
it('passes custom offset to the dropdown', () => {
|
||||
|
|
@ -121,9 +146,12 @@ describe('HelpCenter component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('with Gitlab version check feature enabled', () => {
|
||||
describe('with GitLab version check feature enabled', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ ...sidebarData, show_version_check: true });
|
||||
createWrapper({
|
||||
...sidebarData,
|
||||
show_version_check: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows version information as first item', () => {
|
||||
|
|
@ -138,12 +166,17 @@ describe('HelpCenter component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('if Terms of Service and Data Privacy is set', () => {
|
||||
describe('when Terms of Service and Data Privacy is set', () => {
|
||||
it('shows link to Terms of Service and Data Privacy', () => {
|
||||
createWrapper({ ...sidebarData, terms: '/-/users/terms' });
|
||||
const customSidebarData = {
|
||||
...sidebarData,
|
||||
terms: '/-/users/terms',
|
||||
};
|
||||
|
||||
createWrapper(customSidebarData);
|
||||
|
||||
expect(findDropdownGroup(0).props('group').items).toEqual([
|
||||
...DEFAULT_HELP_ITEMS,
|
||||
...getDefaultHelpItems(customSidebarData),
|
||||
expect.objectContaining({
|
||||
text: HelpCenter.i18n.terms,
|
||||
href: '/-/users/terms',
|
||||
|
|
@ -155,18 +188,26 @@ describe('HelpCenter component', () => {
|
|||
});
|
||||
|
||||
it('does not show link to Terms of Service and Data Privacy on SaaS even if it is set', () => {
|
||||
createWrapper({ ...sidebarData, terms: '/-/users/terms' }, { isSaas: true });
|
||||
const customSidebarData = {
|
||||
...sidebarData,
|
||||
terms: '/-/users/terms',
|
||||
};
|
||||
|
||||
createWrapper(customSidebarData, { isSaas: true });
|
||||
|
||||
expect(findDropdownGroup(0).props('group').items).toEqual([
|
||||
...DEFAULT_HELP_ITEMS,
|
||||
...getDefaultHelpItems(customSidebarData),
|
||||
PRIVACY_HELP_ITEM,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('If Terms of Service and Data Privacy is undefined', () => {
|
||||
describe('when Terms of Service and Data Privacy is undefined', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ ...sidebarData, terms: undefined });
|
||||
createWrapper({
|
||||
...sidebarData,
|
||||
terms: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show link to Terms of Service and Data Privacy', () => {
|
||||
|
|
@ -177,7 +218,7 @@ describe('HelpCenter component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('showKeyboardShortcuts', () => {
|
||||
describe('keyboard shortcuts', () => {
|
||||
let button;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -185,12 +226,10 @@ describe('HelpCenter component', () => {
|
|||
});
|
||||
|
||||
it('shows the keyboard shortcuts modal', () => {
|
||||
// This relies on the event delegation set up by the Shortcuts class in
|
||||
// ~/behaviors/shortcuts/shortcuts.js.
|
||||
expect(button.classList.contains('js-shortcuts-modal-trigger')).toBe(true);
|
||||
});
|
||||
|
||||
it('should have Snowplow tracking attributes', () => {
|
||||
it('has Snowplow tracking attributes', () => {
|
||||
expect(findButton('Keyboard shortcuts').dataset).toEqual(
|
||||
expect.objectContaining({
|
||||
trackAction: 'click_button',
|
||||
|
|
@ -201,25 +240,31 @@ describe('HelpCenter component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('showWhatsNew', () => {
|
||||
describe("What's new", () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ ...sidebarData, show_version_check: true });
|
||||
createWrapper({
|
||||
...sidebarData,
|
||||
show_version_check: true,
|
||||
});
|
||||
|
||||
findButton("What's new").click();
|
||||
});
|
||||
|
||||
it('shows the "What\'s new" slideout', () => {
|
||||
it("shows the What's new slideout", () => {
|
||||
expect(toggleWhatsNewDrawer).toHaveBeenCalledWith(sidebarData.whats_new_version_digest);
|
||||
});
|
||||
|
||||
it('shows the existing "What\'s new" slideout instance on subsequent clicks', () => {
|
||||
it("shows the existing What's new slideout instance on subsequent clicks", () => {
|
||||
findButton("What's new").click();
|
||||
expect(toggleWhatsNewDrawer).toHaveBeenCalledTimes(2);
|
||||
expect(toggleWhatsNewDrawer).toHaveBeenLastCalledWith();
|
||||
});
|
||||
|
||||
it('should have Snowplow tracking attributes', () => {
|
||||
createWrapper({ ...sidebarData, display_whats_new: true });
|
||||
it('has Snowplow tracking attributes', () => {
|
||||
createWrapper({
|
||||
...sidebarData,
|
||||
display_whats_new: true,
|
||||
});
|
||||
|
||||
expect(findButton("What's new").dataset).toEqual(
|
||||
expect.objectContaining({
|
||||
|
|
@ -231,10 +276,13 @@ describe('HelpCenter component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('shouldShowWhatsNewNotification', () => {
|
||||
describe("What's new notification", () => {
|
||||
describe('when setting is disabled', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ ...sidebarData, display_whats_new: false });
|
||||
createWrapper({
|
||||
...sidebarData,
|
||||
display_whats_new: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render notification dot', () => {
|
||||
|
|
@ -246,14 +294,17 @@ describe('HelpCenter component', () => {
|
|||
useLocalStorageSpy();
|
||||
|
||||
beforeEach(() => {
|
||||
createWrapper({ ...sidebarData, display_whats_new: true });
|
||||
createWrapper({
|
||||
...sidebarData,
|
||||
display_whats_new: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders notification dot', () => {
|
||||
expect(findNotificationDot().exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('when "What\'s new" drawer got opened', () => {
|
||||
describe("when What's new drawer is opened", () => {
|
||||
beforeEach(() => {
|
||||
findButton("What's new").click();
|
||||
});
|
||||
|
|
@ -266,7 +317,10 @@ describe('HelpCenter component', () => {
|
|||
describe('with matching version digest in local storage', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.setItem(STORAGE_KEY, 1);
|
||||
createWrapper({ ...sidebarData, display_whats_new: true });
|
||||
createWrapper({
|
||||
...sidebarData,
|
||||
display_whats_new: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render notification dot', () => {
|
||||
|
|
@ -276,8 +330,8 @@ describe('HelpCenter component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('toggle dropdown', () => {
|
||||
it('should track Snowplow event when dropdown is shown', () => {
|
||||
describe('dropdown toggle', () => {
|
||||
it('tracks Snowplow event when dropdown is shown', () => {
|
||||
findDropdown().vm.$emit('shown');
|
||||
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', {
|
||||
label: 'show_help_dropdown',
|
||||
|
|
@ -285,7 +339,7 @@ describe('HelpCenter component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should track Snowplow event when dropdown is hidden', () => {
|
||||
it('tracks Snowplow event when dropdown is hidden', () => {
|
||||
findDropdown().vm.$emit('hidden');
|
||||
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', {
|
||||
label: 'hide_help_dropdown',
|
||||
|
|
|
|||
|
|
@ -764,6 +764,16 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#compare_plans_url' do
|
||||
before do
|
||||
allow(helper).to receive(:promo_url).and_return('https://about.gitlab.com')
|
||||
end
|
||||
|
||||
it 'always returns the pricing page URL' do
|
||||
expect(helper.compare_plans_url(user: nil, group: nil, project: nil)).to eq('https://about.gitlab.com/pricing')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#project_sidebar_context_data' do
|
||||
# Testing this private method because:
|
||||
# 1. This helper is just so complex that it isn't feasible to test everything through the few public methods.
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Sidebars::Explore::Menus::AiCatalogMenu, feature_category: :navigation do
|
||||
let_it_be(:current_user) { build(:user) }
|
||||
let_it_be(:user) { build(:user) }
|
||||
|
||||
let(:context) { Sidebars::Context.new(current_user: current_user, container: user) }
|
||||
|
||||
subject(:menu_item) { described_class.new(context) }
|
||||
|
||||
describe '#link' do
|
||||
it 'matches the expected path pattern' do
|
||||
expect(menu_item.link).to match %r{explore/ai-catalog}
|
||||
end
|
||||
end
|
||||
|
||||
describe '#title' do
|
||||
it 'returns the correct title' do
|
||||
expect(menu_item.title).to eq 'AI Catalog'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sprite_icon' do
|
||||
it 'returns the correct icon' do
|
||||
expect(menu_item.sprite_icon).to eq 'tanuki-ai'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active_routes' do
|
||||
it 'returns the correct active routes' do
|
||||
expect(menu_item.active_routes).to eq({ controller: ['explore/ai_catalog'] })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#render?' do
|
||||
it 'renders the menu' do
|
||||
expect(menu_item.render?).to be(true)
|
||||
end
|
||||
|
||||
context 'when global_ai_catalog feature flag is disabled' do
|
||||
before do
|
||||
stub_feature_flags(global_ai_catalog: false)
|
||||
end
|
||||
|
||||
it 'does not render the menu' do
|
||||
expect(menu_item.render?).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'feature flag integration' do
|
||||
it 'calls Feature.enabled? with correct parameters' do
|
||||
expect(Feature).to receive(:enabled?).with(:global_ai_catalog, current_user)
|
||||
|
||||
menu_item.render?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Explore::AiCatalogController, feature_category: :duo_workflow do
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
describe 'GET #index' do
|
||||
let(:path) { explore_ai_catalog_path }
|
||||
|
||||
before do
|
||||
stub_feature_flags(global_ai_catalog: true)
|
||||
end
|
||||
|
||||
context 'when user is signed in' do
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'responds with success' do
|
||||
get path
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'renders the index template' do
|
||||
get path
|
||||
|
||||
expect(response).to render_template('index')
|
||||
end
|
||||
|
||||
it 'uses the explore layout' do
|
||||
get path
|
||||
|
||||
expect(response).to render_template(layout: 'explore')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not signed in' do
|
||||
it 'responds with success' do
|
||||
get path
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'renders the index template' do
|
||||
get path
|
||||
|
||||
expect(response).to render_template('index')
|
||||
end
|
||||
|
||||
it 'uses the explore layout' do
|
||||
get path
|
||||
|
||||
expect(response).to render_template(layout: 'explore')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when public visibility is restricted' do
|
||||
before do
|
||||
stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC])
|
||||
end
|
||||
|
||||
context 'when user is signed in' do
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'responds with success' do
|
||||
get path
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'renders the index template' do
|
||||
get path
|
||||
|
||||
expect(response).to render_template('index')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not signed in' do
|
||||
it 'redirects to login page' do
|
||||
get path
|
||||
|
||||
expect(response).to redirect_to new_user_session_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when global_ai_catalog feature flag is disabled' do
|
||||
before do
|
||||
stub_feature_flags(global_ai_catalog: false)
|
||||
end
|
||||
|
||||
context 'when user is signed in' do
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'renders 404' do
|
||||
get path
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not signed in' do
|
||||
it 'renders 404' do
|
||||
get path
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when global_ai_catalog feature flag is enabled for specific user' do
|
||||
let_it_be(:enabled_user) { create(:user) }
|
||||
let_it_be(:disabled_user) { create(:user) }
|
||||
|
||||
before do
|
||||
stub_feature_flags(global_ai_catalog: enabled_user)
|
||||
end
|
||||
|
||||
context 'when enabled user is signed in' do
|
||||
before do
|
||||
sign_in(enabled_user)
|
||||
end
|
||||
|
||||
it 'responds with success' do
|
||||
get path
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'renders the index template' do
|
||||
get path
|
||||
|
||||
expect(response).to render_template('index')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when disabled user is signed in' do
|
||||
before do
|
||||
sign_in(disabled_user)
|
||||
end
|
||||
|
||||
it 'renders 404' do
|
||||
get path
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not signed in' do
|
||||
it 'renders 404' do
|
||||
get path
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -302,10 +302,14 @@ RSpec.shared_context '"Explore" navbar structure' do
|
|||
nav_item: _("CI/CD Catalog"),
|
||||
nav_sub_items: []
|
||||
},
|
||||
{
|
||||
nav_item: s_("AICatalog|AI Catalog"),
|
||||
nav_sub_items: []
|
||||
},
|
||||
|
||||
if Gitlab.ee?
|
||||
{
|
||||
nav_item: s_("AICatalog|AI Catalog"),
|
||||
nav_sub_items: []
|
||||
}
|
||||
end,
|
||||
|
||||
{
|
||||
nav_item: _("Topics"),
|
||||
nav_sub_items: []
|
||||
|
|
|
|||
|
|
@ -276,85 +276,4 @@ RSpec.shared_examples 'namespace traversal scopes' do
|
|||
it { is_expected.to contain_exactly(group_1, nested_group_1, deep_nested_group_1) }
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples '.within' do
|
||||
context 'with a root group traversal_ids' do
|
||||
subject { described_class.where(id: groups).within(group_1.traversal_ids) }
|
||||
|
||||
it 'returns the group and all its descendants' do
|
||||
is_expected.to contain_exactly(group_1, nested_group_1, deep_nested_group_1)
|
||||
end
|
||||
|
||||
it 'excludes groups from other hierarchies' do
|
||||
is_expected.not_to include(group_2, nested_group_2, deep_nested_group_2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a nested group traversal_ids' do
|
||||
subject { described_class.where(id: groups).within(nested_group_1.traversal_ids) }
|
||||
|
||||
it 'returns the nested group and its descendants' do
|
||||
is_expected.to contain_exactly(nested_group_1, deep_nested_group_1)
|
||||
end
|
||||
|
||||
it 'excludes the parent group' do
|
||||
is_expected.not_to include(group_1)
|
||||
end
|
||||
|
||||
it 'excludes groups from other hierarchies' do
|
||||
is_expected.not_to include(group_2, nested_group_2, deep_nested_group_2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple sibling groups' do
|
||||
let!(:nested_group_1b) { create(:group, parent: group_1) }
|
||||
let!(:deep_nested_group_1b) { create(:group, parent: nested_group_1b) }
|
||||
let(:all_groups) { groups + [nested_group_1b, deep_nested_group_1b] }
|
||||
|
||||
subject { described_class.where(id: all_groups).within(group_1.traversal_ids) }
|
||||
|
||||
it 'returns all descendants within the hierarchy' do
|
||||
is_expected.to contain_exactly(
|
||||
group_1, nested_group_1, deep_nested_group_1, nested_group_1b, deep_nested_group_1b)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with offset and limit' do
|
||||
subject do
|
||||
described_class
|
||||
.where(id: [group_1, nested_group_1, deep_nested_group_1])
|
||||
.order(:traversal_ids)
|
||||
.limit(2)
|
||||
.within(group_1.traversal_ids)
|
||||
end
|
||||
|
||||
it 'respects the limit while maintaining within logic' do
|
||||
expect(subject.count).to eq(2)
|
||||
is_expected.to be_all { |group| [group_1, nested_group_1, deep_nested_group_1].include?(group) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty result set' do
|
||||
subject { described_class.where(id: groups).within([999, 999]) }
|
||||
|
||||
it 'returns empty result' do
|
||||
is_expected.to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'SQL injection prevention' do
|
||||
it 'raises ArgumentError for malicious SQL input' do
|
||||
expect { described_class.within(["1'; DROP TABLE namespaces; --"]) }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.within' do
|
||||
include_examples '.within'
|
||||
|
||||
it 'does not make recursive queries' do
|
||||
expect { described_class.where(id: [nested_group_1]).within(nested_group_1.traversal_ids).load }
|
||||
.not_to make_queries_matching(/WITH RECURSIVE/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -226,6 +226,30 @@ RSpec.describe JsonSchemaValidator, feature_category: :shared do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when size_limit is specified' do
|
||||
let(:validator) { described_class.new(attributes: [:data], filename: "build_report_result_data", size_limit: 10.kilobytes) }
|
||||
|
||||
context 'when data exceeds size limit' do
|
||||
it 'returns size error message' do
|
||||
build_report_result.data = { large_field: 'x' * 15000 }
|
||||
|
||||
subject
|
||||
|
||||
expect(build_report_result.errors.size).to eq(1)
|
||||
expect(build_report_result.errors.full_messages.first).to include("is too large")
|
||||
expect(build_report_result.errors.full_messages.first).to include("10 KiB")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when data is within size limit' do
|
||||
it 'validates schema normally' do
|
||||
subject
|
||||
|
||||
expect(build_report_result.errors).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#schema' do
|
||||
|
|
|
|||
Loading…
Reference in New Issue