diff --git a/GITLAB_KAS_VERSION b/GITLAB_KAS_VERSION index f0b1dc3839f..bd1a31b8929 100644 --- a/GITLAB_KAS_VERSION +++ b/GITLAB_KAS_VERSION @@ -1 +1 @@ -6119a6d998d6c8a7f2bdfcf113068c7a00feef08 +1ed908c7dd2f4daef8f29d9f88461b81d16559ea diff --git a/app/assets/javascripts/ai/catalog/ai_catalog_app.vue b/app/assets/javascripts/ai/catalog/ai_catalog_app.vue deleted file mode 100644 index 9ade4051dd6..00000000000 --- a/app/assets/javascripts/ai/catalog/ai_catalog_app.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - diff --git a/app/assets/javascripts/ai/catalog/components/ai_catalog_list.vue b/app/assets/javascripts/ai/catalog/components/ai_catalog_list.vue deleted file mode 100644 index b09c54e36bb..00000000000 --- a/app/assets/javascripts/ai/catalog/components/ai_catalog_list.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - diff --git a/app/assets/javascripts/ai/catalog/components/ai_catalog_list_item.vue b/app/assets/javascripts/ai/catalog/components/ai_catalog_list_item.vue deleted file mode 100644 index 0c8857ecc48..00000000000 --- a/app/assets/javascripts/ai/catalog/components/ai_catalog_list_item.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - {{ item.model }} - - - - - - {{ item.name }} - - {{ item.type }} - - {{ item.version }} - - - - - {{ item.description }} - - - - diff --git a/app/assets/javascripts/ai/catalog/components/ai_catalog_nav_tabs.vue b/app/assets/javascripts/ai/catalog/components/ai_catalog_nav_tabs.vue deleted file mode 100644 index 7ed7a2d7510..00000000000 --- a/app/assets/javascripts/ai/catalog/components/ai_catalog_nav_tabs.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - diff --git a/app/assets/javascripts/ai/catalog/graphql/ai_catalog_agents.query.graphql b/app/assets/javascripts/ai/catalog/graphql/ai_catalog_agents.query.graphql deleted file mode 100644 index 01d6512a9de..00000000000 --- a/app/assets/javascripts/ai/catalog/graphql/ai_catalog_agents.query.graphql +++ /dev/null @@ -1,14 +0,0 @@ -query aiCatalogAgents { - aiCatalogAgents @client { - nodes { - id - type - name - description - model - verified - version - releasedAt - } - } -} diff --git a/app/assets/javascripts/ai/catalog/index.js b/app/assets/javascripts/ai/catalog/index.js deleted file mode 100644 index e2f890ca6e1..00000000000 --- a/app/assets/javascripts/ai/catalog/index.js +++ /dev/null @@ -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); - }, - }); -}; diff --git a/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue b/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue deleted file mode 100644 index eaa8e82f3d4..00000000000 --- a/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - - - diff --git a/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents_show.vue b/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents_show.vue deleted file mode 100644 index 3e824d279f2..00000000000 --- a/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents_show.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/assets/javascripts/ai/catalog/router/constants.js b/app/assets/javascripts/ai/catalog/router/constants.js deleted file mode 100644 index 1c9a007c2d8..00000000000 --- a/app/assets/javascripts/ai/catalog/router/constants.js +++ /dev/null @@ -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'; diff --git a/app/assets/javascripts/ai/catalog/router/index.js b/app/assets/javascripts/ai/catalog/router/index.js deleted file mode 100644 index 700ad4683d4..00000000000 --- a/app/assets/javascripts/ai/catalog/router/index.js +++ /dev/null @@ -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, - }, - ], - }); -}; diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 683f14c0878..a49aa3500e0 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -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'; diff --git a/app/assets/javascripts/pages/explore/ai_catalog/index.js b/app/assets/javascripts/pages/explore/ai_catalog/index.js deleted file mode 100644 index cc2c37a7b92..00000000000 --- a/app/assets/javascripts/pages/explore/ai_catalog/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { initAiCatalog } from '~/ai/catalog/'; - -initAiCatalog(); diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue index 1b61b35cd67..ba4ac7d3164 100644 --- a/app/assets/javascripts/super_sidebar/components/help_center.vue +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -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'), }, diff --git a/app/controllers/explore/ai_catalog_controller.rb b/app/controllers/explore/ai_catalog_controller.rb deleted file mode 100644 index 5c4119a5519..00000000000 --- a/app/controllers/explore/ai_catalog_controller.rb +++ /dev/null @@ -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 diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index fb7fc30a887..5feb81a9dc4 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -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 diff --git a/app/models/namespace.rb b/app/models/namespace.rb index c792f649344..bdeb5467a68 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -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 diff --git a/app/models/namespaces/traversal/traversable.rb b/app/models/namespaces/traversal/traversable.rb deleted file mode 100644 index 271746bd812..00000000000 --- a/app/models/namespaces/traversal/traversable.rb +++ /dev/null @@ -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 diff --git a/app/validators/json_schema_validator.rb b/app/validators/json_schema_validator.rb index 52780671ad6..068df308e89 100644 --- a/app/validators/json_schema_validator.rb +++ b/app/validators/json_schema_validator.rb @@ -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'] diff --git a/app/views/explore/ai_catalog/index.html.haml b/app/views/explore/ai_catalog/index.html.haml deleted file mode 100644 index d46b9a1d71a..00000000000 --- a/app/views/explore/ai_catalog/index.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -- page_title s_('AICatalog|AI Catalog') - -#js-ai-catalog{ data: { ai_catalog_index_path: explore_ai_catalog_path } } diff --git a/config/routes/explore.rb b/config/routes/explore.rb index 95959bc0325..7716015baba 100644 --- a/config/routes/explore.rb +++ b/config/routes/explore.rb @@ -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 diff --git a/doc/administration/gitaly/kubernetes.md b/doc/administration/gitaly/kubernetes.md index 5a58e7cf2bd..ca9f3e92f0c 100644 --- a/doc/administration/gitaly/kubernetes.md +++ b/doc/administration/gitaly/kubernetes.md @@ -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). diff --git a/doc/development/documentation/testing/_index.md b/doc/development/documentation/testing/_index.md index 3d6d4938d87..6873c950e98 100644 --- a/doc/development/documentation/testing/_index.md +++ b/doc/development/documentation/testing/_index.md @@ -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` `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` `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 diff --git a/lib/sidebars/explore/menus/ai_catalog_menu.rb b/lib/sidebars/explore/menus/ai_catalog_menu.rb deleted file mode 100644 index be7c055f83b..00000000000 --- a/lib/sidebars/explore/menus/ai_catalog_menu.rb +++ /dev/null @@ -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 diff --git a/lib/sidebars/explore/panel.rb b/lib/sidebars/explore/panel.rb index 970719e3d3e..a50e18fd0ff 100644 --- a/lib/sidebars/explore/panel.rb +++ b/lib/sidebars/explore/panel.rb @@ -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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ee92375f02e..79632d7479a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/frontend/ai/catalog/components/ai_catalog_list_item_spec.js b/spec/frontend/ai/catalog/components/ai_catalog_list_item_spec.js deleted file mode 100644 index dcdc0c6645e..00000000000 --- a/spec/frontend/ai/catalog/components/ai_catalog_list_item_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/spec/frontend/ai/catalog/components/ai_catalog_list_spec.js b/spec/frontend/ai/catalog/components/ai_catalog_list_spec.js deleted file mode 100644 index e008775fdd5..00000000000 --- a/spec/frontend/ai/catalog/components/ai_catalog_list_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/spec/frontend/ai/catalog/components/ai_catalog_nav_tabs_spec.js b/spec/frontend/ai/catalog/components/ai_catalog_nav_tabs_spec.js deleted file mode 100644 index 6903e1b64da..00000000000 --- a/spec/frontend/ai/catalog/components/ai_catalog_nav_tabs_spec.js +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/spec/frontend/ai/catalog/index_spec.js b/spec/frontend/ai/catalog/index_spec.js deleted file mode 100644 index ab4ddeca4a8..00000000000 --- a/spec/frontend/ai/catalog/index_spec.js +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/spec/frontend/ai/catalog/pages/ai_catalog_agents_show_spec.js b/spec/frontend/ai/catalog/pages/ai_catalog_agents_show_spec.js deleted file mode 100644 index a58b2c01047..00000000000 --- a/spec/frontend/ai/catalog/pages/ai_catalog_agents_show_spec.js +++ /dev/null @@ -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}`); - }); - }); -}); diff --git a/spec/frontend/ai/catalog/pages/ai_catalog_agents_spec.js b/spec/frontend/ai/catalog/pages/ai_catalog_agents_spec.js deleted file mode 100644 index c49396b427a..00000000000 --- a/spec/frontend/ai/catalog/pages/ai_catalog_agents_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js index 8792fa2f38b..4cbadc24bc4 100644 --- a/spec/frontend/super_sidebar/components/help_center_spec.js +++ b/spec/frontend/super_sidebar/components/help_center_spec.js @@ -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', diff --git a/spec/helpers/sidebars_helper_spec.rb b/spec/helpers/sidebars_helper_spec.rb index 1f62cda5959..3ae0f84b04e 100644 --- a/spec/helpers/sidebars_helper_spec.rb +++ b/spec/helpers/sidebars_helper_spec.rb @@ -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. diff --git a/spec/lib/sidebars/explore/menus/ai_catalog_menu_spec.rb b/spec/lib/sidebars/explore/menus/ai_catalog_menu_spec.rb deleted file mode 100644 index 906ce875021..00000000000 --- a/spec/lib/sidebars/explore/menus/ai_catalog_menu_spec.rb +++ /dev/null @@ -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 diff --git a/spec/requests/explore/ai_catalog_controller_spec.rb b/spec/requests/explore/ai_catalog_controller_spec.rb deleted file mode 100644 index 38d5c6a6509..00000000000 --- a/spec/requests/explore/ai_catalog_controller_spec.rb +++ /dev/null @@ -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 diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index a46d2ba7336..0037bac9052 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -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: [] diff --git a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb index 5104ccfb918..83507bd7877 100644 --- a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb +++ b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb @@ -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 diff --git a/spec/validators/json_schema_validator_spec.rb b/spec/validators/json_schema_validator_spec.rb index 4c6a0cd6587..7b3344d7d0e 100644 --- a/spec/validators/json_schema_validator_spec.rb +++ b/spec/validators/json_schema_validator_spec.rb @@ -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