feat: dead-config sweep, test suite foundation, widget settingsSchema validation
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Sprint G: audited every BootstrapConfig field for a real runtime consumer
(docs/DEAD-CONFIG-AUDIT.md). Wired 3 previously-dead editable fields:
footer.logoUrl, company.address.street/contacts.phone, catalog.suggestionsEnabled.
Remaining dead fields needing a business/design decision tracked in
PRODUCT_BACKLOG.md/KNOWN-ISSUES.md, not silently left.

Sprint H: 6 new spec files (test count 57 -> 83), covering ProjectEditorFacade
(undo/redo, draft persistence, publish gating), AdminAnalyticsFacade
(never-fabricate-a-number contract), and regression coverage for this
session's carousel/hero/profile-toggle fixes.

Sprint I: widget settingsSchema (declared in widget-manifest.json, never
validated) now enforced via a new lightweight schema check in
ProjectValidator, surfaced through the existing issuesByField pipeline.
Same check reused in diagnostics so editor and diagnostics can't disagree.

Verification: tsc clean, ng build clean, 83/83 tests pass, barry-cache
validate clean (2 pre-existing unrelated warnings only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-05 20:47:13 +04:00
parent 6f9401fa8f
commit ce63931bc2
25 changed files with 814 additions and 25 deletions

View File

@@ -0,0 +1,98 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AdminAnalyticsFacade } from './admin-analytics.facade';
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
import { AdminProductsLocalGateway } from '../../products/services/admin-products-local.gateway';
import { ADMIN_CATEGORIES_GATEWAY } from '../../categories/services/admin-categories-gateway.token';
import { AdminModerationLocalGateway } from '../../moderation/services/admin-moderation-local.gateway';
import { AdminDashboardFacade } from '../../dashboard/facade/admin-dashboard.facade';
import { AdminOrder } from '../../orders/models/admin-order.model';
function makeOrder(overrides: Partial<AdminOrder> = {}): AdminOrder {
return {
id: 'order-1',
createdAt: new Date().toISOString(),
status: 'completed',
total: 100,
currency: 'RUB',
customer: { email: 'buyer@example.com' },
items: [{ productId: 'p1', name: 'Widget', quantity: 1, price: 100 }],
...overrides,
} as unknown as AdminOrder;
}
describe('AdminAnalyticsFacade (never-fabricate-a-number contract)', () => {
let facade: AdminAnalyticsFacade;
let ordersGateway: jasmine.SpyObj<AdminOrdersLocalGateway>;
let productsGateway: jasmine.SpyObj<AdminProductsLocalGateway>;
let categoriesGateway: jasmine.SpyObj<{ loadCategories: () => unknown }>;
let moderationGateway: jasmine.SpyObj<AdminModerationLocalGateway>;
let dashboardFacade: jasmine.SpyObj<AdminDashboardFacade>;
function configure(bootstrapPresent: boolean): void {
ordersGateway = jasmine.createSpyObj('AdminOrdersLocalGateway', ['loadOrders']);
productsGateway = jasmine.createSpyObj('AdminProductsLocalGateway', ['loadProducts']);
categoriesGateway = jasmine.createSpyObj('ADMIN_CATEGORIES_GATEWAY', ['loadCategories']);
moderationGateway = jasmine.createSpyObj('AdminModerationLocalGateway', ['loadReviews']);
dashboardFacade = jasmine.createSpyObj('AdminDashboardFacade', [
'ensureLoaded', 'activityEntries', 'bootstrap', 'validationIssues', 'enabledWidgetsCount', 'staticPagesUnpublishedCount',
]);
ordersGateway.loadOrders.and.returnValue(of({ items: [makeOrder()], total: 1 } as any));
productsGateway.loadProducts.and.returnValue(of({ items: [], total: 0 } as any));
categoriesGateway.loadCategories.and.returnValue(of([]));
moderationGateway.loadReviews.and.returnValue(of({ items: [], total: 0 } as any));
dashboardFacade.activityEntries.and.returnValue([]);
dashboardFacade.bootstrap.and.returnValue(bootstrapPresent ? ({ schemaVersion: '1', tenant: { id: 't1' } } as any) : null);
dashboardFacade.validationIssues.and.returnValue([]);
dashboardFacade.enabledWidgetsCount.and.returnValue(0);
dashboardFacade.staticPagesUnpublishedCount.and.returnValue(0);
TestBed.configureTestingModule({
providers: [
{ provide: AdminOrdersLocalGateway, useValue: ordersGateway },
{ provide: AdminProductsLocalGateway, useValue: productsGateway },
{ provide: ADMIN_CATEGORIES_GATEWAY, useValue: categoriesGateway },
{ provide: AdminModerationLocalGateway, useValue: moderationGateway },
{ provide: AdminDashboardFacade, useValue: dashboardFacade },
],
});
facade = TestBed.inject(AdminAnalyticsFacade);
}
it('never fabricates conversionRate - stays null even with real order data', () => {
configure(true);
facade.load();
expect(facade.summary()?.conversionRate).toBeNull();
expect(facade.summary()?.revenueTotal).toBe(100);
expect(facade.summary()?.ordersCount).toBe(1);
});
it('never fabricates the "performance" health check - always unknown (no real data source)', () => {
configure(true);
facade.load();
const performance = facade.marketplaceHealth().find(check => check.code === 'performance');
expect(performance?.status).toBe('unknown');
});
it('reports backend-connectivity and homepage-configured as unknown when bootstrap has not loaded, instead of guessing', () => {
configure(false);
facade.load();
const backend = facade.marketplaceHealth().find(check => check.code === 'backend-connectivity');
const homepage = facade.marketplaceHealth().find(check => check.code === 'homepage-configured');
expect(backend?.status).toBe('unknown');
expect(homepage?.status).toBe('unknown');
});
it('reports backend-connectivity as healthy only once bootstrap is actually present', () => {
configure(true);
facade.load();
const backend = facade.marketplaceHealth().find(check => check.code === 'backend-connectivity');
expect(backend?.status).toBe('healthy');
});
});

