Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-06-27 03:12:01 +00:00
parent 5506a7c09b
commit 270e08fdb1
39 changed files with 188 additions and 1313 deletions

View File

@ -1 +1 @@
6119a6d998d6c8a7f2bdfcf113068c7a00feef08
1ed908c7dd2f4daef8f29d9f88461b81d16559ea

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,14 +0,0 @@
query aiCatalogAgents {
aiCatalogAgents @client {
nodes {
id
type
name
description
model
verified
version
releasedAt
}
}
}

View File

@ -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);
},
});
};

View File

@ -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>

View File

@ -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>

View File

@ -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';

View File

@ -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,
},
],
});
};

View File

@ -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';

View File

@ -1,3 +0,0 @@
import { initAiCatalog } from '~/ai/catalog/';
initAiCatalog();

View File

@ -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'),
},

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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']

View File

@ -1,3 +0,0 @@
- page_title s_('AICatalog|AI Catalog')
#js-ai-catalog{ data: { ai_catalog_index_path: explore_ai_catalog_path } }

View File

@ -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

View File

@ -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).

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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();
});
});
});

View File

@ -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();
});
});
});

View File

@ -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}`);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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',

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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: []

View File

@ -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

View File

@ -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