View File

@@ -1,6 +1,7 @@
import { TranslateService } from '../../../i18n/translate.service';
import { BootstrapConfig } from '../../../shared/models/config';
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
import { validateAgainstSchemaLite } from '../../project-editor/schema/validators/primitives';
import { DiagnosticEntry } from '../models/diagnostics.model';
const KNOWN_LAYOUTS = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
@@ -28,6 +29,7 @@ export class BootstrapDiagnosticsValidator {
this.validateRequiredProperties(bootstrap, entries);
this.validateUnknownWidgetTypes(bootstrap, manifest, entries);
this.validateWidgetSettingsSchema(bootstrap, manifest, entries);
this.validateDuplicateIds(bootstrap, entries);
this.validateLayouts(bootstrap, entries);
this.validateFeatureFlags(bootstrap, entries);
@@ -85,6 +87,36 @@ export class BootstrapDiagnosticsValidator {
}
}
/**
* Reuses the same shallow schema check as ProjectValidator.widgetSettingsSchemaIssues
* (`validateAgainstSchemaLite`) so the editor and the diagnostics page never
* disagree about what "valid widget settings" means - one check, two surfaces.
*/
private validateWidgetSettingsSchema(bootstrap: BootstrapConfig, manifest: WidgetManifestFile | null, entries: DiagnosticEntry[]): void {
const manifestByType = new Map((manifest?.widgets ?? []).map(widget => [widget.type, widget]));
for (const page of bootstrap.pages ?? []) {
for (const section of page.sections ?? []) {
for (const widget of section.widgets ?? []) {
const entry = manifestByType.get(widget.type);
if (!entry?.settingsSchema || !widget.props || typeof widget.props !== 'object') {
continue;
}
const errors = validateAgainstSchemaLite(widget.props, entry.settingsSchema);
if (errors.length > 0) {
entries.push({
code: 'BOOTSTRAP_WIDGET_SETTINGS_SCHEMA_MISMATCH',
severity: 'warning',
title: 'diagnostics.widgetSettingsSchemaMismatchTitle',
description: `diagnostics.widgetSettingsSchemaMismatchDescription:${widget.type}(${errors.join(', ')})`,
affectedComponent: `page:${page.key}/section:${section.id}/widget:${widget.id}`,
suggestedResolution: 'diagnostics.widgetSettingsSchemaMismatchResolution'
});
}
}
}
}
}
private validateDuplicateIds(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
const pageIds = new Set<string>();

View File

@@ -0,0 +1,149 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { BootstrapConfig } from '../../../shared/models/config';
import { CONFIG_PROVIDER } from '../../../core/config/config-provider.token';
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
import { ProjectEditorFacade } from './project-editor.facade';
function makeBootstrap(): BootstrapConfig {
return {
tenant: { id: 'tenant-1', defaultLocale: 'en', supportedLocales: ['en'], websiteBaseUrl: 'https://dexar.market' },
branding: { logoUrl: 'logo.png' },
localization: { supportedLocales: ['en'], defaultLocale: 'en', currencyByLocale: {}, dictionaries: [] },
staticPages: {},
pages: [
{
key: 'home',
route: { path: '/' },
sections: [
{
order: 0,
layout: { strategy: 'stack' },
widgets: [{ id: 'w1', type: 'hero', version: '1', props: {} }],
},
],
},
],
navigation: { header: [], footer: [] },
theme: {
palette: {
primary: '#497671', secondary: '#a1b4b5', accent: '#a7ceca',
success: '#10b981', warning: '#f59e0b', danger: '#ef4444', info: '#3b82f6',
textPrimary: '#1e3c38', textSecondary: '#667a77',
backgroundPrimary: '#ffffff', backgroundSecondary: '#f5f5f5', border: '#d3dad9',
},
},
layout: { type: 'default' },
} as unknown as BootstrapConfig;
}
describe('ProjectEditorFacade', () => {
let facade: ProjectEditorFacade;
let runtimeSpy: jasmine.SpyObj<PlatformRuntimeService>;
beforeEach(() => {
localStorage.clear();
runtimeSpy = jasmine.createSpyObj<PlatformRuntimeService>('PlatformRuntimeService', ['reloadFromBootstrap']);
TestBed.configureTestingModule({
providers: [
provideRouter([]), provideHttpClient(), provideHttpClientTesting(),
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
{ provide: PlatformRuntimeService, useValue: runtimeSpy },
],
});
facade = TestBed.inject(ProjectEditorFacade);
facade.loadBootstrap();
});
afterEach(() => localStorage.clear());
it('loads the bootstrap and starts with a clean undo/redo stack', () => {
expect(facade.bootstrap()).toBeTruthy();
expect(facade.canUndo()).toBeFalse();
expect(facade.canRedo()).toBeFalse();
});
describe('undo/redo', () => {
it('undo reverts the last edit and enables redo', (done) => {
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'new-logo.png' } }));
// updateBootstrap debounces the history commit - wait past HISTORY_DEBOUNCE_MS (300ms).
setTimeout(() => {
expect(facade.bootstrap()?.branding.logoUrl).toBe('new-logo.png');
expect(facade.canUndo()).toBeTrue();
facade.undo();
expect(facade.bootstrap()?.branding.logoUrl).toBe('logo.png');
expect(facade.canUndo()).toBeFalse();
expect(facade.canRedo()).toBeTrue();
facade.redo();
expect(facade.bootstrap()?.branding.logoUrl).toBe('new-logo.png');
expect(facade.canRedo()).toBeFalse();
done();
}, 350);
});
it('a fresh edit after undo discards the redo stack', (done) => {
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'v2.png' } }));
setTimeout(() => {
facade.undo();
expect(facade.canRedo()).toBeTrue();
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'v3.png' } }));
expect(facade.canRedo()).toBeFalse();
done();
}, 350);
});
});
describe('draft persistence round-trip', () => {
it('persists edits to the draft store and restores them on next load', (done) => {
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'draft-logo.png' } }));
setTimeout(() => {
// Simulate a fresh facade instance (e.g. page reload) picking up the persisted draft.
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideRouter([]), provideHttpClient(), provideHttpClientTesting(),
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
{ provide: PlatformRuntimeService, useValue: runtimeSpy },
],
});
const reloaded = TestBed.inject(ProjectEditorFacade);
reloaded.loadBootstrap();
expect(reloaded.draftRestored()).toBeTrue();
expect(reloaded.bootstrap()?.branding.logoUrl).toBe('draft-logo.png');
done();
}, 350);
});
});
describe('publish gating', () => {
it('publishes when there are no blocking validation issues', () => {
expect(facade.hasBlockingIssues()).toBeFalse();
const result = facade.publish();
expect(result).toBeTrue();
expect(facade.status()).toBe('published');
expect(runtimeSpy.reloadFromBootstrap).toHaveBeenCalled();
});
it('refuses to publish while a blocking issue is present', () => {
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: '' } }));
expect(facade.hasBlockingIssues()).toBeTrue();
const result = facade.publish();
expect(result).toBeFalse();
expect(facade.status()).not.toBe('published');
expect(runtimeSpy.reloadFromBootstrap).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,6 +4,7 @@ import {
isValidHexColor,
isValidHttpUrl,
normalizeRoute,
validateAgainstSchemaLite,
validateCss,
validateHtml,
validateJson,
@@ -79,6 +80,36 @@ describe('validation primitives', () => {
});
});
describe('validateAgainstSchemaLite', () => {
const schema = {
type: 'object' as const,
properties: { title: { type: 'string' }, count: { type: 'number' } },
required: ['title'],
};
it('accepts a value matching required fields and property types', () => {
expect(validateAgainstSchemaLite({ title: 'Hero', count: 3 }, schema)).toEqual([]);
});
it('flags a missing required property', () => {
const errors = validateAgainstSchemaLite({ count: 3 }, schema);
expect(errors.some(e => e.includes('title'))).toBeTrue();
});
it('flags a property with the wrong type', () => {
const errors = validateAgainstSchemaLite({ title: 'Hero', count: 'three' }, schema);
expect(errors.some(e => e.includes('count'))).toBeTrue();
});
it('ignores properties not declared in the schema', () => {
expect(validateAgainstSchemaLite({ title: 'Hero', extra: 'ignored' }, schema)).toEqual([]);
});
it('ignores an undeclared-optional property that is simply absent', () => {
expect(validateAgainstSchemaLite({ title: 'Hero' }, schema)).toEqual([]);
});
});
describe('validateHtml', () => {
it('accepts well-formed markup with nested tags', () => {
expect(validateHtml('<p>Hello <strong>world</strong></p><ul><li>one</li></ul>').ok).toBeTrue();

View File

@@ -80,6 +80,54 @@ export function normalizeRoute(route: string): string {
return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
}
export interface JsonSchemaLite {
type?: 'object';
/** Loosely typed to accept the wider `WidgetManifestEntry.settingsSchema` shape as-is - each entry's `type` is read defensively below. */
properties?: Record<string, unknown>;
required?: string[];
}
/**
* Basic (type + required) JSON Schema check against an object - not a full
* JSON Schema engine (no nested schemas, enums, formats, or $ref). Enough to
* catch a widget's `props` missing a required field or holding the wrong
* primitive type for a declared property, without pulling in a schema
* validation dependency for a check this shallow.
*/
export function validateAgainstSchemaLite(value: Record<string, unknown>, schema: JsonSchemaLite): string[] {
const errors: string[] = [];
for (const key of schema.required ?? []) {
if (value[key] === undefined || value[key] === null || value[key] === '') {
errors.push(`missing required property "${key}"`);
}
}
for (const [key, propSchema] of Object.entries(schema.properties ?? {})) {
const propValue = value[key];
const propType = propSchema && typeof propSchema === 'object' ? (propSchema as { type?: string }).type : undefined;
if (propValue === undefined || !propType) {
continue;
}
if (!matchesJsonSchemaType(propValue, propType)) {
errors.push(`property "${key}" should be ${propType}`);
}
}
return errors;
}
function matchesJsonSchemaType(value: unknown, type: string): boolean {
switch (type) {
case 'string': return typeof value === 'string';
case 'number': case 'integer': return typeof value === 'number';
case 'boolean': return typeof value === 'boolean';
case 'array': return Array.isArray(value);
case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value);
default: return true;
}
}
/** HTML void elements per the WHATWG spec: never require (or accept) a closing tag. */
const VOID_ELEMENTS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',

View File

@@ -1,4 +1,9 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { BootstrapConfig } from '../../../shared/models/config';
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
import { ProjectValidator } from './project-validator.service';
function makeBootstrap(): BootstrapConfig {
@@ -45,7 +50,8 @@ describe('ProjectValidator', () => {
let validator: ProjectValidator;
beforeEach(() => {
validator = new ProjectValidator();
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
validator = TestBed.inject(ProjectValidator);
});
it('reports no issues for a valid baseline config', () => {
@@ -95,4 +101,45 @@ describe('ProjectValidator', () => {
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-url');
expect(issue?.fieldKey).toBe('tenant.websiteBaseUrl');
});
describe('widgetSettingsSchema (against widget-manifest settingsSchema)', () => {
const manifest: WidgetManifestFile = {
widgets: [
{
type: 'hero',
version: '1',
componentKey: 'hero',
supportedLayouts: ['hero'],
supportedDataSources: [],
settingsSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] },
defaultSettings: {},
},
],
};
it('does nothing before the manifest has ever loaded (getManifestSnapshot() is null)', () => {
const bootstrap = makeBootstrap();
bootstrap.pages[0].sections[0].widgets[0].props = {};
expect(validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings')).toBeUndefined();
});
it('flags a widget missing a required settingsSchema property once the manifest is loaded', () => {
spyOn(TestBed.inject(WidgetManifestService), 'getManifestSnapshot').and.returnValue(manifest);
const bootstrap = makeBootstrap();
bootstrap.pages[0].sections[0].widgets[0].props = {};
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings');
expect(issue).toBeDefined();
expect(issue?.section).toBe('widgets');
expect(issue?.severity).toBe('warning');
});
it('passes when the widget props satisfy the manifest settingsSchema', () => {
spyOn(TestBed.inject(WidgetManifestService), 'getManifestSnapshot').and.returnValue(manifest);
const bootstrap = makeBootstrap();
bootstrap.pages[0].sections[0].widgets[0].props = { title: 'Welcome' };
expect(validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings')).toBeUndefined();
});
});
});

View File

@@ -1,12 +1,14 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { BootstrapConfig } from '../../../shared/models/config';
import { ProjectEditorSectionId } from '../models/project-editor.model';
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
import {
extractStyleBlocks,
isValidEmail,
isValidHexColor,
isValidHttpUrl,
normalizeRoute,
validateAgainstSchemaLite,
validateCss,
} from '../schema/validators/primitives';
@@ -43,6 +45,8 @@ function warning(code: string, message: string, section: ProjectEditorSectionId,
*/
@Injectable({ providedIn: 'root' })
export class ProjectValidator {
private readonly widgetManifest = inject(WidgetManifestService);
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
return [
...this.brandingIssues(bootstrap),
@@ -52,6 +56,7 @@ export class ProjectValidator {
...this.duplicateRouteIssues(bootstrap),
...this.homepageIssues(bootstrap),
...this.widgetConfigIssues(bootstrap),
...this.widgetSettingsSchemaIssues(bootstrap),
...this.navigationIssues(bootstrap),
...this.colorIssues(bootstrap),
...this.cssIssues(bootstrap),
@@ -162,6 +167,37 @@ export class ProjectValidator {
: [];
}
/**
* Validates each widget's `props` against its manifest entry's `settingsSchema`
* (basic type/required checks - see validateAgainstSchemaLite). No-ops until
* the manifest has loaded once (same early-return convention as
* RuntimeDiagnosticsValidator.validateDatasourceCoverage) rather than blocking
* publish on a manifest fetch race.
*/
private widgetSettingsSchemaIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const manifest = this.widgetManifest.getManifestSnapshot();
if (!manifest) {
return [];
}
const manifestByType = new Map(manifest.widgets.map(entry => [entry.type, entry]));
const hasInvalidSettings = bootstrap.pages.some(page =>
page.sections.some(section =>
section.widgets.some(widget => {
const entry = manifestByType.get(widget.type);
if (!entry?.settingsSchema || !widget.props || typeof widget.props !== 'object') {
return false;
}
return validateAgainstSchemaLite(widget.props, entry.settingsSchema).length > 0;
}),
),
);
return hasInvalidSettings
? [warning('invalid-widget-settings', 'builder.validationInvalidWidgetSettings', 'widgets', 'pages')]
: [];
}
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const keyOf = (item: { label?: string | Record<string, string>; route?: string }): string =>
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;

View File

@@ -3,6 +3,7 @@ import { ParamMap, Params } from '@angular/router';
import { Observable, Subject, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
import { Category } from '../../../core/categories/models/category-domain.model';
import { ConfigService } from '../../../core/config/config.service';
import { Product, ProductListResult } from '../../../core/products/models/product-domain.model';
import { CategoryFacade } from '../../../facades/platform/category.facade';
import { ProductFacade } from '../../../facades/platform/product.facade';
@@ -42,6 +43,7 @@ export class SearchFacade {
private readonly trendingService = inject(SearchTrendingService);
private readonly cacheService = inject(SearchCacheService);
private readonly store = inject(SearchStore);
private readonly configService = inject(ConfigService);
private readonly metadataMemo = new Map<string, FilterGroup[]>();
private readonly autocompleteInput$ = new Subject<{
@@ -123,6 +125,10 @@ export class SearchFacade {
autocomplete(query: string, products: Product[], categories: Category[] = [], limit = 10): void {
this.store.setQuery(query);
if (this.configService.getBootstrapSnapshot()?.catalog?.suggestionsEnabled === false) {
this.store.setSuggestions([]);
return;
}
this.autocompleteInput$.next({ query, products, categories, limit });
}