diff --git a/.env.example b/.env.example index 10a052fee..1c8977425 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,6 @@ ENV_IS_SSP_ENABLED=false E2E_BASE_HOST=yves.de.spryker.local VIEWPORT_WIDGTH=1920 VIEWPORT_HEIGHT=1080 + +# Gates the @demo-full tier (cy:demo:full): set to 1 once real AI provider tokens are configured in the Back Office. +DEMO_AI_PROVIDER_ENABLED=1 diff --git a/cypress.config.ts b/cypress.config.ts index 2237f836d..f754dc57b 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ env: { CI: true, repositoryId: process.env.ENV_REPOSITORY_ID, + DEMO_AI_PROVIDER_ENABLED: process.env.DEMO_AI_PROVIDER_ENABLED === '1', isDynamicStoreEnabled: getEnvVar('ENV_IS_DYNAMIC_STORE_ENABLED', 'SPRYKER_DYNAMIC_STORE_MODE') === 'true', ENV_IS_SSP_ENABLED: getEnvVar('ENV_IS_SSP_ENABLED', 'ENV_IS_SSP_ENABLED') === 'true', backofficeUrl: `${protocol}://${backofficeHost}`, diff --git a/cypress/e2e/demo/00-ai-configuration.cy.ts b/cypress/e2e/demo/00-ai-configuration.cy.ts new file mode 100644 index 000000000..329c4b5d3 --- /dev/null +++ b/cypress/e2e/demo/00-ai-configuration.cy.ts @@ -0,0 +1,380 @@ +import { container, skipUnlessAiProviderEnabled } from '@utils'; +import { UserLoginScenario } from '@scenarios/backoffice'; +import { AiConfigurationPage } from '@pages/backoffice'; +import { AiConfigurationDemoStaticFixtures } from '@interfaces/demo'; + +describe( + 'AI Configuration - Back Office AI Vendor & Assistant provider settings', + { + tags: ['@demo', '@ai-configuration', '@ai-commerce'], + }, + (): void => { + const userLoginScenario = container.get(UserLoginScenario); + const aiConfigurationPage = container.get(AiConfigurationPage); + + const AI_CONFIGURATION_KEY = 'ai_commerce:backoffice_assistant:ai_vendor:ai_configuration'; + const OPENAI_MODEL_KEY = 'ai_commerce:backoffice_assistant:ai_vendor:openai_model'; + const AWS_MODEL_KEY = 'ai_commerce:backoffice_assistant:ai_vendor:aws_model'; + const ANTHROPIC_MODEL_KEY = 'ai_commerce:backoffice_assistant:ai_vendor:anthropic_model'; + + const OPENAI_OPTION = 'AI_COMMERCE:AI_CONFIGURATION_BACKOFFICE_ASSISTANT_OPENAI'; + const AWS_OPTION = 'AI_COMMERCE:AI_CONFIGURATION_BACKOFFICE_ASSISTANT_AWS'; + + const OPENAI_MODEL_DEFAULT = 'gpt-4.1'; + const OPENAI_MODEL_UPDATED = 'gpt-4.1-mini'; + const AWS_MODEL_DEFAULT = 'eu.anthropic.claude-sonnet-4-5-20250929-v1:0'; + const AWS_REGION_DEFAULT = 'eu-central-1'; + + type VendorTab = { + vendor: string; + extraFields?: Array<{ field: string; value: string }>; + }; + + const VENDOR_TABS: VendorTab[] = [ + { vendor: 'openai' }, + { vendor: 'aws', extraFields: [{ field: 'region', value: AWS_REGION_DEFAULT }] }, + ]; + + type PromptField = { + key: string; + label: string; + placeholders: string[]; + }; + + const PROMPT_TABS: Array<{ tab: string; fields: PromptField[] }> = [ + { + tab: 'quick_order', + fields: [ + { + key: 'ai_commerce:quick_order:system_prompts:image_recognition_prompt', + label: 'Image Recognition Prompt', + placeholders: ['%s'], + }, + ], + }, + { + tab: 'search_by_image', + fields: [ + { + key: 'ai_commerce:search_by_image:system_prompts:search_term_prompt', + label: 'Search Term Prompt', + placeholders: [], + }, + ], + }, + { + tab: 'smart_pim', + fields: [ + { + key: 'ai_commerce:smart_pim:system_prompts:content_improver_prompt', + label: 'Content Improver Prompt', + placeholders: ['%s'], + }, + { + key: 'ai_commerce:smart_pim:system_prompts:translation_prompt', + label: 'Translation Prompt', + placeholders: ['%s'], + }, + { + key: 'ai_commerce:smart_pim:system_prompts:translation_collection_prompt', + label: 'Translation Collection Prompt', + placeholders: ['%s'], + }, + { + key: 'ai_commerce:smart_pim:system_prompts:category_suggestion_prompt', + label: 'Category Suggestion Prompt', + placeholders: ['%s'], + }, + { + key: 'ai_commerce:smart_pim:system_prompts:image_alt_text_prompt', + label: 'Image Alt Text Prompt', + placeholders: ['%s'], + }, + ], + }, + ]; + + let staticFixtures: AiConfigurationDemoStaticFixtures; + + before((): void => { + staticFixtures = Cypress.env('staticFixtures'); + }); + + beforeEach((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + }); + + it('AI Vendor feature lists the OpenAI, Anthropic and AWS tabs', { tags: ['@demo-smoke'] }, (): void => { + aiConfigurationPage.visitTab('ai_vendor', 'openai').its('response.statusCode').should('eq', 200); + + aiConfigurationPage.getCardTitle().should('contain.text', aiConfigurationPage.getCardTitleText()); + aiConfigurationPage.getFeatureNav('ai_vendor').should('exist'); + + VENDOR_TABS.forEach(({ vendor }) => { + aiConfigurationPage.getTabNav('ai_vendor', vendor).should('exist'); + }); + }); + + VENDOR_TABS.forEach(({ vendor, extraFields = [] }) => { + it( + `${vendor} AI Vendor tab returns HTTP 200 and shows a masked API token field and a model-prices JSON editor`, + { tags: ['@demo-smoke'] }, + (): void => { + aiConfigurationPage.visitTab('ai_vendor', vendor).its('response.statusCode').should('eq', 200); + + aiConfigurationPage.getApiTokenSettingInput(vendor).should('be.visible').and('have.attr', 'type', 'password'); + aiConfigurationPage.getModelPricesEditor(vendor).should('be.visible'); + + extraFields.forEach(({ field, value }) => { + aiConfigurationPage + .getSettingInput(aiConfigurationPage.getVendorSettingKey(vendor, field)) + .should('be.visible') + .and('have.value', value); + }); + } + ); + }); + + it( + 'the masked OpenAI API token field stays type=password: a preset token is kept, otherwise typed input raises the save bar', + { tags: ['@demo-smoke'] }, + (): void => { + const dummyToken = 'dummy-local-not-a-real-token'; + + aiConfigurationPage.visitTab('ai_vendor', 'openai'); + + aiConfigurationPage + .getApiTokenSettingInput('openai') + .should('be.visible') + .and('have.attr', 'type', 'password') + .then(($input) => { + const presetToken = String($input.val() ?? ''); + + if (presetToken.trim()) { + cy.wrap($input).should('have.attr', 'type', 'password').and('have.value', presetToken); + expect( + presetToken.trim().length, + 'preset OpenAI API token must not be trivially short' + ).to.be.greaterThan(0); + + return; + } + + cy.wrap($input).type(dummyToken); + aiConfigurationPage + .getApiTokenSettingInput('openai') + .should('have.value', dummyToken) + .and('have.attr', 'type', 'password'); + + aiConfigurationPage.getSaveBar().should('have.css', 'display', 'block'); + aiConfigurationPage.getChangesCount().should('have.text', '1'); + }); + } + ); + + it( + 'Backoffice Assistant provider radio offers three options and pre-selects OpenAI by default', + { tags: ['@demo-smoke'] }, + (): void => { + aiConfigurationPage + .visitTab('ai_commerce', 'backoffice_assistant') + .its('response.statusCode') + .should('eq', 200); + + aiConfigurationPage.getRadioOptions(AI_CONFIGURATION_KEY).should('have.length', 3); + aiConfigurationPage.getCheckedRadioOption(AI_CONFIGURATION_KEY).should('have.value', OPENAI_OPTION); + } + ); + + it( + 'with OpenAI selected, only the OpenAI model field (gpt-4.1) is visible and the AWS and Anthropic model fields are hidden', + { tags: ['@demo-smoke'] }, + (): void => { + aiConfigurationPage.visitTab('ai_commerce', 'backoffice_assistant'); + + aiConfigurationPage.getSettingRow(OPENAI_MODEL_KEY).should('be.visible'); + aiConfigurationPage.getSettingInput(OPENAI_MODEL_KEY).should('have.value', OPENAI_MODEL_DEFAULT); + aiConfigurationPage.getSettingRow(AWS_MODEL_KEY).should('not.be.visible'); + aiConfigurationPage.getSettingRow(ANTHROPIC_MODEL_KEY).should('not.be.visible'); + } + ); + + it( + 'selecting the AWS Bedrock provider radio hides the OpenAI model field and reveals the AWS Bedrock model field', + { tags: ['@demo-smoke'] }, + (): void => { + aiConfigurationPage.visitTab('ai_commerce', 'backoffice_assistant'); + + aiConfigurationPage.getSettingRow(OPENAI_MODEL_KEY).should('be.visible'); + aiConfigurationPage.getSettingRow(AWS_MODEL_KEY).should('not.be.visible'); + + aiConfigurationPage.selectRadioOption(AI_CONFIGURATION_KEY, AWS_OPTION); + + aiConfigurationPage.getCheckedRadioOption(AI_CONFIGURATION_KEY).should('have.value', AWS_OPTION); + aiConfigurationPage.getSettingRow(AWS_MODEL_KEY).should('be.visible'); + aiConfigurationPage.getSettingInput(AWS_MODEL_KEY).should('have.value', AWS_MODEL_DEFAULT); + aiConfigurationPage.getSettingRow(OPENAI_MODEL_KEY).should('not.be.visible'); + aiConfigurationPage.getSettingRow(ANTHROPIC_MODEL_KEY).should('not.be.visible'); + } + ); + + it( + 'changing the OpenAI model value shows the unsaved-changes bar, and Save persists the value across a reload', + { tags: ['@demo-smoke'] }, + (): void => { + aiConfigurationPage.visitTab('ai_commerce', 'backoffice_assistant'); + aiConfigurationPage.getSaveBar().should('have.css', 'display', 'none'); + + aiConfigurationPage.setSettingInputValue(OPENAI_MODEL_KEY, OPENAI_MODEL_UPDATED); + aiConfigurationPage.getSaveBar().should('have.css', 'display', 'block'); + aiConfigurationPage.getChangesCount().should('have.text', '1'); + + aiConfigurationPage.getSaveButton().should('be.enabled'); + aiConfigurationPage.saveConfiguration(); + cy.wait('@saveConfiguration').then((interception) => { + expect(interception.request.body.changes).to.be.an('array'); + const change = interception.request.body.changes.find( + (entry: { key: string }) => entry.key === OPENAI_MODEL_KEY + ); + expect(change).to.not.be.undefined; + expect(change.value).to.eq(OPENAI_MODEL_UPDATED); + expect(interception.response?.body).to.have.property('success', true); + }); + + aiConfigurationPage.visitTab('ai_commerce', 'backoffice_assistant'); + aiConfigurationPage.getSettingInput(OPENAI_MODEL_KEY).should('have.value', OPENAI_MODEL_UPDATED); + aiConfigurationPage.getSaveBar().should('have.css', 'display', 'none'); + + aiConfigurationPage.setSettingInputValue(OPENAI_MODEL_KEY, OPENAI_MODEL_DEFAULT); + aiConfigurationPage.saveConfiguration(); + cy.wait('@saveConfiguration').its('response.body').should('have.property', 'success', true); + + aiConfigurationPage.visitTab('ai_commerce', 'backoffice_assistant'); + aiConfigurationPage.getSettingInput(OPENAI_MODEL_KEY).should('have.value', OPENAI_MODEL_DEFAULT); + } + ); + + PROMPT_TABS.forEach(({ tab, fields }) => { + it( + `${tab} tab returns HTTP 200 and renders its System Prompts textarea(s) with the non-empty shipped default(s)`, + { tags: ['@demo-smoke'] }, + (): void => { + aiConfigurationPage.visitTab('ai_commerce', tab).its('response.statusCode').should('eq', 200); + + aiConfigurationPage.getSettingRows('system_prompts').should('have.length', fields.length); + + fields.forEach((field) => { + aiConfigurationPage.getSettingRow(field.key).should('be.visible'); + aiConfigurationPage + .getSettingInput(field.key) + .should('be.visible') + .and(($textarea) => { + expect($textarea.prop('tagName'), `${field.label} must render as a textarea`).to.eq('TEXTAREA'); + expect( + String($textarea.val() ?? '').trim().length, + `${field.label} default prompt must not be empty` + ).to.be.greaterThan(0); + }); + }); + } + ); + }); + + it( + 'shipped default prompts keep their dynamic placeholders so runtime substitution stays intact', + { tags: ['@demo-smoke'] }, + (): void => { + PROMPT_TABS.filter(({ fields }) => fields.some((field) => field.placeholders.length > 0)).forEach( + ({ tab, fields }) => { + aiConfigurationPage.visitTab('ai_commerce', tab); + + fields + .filter((field) => field.placeholders.length > 0) + .forEach((field) => { + aiConfigurationPage.getSettingInput(field.key).then(($textarea) => { + const value = String($textarea.val() ?? ''); + field.placeholders.forEach((placeholder) => { + expect(value, `${field.label} must retain the ${placeholder} placeholder`).to.contain(placeholder); + }); + }); + }); + } + ); + } + ); + + it( + 'editing a Smart PIM prompt raises the unsaved-changes bar and persists the override across a reload before restoring the default', + { tags: ['@demo-smoke'] }, + (): void => { + const promptKey = 'ai_commerce:smart_pim:system_prompts:content_improver_prompt'; + const overridePrompt = 'CC-38802 override: improve this product copy. Text to improve: %s'; + + aiConfigurationPage.visitTab('ai_commerce', 'smart_pim'); + aiConfigurationPage.getSaveBar().should('have.css', 'display', 'none'); + + aiConfigurationPage + .getSettingInput(promptKey) + .invoke('val') + .then((defaultValue) => { + const originalPrompt = String(defaultValue ?? ''); + expect(originalPrompt.trim().length, 'default prompt must exist before overriding').to.be.greaterThan(0); + + aiConfigurationPage.setSettingInputValue(promptKey, overridePrompt); + aiConfigurationPage.getSaveBar().should('have.css', 'display', 'block'); + aiConfigurationPage.getChangesCount().should('have.text', '1'); + + aiConfigurationPage.getSaveButton().should('be.enabled'); + aiConfigurationPage.saveConfiguration(); + cy.wait('@saveConfiguration').then((interception) => { + const change = interception.request.body.changes.find( + (entry: { key: string }) => entry.key === promptKey + ); + expect(change, 'the prompt override must be part of the save payload').to.not.be.undefined; + expect(change.value).to.eq(overridePrompt); + expect(interception.response?.body).to.have.property('success', true); + }); + + aiConfigurationPage.visitTab('ai_commerce', 'smart_pim'); + aiConfigurationPage.getSettingInput(promptKey).should('have.value', overridePrompt); + aiConfigurationPage.getSaveBar().should('have.css', 'display', 'none'); + + aiConfigurationPage.setSettingInputValue(promptKey, originalPrompt); + aiConfigurationPage.saveConfiguration(); + cy.wait('@saveConfiguration').its('response.body').should('have.property', 'success', true); + + aiConfigurationPage.visitTab('ai_commerce', 'smart_pim'); + aiConfigurationPage.getSettingInput(promptKey).should('have.value', originalPrompt); + }); + } + ); + + it( + 'the OpenAI, Anthropic and AWS Bedrock API tokens are all configured (real provider keys present for a full run)', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + VENDOR_TABS.forEach(({ vendor }) => { + aiConfigurationPage.visitTab('ai_vendor', vendor).its('response.statusCode').should('eq', 200); + aiConfigurationPage + .getApiTokenSettingInput(vendor) + .should('be.visible') + .and('have.attr', 'type', 'password') + .then(($input) => { + const token = String($input.val() ?? '').trim(); + expect(token.length, `${vendor} API token must be set for a full run`).to.be.greaterThan(0); + }); + }); + + aiConfigurationPage.getSettingInput(aiConfigurationPage.getVendorSettingKey('aws', 'region')).then(($input) => { + const region = String($input.val() ?? '').trim(); + expect(region.length, 'AWS Bedrock region must be set for a full run').to.be.greaterThan(0); + }); + } + ); + } +); diff --git a/cypress/e2e/demo/ai-workflows.cy.ts b/cypress/e2e/demo/ai-workflows.cy.ts new file mode 100644 index 000000000..96dc4fa23 --- /dev/null +++ b/cypress/e2e/demo/ai-workflows.cy.ts @@ -0,0 +1,112 @@ +import { container } from '@utils'; +import { UserLoginScenario } from '@scenarios/backoffice'; +import { AiWorkflowsPage } from '@pages/backoffice'; +import { AiWorkflowsDemoStaticFixtures } from '@interfaces/demo'; + +describe( + 'AI Workflows - Back Office workflow runs list', + { + tags: ['@demo', '@ai-workflows', '@ai-foundation'], + }, + (): void => { + const userLoginScenario = container.get(UserLoginScenario); + const aiWorkflowsPage = container.get(AiWorkflowsPage); + + const EXPECTED_COLUMN_HEADERS: Array<{ dataQa: string; label: string }> = [ + { dataQa: 'spy_ai_workflow_item.id_ai_workflow_item', label: 'ID' }, + { dataQa: 'process_name', label: 'Process Name' }, + { dataQa: 'state_name', label: 'State' }, + { dataQa: 'spy_ai_workflow_item.created_at', label: 'Created At' }, + { dataQa: 'spy_ai_workflow_item.updated_at', label: 'Updated At' }, + { dataQa: 'Actions', label: 'Actions' }, + ]; + + const SORTABLE_COLUMNS: Array = [ + 'spy_ai_workflow_item.id_ai_workflow_item', + 'process_name', + 'state_name', + 'spy_ai_workflow_item.created_at', + 'spy_ai_workflow_item.updated_at', + ]; + + const NON_SORTABLE_COLUMNS: Array = ['Actions']; + + let staticFixtures: AiWorkflowsDemoStaticFixtures; + + before((): void => { + staticFixtures = Cypress.env('staticFixtures'); + }); + + beforeEach((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + }); + + it( + 'opens the Workflows page (HTTP 200) with the section heading, widget title and all six column headers', + { tags: ['@demo-smoke'] }, + (): void => { + aiWorkflowsPage.visitAiWorkflows().its('response.statusCode').should('eq', 200); + + aiWorkflowsPage.getSectionTitle().should('contain.text', aiWorkflowsPage.getSectionTitleText()); + aiWorkflowsPage.getWidgetTitle().should('contain.text', aiWorkflowsPage.getWidgetTitleText()); + + aiWorkflowsPage.getTable().should('exist'); + aiWorkflowsPage.getTableHeaders().should('have.length', EXPECTED_COLUMN_HEADERS.length); + EXPECTED_COLUMN_HEADERS.forEach((column): void => { + aiWorkflowsPage.getColumnHeader(column.dataQa).should('exist').and('contain.text', column.label); + }); + } + ); + + it( + 'initializes a live DataTable (HTTP 200 JSON with the draw/recordsTotal/recordsFiltered/data shape) and marks the five data columns sortable and Actions non-sortable', + { tags: ['@demo-smoke'] }, + (): void => { + aiWorkflowsPage.visitAndAwaitTableData().then((interception): void => { + expect(interception.response?.statusCode).to.eq(200); + expect(interception.response?.headers['content-type']).to.contain('application/json'); + + const body = interception.response?.body; + expect(body).to.have.property('draw'); + expect(body).to.have.property('recordsTotal'); + expect(body).to.have.property('recordsFiltered'); + expect(body).to.have.property('data'); + expect(body.data).to.be.an('array'); + }); + + aiWorkflowsPage.getTableWrapper().should('exist'); + aiWorkflowsPage.getTableInfo().should('be.visible'); + + SORTABLE_COLUMNS.forEach((column): void => { + aiWorkflowsPage.getSortableColumnHeader(column).should('exist'); + }); + NON_SORTABLE_COLUMNS.forEach((column): void => { + aiWorkflowsPage.getNonSortableColumnHeader(column).should('exist'); + }); + } + ); + + it( + 'the page-length control and the Created At sort header each issue a fresh table data request that returns HTTP 200', + { tags: ['@demo-smoke'] }, + (): void => { + aiWorkflowsPage.visitAndAwaitTableData(); + + aiWorkflowsPage.getLengthSelect().should('exist'); + aiWorkflowsPage.aliasTableData('lengthChangeData'); + aiWorkflowsPage.selectPageLength('50'); + cy.wait('@lengthChangeData').then((interception): void => { + expect(interception.response?.statusCode).to.eq(200); + expect(interception.request.url).to.contain('length=50'); + }); + + aiWorkflowsPage.aliasTableData('sortChangeData'); + aiWorkflowsPage.getSortableColumnHeader('spy_ai_workflow_item.created_at').click(); + cy.wait('@sortChangeData').its('response.statusCode').should('eq', 200); + } + ); + } +); diff --git a/cypress/e2e/demo/audit-logs.cy.ts b/cypress/e2e/demo/audit-logs.cy.ts new file mode 100644 index 000000000..0d53c3b08 --- /dev/null +++ b/cypress/e2e/demo/audit-logs.cy.ts @@ -0,0 +1,203 @@ +import { container, skipUnlessAiProviderEnabled } from '@utils'; +import { UserLoginScenario } from '@scenarios/backoffice'; +import { AuditLogsPage, SmartPimPage } from '@pages/backoffice'; +import { AuditLogsDemoStaticFixtures } from '@interfaces/demo'; + +describe( + 'Audit Logs - Back Office AI interaction log', + { + tags: ['@demo', '@audit-logs', '@ai-foundation'], + }, + (): void => { + const userLoginScenario = container.get(UserLoginScenario); + const auditLogsPage = container.get(AuditLogsPage); + const smartPimPage = container.get(SmartPimPage); + + const EXPECTED_COLUMN_HEADERS: Array<{ dataQa: string; label: string }> = [ + { dataQa: 'spy_ai_interaction_log.prompt', label: 'Prompt' }, + { dataQa: 'spy_ai_interaction_log.conversation_reference', label: 'Conversation' }, + { dataQa: 'spy_ai_interaction_log.provider', label: 'Provider' }, + { dataQa: 'spy_ai_interaction_log.model', label: 'Model' }, + { dataQa: 'total_tokens', label: 'Total Tokens' }, + { dataQa: 'estimated_cost', label: 'Estimated cost' }, + { dataQa: 'spy_ai_interaction_log.configuration_name', label: 'Configuration' }, + { dataQa: 'spy_ai_interaction_log.is_successful', label: 'Status' }, + { dataQa: 'spy_ai_interaction_log.inference_time_ms', label: 'Inference (ms)' }, + { dataQa: 'spy_ai_interaction_log.created_at', label: 'Created At' }, + ]; + + const EXPECTED_STATS_CARDS: Array = [ + 'Total Interactions', + 'Total Tokens', + 'Total estimated cost', + 'Success Rate', + 'Avg Inference Time', + ]; + + const SORTABLE_COLUMNS: Array = [ + 'spy_ai_interaction_log.configuration_name', + 'spy_ai_interaction_log.is_successful', + 'spy_ai_interaction_log.created_at', + ]; + + const NON_SORTABLE_COLUMNS: Array = [ + 'spy_ai_interaction_log.prompt', + 'spy_ai_interaction_log.conversation_reference', + 'spy_ai_interaction_log.provider', + 'spy_ai_interaction_log.model', + 'total_tokens', + 'estimated_cost', + 'spy_ai_interaction_log.inference_time_ms', + ]; + + let staticFixtures: AuditLogsDemoStaticFixtures; + + before((): void => { + staticFixtures = Cypress.env('staticFixtures'); + }); + + beforeEach((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + }); + + it( + 'page opens (HTTP 200) with the section heading, all ten column headers and the five summary stats cards', + { tags: ['@demo-smoke'] }, + (): void => { + auditLogsPage.visitAuditLogs().its('response.statusCode').should('eq', 200); + + auditLogsPage.getSectionTitle().should('contain.text', auditLogsPage.getSectionTitleText()); + + auditLogsPage.getTable().should('exist'); + auditLogsPage.getTableHeaders().should('have.length', EXPECTED_COLUMN_HEADERS.length); + EXPECTED_COLUMN_HEADERS.forEach((column): void => { + auditLogsPage.getColumnHeader(column.dataQa).should('exist').and('contain.text', column.label); + }); + + auditLogsPage.getStatsCards().should('be.visible'); + auditLogsPage.getStatsCardTitles().should('have.length.at.least', EXPECTED_STATS_CARDS.length); + EXPECTED_STATS_CARDS.forEach((cardTitle): void => { + auditLogsPage.getStatsCards().should('contain.text', cardTitle); + }); + } + ); + + it( + 'initializes a live DataTable (HTTP 200 JSON with the recordsTotal/recordsFiltered/data shape) and loads the stats partial over AJAX, and marks Configuration/Status/Created At sortable and the rest non-sortable', + { tags: ['@demo-smoke'] }, + (): void => { + auditLogsPage.visitAndAwaitTableData().then((interception): void => { + expect(interception.response?.statusCode).to.eq(200); + expect(interception.response?.headers['content-type']).to.contain('application/json'); + + const body = interception.response?.body; + expect(body).to.have.property('draw'); + expect(body).to.have.property('recordsTotal'); + expect(body).to.have.property('recordsFiltered'); + expect(body).to.have.property('data'); + expect(body.data).to.be.an('array'); + }); + + auditLogsPage.getTableWrapper().should('exist'); + auditLogsPage.getTableInfo().should('be.visible'); + + auditLogsPage.waitForStats().its('response.statusCode').should('eq', 200); + auditLogsPage.getStatsCards().should('contain.text', auditLogsPage.getTotalInteractionsCardText()); + + SORTABLE_COLUMNS.forEach((column): void => { + auditLogsPage.getSortableColumnHeader(column).should('exist'); + }); + NON_SORTABLE_COLUMNS.forEach((column): void => { + auditLogsPage.getNonSortableColumnHeader(column).should('exist'); + }); + } + ); + + it( + 'the page-length control and the Created At sort header each issue a fresh table data request that returns HTTP 200', + { tags: ['@demo-smoke'] }, + (): void => { + auditLogsPage.visitAndAwaitTableData(); + + auditLogsPage.getLengthSelect().should('exist'); + auditLogsPage.aliasTableData('lengthChangeData'); + auditLogsPage.selectPageLength('50'); + cy.wait('@lengthChangeData').then((interception): void => { + expect(interception.response?.statusCode).to.eq(200); + expect(interception.request.url).to.contain('length=50'); + }); + + auditLogsPage.aliasTableData('sortChangeData'); + auditLogsPage.getSortableColumnHeader('spy_ai_interaction_log.created_at').click(); + cy.wait('@sortChangeData').its('response.statusCode').should('eq', 200); + } + ); + + describe('real AI audit-log data (full, requires provider token)', { tags: ['@demo-full'] }, (): void => { + const REAL_FLOW_TIMEOUT = 15000; + + it( + 'captures a self-seeded AI interaction as an audit-log row with populated provider/model/tokens/cost/status/inference fields', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + auditLogsPage.fetchTableData().then((baseline): void => { + const baselineTotal = baseline.recordsTotal; + const baselineCreatedAt = auditLogsPage.getRowCreatedAt(baseline.rows[0]); + + cy.intercept('POST', '**/ai-commerce/content-improver').as('contentImprover'); + + // Resolve by SKU: id_product_abstract is import-order dependent, not stable across envs. + smartPimPage + .resolveProductAbstractIdBySku(staticFixtures.product.sku) + .then((id) => smartPimPage.visitProductEdit(id)); + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + smartPimPage.clickImproveContent(); + + cy.wait('@contentImprover', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + smartPimPage.shouldBeOpenPopover(smartPimPage.getResponsePopover()); + smartPimPage.getResponseField().should('not.have.value', ''); + + auditLogsPage.fetchTableData().then((after): void => { + const newRowCount = after.recordsTotal - baselineTotal; + + expect(newRowCount, 'at least one interaction was created after the baseline').to.be.at.least(1); + + const row = after.rows.slice(0, newRowCount)[0]; + expect( + auditLogsPage.getRowCreatedAt(row) >= baselineCreatedAt, + 'the new row is at least as recent as the baseline newest row' + ).to.eq(true); + + const toNumber = (value: unknown): number => parseInt(String(value ?? '').replace(/[^\d-]/g, ''), 10); + + const provider = String(row['spy_ai_interaction_log.provider'] ?? ''); + const model = String(row['spy_ai_interaction_log.model'] ?? ''); + const totalTokens = toNumber(row.total_tokens); + const estimatedCost = String(row.estimated_cost ?? ''); + const status = String(row['spy_ai_interaction_log.is_successful'] ?? ''); + const inferenceMs = toNumber(row['spy_ai_interaction_log.inference_time_ms']); + + expect(provider.trim(), 'provider is captured').to.not.be.empty; + expect(model.trim(), 'model is captured').to.not.be.empty; + expect(totalTokens, 'total_tokens parses to a positive number').to.be.greaterThan(0); + expect(estimatedCost.trim(), 'estimated_cost is present (formatted string)').to.not.be.empty; + expect(status, 'status reflects a successful interaction').to.contain( + auditLogsPage.getSuccessStatusText() + ); + expect(inferenceMs, 'inference_time_ms parses to a non-negative number').to.be.at.least(0); + }); + }); + } + ); + }); + } +); diff --git a/cypress/e2e/demo/backoffice-assistant.cy.ts b/cypress/e2e/demo/backoffice-assistant.cy.ts new file mode 100644 index 000000000..3af981107 --- /dev/null +++ b/cypress/e2e/demo/backoffice-assistant.cy.ts @@ -0,0 +1,1061 @@ +import { container, skipUnlessAiProviderEnabled } from '@utils'; +import { UserLoginScenario } from '@scenarios/backoffice'; +import { AiConfigurationPage, AuditLogsPage, BackofficeAssistantPage } from '@pages/backoffice'; +import { BackofficeAssistantDemoStaticFixtures } from '@interfaces/demo'; + +const ASSISTANT_FEATURE = 'backoffice_assistant'; + +describe( + 'Backoffice Assistant - global Back Office chat widget', + { + tags: ['@demo', '@backoffice-assistant', '@ai-commerce'], + }, + (): void => { + const userLoginScenario = container.get(UserLoginScenario); + const backofficeAssistantPage = container.get(BackofficeAssistantPage); + const aiConfigurationPage = container.get(AiConfigurationPage); + const auditLogsPage = container.get(AuditLogsPage); + + const restoreBackofficeAssistantOpenAiVendor = (): Cypress.Chainable => { + backofficeAssistantPage.clearWidgetPanelState(); + + return aiConfigurationPage.setFeatureVendor(ASSISTANT_FEATURE, 'openai'); + }; + + let staticFixtures: BackofficeAssistantDemoStaticFixtures; + + before((): void => { + staticFixtures = Cypress.env('staticFixtures'); + }); + + beforeEach((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + + backofficeAssistantPage.enableAssistant(); + }); + + it( + 'injects the launcher globally (dashboard + Sales), and clicking it opens the panel with the greeting and core controls', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.visitDashboard().its('response.statusCode').should('eq', 200); + + backofficeAssistantPage + .getWidgetToggle() + .should('be.visible') + .and('contain.text', backofficeAssistantPage.getWidgetToggleLabel()); + backofficeAssistantPage.getWidgetPanel().should('exist').and('have.attr', 'role', 'dialog'); + + backofficeAssistantPage.visitSales().its('response.statusCode').should('eq', 200); + backofficeAssistantPage + .getWidgetToggle() + .should('be.visible') + .and('contain.text', backofficeAssistantPage.getWidgetToggleLabel()); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.getWidgetToggle().should('not.be.visible'); + backofficeAssistantPage.getWidgetMessages().should('contain.text', backofficeAssistantPage.getGreetingText()); + backofficeAssistantPage.getWidgetAgentSelect().should('be.visible'); + backofficeAssistantPage + .getWidgetInput() + .should('be.visible') + .and('have.attr', 'placeholder', backofficeAssistantPage.getInputPlaceholder()); + backofficeAssistantPage.getWidgetSend().should('be.visible'); + backofficeAssistantPage.getWidgetHistoryButton().should('be.visible'); + backofficeAssistantPage.getWidgetNewChat().should('be.visible'); + backofficeAssistantPage.getWidgetAttach().should('be.visible'); + } + ); + + it( + 'the agent picker lists Auto plus every config-enabled agent, and selecting one updates the active-agent badge', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage + .getWidgetAgentSelect() + .find('option') + .first() + .should('contain.text', backofficeAssistantPage.getAutoAgentLabel()); + staticFixtures.enabledAgents.forEach((agentName): void => { + backofficeAssistantPage.getWidgetAgentSelect().find('option').should('contain.text', agentName); + }); + + backofficeAssistantPage.selectAgent(backofficeAssistantPage.getOrderManagementAgentLabel()); + + backofficeAssistantPage + .getWidgetAgentSelect() + .should('have.value', backofficeAssistantPage.getOrderManagementAgentLabel()); + backofficeAssistantPage + .getWidgetAgentBadge() + .should('contain.text', backofficeAssistantPage.getOrderManagementAgentLabel()); + } + ); + + it('the message input accepts typed text', { tags: ['@demo-smoke'] }, (): void => { + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage('List the latest orders'); + + backofficeAssistantPage.getWidgetInput().should('have.value', 'List the latest orders'); + }); + + it( + 'sends the prompt POST with the message payload and recovers when the provider fails', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithFailure(); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage('List the latest orders'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt').then((interception): void => { + const body = interception.request.body; + const serialized = typeof body === 'string' ? body : JSON.stringify(body); + expect(serialized).to.include('prompt'); + expect(serialized).to.include('List the latest orders'); + expect(serialized).to.include('selected_agent'); + expect(serialized).to.include('_token'); + }); + + backofficeAssistantPage.getWidgetMessages().should('contain.text', 'List the latest orders'); + backofficeAssistantPage + .getWidgetMessages() + .should('contain.text', backofficeAssistantPage.getTransportFailureText()); + backofficeAssistantPage.getWidgetRetryButton().should('be.visible'); + backofficeAssistantPage.getWidgetInput().should('not.be.disabled'); + } + ); + + it( + 'attaches a file as a chip, carries it in the prompt POST, and recovers when the provider fails', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithFailure(); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.attachFile(staticFixtures.attachmentImagePath); + + backofficeAssistantPage + .getWidgetAttachmentChipName() + .should('be.visible') + .and('have.attr', 'title', 'backoffice-assistant-attachment.png'); + + backofficeAssistantPage.typeMessage('Describe the attached image'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt').then((interception): void => { + const body = interception.request.body; + const serialized = typeof body === 'string' ? body : JSON.stringify(body); + expect(serialized).to.include('attachments'); + expect(serialized).to.include('mediaType'); + expect(serialized).to.include('image/png'); + }); + + backofficeAssistantPage.getWidgetMessageAttachmentPill().should('exist'); + backofficeAssistantPage + .getWidgetMessages() + .should('contain.text', backofficeAssistantPage.getTransportFailureText()); + backofficeAssistantPage.getWidgetAttachmentsPreview().children().should('have.length', 0); + backofficeAssistantPage.getWidgetInput().should('not.be.disabled'); + } + ); + + it( + 'adds the page form as a context chip, prefixes the prompt POST with it, and recovers when the provider fails', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithFailure(); + + backofficeAssistantPage.visitContextPage(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage + .getWidgetFormContextSuggestionLabel() + .should('be.visible') + .and('contain.text', staticFixtures.contextFormName); + + backofficeAssistantPage.addFormContext(); + + backofficeAssistantPage + .getWidgetContextChipName() + .should('be.visible') + .and('contain.text', staticFixtures.contextFormName); + + backofficeAssistantPage.typeMessage('Review the fields on this form'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt').then((interception): void => { + const body = interception.request.body; + const serialized = typeof body === 'string' ? body : JSON.stringify(body); + expect(serialized).to.include('[Form:'); + expect(serialized).to.include(staticFixtures.contextFormName); + expect(serialized).to.include('Review the fields on this form'); + }); + + backofficeAssistantPage.getWidgetMessages().should('contain.text', 'Review the fields on this form'); + backofficeAssistantPage + .getWidgetMessages() + .should('contain.text', backofficeAssistantPage.getTransportFailureText()); + backofficeAssistantPage.getWidgetContextChip().should('not.exist'); + backofficeAssistantPage.getWidgetInput().should('not.be.disabled'); + } + ); + + it( + 'New chat resets the conversation locally — it clears the messages, re-renders the greeting and issues no prompt/detail fetch', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithFailure(); + backofficeAssistantPage.interceptDetail(); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage('A message that should be wiped by New chat'); + backofficeAssistantPage.getWidgetInput().should('have.value', 'A message that should be wiped by New chat'); + + backofficeAssistantPage.newChat(); + + backofficeAssistantPage.getWidgetInput().should('have.value', ''); + backofficeAssistantPage.getWidgetMessages().should('contain.text', backofficeAssistantPage.getGreetingText()); + backofficeAssistantPage.getWidgetUserMessage().should('not.exist'); + + // eslint-disable-next-line spryker-cypress/no-cy-get-outside-repository -- Reads a cy.intercept() alias, not a DOM selector; there is no selector to move into a repository. + cy.get('@assistantPrompt.all').should('have.length', 0); + // eslint-disable-next-line spryker-cypress/no-cy-get-outside-repository -- Reads a cy.intercept() alias, not a DOM selector; there is no selector to move into a repository. + cy.get('@assistantDetail.all').should('have.length', 0); + } + ); + + it( + 'a streamed reasoning event renders as a dedicated reasoning bubble, distinct from the AI answer bubble', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithSseEvents([ + { type: 'reasoning', message: 'Inspecting the latest orders to answer the request.' }, + { type: 'ai_response', message: 'Here is a short summary.', conversation_reference: '' }, + ]); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage('Explain your reasoning'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt'); + + backofficeAssistantPage + .getWidgetReasoningMessage() + .should('be.visible') + .and('contain.text', 'Inspecting the latest orders to answer the request.'); + backofficeAssistantPage.getWidgetAiMessage().last().should('contain.text', 'Here is a short summary.'); + backofficeAssistantPage.getWidgetRetryButton().should('not.exist'); + } + ); + + it( + 'a streamed error event renders as an AI error bubble with a retry control, distinct from an HTTP transport failure', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithSseEvents([ + { type: 'error', message: 'The selected agent could not process the request.' }, + ]); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage('Trigger an in-stream error'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt'); + + backofficeAssistantPage + .getWidgetMessages() + .should('contain.text', 'Error: The selected agent could not process the request.'); + backofficeAssistantPage.getWidgetMessages().should('not.contain.text', 'Request failed with status'); + backofficeAssistantPage.getWidgetRetryButton().should('be.visible'); + backofficeAssistantPage.getWidgetInput().should('not.be.disabled'); + } + ); + + it( + 'a streamed agent_selected event updates the active-agent badge without a user selection', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithSseEvents([ + { type: 'agent_selected', agent: staticFixtures.stubbedAgentName, conversation_reference: '' }, + { type: 'ai_response', message: 'Handled by the resolved agent.', conversation_reference: '' }, + ]); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.getWidgetAgentSelect().should('have.value', ''); + backofficeAssistantPage.getWidgetAgentBadge().should('not.contain.text', staticFixtures.stubbedAgentName); + + backofficeAssistantPage.typeMessage('Let the assistant pick an agent'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt'); + + backofficeAssistantPage.getWidgetAgentBadge().should('contain.text', staticFixtures.stubbedAgentName); + backofficeAssistantPage.getWidgetAiMessage().last().should('contain.text', 'Handled by the resolved agent.'); + } + ); + + it( + 'renders no tool-call block for a streamed tool_call, only the final assistant answer', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithSseEvents([ + { type: 'tool_call', name: 'list_orders', arguments: { limit: 5 } }, + { type: 'ai_response', message: 'Fetched the orders.', conversation_reference: '' }, + ]); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage('Trigger a tool call'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt'); + + // A tool REQUEST is an in-progress signal only — it names the running tool on the + // transient loading indicator (removed once the stream closes) and never becomes a + // message block. Only tool_call_result renders a block; see the sibling test below. + backofficeAssistantPage.getWidgetAiMessage().last().should('contain.text', 'Fetched the orders.'); + backofficeAssistantPage.getWidgetToolCallMessage().should('not.exist'); + } + ); + + it( + 'a streamed tool_call_result event renders a tool-call block with a result toggle, distinct from a plain tool_call', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithSseEvents([ + { type: 'tool_call_result', name: 'list_orders', result: '{"orders":3}' }, + { type: 'ai_response', message: 'Summarised the result.', conversation_reference: '' }, + ]); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage('Trigger a tool result'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt'); + + backofficeAssistantPage.getWidgetToolCallMessage().should('have.length', 1); + backofficeAssistantPage + .getWidgetToolCallMessage() + .find(backofficeAssistantPage.getWidgetToolCallNameSelector()) + .should('contain.text', 'list_orders'); + backofficeAssistantPage.getWidgetToolCallResultToggle().should('exist').and('be.visible'); + } + ); + + it( + 'a streamed form_fill event writes the value into the current page form, no chat bubble', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithSseEvents([ + { + type: 'form_fill', + fields: { [staticFixtures.formFill.targetFieldName]: staticFixtures.formFillTargetValue }, + }, + { type: 'ai_response', message: 'Filled the requested field.', conversation_reference: '' }, + ]); + + backofficeAssistantPage.visitContextPage(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.getFormFillTargetField(staticFixtures.formFill.targetFieldName).should('exist'); + + backofficeAssistantPage.typeMessage('Fill the product name field'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt'); + + backofficeAssistantPage + .getFormFillTargetField(staticFixtures.formFill.targetFieldName) + .should('have.value', staticFixtures.formFillTargetValue); + backofficeAssistantPage.getWidgetAiMessage().last().should('contain.text', 'Filled the requested field.'); + } + ); + + it( + 'persists a streamed ai_response conversation_reference so New chat then reopen starts fresh', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithSseEvents([ + { type: 'ai_response', message: 'Persisted answer.', conversation_reference: 'cypress-stub-conv-ref-1' }, + ]); + backofficeAssistantPage.interceptDetail(); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage('Persist this conversation'); + backofficeAssistantPage.send(); + cy.wait('@assistantPrompt'); + + backofficeAssistantPage.getWidgetAiMessage().last().should('contain.text', 'Persisted answer.'); + + backofficeAssistantPage.newChat(); + backofficeAssistantPage.getWidgetMessages().should('contain.text', backofficeAssistantPage.getGreetingText()); + backofficeAssistantPage.getWidgetUserMessage().should('not.exist'); + // eslint-disable-next-line spryker-cypress/no-cy-get-outside-repository -- Reads a cy.intercept() alias, not a DOM selector; there is no selector to move into a repository. + cy.get('@assistantDetail.all').should('have.length', 0); + } + ); + + it( + 'sanitizes a streamed ai_response: markdown renders but script/onerror payloads are stripped', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithSseEvents([ + { type: 'ai_response', message: staticFixtures.xssPromptPayload, conversation_reference: '' }, + ]); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + cy.window().then((win) => { + (win as unknown as Record).__baXss = undefined; + }); + + backofficeAssistantPage.typeMessage('Render untrusted markup'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt'); + + backofficeAssistantPage + .getWidgetAiMessage() + .last() + /* eslint-disable spryker-cypress/no-cy-get-outside-repository -- XSS sanitization probe: generic HTML tag/attribute names scoped by .within(), not page selectors to move into a repository. */ + .within((): void => { + cy.get('strong').should('contain.text', 'bold'); + cy.get('script').should('not.exist'); + cy.get('[onerror]').should('not.exist'); + }); + /* eslint-enable spryker-cypress/no-cy-get-outside-repository */ + + cy.window().its('__baXss').should('be.undefined'); + } + ); + + it( + 'the retry control after a transport failure actually re-fires the prompt POST', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithFailure(); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage('Retry me'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt'); + backofficeAssistantPage.getWidgetRetryButton().should('be.visible').click(); + + cy.wait('@assistantPrompt').then((interception): void => { + const body = interception.request.body; + const serialized = typeof body === 'string' ? body : JSON.stringify(body); + expect(serialized).to.include('Retry me'); + }); + // eslint-disable-next-line spryker-cypress/no-cy-get-outside-repository -- Reads a cy.intercept() alias, not a DOM selector; there is no selector to move into a repository. + cy.get('@assistantPrompt.all').should('have.length.at.least', 2); + } + ); + + it( + 'removing an attachment chip before sending drops it — the sent prompt POST carries no attachment', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithFailure(); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.attachFile(staticFixtures.attachmentImagePath); + backofficeAssistantPage.getWidgetAttachmentChip().should('have.length', 1); + + backofficeAssistantPage.removeFirstAttachmentChip(); + backofficeAssistantPage.getWidgetAttachmentChip().should('have.length', 0); + + backofficeAssistantPage.typeMessage('No attachment should be sent'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt').then((interception): void => { + const body = interception.request.body; + const serialized = typeof body === 'string' ? body : JSON.stringify(body); + expect(serialized).to.not.include('attachments'); + }); + backofficeAssistantPage.getWidgetMessageAttachmentPill().should('not.exist'); + } + ); + + it( + 'attaching two files shows two chips and the sent prompt POST carries both attachments', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptPromptWithFailure(); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.attachFile([staticFixtures.attachmentImagePath, staticFixtures.attachmentImagePath]); + backofficeAssistantPage.getWidgetAttachmentChip().should('have.length', 2); + + backofficeAssistantPage.typeMessage('Describe both attachments'); + backofficeAssistantPage.send(); + + cy.wait('@assistantPrompt').then((interception): void => { + const body = interception.request.body; + const parsed = typeof body === 'string' ? JSON.parse(body) : body; + expect(parsed.attachments).to.have.length(2); + }); + backofficeAssistantPage.getWidgetMessageAttachmentPill().should('have.length', 2); + } + ); + + it( + 'selecting an unsupported file type is rejected client-side with an inline notice and no attachment chip', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.attachFile(staticFixtures.unsupportedAttachmentPath); + + backofficeAssistantPage.getWidgetAttachmentChip().should('have.length', 0); + backofficeAssistantPage + .getWidgetMessages() + .should('contain.text', backofficeAssistantPage.getUnsupportedFileTypeText()); + } + ); + + it( + 'lists conversations in the histories panel and removes a row on delete', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptHistoriesWith( + [ + { + conversation_reference: 'cypress-stub-conv-a', + name: staticFixtures.historyEntryName, + agent: backofficeAssistantPage.getOrderManagementAgentLabel(), + }, + { conversation_reference: 'cypress-stub-conv-b', name: 'Second stubbed conversation', agent: '' }, + ], + staticFixtures.enabledAgents + ); + backofficeAssistantPage.interceptDeleteSuccess(); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + backofficeAssistantPage.openHistories(); + + cy.wait('@assistantHistories'); + backofficeAssistantPage.getWidgetHistoryItem().should('have.length', 2); + backofficeAssistantPage.getWidgetHistoryItem().first().should('contain.text', staticFixtures.historyEntryName); + + backofficeAssistantPage.deleteFirstHistoryItem(); + + cy.wait('@assistantDelete').then((interception): void => { + expect(interception.response?.statusCode).to.eq(200); + expect(JSON.stringify(interception.response?.body)).to.include('success'); + }); + backofficeAssistantPage.getWidgetHistoryItem().should('have.length', 1); + } + ); + + it( + 'the histories panel shows the empty-state notice when no conversations exist', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.interceptHistoriesWith([], staticFixtures.enabledAgents); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + backofficeAssistantPage.openHistories(); + + cy.wait('@assistantHistories'); + backofficeAssistantPage.getWidgetHistoryItem().should('have.length', 0); + backofficeAssistantPage + .getWidgetHistoriesEmpty() + .should('be.visible') + .and('contain.text', backofficeAssistantPage.getHistoriesEmptyText()); + } + ); + + it( + 'the assistant endpoints reject unauthorized/invalid requests with the documented status contract', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.visitDashboard(); + + const detailUrl = backofficeAssistantPage.getBackofficeAbsoluteUrl( + backofficeAssistantPage.repositoryDetailPath() + ); + const deleteUrl = backofficeAssistantPage.getBackofficeAbsoluteUrl( + backofficeAssistantPage.repositoryDeletePath() + ); + const promptUrl = backofficeAssistantPage.getBackofficeAbsoluteUrl( + backofficeAssistantPage.repositoryPromptPath() + ); + const invalidToken = backofficeAssistantPage.getInvalidCsrfToken(); + const unknownReference = staticFixtures.unknownConversationReference; + + const contract: Array<{ + description: string; + request: Partial; + expectedStatus: number; + }> = [ + { + description: 'conversation detail without a reference is a 400', + request: { method: 'GET', url: detailUrl }, + expectedStatus: 400, + }, + { + description: 'conversation detail with an unknown reference is a 404', + request: { + method: 'GET', + url: `${detailUrl}?conversationReference=${encodeURIComponent(unknownReference)}`, + }, + expectedStatus: 404, + }, + { + description: 'conversation delete with an invalid CSRF token is a 403', + request: { + method: 'POST', + url: deleteUrl, + body: { conversation_reference: unknownReference, _token: invalidToken }, + }, + expectedStatus: 403, + }, + { + description: 'prompt with an invalid CSRF token is a 403', + request: { + method: 'POST', + url: promptUrl, + body: { prompt: 'Should never reach the provider', _token: invalidToken }, + }, + expectedStatus: 403, + }, + ]; + + contract.forEach(({ description, request, expectedStatus }): void => { + cy.request({ failOnStatusCode: false, ...request }).then((response): void => { + expect(response.status, description).to.eq(expectedStatus); + }); + }); + + backofficeAssistantPage.readCsrfToken().then((csrfToken): void => { + expect(csrfToken, 'CSRF token exposed to the widget').to.have.length.greaterThan(0); + + cy.request({ + method: 'POST', + url: deleteUrl, + failOnStatusCode: false, + body: { conversation_reference: unknownReference, _token: csrfToken }, + }).then((response): void => { + expect(response.status, 'valid-token delete of an unknown reference is a 404').to.eq(404); + }); + }); + } + ); + + it( + 'the prompt endpoint streams a resolved validation error (no 500, no raw glossary key) for each invalid payload', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.visitDashboard(); + + const promptUrl = backofficeAssistantPage.getBackofficeAbsoluteUrl( + backofficeAssistantPage.repositoryPromptPath() + ); + + backofficeAssistantPage.readCsrfToken().then((csrfToken): void => { + expect(csrfToken, 'CSRF token exposed to the widget').to.have.length.greaterThan(0); + + const tooManyAttachments = Array.from({ length: staticFixtures.attachmentMaxCount + 1 }, () => ({ + mediaType: 'image/png', + content: 'QUJD', + })); + + const invalidPayloads: Array<{ description: string; body: Record; message: string }> = [ + { + description: 'empty prompt', + body: { prompt: '', _token: csrfToken }, + message: staticFixtures.promptRequiredValidationMessage, + }, + { + description: 'unsupported attachment media type', + body: { + prompt: 'Describe this file', + _token: csrfToken, + attachments: [{ mediaType: 'application/zip', content: 'QUJD' }], + }, + message: staticFixtures.attachmentUnsupportedMediaTypeMessage, + }, + { + description: 'attachment count exceeded', + body: { prompt: 'Describe these files', _token: csrfToken, attachments: tooManyAttachments }, + message: staticFixtures.attachmentCountExceededMessage, + }, + ]; + + invalidPayloads.forEach(({ description, body, message }): void => { + cy.request({ method: 'POST', url: promptUrl, failOnStatusCode: false, body }).then((response): void => { + expect(response.status, `${description}: streamed, not a 500`).to.eq(200); + + const rawBody = typeof response.body === 'string' ? response.body : JSON.stringify(response.body); + expect(rawBody, `${description}: SSE stream carries an error event`).to.include('"type":"error"'); + expect(rawBody, `${description}: validation message is resolved`).to.include(message); + expect(rawBody, `${description}: no unresolved glossary key leaks`).to.not.include( + backofficeAssistantPage.getValidationGlossaryKey() + ); + }); + }); + }); + } + ); + + it( + 'when disabled, every assistant endpoint returns 403 and the widget is not injected', + { tags: ['@demo-smoke'] }, + (): void => { + backofficeAssistantPage.disableAssistant(); + + const indexUrl = backofficeAssistantPage.getBackofficeAbsoluteUrl( + backofficeAssistantPage.getHistoriesEndpointPath() + ); + const detailUrl = backofficeAssistantPage.getBackofficeAbsoluteUrl( + backofficeAssistantPage.repositoryDetailPath() + ); + + [indexUrl, detailUrl].forEach((url): void => { + cy.request({ + method: 'GET', + url, + failOnStatusCode: false, + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }).then((response): void => { + expect(response.status, 'disabled assistant endpoint is a 403').to.eq(403); + }); + }); + + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.getWidgetToggle().should('not.exist'); + backofficeAssistantPage.getWidgetPanel().should('not.exist'); + + backofficeAssistantPage.enableAssistant(); + } + ); + + describe('real AI provider flow (full, requires provider token)', { tags: ['@demo-full'] }, (): void => { + const REAL_FLOW_TIMEOUT = 15000; + const AWS_REAL_FLOW_TIMEOUT = 45000; + + it( + 'a real prompt streams an assistant answer that renders as a non-empty AI message bubble', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + backofficeAssistantPage.interceptRealPrompt(); + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage(staticFixtures.simplePrompt); + backofficeAssistantPage.send(); + + cy.wait('@assistantRealPrompt', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + backofficeAssistantPage + .getWidgetAiMessage({ timeout: REAL_FLOW_TIMEOUT }) + .should('have.length.at.least', 2) + .last() + .should('not.be.empty'); + backofficeAssistantPage.getWidgetUserMessage().should('contain.text', staticFixtures.simplePrompt); + backofficeAssistantPage.getWidgetInput().should('not.be.disabled'); + backofficeAssistantPage.getWidgetRetryButton().should('not.exist'); + } + ); + + it( + 'a persisted conversation reloads its prior user and assistant messages from the history detail endpoint', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + backofficeAssistantPage.interceptRealPrompt(); + backofficeAssistantPage.interceptHistories(); + backofficeAssistantPage.interceptDetail(); + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage(staticFixtures.simplePrompt); + backofficeAssistantPage.send(); + cy.wait('@assistantRealPrompt', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + backofficeAssistantPage.getWidgetAiMessage({ timeout: REAL_FLOW_TIMEOUT }).should('have.length.at.least', 2); + + backofficeAssistantPage.newChat(); + backofficeAssistantPage.openHistories(); + + cy.wait('@assistantHistories', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + backofficeAssistantPage.getWidgetHistoryItem().should('have.length.at.least', 1); + + backofficeAssistantPage.clickFirstHistoryItem(); + + cy.wait('@assistantDetail', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + backofficeAssistantPage.getWidgetUserMessage().should('have.length.at.least', 1); + backofficeAssistantPage.getWidgetAiMessage().should('have.length.at.least', 1); + } + ); + + it( + 'deleting a conversation this spec created removes its entry from the history list', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + backofficeAssistantPage.interceptRealPrompt(); + backofficeAssistantPage.interceptHistories(); + backofficeAssistantPage.interceptDelete(); + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.typeMessage(staticFixtures.simplePrompt); + backofficeAssistantPage.send(); + cy.wait('@assistantRealPrompt', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + backofficeAssistantPage.getWidgetAiMessage({ timeout: REAL_FLOW_TIMEOUT }).should('have.length.at.least', 2); + + backofficeAssistantPage.newChat(); + backofficeAssistantPage.openHistories(); + cy.wait('@assistantHistories', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + backofficeAssistantPage + .getWidgetHistoryItem() + .should('have.length.at.least', 1) + .then(($items): void => { + const initialCount = $items.length; + + backofficeAssistantPage.deleteFirstHistoryItem(); + + cy.wait('@assistantDelete', { timeout: REAL_FLOW_TIMEOUT }).then((interception): void => { + expect(interception.response?.statusCode).to.be.within(200, 299); + expect(JSON.stringify(interception.response?.body)).to.include('success'); + }); + + backofficeAssistantPage.getWidgetHistoryItem().should('have.length', initialCount - 1); + }); + } + ); + + it( + 'the Form Fill agent writes a value into the current page form via a real prompt', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + backofficeAssistantPage.interceptRealPrompt(); + backofficeAssistantPage.visitContextPage(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.addFormContext(); + backofficeAssistantPage.selectAgent(staticFixtures.formFill.agentName); + + backofficeAssistantPage.expandFormFillTargetFieldLocale(staticFixtures.formFill.targetFieldName); + + backofficeAssistantPage + .getFormFillTargetField(staticFixtures.formFill.targetFieldName) + .should('be.visible') + .clear() + .then(($field): void => { + const originalValue = String($field.val() ?? ''); + + backofficeAssistantPage.typeMessage(staticFixtures.formFill.prompt); + backofficeAssistantPage.send(); + + cy.wait('@assistantRealPrompt', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + backofficeAssistantPage + .getFormFillTargetField(staticFixtures.formFill.targetFieldName) + .should(($updated): void => { + const filledValue = String($updated.val() ?? ''); + expect(filledValue).to.not.equal(originalValue); + expect(filledValue.trim()).to.not.be.empty; + }); + }); + } + ); + + it( + 'the Order Management agent renders a tool-call block alongside a final assistant answer', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + backofficeAssistantPage.interceptRealPrompt(); + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.selectAgent(staticFixtures.orderManagement.agentName); + backofficeAssistantPage.typeMessage(staticFixtures.orderManagement.prompt); + backofficeAssistantPage.send(); + + cy.wait('@assistantRealPrompt', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + backofficeAssistantPage + .getWidgetToolCallMessage({ timeout: REAL_FLOW_TIMEOUT }) + .should('have.length.at.least', 1); + backofficeAssistantPage + .getWidgetToolCallMessage() + .first() + .find(backofficeAssistantPage.getWidgetToolCallNameSelector()) + .should('exist'); + backofficeAssistantPage.getWidgetAiMessage().should('have.length.at.least', 2); + } + ); + + it( + 'an attached image sends with a real prompt, shows the attachment pill and renders a non-empty answer', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + backofficeAssistantPage.interceptRealPrompt(); + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.attachFile(staticFixtures.attachmentImagePath); + backofficeAssistantPage.getWidgetAttachmentChipName().should('be.visible'); + + backofficeAssistantPage.typeMessage('Describe the attached image.'); + backofficeAssistantPage.send(); + + cy.wait('@assistantRealPrompt', { timeout: REAL_FLOW_TIMEOUT }).then((interception): void => { + expect(interception.response?.statusCode).to.be.within(200, 299); + const body = interception.request.body; + const serialized = typeof body === 'string' ? body : JSON.stringify(body); + expect(serialized).to.include('attachments'); + }); + + backofficeAssistantPage.getWidgetMessageAttachmentPill().should('exist'); + backofficeAssistantPage + .getWidgetAiMessage({ timeout: REAL_FLOW_TIMEOUT }) + .should('have.length.at.least', 2) + .last() + .should('not.be.empty'); + } + ); + + it( + 'with Auto selected a real answer resolves an agent and populates the active-agent badge', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + backofficeAssistantPage.interceptRealPrompt(); + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.getWidgetAgentSelect().should('have.value', ''); + backofficeAssistantPage.typeMessage(staticFixtures.simplePrompt); + backofficeAssistantPage.send(); + + cy.wait('@assistantRealPrompt', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + backofficeAssistantPage.getWidgetAiMessage({ timeout: REAL_FLOW_TIMEOUT }).should('have.length.at.least', 2); + backofficeAssistantPage.getWidgetAgentBadge().invoke('text').should('have.length.greaterThan', 0); + } + ); + + let switchedToAws = false; + + after((): void => { + if (!switchedToAws) { + return; + } + + restoreBackofficeAssistantOpenAiVendor(); + }); + + it( + 'drives a real prompt against the AWS Bedrock provider and logs a configuration-filterable audit row', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + switchedToAws = true; + backofficeAssistantPage.clearWidgetPanelState(); + aiConfigurationPage.setFeatureVendor(ASSISTANT_FEATURE, 'aws'); + + backofficeAssistantPage.interceptRealPrompt(); + backofficeAssistantPage.visitDashboard(); + backofficeAssistantPage.openPanel(); + + backofficeAssistantPage.selectAgent(staticFixtures.orderManagement.agentName); + backofficeAssistantPage.typeMessage(staticFixtures.orderManagement.prompt); + backofficeAssistantPage.send(); + + cy.wait('@assistantRealPrompt', { timeout: AWS_REAL_FLOW_TIMEOUT }).then((interception): void => { + if ((interception.response?.statusCode ?? 0) >= 200 && (interception.response?.statusCode ?? 0) <= 299) { + return; + } + + backofficeAssistantPage.getWidgetRetryButton().should('be.visible').click(); + cy.wait('@assistantRealPrompt', { timeout: AWS_REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + }); + + backofficeAssistantPage + .getWidgetAiMessage({ timeout: AWS_REAL_FLOW_TIMEOUT }) + .should('have.length.at.least', 2) + .last() + .should('not.be.empty'); + + restoreBackofficeAssistantOpenAiVendor(); + + auditLogsPage.assertNewestRowConfigurationIsFilterable('AWS'); + } + ); + }); + } +); diff --git a/cypress/e2e/demo/cost-price.cy.ts b/cypress/e2e/demo/cost-price.cy.ts new file mode 100644 index 000000000..7cf651cc7 --- /dev/null +++ b/cypress/e2e/demo/cost-price.cy.ts @@ -0,0 +1,325 @@ +import { container } from '@utils'; +import { UserLoginScenario } from '@scenarios/backoffice'; +import { AgentLoginScenario, CustomerLoginScenario } from '@scenarios/yves'; +import { MerchantUserLoginScenario } from '@scenarios/mp'; +import { CostPricePage } from '@pages/backoffice'; +import { AgentQuoteRequestPage } from '@pages/yves'; +import { ProductsPage } from '@pages/mp'; +import { CostPriceDemoStaticFixtures } from '@interfaces/demo'; + +describe( + 'Cost Price - RFQ cost prices & margins across Back Office, Merchant Portal and storefront', + { + tags: ['@demo', '@cost-price'], + }, + (): void => { + const userLoginScenario = container.get(UserLoginScenario); + const agentLoginScenario = container.get(AgentLoginScenario); + const customerLoginScenario = container.get(CustomerLoginScenario); + const merchantUserLoginScenario = container.get(MerchantUserLoginScenario); + + const costPricePage = container.get(CostPricePage); + const quoteRequestPage = container.get(AgentQuoteRequestPage); + const productsPage = container.get(ProductsPage); + + let staticFixtures: CostPriceDemoStaticFixtures; + // Resolved from the SKU at runtime — see BackofficePage.resolveProductAbstractIdBySku(). + let idProductAbstract: number; + + before((): void => { + staticFixtures = Cypress.env('staticFixtures'); + }); + + describe('Back Office - admin edits & views imported cost prices', (): void => { + beforeEach((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + + costPricePage.resolveProductAbstractIdBySku(staticFixtures.product.sku).then((id) => { + idProductAbstract = id; + }); + }); + + it( + 'product edit form opens for the SKU and shows a price table with Cost, Gross and Net price columns and editable cost-amount inputs', + { tags: ['@demo-smoke'] }, + (): void => { + costPricePage.visitProductEdit(idProductAbstract).its('response.statusCode').should('eq', 200); + + cy.title().should('contain', staticFixtures.product.sku); + + costPricePage.getPriceTable().should('exist'); + + costPricePage.getPriceTableHeaders().then(($headers) => { + const headerTexts = $headers.toArray().map((th: HTMLElement) => th.textContent?.trim()); + + expect(headerTexts).to.include('Cost price'); + expect(headerTexts).to.include('Gross price'); + expect(headerTexts).to.include('Net price'); + }); + + costPricePage + .getCostAmountInputs() + .should('have.length.at.least', 1) + .each(($input) => { + cy.wrap($input).should('not.be.disabled').and('not.have.attr', 'readonly'); + }); + } + ); + + it( + 'editing a cost-amount value and saving the product persists the new cost price across a reload', + { tags: ['@demo-smoke'] }, + (): void => { + costPricePage.visitProductEdit(idProductAbstract).its('response.statusCode').should('eq', 200); + + costPricePage.openPriceTaxTab(); + + costPricePage.getFirstCostAmountValue().then((originalValue) => { + const newCostValue = '333.33'; + + costPricePage.setFirstCostAmount(newCostValue); + costPricePage.save(); + costPricePage.getSaveSuccessMessage(staticFixtures.product.sku).should('be.visible'); + + costPricePage.visitProductEdit(idProductAbstract); + costPricePage.openPriceTaxTab(); + costPricePage.getFirstCostAmountInput().should('have.value', newCostValue); + + costPricePage.setFirstCostAmount(String(originalValue)); + costPricePage.save(); + costPricePage.getSaveSuccessMessage(staticFixtures.product.sku).should('be.visible'); + }); + } + ); + + it( + 'product view page shows a visible Price & Taxes widget with a Cost price row rendering a non-empty value', + { tags: ['@demo-smoke'] }, + (): void => { + costPricePage.visitProductView(idProductAbstract).its('response.statusCode').should('eq', 200); + + costPricePage.getPriceTaxWidget().should('be.visible'); + costPricePage.getCostPriceViewRow().should('have.length.at.least', 1).and('be.visible'); + + costPricePage + .getCostPriceViewValues() + .should('have.length.at.least', 1) + .first() + .invoke('text') + .then((text) => { + expect(text.trim()).to.match(/\d/); + }); + } + ); + }); + + describe('Merchant Portal - merchant user manages the Cost price column', (): void => { + const editedCostValue = '291.00'; + const editedCostDisplayValue = '291'; + let costValueToRestore: string | null = null; + + beforeEach((): void => { + merchantUserLoginScenario.execute({ + username: staticFixtures.merchant.username, + password: staticFixtures.defaultPassword, + }); + }); + + afterEach((): void => { + if (costValueToRestore === null) { + return; + } + + const originalValue = costValueToRestore; + costValueToRestore = null; + + merchantUserLoginScenario.execute({ + username: staticFixtures.merchant.username, + password: staticFixtures.defaultPassword, + }); + productsPage.visit(); + productsPage.find({ query: staticFixtures.merchantProduct.sku }); + productsPage.openProductBySku(staticFixtures.merchantProduct.sku); + + productsPage.getCostColumnValues().then((currentCostValues) => { + if (!currentCostValues.includes(editedCostDisplayValue)) { + return; + } + + productsPage.editCostAmountByCurrentValue(editedCostDisplayValue, originalValue); + productsPage.saveDrawer(); + }); + }); + + it( + 'merchant product price grid exposes a "Cost Default" column alongside the Net/Gross price columns', + { tags: ['@demo-smoke'] }, + (): void => { + productsPage.visit(); + productsPage.find({ query: staticFixtures.merchantProduct.sku }); + productsPage.openProductBySku(staticFixtures.merchantProduct.sku); + + productsPage.getPriceTableHeaders().then(($headers) => { + const headerTexts = $headers.toArray().map((th: HTMLElement) => th.textContent?.trim()); + + expect(headerTexts).to.include(productsPage.getCostPriceColumnTitle()); + expect(headerTexts).to.include('Gross Default'); + expect(headerTexts).to.include('Net Default'); + }); + } + ); + + it( + 'merchant edits a Cost Default cell in the price grid, saves, and the new cost value persists after reopening the product', + { tags: ['@demo-smoke'] }, + (): void => { + productsPage.visit(); + productsPage.find({ query: staticFixtures.merchantProduct.sku }); + productsPage.openProductBySku(staticFixtures.merchantProduct.sku); + + productsPage.getCostColumnValues().then((originalCostValues) => { + const originalFirstCost = originalCostValues[0]; + + expect(originalFirstCost, 'the first price row has a cost value to edit').to.match(/\d/); + costValueToRestore = originalFirstCost; + + productsPage.editFirstCostAmount(editedCostValue); + productsPage.saveDrawer(); + + productsPage.visit(); + productsPage.find({ query: staticFixtures.merchantProduct.sku }); + productsPage.openProductBySku(staticFixtures.merchantProduct.sku); + + productsPage.getCostColumnValues().then((persistedCostValues) => { + expect(persistedCostValues, 'the edited cost value is persisted in the Cost Default column').to.include( + editedCostDisplayValue + ); + }); + }); + } + ); + }); + + describe('Storefront customer - cost price and margin are hidden', (): void => { + beforeEach((): void => { + customerLoginScenario.execute({ + email: staticFixtures.customer.email, + password: staticFixtures.defaultPassword, + }); + }); + + it( + 'a plain customer opening their quote request sees the line items but no cost-price molecule or gross-margin', + { tags: ['@demo-smoke'] }, + (): void => { + quoteRequestPage + .visitCustomerDetails(staticFixtures.quoteRequest.calculatedReference) + .its('response.statusCode') + .should('eq', 200); + + quoteRequestPage.getCartItems().should('have.length.at.least', 1); + quoteRequestPage.getCostPriceMolecules().should('have.length', 0); + quoteRequestPage.getGrossMargins().should('have.length', 0); + } + ); + }); + + describe('Storefront agent - cost price and gross margin per line item', (): void => { + beforeEach((): void => { + agentLoginScenario.execute({ + username: staticFixtures.agent.username, + password: staticFixtures.defaultPassword, + }); + }); + + it( + 'agent opening a calculated quote request sees a cost-price molecule with a real gross-margin percentage on every line item', + { tags: ['@demo-smoke'] }, + (): void => { + quoteRequestPage.recalculate(staticFixtures.quoteRequest.calculatedReference); + + quoteRequestPage.getCartItems().then(($items) => { + const itemCount = $items.length; + + expect(itemCount).to.be.greaterThan(0); + + quoteRequestPage.getCostPriceMolecules().should('have.length', itemCount); + quoteRequestPage.getCostPriceValues().filter('[data-has-cost-price="1"]').should('have.length', itemCount); + quoteRequestPage.getGrossMargins().should('have.length', itemCount); + }); + + quoteRequestPage + .getGrossMargins() + .first() + .invoke('text') + .then((text) => { + expect(text.trim()).to.match(/\d+\s*%/); + }); + } + ); + + it( + 'each cost-price molecule renders its two branches consistently: an available margin shows a percentage, an unavailable one shows "—" paired with a warning icon', + { tags: ['@demo-smoke'] }, + (): void => { + quoteRequestPage.visitDetails(staticFixtures.quoteRequest.uncalculatedReference); + + quoteRequestPage.getCartItems().should('have.length.at.least', 1); + quoteRequestPage.getCostPriceMolecules().should('have.length.at.least', 1); + + const unavailableText = quoteRequestPage.getUnavailableCostPriceText(); + + quoteRequestPage.getGrossMargins().each(($margin) => { + const text = $margin.text().trim(); + + if (quoteRequestPage.isGrossMarginUnavailable($margin)) { + expect(text).to.eq(unavailableText); + } else { + expect(text).to.match(/\d+\s*%/); + } + }); + + quoteRequestPage.getCostPriceValues().each(($value) => { + const hasCostPrice = $value.attr(quoteRequestPage.getCostPriceDataAttribute()); + + expect(hasCostPrice, 'each cost-price value declares its data state').to.be.oneOf(['0', '1']); + if (hasCostPrice === '0') { + expect($value.text().trim()).to.eq(unavailableText); + } + }); + + quoteRequestPage.getGrossMarginUnavailable().then(($unavailable) => { + quoteRequestPage.getCostPriceWarningIcon().then(($icons) => { + if ($unavailable.length) { + expect($icons.length, 'unavailable margins are paired with a warning icon').to.be.greaterThan(0); + } else { + expect($icons.length, 'no warning icon without an unavailable margin').to.eq(0); + } + }); + }); + } + ); + + it( + 'editing and saving a quote request as agent keeps a real gross-margin percentage on every line item (recalculation applies)', + { tags: ['@demo-smoke'] }, + (): void => { + quoteRequestPage.recalculate(staticFixtures.quoteRequest.uncalculatedReference); + + quoteRequestPage.getGrossMargins().should('have.length.at.least', 1); + quoteRequestPage.getGrossMarginUnavailable().should('have.length', 0); + quoteRequestPage + .getGrossMargins() + .first() + .invoke('text') + .then((text) => { + expect(text.trim()).to.match(/\d+\s*%/); + }); + } + ); + }); + } +); diff --git a/cypress/e2e/demo/quick-add-by-image.cy.ts b/cypress/e2e/demo/quick-add-by-image.cy.ts new file mode 100644 index 000000000..d60f1e199 --- /dev/null +++ b/cypress/e2e/demo/quick-add-by-image.cy.ts @@ -0,0 +1,229 @@ +import { container, skipUnlessAiProviderEnabled } from '@utils'; +import { CustomerLoginScenario } from '@scenarios/yves'; +import { UserLoginScenario } from '@scenarios/backoffice'; +import { QuickOrderImageToCartPage } from '@pages/yves'; +import { QuickAddByImageDemoStaticFixtures } from '@interfaces/demo'; + +describe( + 'Quick Add by Image - storefront Quick Order image-to-cart', + { + tags: ['@demo', '@quick-add-by-image', '@ai-commerce'], + }, + (): void => { + const customerLoginScenario = container.get(CustomerLoginScenario); + const userLoginScenario = container.get(UserLoginScenario); + const quickOrderImageToCartPage = container.get(QuickOrderImageToCartPage); + + let staticFixtures: QuickAddByImageDemoStaticFixtures; + + before((): void => { + staticFixtures = Cypress.env('staticFixtures'); + + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + quickOrderImageToCartPage.enableQuickAddByImage(); + }); + + beforeEach((): void => { + customerLoginScenario.execute({ + email: staticFixtures.customer.email, + password: staticFixtures.defaultPassword, + }); + }); + + it( + 'shows the "Add to cart from image" section with an image-only file input, browse-file label and enabled Upload button', + { tags: ['@demo-smoke'] }, + (): void => { + quickOrderImageToCartPage.visitQuickOrder().its('response.statusCode').should('eq', 200); + + quickOrderImageToCartPage + .getPageTitle() + .should('be.visible') + .and('contain.text', quickOrderImageToCartPage.getPageTitleText()); + quickOrderImageToCartPage.getImageToCartSection().should('exist'); + quickOrderImageToCartPage + .getImageToCartTitle() + .should('be.visible') + .and('contain.text', quickOrderImageToCartPage.getImageToCartTitleText()); + + quickOrderImageToCartPage + .getImageUploadInput() + .should('exist') + .and('have.attr', 'type', 'file') + .and('have.attr', 'name', quickOrderImageToCartPage.getImageUploadInputName()) + .and('have.attr', 'accept', quickOrderImageToCartPage.getAcceptedImageMimeTypes()); + + quickOrderImageToCartPage.getBrowseFileLabel().should('be.visible'); + + quickOrderImageToCartPage + .getUploadSubmitButton() + .should('be.visible') + .and('not.be.disabled') + .and('contain.text', quickOrderImageToCartPage.getUploadButtonText()); + } + ); + + it( + 'shows an attached image by name and releases the browse label input binding', + { tags: ['@demo-smoke'] }, + (): void => { + quickOrderImageToCartPage.visitQuickOrder(); + + quickOrderImageToCartPage + .getFileSelectLabel() + .should('be.visible') + .and('contain.text', quickOrderImageToCartPage.getBrowseFileText()); + + quickOrderImageToCartPage.attachImage(staticFixtures.imageFilePath); + + quickOrderImageToCartPage.getImageUploadInput().should(($input) => { + const input = $input[0] as HTMLInputElement; + expect(input.files).to.have.length(1); + expect(input.files?.[0].name).to.eq(staticFixtures.imageFileName); + }); + + quickOrderImageToCartPage + .getFileSelectLabel() + .should('contain.text', staticFixtures.imageFileName) + .and('not.contain.text', quickOrderImageToCartPage.getBrowseFileText()); + + quickOrderImageToCartPage.getBrowseFileToggleLabel().should('not.have.attr', 'for'); + } + ); + + it( + 'rejects an oversized file client-side with the too-large error and clears the input', + { tags: ['@demo-smoke'] }, + (): void => { + quickOrderImageToCartPage.visitQuickOrder(); + + quickOrderImageToCartPage.getImageUploadInput().then(($input) => { + const maxFileSizeInBytes = Number( + ($input.closest('[data-qa="component quick-order-image-to-cart"]')[0] as HTMLElement).getAttribute( + 'max-file-size' + ) + ); + expect(maxFileSizeInBytes).to.be.greaterThan(0); + + quickOrderImageToCartPage.attachSyntheticFile({ + fileName: 'oversized-probe.png', + sizeInBytes: maxFileSizeInBytes + 1, + mimeType: 'image/png', + }); + }); + + quickOrderImageToCartPage.getFileSelectError().should('be.visible').invoke('text').should('match', /\S/); + quickOrderImageToCartPage.getImageUploadInput().should(($input) => { + expect(($input[0] as HTMLInputElement).value).to.eq(''); + }); + } + ); + + it( + 'removes the attached file, restoring the "Browse file" placeholder and re-binding the browse label', + { tags: ['@demo-smoke'] }, + (): void => { + quickOrderImageToCartPage.visitQuickOrder(); + quickOrderImageToCartPage.attachImage(staticFixtures.imageFilePath); + + quickOrderImageToCartPage.getFileSelectLabel().should('contain.text', staticFixtures.imageFileName); + quickOrderImageToCartPage.getRemoveFileIcon().should('be.visible'); + + quickOrderImageToCartPage.removeAttachedFile(); + + quickOrderImageToCartPage + .getFileSelectLabel() + .should('contain.text', quickOrderImageToCartPage.getBrowseFileText()) + .and('not.contain.text', staticFixtures.imageFileName); + quickOrderImageToCartPage.getRemoveFileIcon().should('not.be.visible'); + quickOrderImageToCartPage + .getBrowseFileToggleLabel() + .should('have.attr', 'for', 'image_order_form_uploadImageOrder'); + quickOrderImageToCartPage.getImageUploadInput().should(($input) => { + expect(($input[0] as HTMLInputElement).value).to.eq(''); + }); + } + ); + + it( + 'shows the "no image" validation error when Upload is pressed with no file attached', + { tags: ['@demo-smoke'] }, + (): void => { + quickOrderImageToCartPage.visitQuickOrder(); + + quickOrderImageToCartPage.submitEmptyImageOrder().then((interception) => { + expect(interception.request.method).to.eq('POST'); + expect(interception.request.body).to.contain('name="uploadImage"'); + expect(interception.response?.statusCode).to.eq(200); + }); + + quickOrderImageToCartPage.getImageToCartSection().should('exist'); + quickOrderImageToCartPage.getErrorDropzone().should('exist'); + quickOrderImageToCartPage.getErrorMessage().should('be.visible').invoke('text').should('match', /\S/); + } + ); + + it( + 'rejects a non-image file server-side with the error dropzone and no recognized row', + { tags: ['@demo-smoke'] }, + (): void => { + quickOrderImageToCartPage + .submitNonImageFileViaRequest({ + fileName: 'not-an-image.txt', + contents: 'this is plain text, not an image', + mimeType: 'text/plain', + }) + .then((response: { status: number; body: string }) => { + expect(response.status).to.eq(200); + expect(response.body).to.contain('input-dropzone--error'); + expect(response.body).to.not.contain('js-autocomplete-form__value-input" value="'); + }); + } + ); + + it( + 'submits the attached image as a multipart POST and handles the no-provider response gracefully', + { tags: ['@demo-smoke'] }, + (): void => { + quickOrderImageToCartPage.visitQuickOrder(); + quickOrderImageToCartPage.attachImage(staticFixtures.imageFilePath); + + quickOrderImageToCartPage.submitImageOrder().then((interception) => { + expect(interception.request.method).to.eq('POST'); + expect(interception.request.headers['content-type']).to.contain('multipart/form-data'); + expect(interception.request.body).to.contain('name="image_order_form[uploadImageOrder]"'); + expect(interception.request.body).to.contain(staticFixtures.imageFileName); + expect(interception.request.body).to.contain('name="uploadImage"'); + expect(interception.response?.statusCode).to.eq(200); + }); + + quickOrderImageToCartPage.getImageToCartSection().should('exist'); + quickOrderImageToCartPage.getErrorDropzone().should('exist'); + quickOrderImageToCartPage.getErrorMessage().should('be.visible').invoke('text').should('match', /\S/); + } + ); + + describe('real provider flow (full, requires provider token)', { tags: ['@demo-full'] }, (): void => { + it( + 'submits a real image to the AI provider and re-renders the quick-order rows with the recognition result', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + quickOrderImageToCartPage.visitQuickOrder(); + quickOrderImageToCartPage.attachImage(staticFixtures.imageFilePath); + + quickOrderImageToCartPage.submitImageOrderReal().then((interception) => { + expect(interception.response?.statusCode).to.be.within(200, 299); + }); + + quickOrderImageToCartPage.getQuickOrderRows().should('exist'); + quickOrderImageToCartPage.getRecognizedSkuInputs().should('have.length.greaterThan', 0); + } + ); + }); + } +); diff --git a/cypress/e2e/demo/quicksight-analytics.cy.ts b/cypress/e2e/demo/quicksight-analytics.cy.ts new file mode 100644 index 000000000..b3055870c --- /dev/null +++ b/cypress/e2e/demo/quicksight-analytics.cy.ts @@ -0,0 +1,63 @@ +import { container } from '@utils'; +import { UserLoginScenario } from '@scenarios/backoffice'; +import { QuicksightAnalyticsPage } from '@pages/backoffice'; +import { QuicksightAnalyticsDemoStaticFixtures } from '@interfaces/demo'; + +describe( + 'QuickSight Analytics - Back Office embedded analytics page', + { + tags: ['@demo', '@quicksight-analytics'], + }, + (): void => { + const userLoginScenario = container.get(UserLoginScenario); + const quicksightAnalyticsPage = container.get(QuicksightAnalyticsPage); + + let staticFixtures: QuicksightAnalyticsDemoStaticFixtures; + + before((): void => { + staticFixtures = Cypress.env('staticFixtures'); + }); + + beforeEach((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + }); + + it( + 'Analytics page loads with HTTP 200 and shows the "Analytics" section title — confirms the dropped-config 500 regression stays fixed', + { tags: ['@demo-smoke'] }, + (): void => { + quicksightAnalyticsPage.visitAnalytics().its('response.statusCode').should('eq', 200); + + quicksightAnalyticsPage.getSectionTitle().should('contain.text', quicksightAnalyticsPage.getSectionTitleText()); + } + ); + + it( + 'without Analytics permission, shows the no-permission info alert plus a working "Synchronize Users" POST form wired to the endpoint with a CSRF token', + { tags: ['@demo-smoke'] }, + (): void => { + quicksightAnalyticsPage.visitAnalytics(); + + quicksightAnalyticsPage + .getInfoAlert() + .should('be.visible') + .and('contain.text', quicksightAnalyticsPage.getNoPermissionText()); + + quicksightAnalyticsPage + .getSynchronizeUsersButton() + .should('be.visible') + .and('be.enabled') + .and('contain.text', quicksightAnalyticsPage.getSynchronizeUsersLabel()); + + quicksightAnalyticsPage.getSynchronizeUsersForm().should('have.attr', 'method').and('match', /post/i); + quicksightAnalyticsPage + .getSynchronizeUsersForm() + .should('have.attr', 'action', quicksightAnalyticsPage.getSynchronizeUsersFormAction()); + quicksightAnalyticsPage.getSynchronizeUsersCsrfToken().should('have.attr', 'value').and('have.length.gt', 0); + } + ); + } +); diff --git a/cypress/e2e/demo/search-by-image.cy.ts b/cypress/e2e/demo/search-by-image.cy.ts new file mode 100644 index 000000000..5a821d1e7 --- /dev/null +++ b/cypress/e2e/demo/search-by-image.cy.ts @@ -0,0 +1,270 @@ +import { container, skipUnlessAiProviderEnabled } from '@utils'; +import { UserLoginScenario } from '@scenarios/backoffice'; +import { CustomerLoginScenario } from '@scenarios/yves'; +import { SearchByImagePage } from '@pages/yves'; +import { SearchByImageDemoStaticFixtures } from '@interfaces/demo'; + +describe( + 'Search by Image - storefront search-bar image search', + { + tags: ['@demo', '@search-by-image', '@ai-commerce'], + }, + (): void => { + const userLoginScenario = container.get(UserLoginScenario); + const customerLoginScenario = container.get(CustomerLoginScenario); + const searchByImagePage = container.get(SearchByImagePage); + + let staticFixtures: SearchByImageDemoStaticFixtures; + + before((): void => { + staticFixtures = Cypress.env('staticFixtures'); + + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + searchByImagePage.enableSearchByImage(); + }); + + beforeEach((): void => { + customerLoginScenario.execute({ + email: staticFixtures.customer.email, + password: staticFixtures.defaultPassword, + }); + }); + + it('renders the search-by-image control inside the storefront search bar', { tags: ['@demo-smoke'] }, (): void => { + searchByImagePage.visitSearchResults().its('response.statusCode').should('eq', 200); + + searchByImagePage.getWrapper().first().children().should('have.length.greaterThan', 0); + }); + + it( + 'shows the file-search trigger, file input and hidden CSRF token, keeps the camera trigger hidden on desktop, and opens the upload popup on click', + { tags: ['@demo-smoke'] }, + (): void => { + searchByImagePage.visitSearchResults(); + + searchByImagePage.getFileInput().first().should('exist').and('have.attr', 'type', 'file'); + searchByImagePage.getToken().first().should('exist').and('have.attr', 'type', 'hidden'); + + searchByImagePage.getPhotoButton().first().should('exist').and('have.class', 'is-hidden'); + searchByImagePage.getPhotoButton().first().should('not.be.visible'); + + searchByImagePage.getDesktopFileSearchButton().should('be.visible'); + searchByImagePage.clickFileTrigger(); + searchByImagePage.getOpenFilePopupUploadButton().should('be.visible').and('not.be.disabled'); + } + ); + + it( + 'attaching an image posts multipart form-data to /search-by-image and handles the response without crashing', + { tags: ['@demo-smoke'] }, + (): void => { + searchByImagePage.visitSearchResults(); + searchByImagePage.interceptSearchByImageRequest(); + + searchByImagePage.attachImage(staticFixtures.probeImagePath); + + cy.wait('@searchByImageRequest').then((interception) => { + expect(interception.request.method).to.eq('POST'); + expect(interception.request.headers['content-type']).to.contain('multipart/form-data'); + + const rawBody = interception.request.body as unknown; + const isBinaryBody = + typeof rawBody === 'object' && rawBody !== null && typeof (rawBody as ArrayBuffer).byteLength === 'number'; + const requestBody = isBinaryBody + ? new TextDecoder().decode(new Uint8Array(rawBody as ArrayBuffer)) + : String(rawBody ?? ''); + + expect(requestBody).to.contain('search_by_image[image]'); + expect(requestBody).to.contain('search_by_image[_token]'); + expect(requestBody).to.contain('filename'); + + expect(interception.response?.statusCode).to.eq(200); + }); + + searchByImagePage.getDesktopInstance().should('exist'); + } + ); + + it( + 'surfaces a stubbed failure response in the upload popup error list and does not redirect', + { tags: ['@demo-smoke'] }, + (): void => { + const stubbedError = 'Search by Image is currently unavailable. Please try again later.'; + + searchByImagePage.visitSearchResults(); + searchByImagePage.stubSearchByImageFailure([stubbedError]); + + searchByImagePage.submitImageThroughFilePopup(staticFixtures.probeImagePath); + + cy.wait('@searchByImageRequest').its('response.statusCode').should('eq', 200); + + searchByImagePage.getFilePopupError().should('be.visible').and('not.have.class', 'is-hidden'); + searchByImagePage.getFilePopupErrorItems().should('contain.text', stubbedError); + + cy.location('pathname').should('eq', staticFixtures.searchResultsUrl.split('?')[0]); + } + ); + + it( + 'handles a stubbed HTTP 503 gracefully, keeping the control intact with no redirect', + { tags: ['@demo-smoke'] }, + (): void => { + searchByImagePage.visitSearchResults(); + searchByImagePage.stubSearchByImageServerError(); + + searchByImagePage.submitImageThroughFilePopup(staticFixtures.probeImagePath); + + cy.wait('@searchByImageRequest').its('response.statusCode').should('eq', 503); + + searchByImagePage.getDesktopInstance().should('exist'); + cy.location('pathname').should('eq', staticFixtures.searchResultsUrl.split('?')[0]); + } + ); + + it( + 'renders a search-by-image instance on every search bar, each with its own file input and CSRF token', + { tags: ['@demo-smoke'] }, + (): void => { + searchByImagePage.visitSearchResults(); + + searchByImagePage.getInstances().should('have.length.greaterThan', 0); + + searchByImagePage.getInstances().each(($instance) => { + cy.wrap($instance).find(searchByImagePage.getFileInputSelector()).should('exist'); + cy.wrap($instance).find(searchByImagePage.getTokenSelector()).should('exist'); + }); + } + ); + + it( + 'rejects GET with 405 and returns a validation error for an image-less POST', + { tags: ['@demo-smoke'] }, + (): void => { + searchByImagePage.requestEndpoint('GET').then((response) => { + expect(response.status).to.eq(405); + }); + + searchByImagePage.requestEndpoint('POST').then((response) => { + expect(response.status).to.eq(200); + expect(response.body).to.deep.eq({ + isSuccessful: false, + errors: [searchByImagePage.getNoImageErrorText()], + }); + }); + } + ); + + it( + 'rejects an image POST carrying an invalid CSRF token with a CSRF validation error and no redirectUrl', + { tags: ['@demo-smoke'] }, + (): void => { + const boundary = '----cypressSearchByImageBoundary'; + const body = searchByImagePage.buildMultipartBody({ + boundary, + fileName: 'probe.png', + contentType: 'image/png', + contents: 'binarystub', + token: 'INVALID_CSRF_TOKEN', + }); + + searchByImagePage + .requestEndpoint('POST', { body, contentType: `multipart/form-data; boundary=${boundary}` }) + .then((response) => { + expect(response.status).to.eq(200); + expect(response.body.isSuccessful).to.eq(false); + expect(response.body).to.not.have.property('redirectUrl'); + expect(JSON.stringify(response.body.errors)).to.contain(searchByImagePage.getCsrfErrorMarker()); + }); + } + ); + + it( + 'rejects an unsupported MIME type (text/plain upload) with an unsupported-type error and no redirectUrl', + { tags: ['@demo-smoke'] }, + (): void => { + const boundary = '----cypressSearchByImageMimeBoundary'; + const body = searchByImagePage.buildMultipartBody({ + boundary, + fileName: 'not-an-image.txt', + contentType: 'text/plain', + contents: 'this is plain text, not an image', + token: 'INVALID_CSRF_TOKEN', + }); + + searchByImagePage + .requestEndpoint('POST', { body, contentType: `multipart/form-data; boundary=${boundary}` }) + .then((response) => { + expect(response.status).to.eq(200); + expect(response.body.isSuccessful).to.eq(false); + expect(response.body).to.not.have.property('redirectUrl'); + expect(JSON.stringify(response.body.errors)).to.contain(searchByImagePage.getUnsupportedTypeErrorMarker()); + }); + } + ); + + describe('feature disabled - storefront hides the control and the endpoint refuses to search', (): void => { + before((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + searchByImagePage.setSearchByImageEnabled(false); + }); + + after((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + searchByImagePage.setSearchByImageEnabled(true); + }); + + it('renders the search-bar wrapper empty when the feature is disabled', { tags: ['@demo-smoke'] }, (): void => { + searchByImagePage.visitSearchResults().its('response.statusCode').should('eq', 200); + + searchByImagePage.getWrapper().should('exist'); + searchByImagePage.getInstances().should('have.length', 0); + }); + + it( + 'refuses to search while disabled: a bodyless POST returns isSuccessful:false with no redirectUrl', + { tags: ['@demo-smoke'] }, + (): void => { + searchByImagePage.requestEndpoint('POST').then((response) => { + expect(response.status).to.eq(200); + expect(response.body.isSuccessful).to.eq(false); + expect(response.body).to.not.have.property('redirectUrl'); + }); + } + ); + }); + + describe('real provider flow (full, requires provider token)', { tags: ['@demo-full'] }, (): void => { + it( + 'submitting a real product image returns a redirect and lands on a results surface', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + searchByImagePage + .submitImageThroughFilePopupUntilSuccessful(staticFixtures.probeImagePath, { + maxAttempts: 3, + timeout: 30000, + }) + .then((interception) => { + expect(interception.response?.statusCode).to.be.within(200, 299); + expect(interception.response?.body).to.have.property('isSuccessful', true); + expect(interception.response?.body.redirectUrl).to.be.a('string').and.not.be.empty; + }); + + cy.location('pathname', { timeout: 30000 }).should('not.eq', staticFixtures.searchResultsUrl.split('?')[0]); + + searchByImagePage.getResultsPageSurface().should('exist').its('length').should('be.greaterThan', 0); + } + ); + }); + } +); diff --git a/cypress/e2e/demo/smart-cms.cy.ts b/cypress/e2e/demo/smart-cms.cy.ts new file mode 100644 index 000000000..da7b62a9b --- /dev/null +++ b/cypress/e2e/demo/smart-cms.cy.ts @@ -0,0 +1,467 @@ +import { container, skipUnlessAiProviderEnabled } from '@utils'; +import { UserLoginScenario } from '@scenarios/backoffice'; +import { AiConfigurationPage, AuditLogsPage, SmartCmsPage } from '@pages/backoffice'; +import { SmartCmsDemoStaticFixtures } from '@interfaces/demo'; + +describe( + 'Smart CMS - Back Office CMS content assistant panel', + { + tags: ['@demo', '@smart-cms', '@ai-commerce'], + }, + (): void => { + const userLoginScenario = container.get(UserLoginScenario); + const smartCmsPage = container.get(SmartCmsPage); + const aiConfigurationPage = container.get(AiConfigurationPage); + const auditLogsPage = container.get(AuditLogsPage); + + const REAL_FLOW_TIMEOUT = 15000; + const AWS_GENERATE_ATTEMPT_TIMEOUT_MS = 45000; + const AWS_GENERATE_MAX_ATTEMPTS = 3; + + const restoreSmartCmsOpenAiVendor = (): Cypress.Chainable => + aiConfigurationPage.setFeatureVendor('smart_cms', 'openai'); + + type GenerateInterception = { response?: { statusCode?: number } }; + + const isSuccessfulInterception = (interception: GenerateInterception): boolean => + (interception.response?.statusCode ?? 0) >= 200 && (interception.response?.statusCode ?? 0) <= 299; + + const isSettledInterception = (interception: GenerateInterception): boolean => interception.response !== undefined; + + const findSettledInterceptionForClick = ( + allInterceptions: Array, + requestCountBeforeClick: number + ): GenerateInterception | undefined => allInterceptions.slice(requestCountBeforeClick).find(isSettledInterception); + + const clickAskAiAndAwaitRealGenerate = (attemptsLeft = AWS_GENERATE_MAX_ATTEMPTS): void => { + // eslint-disable-next-line spryker-cypress/no-cy-get-outside-repository -- Reads a cy.intercept() alias, not a DOM selector; there is no selector to move into a repository. + cy.get('@generateRequest.all').then((before: unknown): void => { + const requestCountBeforeClick = (before as Array).length; + + smartCmsPage.clickAskAi(); + + // eslint-disable-next-line spryker-cypress/no-cy-get-outside-repository -- Reads a cy.intercept() alias, not a DOM selector; there is no selector to move into a repository. + cy.get('@generateRequest.all', { timeout: AWS_GENERATE_ATTEMPT_TIMEOUT_MS }) + .should((interceptions: unknown): void => { + const settled = findSettledInterceptionForClick( + interceptions as Array, + requestCountBeforeClick + ); + + expect(settled, 'the request triggered by this click has a settled response').to.not.be.undefined; + }) + .then((interceptions: unknown): void => { + const settled = findSettledInterceptionForClick( + interceptions as Array, + requestCountBeforeClick + ) as GenerateInterception; + + if (isSuccessfulInterception(settled)) { + return; + } + + if (attemptsLeft <= 1) { + expect( + settled.response?.statusCode ?? 0, + `generate responded 2xx within ${AWS_GENERATE_MAX_ATTEMPTS} attempts` + ).to.be.within(200, 299); + + return; + } + + smartCmsPage.getPanelAsk().should('not.be.disabled'); + clickAskAiAndAwaitRealGenerate(attemptsLeft - 1); + }); + }); + }; + + let staticFixtures: SmartCmsDemoStaticFixtures; + + before((): void => { + staticFixtures = Cypress.env('staticFixtures'); + }); + + beforeEach((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + + smartCmsPage.enableSmartCms(); + }); + + after((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + + smartCmsPage.enableSmartCms(); + }); + + it( + 'renders the Smart CMS panel on the Page editor, reveals its controls when expanded, and exposes its inline config on the Block editor', + { tags: ['@demo-smoke'] }, + (): void => { + smartCmsPage.visitCmsPageEditor().its('response.statusCode').should('eq', 200); + + smartCmsPage.getPanel().should('be.visible'); + smartCmsPage.getPanelToggle().should('be.visible').and('contain.text', smartCmsPage.getPanelToggleTitle()); + + smartCmsPage.getPanelInput().should('exist'); + smartCmsPage.getPanelAsk().should('exist'); + smartCmsPage.getPanelAttach().should('exist'); + + smartCmsPage.getPanelToggle().click(); + + smartCmsPage + .getPanelInput() + .should('be.visible') + .and('have.attr', 'placeholder', smartCmsPage.getPanelInputPlaceholder()); + smartCmsPage.getPanelAsk().should('be.visible').and('contain.text', smartCmsPage.getPanelAskLabel()); + smartCmsPage.getPanelAttach().should('be.visible'); + + smartCmsPage.visitCmsBlockEditor().its('response.statusCode').should('eq', 200); + smartCmsPage.getPanel().should('be.visible'); + cy.window().its(smartCmsPage.getContentConfigWindowKey()).should('be.an', 'object'); + } + ); + + it('accepts typed text in the prompt input once the panel is expanded', { tags: ['@demo-smoke'] }, (): void => { + smartCmsPage.visitCmsPageEditor(); + smartCmsPage.expandPanel(); + + smartCmsPage.typePrompt('Write a punchy hero title for this landing page'); + }); + + it( + 'lists an attached file by its name and clears it via its remove control', + { tags: ['@demo-smoke'] }, + (): void => { + smartCmsPage.visitCmsPageEditor(); + smartCmsPage.expandPanel(); + + smartCmsPage.attachFile(staticFixtures.probeImagePath); + smartCmsPage + .getPanelAttachmentName() + .should('have.length', 1) + .and('contain.text', smartCmsPage.getProbeImageFileName()); + + smartCmsPage.removeFirstAttachment(); + smartCmsPage.getPanelAttachmentName().should('have.length', 0); + } + ); + + it( + 'sends the typed prompt in the generate POST and surfaces an error when the provider fails', + { tags: ['@demo-smoke'] }, + (): void => { + smartCmsPage.visitCmsPageEditor(); + smartCmsPage.expandPanel(); + smartCmsPage.interceptGenerateWithProviderFailure(); + + const prompt = 'Write a punchy hero title for this landing page'; + smartCmsPage.typePrompt(prompt); + smartCmsPage.clickAskAi(); + + cy.wait('@generateRequest').then((interception): void => { + expect(interception.request.method).to.eq('POST'); + + const body = interception.request.body; + const payload = typeof body === 'string' ? JSON.parse(body) : body; + + expect(payload).to.have.property('userPrompt', prompt); + expect(payload).to.have.property('entityType', staticFixtures.cmsPageEntityType); + expect(payload).to.have.property('idEntity', staticFixtures.cmsPageIdEntity); + expect(payload).to.have.property('_token').that.is.a('string').and.not.empty; + expect(payload).to.have.property('placeholders'); + }); + + smartCmsPage.getPanelMessage().should('have.class', smartCmsPage.getPanelMessageErrorClass()).and('be.visible'); + smartCmsPage.getPanelAsk().should('be.visible').and('not.be.disabled'); + } + ); + + it('includes the attached file in the generate POST payload', { tags: ['@demo-smoke'] }, (): void => { + smartCmsPage.visitCmsPageEditor(); + smartCmsPage.expandPanel(); + smartCmsPage.interceptGenerateWithProviderFailure(); + + smartCmsPage.attachFile(staticFixtures.probeImagePath); + smartCmsPage.getPanelAttachmentName().should('have.length', 1); + + smartCmsPage.typePrompt('Suggest alt text based on the attached image'); + smartCmsPage.clickAskAi(); + + cy.wait('@generateRequest').then((interception): void => { + const body = interception.request.body; + const payload = typeof body === 'string' ? JSON.parse(body) : body; + + expect(payload.attachments).to.be.an('array').with.length(1); + expect(payload.attachments[0]).to.have.property('mediaType', staticFixtures.probeImageMediaType); + expect(payload.attachments[0]).to.have.property('content').that.is.a('string').and.not.empty; + }); + + smartCmsPage.getPanelMessage().should('be.visible'); + }); + + it( + 'guards the generate endpoint: GET, token-less POST, invalid CSRF token and (feature off) all rejected', + { tags: ['@demo-smoke'] }, + (): void => { + const guardContracts: Array<{ + description: string; + options: { method?: string; body?: Record }; + expectedStatus: number; + expectedErrorKey?: string; + disableFeature?: boolean; + }> = [ + { description: 'GET is rejected', options: { method: 'GET' }, expectedStatus: 403 }, + { description: 'token-less POST is rejected', options: { method: 'POST', body: {} }, expectedStatus: 403 }, + { + description: 'invalid CSRF token is rejected with the invalid-csrf error key', + options: { method: 'POST', body: { _token: 'not-a-valid-token', userPrompt: 'x' } }, + expectedStatus: 403, + expectedErrorKey: staticFixtures.invalidCsrfErrorKey, + }, + { + description: 'when disabled, the disabled error key is returned ahead of the CSRF check', + options: { method: 'POST', body: { _token: 'not-a-valid-token' } }, + expectedStatus: 403, + expectedErrorKey: staticFixtures.disabledErrorKey, + disableFeature: true, + }, + ]; + + cy.wrap(guardContracts).each((contract: (typeof guardContracts)[number]): void => { + if (contract.disableFeature) { + smartCmsPage.disableSmartCms(); + } + + smartCmsPage.requestGenerate(staticFixtures.generateEndpointPath, contract.options).then((response): void => { + expect(response.status, contract.description).to.eq(contract.expectedStatus); + if (contract.expectedErrorKey) { + expect(response.body).to.have.property('error', contract.expectedErrorKey); + } + }); + + if (contract.disableFeature) { + smartCmsPage.enableSmartCms(); + } + }); + } + ); + + it( + 'rejects an unsupported attachment media type with 422 and a structured error', + { tags: ['@demo-smoke'] }, + (): void => { + smartCmsPage.visitCmsPageEditor(); + + smartCmsPage.getInjectedCsrfToken().then((token): void => { + expect(token, 'editor injected a CSRF token').to.be.a('string').and.not.empty; + + smartCmsPage + .requestGenerate(staticFixtures.generateEndpointPath, { + method: 'POST', + body: { + _token: token, + userPrompt: 'x', + attachments: [{ mediaType: staticFixtures.unsupportedAttachmentMediaType, content: 'QUJD' }], + }, + }) + .then((response): void => { + expect(response.status).to.eq(422); + expect(response.body.error).to.be.an('array').with.length.of.at.least(1); + expect((response.body.error as Array<{ message: string }>)[0]).to.have.property( + 'message', + staticFixtures.unsupportedAttachmentError + ); + }); + }); + } + ); + + it( + 'expands then collapses the panel, updating the collapsed class and aria-expanded', + { tags: ['@demo-smoke'] }, + (): void => { + smartCmsPage.visitCmsPageEditor(); + + smartCmsPage.getPanel().should('have.class', smartCmsPage.getPanelCollapsedClass()); + smartCmsPage.getPanelToggle().should('have.attr', 'aria-expanded', 'false'); + + smartCmsPage.getPanelToggle().click(); + smartCmsPage.getPanel().should('not.have.class', smartCmsPage.getPanelCollapsedClass()); + smartCmsPage.getPanelToggle().should('have.attr', 'aria-expanded', 'true'); + smartCmsPage.getPanelInput().should('be.visible'); + + smartCmsPage.getPanelToggle().click(); + smartCmsPage.getPanel().should('have.class', smartCmsPage.getPanelCollapsedClass()); + smartCmsPage.getPanelToggle().should('have.attr', 'aria-expanded', 'false'); + } + ); + + it( + 'issues no generate request when Ask AI is clicked with an empty prompt', + { tags: ['@demo-smoke'] }, + (): void => { + smartCmsPage.visitCmsPageEditor(); + smartCmsPage.expandPanel(); + smartCmsPage.interceptGenerateWithProviderFailure(); + + smartCmsPage.getPanelInput().clear(); + smartCmsPage.clickAskAi(); + + // eslint-disable-next-line spryker-cypress/no-cy-get-outside-repository -- Reads a cy.intercept() alias, not a DOM selector; there is no selector to move into a repository. + cy.get('@generateRequest.all').should('have.length', 0); + smartCmsPage.getPanelMessage().should('not.have.class', smartCmsPage.getPanelMessageVisibleClass()); + } + ); + + it( + 'rejects an unsupported file type client-side with an error and does not list it as an attachment', + { tags: ['@demo-smoke'] }, + (): void => { + smartCmsPage.visitCmsPageEditor(); + smartCmsPage.expandPanel(); + + smartCmsPage.attachUnsupportedFile(staticFixtures.unsupportedAttachmentMediaType); + + smartCmsPage.getPanelMessage().should('have.class', smartCmsPage.getPanelMessageErrorClass()).and('be.visible'); + smartCmsPage.getPanelAttachmentName().should('have.length', 0); + } + ); + + it( + 'issues the block-scoped generate POST after expanding, typing and attaching in the CMS Block editor', + { tags: ['@demo-smoke'] }, + (): void => { + smartCmsPage.visitCmsBlockEditor(); + smartCmsPage.expandPanel(); + smartCmsPage.interceptGenerateWithProviderFailure(); + + smartCmsPage.attachFile(staticFixtures.probeImagePath); + smartCmsPage.getPanelAttachmentName().should('have.length', 1); + + const prompt = 'Write a short promotional block heading'; + smartCmsPage.typePrompt(prompt); + smartCmsPage.clickAskAi(); + + cy.wait('@generateRequest').then((interception): void => { + const body = interception.request.body; + const payload = typeof body === 'string' ? JSON.parse(body) : body; + + expect(payload).to.have.property('userPrompt', prompt); + expect(payload).to.have.property('entityType', staticFixtures.cmsBlockEntityType); + expect(payload).to.have.property('idEntity', staticFixtures.cmsBlockIdEntity); + expect(payload.attachments).to.be.an('array').with.length(1); + }); + + smartCmsPage.getPanelMessage().should('have.class', smartCmsPage.getPanelMessageErrorClass()).and('be.visible'); + } + ); + + describe('real AI provider generate (full, requires provider token)', { tags: ['@demo-full'] }, (): void => { + it( + 'drives the real generate (with and without an attachment) and writes the returned content into the glossary editor', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + smartCmsPage.visitCmsPageEditor(); + smartCmsPage.expandPanel(); + smartCmsPage.interceptRealGenerate(); + + smartCmsPage.typePrompt(smartCmsPage.getHeroPrompt()); + smartCmsPage.clickAskAi(); + + cy.wait('@generateRequest', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + smartCmsPage.getPanelSuccessMessage().should('be.visible'); + smartCmsPage.getPanelAsk().should('be.visible').and('not.be.disabled'); + smartCmsPage.getGlossaryEditorValue().should('have.length.greaterThan', 0); + + smartCmsPage.visitCmsPageEditor(); + smartCmsPage.expandPanel(); + smartCmsPage.interceptRealGenerate(); + + smartCmsPage.attachFile(staticFixtures.probeImagePath); + smartCmsPage.getPanelAttachmentName().should('have.length', 1); + + smartCmsPage.typePrompt('Use the attached image to suggest a hero title and matching intro copy'); + smartCmsPage.clickAskAi(); + + cy.wait('@generateRequest', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + smartCmsPage.getPanelSuccessMessage().should('be.visible'); + } + ); + }); + + describe( + 'real AI provider generate against AWS Bedrock (full, requires provider token)', + { tags: ['@demo-full'] }, + (): void => { + let switchedToAws = false; + + after((): void => { + if (!switchedToAws) { + return; + } + + restoreSmartCmsOpenAiVendor(); + }); + + const driveRealGenerate = (editor: { + visit: () => Cypress.Chainable; + entityType: string; + prompt: string; + }): void => { + editor.visit(); + cy.window().its(smartCmsPage.getContentConfigWindowKey()).its('entityType').should('eq', editor.entityType); + smartCmsPage.getPanelToggle().should('be.visible'); + smartCmsPage.expandPanel(); + smartCmsPage.interceptRealGenerate(); + + smartCmsPage.typePrompt(editor.prompt); + clickAskAiAndAwaitRealGenerate(); + + smartCmsPage.getPanelSuccessMessage().should('be.visible'); + smartCmsPage.getGlossaryEditorValue().should('have.length.greaterThan', 0); + }; + + it( + 'drives real generate on both a CMS Page and a CMS Block against AWS Bedrock and logs a configuration-filterable audit row', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + switchedToAws = true; + aiConfigurationPage.setFeatureVendor('smart_cms', 'aws'); + + driveRealGenerate({ + visit: () => smartCmsPage.visitCmsPageEditor(), + entityType: staticFixtures.cmsPageEntityType, + prompt: smartCmsPage.getHeroPrompt(), + }); + + driveRealGenerate({ + visit: () => smartCmsPage.visitCmsBlockEditor(), + entityType: staticFixtures.cmsBlockEntityType, + prompt: smartCmsPage.getBlockPrompt(), + }); + + restoreSmartCmsOpenAiVendor(); + + auditLogsPage.assertNewestRowConfigurationIsFilterable('AWS'); + } + ); + } + ); + } +); diff --git a/cypress/e2e/demo/smart-pim.cy.ts b/cypress/e2e/demo/smart-pim.cy.ts new file mode 100644 index 000000000..84ac14ba3 --- /dev/null +++ b/cypress/e2e/demo/smart-pim.cy.ts @@ -0,0 +1,584 @@ +import { container, skipUnlessAiProviderEnabled } from '@utils'; +import { UserLoginScenario } from '@scenarios/backoffice'; +import { AiConfigurationPage, AuditLogsPage, SmartPimPage } from '@pages/backoffice'; +import { SmartPimDemoStaticFixtures } from '@interfaces/demo'; + +const SMART_PIM_FEATURE = 'smart_pim'; +const REAL_FLOW_TIMEOUT = 15000; + +describe( + 'Smart PIM - Back Office product AI-assist controls', + { + tags: ['@demo', '@smart-pim', '@ai-commerce'], + }, + (): void => { + const userLoginScenario = container.get(UserLoginScenario); + const smartPimPage = container.get(SmartPimPage); + const aiConfigurationPage = container.get(AiConfigurationPage); + const auditLogsPage = container.get(AuditLogsPage); + + let staticFixtures: SmartPimDemoStaticFixtures; + // Resolved from the SKU at runtime — see BackofficePage.resolveProductAbstractIdBySku(). + let idProductAbstract: number; + + before((): void => { + staticFixtures = Cypress.env('staticFixtures'); + }); + + beforeEach((): void => { + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + + smartPimPage.resolveProductAbstractIdBySku(staticFixtures.product.sku).then((id) => { + idProductAbstract = id; + }); + }); + + it( + 'renders the Smart PIM bundles and AI-assist controls on the product edit page', + { tags: ['@demo-smoke'] }, + (): void => { + smartPimPage.visitProductEdit(idProductAbstract).its('response.statusCode').should('eq', 200); + + cy.title().should('contain', staticFixtures.product.sku); + smartPimPage.getSmartPimScript().should('exist'); + smartPimPage.getRequestBuilderScript().should('exist'); + + smartPimPage.getRequestBuilderTriggers().should('have.length.at.least', 1).and('be.visible'); + smartPimPage + .getCategoryTrigger() + .should('be.visible') + .and('have.attr', 'data-url', smartPimPage.getCategorySuggestionPath()) + .and('have.attr', 'popovertarget', smartPimPage.getCategoryModalId()); + smartPimPage.getAltImageTriggers().should('have.length.at.least', 1); + + smartPimPage.getCategoryModal().should('exist'); + smartPimPage.getAltTextModal().should('exist'); + smartPimPage.getTranslationModal().should('exist'); + smartPimPage.getAllActionsPopover().should('exist'); + smartPimPage.getLocaleSelectorPopover().should('exist'); + } + ); + + it( + 'opening the AI-assist controls (load, all-actions, locale-selector) fires no provider request', + { tags: ['@demo-smoke'] }, + (): void => { + smartPimPage.getProviderEndpointGlobs().forEach((endpoint, index): void => { + cy.intercept('POST', endpoint).as(`providerCall${index}`); + }); + + smartPimPage.visitProductEdit(idProductAbstract); + smartPimPage.getCategoryTrigger().should('be.visible'); + + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + smartPimPage + .getTranslateActionButton() + .should('be.visible') + .and('have.attr', 'data-target-popover', '.locale-selector-popover'); + smartPimPage + .getImproveContentButton() + .should('be.visible') + .and('have.attr', 'data-request-url', smartPimPage.getContentImproverPath()) + .and('have.attr', 'data-request-ready'); + + smartPimPage.closeAllActionsPopover(); + smartPimPage.shouldBeClosedPopover(smartPimPage.getAllActionsPopover()); + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + + smartPimPage.openLocaleSelectorPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getLocaleSelectorPopover()); + smartPimPage + .getLocaleButtons() + .should('have.length.at.least', 1) + .and('have.attr', 'data-request-url', smartPimPage.getTranslatePath()); + + smartPimPage.getProviderEndpointGlobs().forEach((_endpoint, index): void => { + // eslint-disable-next-line spryker-cypress/no-cy-get-outside-repository -- Reads a cy.intercept() alias, not a DOM selector; there is no selector to move into a repository. + cy.get(`@providerCall${index}.all`).should('have.length', 0); + }); + } + ); + + it( + 'opens the category and alt-text modals in their empty state for an empty product and fires no provider request', + { tags: ['@demo-smoke'] }, + (): void => { + const emptyStateModals = [ + { + endpoint: `**${smartPimPage.getCategorySuggestionPath()}`, + alias: 'categorySuggestion', + prepare: (): void => { + smartPimPage.clearInformationalFields(); + }, + trigger: (): void => { + smartPimPage.clickCategoryTrigger(); + }, + getModal: (): Cypress.Chainable => smartPimPage.getCategoryModal(), + getEmptyText: (): Cypress.Chainable => smartPimPage.getCategoryModalEmptyText(), + emptyText: smartPimPage.getCategoryEmptyText(), + getField: (): Cypress.Chainable => smartPimPage.getCategorySelect(), + }, + { + endpoint: `**${smartPimPage.getImageAltTextPath()}`, + alias: 'imageAltText', + prepare: (): void => { + smartPimPage.clearImageUrlFields(); + }, + trigger: (): void => { + smartPimPage.clickFirstAltImageTrigger(); + }, + getModal: (): Cypress.Chainable => smartPimPage.getAltTextModal(), + getEmptyText: (): Cypress.Chainable => smartPimPage.getAltTextModalEmptyText(), + emptyText: smartPimPage.getAltTextEmptyText(), + getField: (): Cypress.Chainable => smartPimPage.getAltTextInput(), + }, + ]; + + emptyStateModals.forEach((modal): void => { + cy.intercept('POST', modal.endpoint).as(modal.alias); + + smartPimPage.visitProductEdit(idProductAbstract); + modal.prepare(); + modal.trigger(); + + smartPimPage.shouldBeOpenPopover(modal.getModal()); + modal.getModal().should('have.class', smartPimPage.getEmptyClass()); + modal.getEmptyText().should('be.visible').and('contain', modal.emptyText); + modal.getField().should('exist'); + + // eslint-disable-next-line spryker-cypress/no-cy-get-outside-repository -- Reads a cy.intercept() alias, not a DOM selector; there is no selector to move into a repository. + cy.get(`@${modal.alias}.all`).should('have.length', 0); + }); + } + ); + + it( + 'issues the AI-assist POST for a populated product and shows the error when the provider fails', + { tags: ['@demo-smoke'] }, + (): void => { + const failingProviderCalls = [ + { + endpoint: `**${smartPimPage.getCategorySuggestionPath()}`, + alias: 'categorySuggestion', + trigger: (): void => { + smartPimPage.clickCategoryTrigger(); + }, + expectedBodyKeys: ['product_name', 'product_description'], + getModal: (): Cypress.Chainable => smartPimPage.getCategoryModal(), + getErrorHolder: (): Cypress.Chainable => smartPimPage.getCategoryModalErrorHolder(), + assertControlIntact: (): void => { + smartPimPage.getCategoryTrigger().should('exist'); + }, + }, + { + endpoint: `**${smartPimPage.getImageAltTextPath()}`, + alias: 'imageAltText', + trigger: (): void => { + smartPimPage.clickFirstAltImageTrigger(); + }, + expectedBodyKeys: ['imageUrl', 'locale'], + getModal: (): Cypress.Chainable => smartPimPage.getAltTextModal(), + getErrorHolder: (): Cypress.Chainable => smartPimPage.getAltTextModalErrorHolder(), + assertControlIntact: (): void => { + smartPimPage.getAltImageTriggers().first().should('exist'); + }, + }, + ]; + + failingProviderCalls.forEach((call): void => { + cy.intercept('POST', call.endpoint, { + statusCode: 503, + body: { errors: [{ message: smartPimPage.getProviderUnavailableMessage() }] }, + }).as(call.alias); + + smartPimPage.visitProductEdit(idProductAbstract); + call.trigger(); + + cy.wait(`@${call.alias}`).then((interception): void => { + const body = interception.request.body; + const serialized = typeof body === 'string' ? body : JSON.stringify(body); + call.expectedBodyKeys.forEach((key): void => { + expect(serialized).to.include(key); + }); + }); + + call.getModal().should('be.visible').and('not.have.class', smartPimPage.getLoadingClass()); + call.getErrorHolder().should('be.visible').and('contain', smartPimPage.getProviderUnavailableMessage()); + call.assertControlIntact(); + }); + } + ); + + it( + 'answers a param-less request to each of the four AI endpoints with its documented 400 contract', + { tags: ['@demo-smoke'] }, + (): void => { + smartPimPage.visitProductEdit(idProductAbstract); + + const contract: Array<{ url: string; expectedMessage: string | null }> = [ + { + url: smartPimPage.getContentImproverUrl(), + expectedMessage: smartPimPage.getMissingParamsMessage('content-improver'), + }, + { + url: smartPimPage.getImageAltTextUrl(), + expectedMessage: smartPimPage.getMissingParamsMessage('image-alt-text'), + }, + { url: smartPimPage.getTranslateUrl(), expectedMessage: smartPimPage.getMissingParamsMessage('translate') }, + { url: smartPimPage.getCategorySuggestionUrl(), expectedMessage: null }, + ]; + + contract.forEach(({ url, expectedMessage }): void => { + (['GET', 'POST'] as Array).forEach((method): void => { + cy.request({ method, url, failOnStatusCode: false }).then((response): void => { + expect(response.status, `${method} ${url}`).to.eq(400); + + if (expectedMessage !== null) { + expect(JSON.stringify(response.body)).to.contain(expectedMessage); + } + }); + }); + }); + } + ); + + it( + 'injects a fresh alt-text trigger into an image wrapper added to the DOM after load', + { tags: ['@demo-smoke'] }, + (): void => { + const marker = 'cypress-injected-image-wrapper'; + + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.getAltTriggerTemplate().should('exist'); + smartPimPage.appendImageAltTextWrapper(marker); + + smartPimPage.getInjectedWrapper(marker).should('have.class', smartPimPage.getAffixClass()); + smartPimPage + .getInjectedAltTrigger(marker) + .should('have.length', 1) + .and('have.attr', 'data-url', smartPimPage.getImageAltTextPath()); + } + ); + + it( + 'surfaces a failed improve-content error in the response popover and leaves the suggestion field empty', + { tags: ['@demo-smoke'] }, + (): void => { + cy.intercept('POST', `**${smartPimPage.getContentImproverPath()}`, { + statusCode: 422, + body: { errors: [{ message: 'Content generation failed' }] }, + }).as('contentImprover'); + + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + smartPimPage.clickImproveContent(); + + cy.wait('@contentImprover'); + + smartPimPage.shouldBeOpenPopover(smartPimPage.getResponsePopover()); + smartPimPage.getResponseErrorBlock().should('be.visible').and('contain', 'Content generation failed'); + smartPimPage.getResponseField().should('have.value', ''); + } + ); + + it( + 're-issues the improve-content request from the "try again" button without reopening the action popover', + { tags: ['@demo-smoke'] }, + (): void => { + cy.intercept('POST', `**${smartPimPage.getContentImproverPath()}`, { + statusCode: 200, + body: { improvedText: 'stubbed improved copy' }, + }).as('contentImprover'); + + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + smartPimPage.clickImproveContent(); + + cy.wait('@contentImprover'); + smartPimPage.getResponseField().should('have.value', 'stubbed improved copy'); + + smartPimPage.clickResponseAgain(); + + cy.wait('@contentImprover'); + // eslint-disable-next-line spryker-cypress/no-cy-get-outside-repository -- Reads a cy.intercept() alias, not a DOM selector; there is no selector to move into a repository. + cy.get('@contentImprover.all').should('have.length', 2); + smartPimPage.shouldBeOpenPopover(smartPimPage.getResponsePopover()); + } + ); + + it( + 'writes the accepted suggestion back into the source field and closes the popovers on "apply"', + { tags: ['@demo-smoke'] }, + (): void => { + cy.intercept('POST', `**${smartPimPage.getContentImproverPath()}`, { + statusCode: 200, + body: { improvedText: 'applied suggestion text' }, + }).as('contentImprover'); + + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + smartPimPage.clickImproveContent(); + + cy.wait('@contentImprover'); + smartPimPage.getResponseField().should('have.value', 'applied suggestion text'); + + smartPimPage.clickResponseApply(); + + smartPimPage.shouldBeClosedPopover(smartPimPage.getResponsePopover()); + } + ); + + it( + 'hides the locale-selector button for the field locale the trigger was opened from', + { tags: ['@demo-smoke'] }, + (): void => { + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.getFirstRequestBuilderLocale().then((currentLocale): void => { + expect(currentLocale, 'trigger exposes its field locale').to.be.a('string').and.not.be.empty; + + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + smartPimPage.openLocaleSelectorPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getLocaleSelectorPopover()); + + smartPimPage.getLocaleButtonFor(currentLocale as string).should('have.attr', 'hidden'); + smartPimPage.getLocaleButtons().filter(':visible').should('have.length.at.least', 1); + }); + } + ); + + describe('real AI provider flow (full, requires provider token)', { tags: ['@demo-full'] }, (): void => { + let switchedToAws = false; + + const restoreOpenAiVendor = (): void => { + aiConfigurationPage.setFeatureVendor(SMART_PIM_FEATURE, 'openai'); + }; + + it( + 'improve-content: issues the real POST and populates the suggestion field', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + cy.intercept('POST', `**${smartPimPage.getContentImproverPath()}`).as('contentImprover'); + + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + smartPimPage.clickImproveContent(); + + cy.wait('@contentImprover', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + smartPimPage.shouldBeOpenPopover(smartPimPage.getResponsePopover()); + smartPimPage.getResponseErrorBlock().should('not.be.visible'); + smartPimPage.getResponseField().should('not.have.value', ''); + } + ); + + it( + 'category-suggestion: issues the real POST and fills the modal with at least one suggestion', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + cy.intercept('POST', `**${smartPimPage.getCategorySuggestionPath()}`).as('categorySuggestion'); + + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.clickCategoryTrigger(); + + cy.wait('@categorySuggestion', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + smartPimPage + .getCategoryModal() + .should('be.visible') + .and('not.have.class', smartPimPage.getLoadingClass()) + .and('not.have.class', smartPimPage.getEmptyClass()); + smartPimPage.getCategorySelectOptions().should('have.length.at.least', 1); + + smartPimPage.clickCategoryModalApply(); + smartPimPage.getOuterCategorySelect().find('option:selected').should('have.length.at.least', 1); + } + ); + + it( + 'image-alt-text: issues the real POST and fills the alt-text field', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + cy.intercept('POST', `**${smartPimPage.getImageAltTextPath()}`).as('imageAltText'); + + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.clickFirstAltImageTrigger(); + + cy.wait('@imageAltText', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + smartPimPage + .getAltTextModal() + .should('be.visible') + .and('not.have.class', smartPimPage.getLoadingClass()) + .and('not.have.class', smartPimPage.getEmptyClass()); + smartPimPage.getAltTextInput().should('not.have.value', ''); + + smartPimPage + .getAltTextInput() + .invoke('val') + .then((generatedAltText): void => { + smartPimPage.clickAltTextModalApply(); + smartPimPage.getFirstTriggeredAltTextField().should('have.value', generatedAltText); + }); + } + ); + + it( + 'translate: posts a target locale different from the source and returns its translation', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + cy.intercept('POST', `**${smartPimPage.getTranslatePath()}`).as('translate'); + + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.getFirstRequestBuilderLocale().then((currentLocale): void => { + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + smartPimPage.openLocaleSelectorPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getLocaleSelectorPopover()); + + smartPimPage + .getLocaleButtons() + .filter(':visible') + .first() + .then(($button): void => { + const targetLocale = $button.attr('data-locale') ?? ''; + expect(targetLocale, 'a target locale distinct from the source is offered').to.not.eq(currentLocale); + + smartPimPage.clickLocaleButtonFor(targetLocale); + + cy.wait('@translate', { timeout: REAL_FLOW_TIMEOUT }).then((interception): void => { + expect(interception.response?.statusCode).to.be.within(200, 299); + const serialized = + typeof interception.request.body === 'string' + ? interception.request.body + : JSON.stringify(interception.request.body); + expect(serialized).to.contain(targetLocale); + }); + + smartPimPage.shouldBeOpenPopover(smartPimPage.getResponsePopover()); + smartPimPage.getResponseErrorBlock().should('not.be.visible'); + smartPimPage.getResponseField().should('not.have.value', ''); + }); + }); + } + ); + + it( + 'improve-content: applying the suggestion writes it back into the source description field', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + cy.intercept('POST', `**${smartPimPage.getContentImproverPath()}`).as('contentImprover'); + + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + smartPimPage.clickImproveContent(); + + cy.wait('@contentImprover', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + smartPimPage.getResponseField().should('not.have.value', ''); + + smartPimPage + .getResponseField() + .invoke('val') + .then((suggestion): void => { + smartPimPage.clickResponseApply(); + + smartPimPage.shouldBeClosedPopover(smartPimPage.getResponsePopover()); + smartPimPage + .getFirstRequestBuilderTargetField() + .invoke('val') + .then((appliedValue): void => { + const normalize = (value: unknown): string => + String(value ?? '') + .replace(/\s+/g, ' ') + .trim(); + + expect(normalize(appliedValue)).to.eq(normalize(suggestion)); + }); + }); + } + ); + + it( + 'improve-content: issues the real POST against the AWS Bedrock provider, and the interaction is logged and filterable by its configuration', + { tags: ['@demo-full'] }, + function (): void { + skipUnlessAiProviderEnabled(this); + + switchedToAws = true; + aiConfigurationPage.setFeatureVendor(SMART_PIM_FEATURE, 'aws'); + + cy.intercept('POST', `**${smartPimPage.getContentImproverPath()}`).as('contentImprover'); + + smartPimPage.visitProductEdit(idProductAbstract); + + smartPimPage.openAllActionsPopover(); + smartPimPage.shouldBeOpenPopover(smartPimPage.getAllActionsPopover()); + smartPimPage.clickImproveContent(); + + cy.wait('@contentImprover', { timeout: REAL_FLOW_TIMEOUT }) + .its('response.statusCode') + .should('be.within', 200, 299); + + smartPimPage.shouldBeOpenPopover(smartPimPage.getResponsePopover()); + smartPimPage.getResponseErrorBlock().should('not.be.visible'); + smartPimPage.getResponseField().should('not.have.value', ''); + + auditLogsPage.assertNewestRowConfigurationIsFilterable('AWS'); + + restoreOpenAiVendor(); + } + ); + + after((): void => { + if (!switchedToAws) { + return; + } + + userLoginScenario.execute({ + username: staticFixtures.rootUser.username, + password: staticFixtures.defaultPassword, + }); + + restoreOpenAiVendor(); + }); + }); + } +); diff --git a/cypress/fixtures/b2b-mp/demo/files/backoffice-assistant-attachment.png b/cypress/fixtures/b2b-mp/demo/files/backoffice-assistant-attachment.png new file mode 100644 index 000000000..d2acb8fde Binary files /dev/null and b/cypress/fixtures/b2b-mp/demo/files/backoffice-assistant-attachment.png differ diff --git a/cypress/fixtures/b2b-mp/demo/files/backoffice-assistant-unsupported.svg b/cypress/fixtures/b2b-mp/demo/files/backoffice-assistant-unsupported.svg new file mode 100644 index 000000000..7916b3ef1 --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/files/backoffice-assistant-unsupported.svg @@ -0,0 +1 @@ + diff --git a/cypress/fixtures/b2b-mp/demo/files/quick-add-by-image-probe.png b/cypress/fixtures/b2b-mp/demo/files/quick-add-by-image-probe.png new file mode 100644 index 000000000..d2acb8fde Binary files /dev/null and b/cypress/fixtures/b2b-mp/demo/files/quick-add-by-image-probe.png differ diff --git a/cypress/fixtures/b2b-mp/demo/files/search-by-image-probe.png b/cypress/fixtures/b2b-mp/demo/files/search-by-image-probe.png new file mode 100644 index 000000000..d2acb8fde Binary files /dev/null and b/cypress/fixtures/b2b-mp/demo/files/search-by-image-probe.png differ diff --git a/cypress/fixtures/b2b-mp/demo/static-00-ai-configuration.json b/cypress/fixtures/b2b-mp/demo/static-00-ai-configuration.json new file mode 100644 index 000000000..64a0b9972 --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/static-00-ai-configuration.json @@ -0,0 +1,6 @@ +{ + "defaultPassword": "change123", + "rootUser": { + "username": "admin@spryker.com" + } +} diff --git a/cypress/fixtures/b2b-mp/demo/static-ai-workflows.json b/cypress/fixtures/b2b-mp/demo/static-ai-workflows.json new file mode 100644 index 000000000..cef1af9f5 --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/static-ai-workflows.json @@ -0,0 +1,7 @@ +{ + "defaultPassword": "change123", + "rootUser": { + "username": "admin@spryker.com" + }, + "aiWorkflowsPageUrl": "/ai-foundation/ai-workflow" +} diff --git a/cypress/fixtures/b2b-mp/demo/static-audit-logs.json b/cypress/fixtures/b2b-mp/demo/static-audit-logs.json new file mode 100644 index 000000000..8d24d7faa --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/static-audit-logs.json @@ -0,0 +1,10 @@ +{ + "defaultPassword": "change123", + "rootUser": { + "username": "admin@spryker.com" + }, + "auditLogsPageUrl": "/ai-foundation/ai-interaction-log", + "product": { + "sku": "M53235" + } +} diff --git a/cypress/fixtures/b2b-mp/demo/static-backoffice-assistant.json b/cypress/fixtures/b2b-mp/demo/static-backoffice-assistant.json new file mode 100644 index 000000000..65e423281 --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/static-backoffice-assistant.json @@ -0,0 +1,30 @@ +{ + "defaultPassword": "change123", + "rootUser": { + "username": "admin@spryker.com" + }, + "contextPageUrl": "/product-management/edit?id-product-abstract=300", + "contextFormName": "product_form_edit", + "attachmentImagePath": "cypress/fixtures/b2b-mp/demo/files/backoffice-assistant-attachment.png", + "formFill": { + "agentName": "Form Fill", + "prompt": "Set the English product name field to the text QA Autofill Sample.", + "targetFieldName": "product_form_edit[general_en_US][name]" + }, + "orderManagement": { + "agentName": "Order Management", + "prompt": "How does order management work — explain the OMS states and what actions are available on the order detail page." + }, + "simplePrompt": "Say hello in one short sentence.", + "unknownConversationReference": "cypress-nonexistent-conversation-reference-0000", + "enabledAgents": ["Order Management", "Discount Management"], + "promptRequiredValidationMessage": "Prompt is required.", + "attachmentUnsupportedMediaTypeMessage": "One or more attachments have an unsupported file type.", + "attachmentCountExceededMessage": "Too many attachments. Please reduce the number of files.", + "attachmentMaxCount": 5, + "formFillTargetValue": "QA Stubbed Form Fill Value", + "xssPromptPayload": "**bold**", + "stubbedAgentName": "Order Management", + "historyEntryName": "QA stubbed conversation", + "unsupportedAttachmentPath": "cypress/fixtures/b2b-mp/demo/files/backoffice-assistant-unsupported.svg" +} diff --git a/cypress/fixtures/b2b-mp/demo/static-cost-price.json b/cypress/fixtures/b2b-mp/demo/static-cost-price.json new file mode 100644 index 000000000..d690ef7cd --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/static-cost-price.json @@ -0,0 +1,25 @@ +{ + "defaultPassword": "change123", + "rootUser": { + "username": "admin@spryker.com" + }, + "agent": { + "username": "agent123@spryker.com" + }, + "merchant": { + "username": "harald@spryker.com" + }, + "customer": { + "email": "sonia@acme.com" + }, + "product": { + "sku": "M53235" + }, + "merchantProduct": { + "sku": "M53235" + }, + "quoteRequest": { + "calculatedReference": "DE--21-6", + "uncalculatedReference": "DE--21-3" + } +} diff --git a/cypress/fixtures/b2b-mp/demo/static-quick-add-by-image.json b/cypress/fixtures/b2b-mp/demo/static-quick-add-by-image.json new file mode 100644 index 000000000..44aee80d0 --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/static-quick-add-by-image.json @@ -0,0 +1,12 @@ +{ + "defaultPassword": "change123", + "customer": { + "email": "sonia@acme.com" + }, + "rootUser": { + "username": "admin@spryker.com" + }, + "quickOrderPageUrl": "/quick-order", + "imageFilePath": "cypress/fixtures/b2b-mp/demo/files/quick-add-by-image-probe.png", + "imageFileName": "quick-add-by-image-probe.png" +} diff --git a/cypress/fixtures/b2b-mp/demo/static-quicksight-analytics.json b/cypress/fixtures/b2b-mp/demo/static-quicksight-analytics.json new file mode 100644 index 000000000..7eb316fe3 --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/static-quicksight-analytics.json @@ -0,0 +1,7 @@ +{ + "defaultPassword": "change123", + "rootUser": { + "username": "admin@spryker.com" + }, + "analyticsPageUrl": "/analytics-gui/analytics" +} diff --git a/cypress/fixtures/b2b-mp/demo/static-search-by-image.json b/cypress/fixtures/b2b-mp/demo/static-search-by-image.json new file mode 100644 index 000000000..4f11f0c4c --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/static-search-by-image.json @@ -0,0 +1,11 @@ +{ + "defaultPassword": "change123", + "rootUser": { + "username": "admin@spryker.com" + }, + "customer": { + "email": "spencor.hopkin@acme.com" + }, + "searchResultsUrl": "/en/search?q=cable", + "probeImagePath": "cypress/fixtures/b2b-mp/demo/files/search-by-image-probe.png" +} diff --git a/cypress/fixtures/b2b-mp/demo/static-smart-cms.json b/cypress/fixtures/b2b-mp/demo/static-smart-cms.json new file mode 100644 index 000000000..da0adb866 --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/static-smart-cms.json @@ -0,0 +1,19 @@ +{ + "defaultPassword": "change123", + "rootUser": { + "username": "admin@spryker.com" + }, + "cmsPageEditorUrl": "/cms-gui/create-glossary?id-cms-page=6", + "cmsBlockEditorUrl": "/cms-block-gui/edit-glossary?id-cms-block=24", + "cmsPageEntityType": "cms_page", + "cmsPageIdEntity": 6, + "cmsBlockEntityType": "cms_block", + "cmsBlockIdEntity": 24, + "generateEndpointPath": "/ai-commerce/smart-cms-content/generate", + "invalidCsrfErrorKey": "smart_cms_content.error.invalid_csrf_token", + "disabledErrorKey": "smart_cms_content.error.disabled", + "unsupportedAttachmentMediaType": "application/x-msdownload", + "unsupportedAttachmentError": "backoffice_assistant.validation.attachment_unsupported_media_type", + "probeImagePath": "cypress/fixtures/b2b-mp/demo/files/search-by-image-probe.png", + "probeImageMediaType": "image/png" +} diff --git a/cypress/fixtures/b2b-mp/demo/static-smart-pim.json b/cypress/fixtures/b2b-mp/demo/static-smart-pim.json new file mode 100644 index 000000000..86dbc4dc7 --- /dev/null +++ b/cypress/fixtures/b2b-mp/demo/static-smart-pim.json @@ -0,0 +1,9 @@ +{ + "defaultPassword": "change123", + "rootUser": { + "username": "admin@spryker.com" + }, + "product": { + "sku": "M53235" + } +} diff --git a/cypress/support/pages/backoffice/ai-configuration/ai-configuration-page.ts b/cypress/support/pages/backoffice/ai-configuration/ai-configuration-page.ts new file mode 100644 index 000000000..81419a9bd --- /dev/null +++ b/cypress/support/pages/backoffice/ai-configuration/ai-configuration-page.ts @@ -0,0 +1,98 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { BackofficePage } from '@pages/backoffice'; +import { AiConfigurationRepository } from './ai-configuration-repository'; + +@injectable() +@autoWired +export class AiConfigurationPage extends BackofficePage { + @inject(AiConfigurationRepository) private repository: AiConfigurationRepository; + + protected PAGE_URL = '/configuration/manage'; + + visitTab = (feature: string, tab: string): Cypress.Chainable => { + cy.intercept('GET', '**/configuration/manage**').as('configurationDocument'); + cy.visitBackoffice(`${this.PAGE_URL}?feature=${feature}&tab=${tab}`); + + return cy.wait('@configurationDocument'); + }; + + getCardTitle = (): Cypress.Chainable => cy.get(this.repository.getCardTitleSelector()); + + getCardTitleText = (): string => this.repository.getCardTitleText(); + + getVendorSettingKey = (vendor: string, field: string): string => this.repository.getVendorSettingKey(vendor, field); + + getApiTokenSettingInput = (vendor: string): Cypress.Chainable => + cy.get(this.repository.getApiTokenSettingInputSelector(vendor)); + + getModelPricesEditor = (vendor: string): Cypress.Chainable => + cy.get(this.repository.getModelPricesEditorSelector(vendor)); + + getFeatureNav = (feature: string): Cypress.Chainable => cy.get(this.repository.getFeatureNavSelector(feature)); + + getTabNav = (feature: string, tab: string): Cypress.Chainable => + cy.get(this.repository.getTabNavSelector(feature, tab)); + + getSaveButton = (): Cypress.Chainable => cy.get(this.repository.getSaveButtonSelector()); + + getSaveBar = (): Cypress.Chainable => cy.get(this.repository.getSaveBarSelector()); + + getChangesCount = (): Cypress.Chainable => cy.get(this.repository.getChangesCountSelector()); + + getSettingRow = (settingKey: string): Cypress.Chainable => cy.get(this.repository.getSettingRowSelector(settingKey)); + + getSettingRows = (groupKey: string): Cypress.Chainable => + cy.get(this.repository.getSettingRowsByGroupSelector(groupKey)); + + getSettingInput = (settingKey: string): Cypress.Chainable => + cy.get(this.repository.getSettingInputSelector(settingKey)); + + getJsonEditor = (settingKey: string): Cypress.Chainable => cy.get(this.repository.getJsonEditorSelector(settingKey)); + + getCheckedRadioOption = (settingKey: string): Cypress.Chainable => + cy.get(this.repository.getRadioOptionSelector(settingKey)).filter(':checked'); + + getRadioOptions = (settingKey: string): Cypress.Chainable => + cy.get(this.repository.getRadioOptionSelector(settingKey)); + + selectRadioOption = (settingKey: string, value: string): Cypress.Chainable => + cy.get(this.repository.getRadioOptionByValueSelector(settingKey, value)).check({ force: true }); + + setSettingInputValue = (settingKey: string, value: string): Cypress.Chainable => + this.getSettingInput(settingKey).clear().type(value).blur(); + + saveConfiguration = (): void => { + cy.intercept('POST', '**/configuration/manage/save').as('saveConfiguration'); + this.getSaveButton().click(); + }; + + getFeatureVendorSettingKey = (feature: string): string => this.repository.getFeatureVendorSettingKey(feature); + + getFeatureVendorOptionValue = (feature: string, vendor: string): string => + this.repository.getFeatureVendorOptionValue(feature, vendor); + + setFeatureVendor = (feature: string, vendor: string): Cypress.Chainable => + this.setVendorConfiguration( + 'ai_commerce', + feature, + this.getFeatureVendorSettingKey(feature), + this.getFeatureVendorOptionValue(feature, vendor) + ); + + setVendorConfiguration = (feature: string, tab: string, settingKey: string, value: string): Cypress.Chainable => { + this.visitTab(feature, tab); + + return this.getCheckedRadioOption(settingKey).then(($checked) => { + if (String($checked.val() ?? '') === value) { + return; + } + + this.selectRadioOption(settingKey, value); + this.saveConfiguration(); + // eslint-disable-next-line spryker-cypress/no-assertions-in-page-objects -- Post-action guard: the setter must not return before the save round-trip succeeds. + cy.wait('@saveConfiguration').its('response.body').should('have.property', 'success', true); + }); + }; +} diff --git a/cypress/support/pages/backoffice/ai-configuration/ai-configuration-repository.ts b/cypress/support/pages/backoffice/ai-configuration/ai-configuration-repository.ts new file mode 100644 index 000000000..ada1c2921 --- /dev/null +++ b/cypress/support/pages/backoffice/ai-configuration/ai-configuration-repository.ts @@ -0,0 +1,50 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class AiConfigurationRepository { + getCardTitleSelector = (): string => '.configuration-management .card-title'; + + getCardTitleText = (): string => 'Configuration Management'; + + getVendorSettingKey = (vendor: string, field: string): string => `ai_vendor:${vendor}:general:${field}`; + + getApiTokenSettingInputSelector = (vendor: string): string => + this.getSettingInputSelector(this.getVendorSettingKey(vendor, 'api_token')); + + getModelPricesEditorSelector = (vendor: string): string => + this.getJsonEditorSelector(this.getVendorSettingKey(vendor, 'model_prices')); + + getFeatureVendorSettingKey = (feature: string): string => `ai_commerce:${feature}:ai_vendor:ai_configuration`; + + getFeatureVendorOptionValue = (feature: string, vendor: string): string => + `AI_COMMERCE:AI_CONFIGURATION_${feature.toUpperCase()}_${vendor.toUpperCase()}`; + + getFeatureNavSelector = (feature: string): string => `.feature-header[data-feature="${feature}"]`; + + getTabNavSelector = (feature: string, tab: string): string => + `.feature-tabs__item[data-feature="${feature}"][data-tab="${tab}"]`; + + getSaveButtonSelector = (): string => '[data-qa="save-configuration"]'; + + getSaveBarSelector = (): string => '#config-save-bar'; + + getChangesCountSelector = (): string => '#changes-count'; + + getSettingRowSelector = (settingKey: string): string => `.setting-row[data-setting-key="${settingKey}"]`; + + getSettingRowsByGroupSelector = (groupKey: string): string => + `.setting-row[data-setting-key*=":${groupKey}:"]:has(.config-input)`; + + getSettingInputSelector = (settingKey: string): string => + `.setting-row[data-setting-key="${settingKey}"] .config-input`; + + getJsonEditorSelector = (settingKey: string): string => `.setting-row[data-setting-key="${settingKey}"] .json-editor`; + + getRadioOptionSelector = (settingKey: string): string => + `.setting-row[data-setting-key="${settingKey}"] input[type="radio"]`; + + getRadioOptionByValueSelector = (settingKey: string, value: string): string => + `.setting-row[data-setting-key="${settingKey}"] input[type="radio"][value="${value}"]`; +} diff --git a/cypress/support/pages/backoffice/ai-workflows/ai-workflows-page.ts b/cypress/support/pages/backoffice/ai-workflows/ai-workflows-page.ts new file mode 100644 index 000000000..06fb8ebc2 --- /dev/null +++ b/cypress/support/pages/backoffice/ai-workflows/ai-workflows-page.ts @@ -0,0 +1,61 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { BackofficePage } from '@pages/backoffice'; +import { AiWorkflowsRepository } from './ai-workflows-repository'; + +@injectable() +@autoWired +export class AiWorkflowsPage extends BackofficePage { + @inject(AiWorkflowsRepository) private repository: AiWorkflowsRepository; + + protected PAGE_URL = '/ai-foundation/ai-workflow'; + + private TABLE_DATA_URL = '**/ai-foundation/ai-workflow/table**'; + + visitAiWorkflows = (): Cypress.Chainable => { + cy.intercept('GET', '**/ai-foundation/ai-workflow').as('aiWorkflowsDocument'); + cy.visitBackoffice(this.PAGE_URL); + + return cy.wait('@aiWorkflowsDocument'); + }; + + visitAndAwaitTableData = (): Cypress.Chainable => { + cy.intercept('GET', this.TABLE_DATA_URL).as('aiWorkflowsTableData'); + cy.visitBackoffice(this.PAGE_URL); + + return cy.wait('@aiWorkflowsTableData'); + }; + + aliasTableData = (alias: string): void => { + cy.intercept('GET', this.TABLE_DATA_URL).as(alias); + }; + + getSectionTitle = (): Cypress.Chainable => cy.get(this.repository.getSectionTitleSelector()); + + getSectionTitleText = (): string => this.repository.getSectionTitleText(); + + getWidgetTitle = (): Cypress.Chainable => cy.get(this.repository.getWidgetTitleSelector()); + + getWidgetTitleText = (): string => this.repository.getWidgetTitleText(); + + getTable = (): Cypress.Chainable => cy.get(this.repository.getTableSelector()); + + getTableWrapper = (): Cypress.Chainable => cy.get(this.repository.getTableWrapperSelector()); + + getTableHeaders = (): Cypress.Chainable => cy.get(this.repository.getTableHeaderSelector()); + + getColumnHeader = (column: string): Cypress.Chainable => cy.get(this.repository.getColumnHeaderSelector(column)); + + getSortableColumnHeader = (column: string): Cypress.Chainable => + cy.get(this.repository.getSortableColumnHeaderSelector(column)); + + getNonSortableColumnHeader = (column: string): Cypress.Chainable => + cy.get(this.repository.getNonSortableColumnHeaderSelector(column)); + + getLengthSelect = (): Cypress.Chainable => cy.get(this.repository.getLengthSelectSelector()); + + getTableInfo = (): Cypress.Chainable => cy.get(this.repository.getTableInfoSelector()); + + selectPageLength = (value: string): Cypress.Chainable => this.getLengthSelect().select(value); +} diff --git a/cypress/support/pages/backoffice/ai-workflows/ai-workflows-repository.ts b/cypress/support/pages/backoffice/ai-workflows/ai-workflows-repository.ts new file mode 100644 index 000000000..f87ad896c --- /dev/null +++ b/cypress/support/pages/backoffice/ai-workflows/ai-workflows-repository.ts @@ -0,0 +1,32 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class AiWorkflowsRepository { + getSectionTitleSelector = (): string => '.page-title-head h2'; + + getSectionTitleText = (): string => 'Workflows'; + + getWidgetTitleSelector = (): string => '.ibox-title h5'; + + getWidgetTitleText = (): string => 'Workflow Items'; + + getTableSelector = (): string => '[data-qa="data-table"]'; + + getTableWrapperSelector = (): string => '.dt-container.dt-bootstrap5'; + + getTableHeaderSelector = (): string => '[data-qa="data-table"] thead th'; + + getColumnHeaderSelector = (column: string): string => `[data-qa="data-table"] thead th[data-qa="${column}"]`; + + getSortableColumnHeaderSelector = (column: string): string => + `[data-qa="data-table"] thead th[data-qa="${column}"].dt-orderable-asc`; + + getNonSortableColumnHeaderSelector = (column: string): string => + `[data-qa="data-table"] thead th[data-qa="${column}"].dt-orderable-none`; + + getLengthSelectSelector = (): string => '.dt-length select'; + + getTableInfoSelector = (): string => '.dt-info'; +} diff --git a/cypress/support/pages/backoffice/audit-logs/audit-logs-page.ts b/cypress/support/pages/backoffice/audit-logs/audit-logs-page.ts new file mode 100644 index 000000000..7df8e6605 --- /dev/null +++ b/cypress/support/pages/backoffice/audit-logs/audit-logs-page.ts @@ -0,0 +1,158 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { BackofficePage } from '@pages/backoffice'; +import { AuditLogsRepository } from './audit-logs-repository'; + +@injectable() +@autoWired +export class AuditLogsPage extends BackofficePage { + @inject(AuditLogsRepository) private repository: AuditLogsRepository; + + protected PAGE_URL = '/ai-foundation/ai-interaction-log'; + + private TABLE_DATA_URL = '**/ai-foundation/ai-interaction-log/table**'; + + private STATS_URL = '**/ai-foundation/ai-interaction-log/stats**'; + + visitAuditLogs = (): Cypress.Chainable => { + cy.intercept('GET', '**/ai-foundation/ai-interaction-log').as('auditLogsDocument'); + cy.visitBackoffice(this.PAGE_URL); + + return cy.wait('@auditLogsDocument'); + }; + + visitAndAwaitTableData = (): Cypress.Chainable => { + cy.intercept('GET', this.TABLE_DATA_URL).as('auditLogsTableData'); + cy.intercept('GET', this.STATS_URL).as('auditLogsStats'); + cy.visitBackoffice(this.PAGE_URL); + + return cy.wait('@auditLogsTableData'); + }; + + waitForStats = (): Cypress.Chainable => cy.wait('@auditLogsStats'); + + aliasTableData = (alias: string): void => { + cy.intercept('GET', this.TABLE_DATA_URL).as(alias); + }; + + getSectionTitle = (): Cypress.Chainable => cy.get(this.repository.getSectionTitleSelector()); + + getSectionTitleText = (): string => this.repository.getSectionTitleText(); + + getTotalInteractionsCardText = (): string => this.repository.getTotalInteractionsCardText(); + + getSuccessStatusText = (): string => this.repository.getSuccessStatusText(); + + getStatsCards = (): Cypress.Chainable => cy.get(this.repository.getStatsCardsSelector()); + + getStatsCardTitles = (): Cypress.Chainable => cy.get(this.repository.getStatsCardTitleSelector()); + + getTable = (): Cypress.Chainable => cy.get(this.repository.getTableSelector()); + + getTableWrapper = (): Cypress.Chainable => cy.get(this.repository.getTableWrapperSelector()); + + getTableHeaders = (): Cypress.Chainable => cy.get(this.repository.getTableHeaderSelector()); + + getColumnHeader = (column: string): Cypress.Chainable => cy.get(this.repository.getColumnHeaderSelector(column)); + + getSortableColumnHeader = (column: string): Cypress.Chainable => + cy.get(this.repository.getSortableColumnHeaderSelector(column)); + + getNonSortableColumnHeader = (column: string): Cypress.Chainable => + cy.get(this.repository.getNonSortableColumnHeaderSelector(column)); + + getLengthSelect = (): Cypress.Chainable => cy.get(this.repository.getLengthSelectSelector()); + + getTableInfo = (): Cypress.Chainable => cy.get(this.repository.getTableInfoSelector()); + + selectPageLength = (value: string): Cypress.Chainable => this.getLengthSelect().select(value); + + private ROW_COLUMNS: Array = [ + 'spy_ai_interaction_log.prompt', + 'spy_ai_interaction_log.conversation_reference', + 'spy_ai_interaction_log.provider', + 'spy_ai_interaction_log.model', + 'total_tokens', + 'estimated_cost', + 'spy_ai_interaction_log.configuration_name', + 'spy_ai_interaction_log.is_successful', + 'spy_ai_interaction_log.inference_time_ms', + 'spy_ai_interaction_log.created_at', + ]; + + private mapRow = (row: Array): Record => + this.ROW_COLUMNS.reduce( + (mapped, column, index) => { + mapped[column] = row[index]; + + return mapped; + }, + {} as Record + ); + + fetchTableData = ({ + length = 100, + configurationName, + }: { length?: number; configurationName?: string } = {}): Cypress.Chainable<{ + recordsTotal: number; + rows: Array>; + }> => { + const url = this.getBackofficeAbsoluteUrl(this.repository.getTableDataPath()); + const qs: Record = { draw: '1', start: '0', length: String(length) }; + if (configurationName) { + qs.configuration_name = configurationName; + } + + return cy + .request({ method: 'GET', url, qs, headers: { 'X-Requested-With': 'XMLHttpRequest' } }) + .then((response) => { + // eslint-disable-next-line spryker-cypress/no-assertions-in-page-objects -- Request guard: fail fast before parsing a non-200 body below. + expect(response.status, 'audit-log table endpoint responds 200').to.eq(200); + + const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body; + const rawRows = (body.data ?? []) as Array>; + + return { + recordsTotal: Number(body.recordsTotal ?? 0), + rows: rawRows.map(this.mapRow), + }; + }); + }; + + getRowCreatedAt = (row: Record | undefined): string => + String(row?.['spy_ai_interaction_log.created_at'] ?? ''); + + getRowConfigurationName = (row: Record | undefined): string => + String(row?.['spy_ai_interaction_log.configuration_name'] ?? ''); + + /** + * Reusable multi-step audit-log verification shared by the backoffice-assistant, + * smart-cms and smart-pim specs: the newest row must carry the expected + * configuration marker, and filtering by that configuration must round-trip. + * Kept here rather than duplicated three times in the specs; the assertions are + * the helper's whole purpose, hence the rule exemptions below. + */ + assertNewestRowConfigurationIsFilterable = (configurationMarker: string): void => { + /* eslint-disable spryker-cypress/no-assertions-in-page-objects -- Shared audit-log verification helper; asserting IS this method's contract. */ + this.fetchTableData().then(({ recordsTotal, rows }) => { + expect(recordsTotal, 'at least one audit-log row exists after the real interaction').to.be.greaterThan(0); + + const newestConfiguration = this.getRowConfigurationName(rows[0]); + expect(newestConfiguration, `newest audit-log row is a ${configurationMarker} configuration`).to.contain( + configurationMarker + ); + + this.fetchTableData({ configurationName: newestConfiguration }).then((filtered) => { + expect(filtered.rows.length, 'the configuration filter returns rows').to.be.greaterThan(0); + filtered.rows.forEach((row) => { + expect(this.getRowConfigurationName(row), 'every filtered row matches the configuration').to.eq( + newestConfiguration + ); + }); + expect(filtered.recordsTotal, 'filtered count is <= unfiltered count').to.be.at.most(recordsTotal); + }); + }); + /* eslint-enable spryker-cypress/no-assertions-in-page-objects */ + }; +} diff --git a/cypress/support/pages/backoffice/audit-logs/audit-logs-repository.ts b/cypress/support/pages/backoffice/audit-logs/audit-logs-repository.ts new file mode 100644 index 000000000..bb3744223 --- /dev/null +++ b/cypress/support/pages/backoffice/audit-logs/audit-logs-repository.ts @@ -0,0 +1,38 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class AuditLogsRepository { + getSectionTitleSelector = (): string => '.page-title-head h2'; + + getSectionTitleText = (): string => 'Audit Logs'; + + getTotalInteractionsCardText = (): string => 'Total Interactions'; + + getSuccessStatusText = (): string => 'Success'; + + getStatsCardsSelector = (): string => '#stats-cards'; + + getStatsCardTitleSelector = (): string => '#stats-cards .card .card-body h2'; + + getTableSelector = (): string => '[data-qa="data-table"]'; + + getTableWrapperSelector = (): string => '.dt-container.dt-bootstrap5'; + + getTableHeaderSelector = (): string => '[data-qa="data-table"] thead th'; + + getColumnHeaderSelector = (column: string): string => `[data-qa="data-table"] thead th[data-qa="${column}"]`; + + getSortableColumnHeaderSelector = (column: string): string => + `[data-qa="data-table"] thead th[data-qa="${column}"].dt-orderable-asc`; + + getNonSortableColumnHeaderSelector = (column: string): string => + `[data-qa="data-table"] thead th[data-qa="${column}"].dt-orderable-none`; + + getLengthSelectSelector = (): string => '.dt-length select'; + + getTableInfoSelector = (): string => '.dt-info'; + + getTableDataPath = (): string => '/ai-foundation/ai-interaction-log/table'; +} diff --git a/cypress/support/pages/backoffice/backoffice-assistant/backoffice-assistant-page.ts b/cypress/support/pages/backoffice/backoffice-assistant/backoffice-assistant-page.ts new file mode 100644 index 000000000..88788274a --- /dev/null +++ b/cypress/support/pages/backoffice/backoffice-assistant/backoffice-assistant-page.ts @@ -0,0 +1,284 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { BackofficePage } from '@pages/backoffice'; +import { BackofficeAssistantRepository } from './backoffice-assistant-repository'; + +@injectable() +@autoWired +export class BackofficeAssistantPage extends BackofficePage { + @inject(BackofficeAssistantRepository) private repository: BackofficeAssistantRepository; + + protected PAGE_URL = '/dashboard'; + + private CONFIGURATION_URL = '/configuration/manage?feature=ai_commerce&tab=backoffice_assistant'; + + // Any product edit page works — it is only a host for the form-context/form-fill assertions. + // Resolved from a SKU because id_product_abstract is import-order dependent, not stable. + private CONTEXT_PAGE_SKU = 'M53235'; + + enableAssistant = (): Cypress.Chainable => { + cy.visitBackoffice(this.CONFIGURATION_URL); + + cy.get(this.repository.getEnableToggleSelector()).then(($toggle) => { + if (!($toggle[0] as HTMLInputElement).checked) { + cy.wrap($toggle).check({ force: true }); + + cy.intercept('POST', '**/configuration/manage/save').as('saveConfiguration'); + cy.get(this.repository.getSaveButtonSelector()).click(); + cy.wait('@saveConfiguration').its('response.statusCode').should('eq', 200); + } + }); + + return cy.get(this.repository.getEnableToggleSelector()).should('be.checked'); + }; + + disableAssistant = (): Cypress.Chainable => { + cy.visitBackoffice(this.CONFIGURATION_URL); + + cy.get(this.repository.getEnableToggleSelector()).then(($toggle) => { + if (($toggle[0] as HTMLInputElement).checked) { + cy.wrap($toggle).uncheck({ force: true }); + + cy.intercept('POST', '**/configuration/manage/save').as('saveConfiguration'); + cy.get(this.repository.getSaveButtonSelector()).click(); + cy.wait('@saveConfiguration').its('response.statusCode').should('eq', 200); + } + }); + + return cy.get(this.repository.getEnableToggleSelector()).should('not.be.checked'); + }; + + visitDashboard = (): Cypress.Chainable => { + cy.intercept('GET', '**/dashboard').as('dashboardDocument'); + cy.visitBackoffice(this.PAGE_URL); + + return cy.wait('@dashboardDocument'); + }; + + getHistoriesEndpointPath = (): string => this.repository.getHistoriesPath(); + + clearWidgetPanelState = (): Cypress.Chainable => + cy.window().then((win): void => win.localStorage.removeItem(this.repository.getWidgetStateStorageKey())); + + getWidgetToggleLabel = (): string => this.repository.getWidgetToggleLabel(); + + getGreetingText = (): string => this.repository.getGreetingText(); + + getInputPlaceholder = (): string => this.repository.getInputPlaceholder(); + + getAutoAgentLabel = (): string => this.repository.getAutoAgentLabel(); + + getOrderManagementAgentLabel = (): string => this.repository.getOrderManagementAgentLabel(); + + getTransportFailureText = (): string => this.repository.getTransportFailureText(); + + getUnsupportedFileTypeText = (): string => this.repository.getUnsupportedFileTypeText(); + + getHistoriesEmptyText = (): string => this.repository.getHistoriesEmptyText(); + + getValidationGlossaryKey = (): string => this.repository.getValidationGlossaryKey(); + + getInvalidCsrfToken = (): string => this.repository.getInvalidCsrfToken(); + + visitSales = (): Cypress.Chainable => { + cy.intercept('GET', '**/sales').as('salesDocument'); + cy.visitBackoffice('/sales'); + + return cy.wait('@salesDocument'); + }; + + visitContextPage = (): Cypress.Chainable => { + cy.intercept('GET', '**/product-management/edit**').as('contextPageDocument'); + + this.resolveProductAbstractIdBySku(this.CONTEXT_PAGE_SKU).then((id) => { + cy.visitBackoffice(`/product-management/edit?id-product-abstract=${id}`); + }); + + return cy.wait('@contextPageDocument'); + }; + + openPanel = (): Cypress.Chainable => { + this.getWidgetToggle().should('be.visible').click(); + + return this.getWidgetPanel().should('have.class', this.repository.getWidgetPanelOpenClass()); + }; + + attachFile = (filePath: string | string[]): Cypress.Chainable => + cy.get(this.repository.getWidgetFileInputSelector()).selectFile(filePath, { force: true }); + + addFormContext = (): Cypress.Chainable => this.getWidgetFormContextSuggestion().click(); + + selectAgent = (agent: string): Cypress.Chainable => this.getWidgetAgentSelect().select(agent); + + typeMessage = (message: string): Cypress.Chainable => this.getWidgetInput().clear().type(message); + + send = (): Cypress.Chainable => this.getWidgetSend().click(); + + getWidgetToggle = (): Cypress.Chainable => cy.get(this.repository.getWidgetToggleSelector()); + + getWidgetPanel = (): Cypress.Chainable => cy.get(this.repository.getWidgetPanelSelector()); + + getWidgetAgentSelect = (): Cypress.Chainable => cy.get(this.repository.getWidgetAgentSelectSelector()); + + getWidgetAgentBadge = (): Cypress.Chainable => cy.get(this.repository.getWidgetAgentBadgeSelector()); + + getWidgetInput = (): Cypress.Chainable => cy.get(this.repository.getWidgetInputSelector()); + + getWidgetSend = (): Cypress.Chainable => cy.get(this.repository.getWidgetSendSelector()); + + getWidgetHistoryButton = (): Cypress.Chainable => cy.get(this.repository.getWidgetHistoryButtonSelector()); + + getWidgetNewChat = (): Cypress.Chainable => cy.get(this.repository.getWidgetNewChatSelector()); + + getWidgetAttach = (): Cypress.Chainable => cy.get(this.repository.getWidgetAttachSelector()); + + getWidgetMessages = (): Cypress.Chainable => cy.get(this.repository.getWidgetMessagesSelector()); + + getWidgetRetryButton = (): Cypress.Chainable => cy.get(this.repository.getWidgetRetryButtonSelector()); + + getWidgetAttachmentsPreview = (): Cypress.Chainable => cy.get(this.repository.getWidgetAttachmentsPreviewSelector()); + + getWidgetAttachmentChip = (): Cypress.Chainable => + this.getWidgetAttachmentsPreview().find(this.repository.getWidgetAttachmentChipSelector()); + + getWidgetAttachmentChipName = (): Cypress.Chainable => + this.getWidgetAttachmentChip().find(this.repository.getWidgetAttachmentChipNameSelector()); + + getWidgetMessageAttachmentPill = (): Cypress.Chainable => + this.getWidgetMessages().find(this.repository.getWidgetMessageAttachmentPillSelector()); + + private getWidgetContextSuggestions = (): Cypress.Chainable => + cy.get(this.repository.getWidgetContextSuggestionsSelector()); + + getWidgetFormContextSuggestion = (): Cypress.Chainable => + this.getWidgetContextSuggestions() + .find(this.repository.getWidgetFormContextSuggestionLabelSelector()) + .parents('button') + .first(); + + getWidgetFormContextSuggestionLabel = (): Cypress.Chainable => + this.getWidgetContextSuggestions().find(this.repository.getWidgetFormContextSuggestionLabelSelector()); + + getWidgetContextChip = (): Cypress.Chainable => + this.getWidgetContextSuggestions().find(this.repository.getWidgetContextChipSelector()); + + getWidgetContextChipName = (): Cypress.Chainable => + this.getWidgetContextChip().find(this.repository.getWidgetContextChipNameSelector()); + + interceptPromptWithFailure = (): Cypress.Chainable => + cy + .intercept('POST', this.repository.getPromptEndpoint(), { + statusCode: 503, + body: { errors: [{ message: 'AI provider unavailable' }] }, + }) + .as('assistantPrompt'); + + interceptPromptWithSseEvents = (events: Array>): Cypress.Chainable => + cy + .intercept('POST', this.repository.getPromptEndpoint(), { + statusCode: 200, + headers: { 'content-type': 'text/event-stream' }, + body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), + }) + .as('assistantPrompt'); + + repositoryPromptPath = (): string => this.repository.getPromptPath(); + + repositoryDetailPath = (): string => this.repository.getDetailPath(); + + repositoryDeletePath = (): string => this.repository.getDeletePath(); + + readCsrfToken = (): Cypress.Chainable => + cy + .window() + .then((win) => + String( + (win as { BackofficeAssistantConfig?: { csrfToken?: string } }).BackofficeAssistantConfig?.csrfToken ?? '' + ) + ); + + interceptRealPrompt = (): Cypress.Chainable => + cy.intercept('POST', this.repository.getPromptEndpoint()).as('assistantRealPrompt'); + + interceptHistories = (): Cypress.Chainable => + cy.intercept('GET', this.repository.getHistoriesEndpoint()).as('assistantHistories'); + + interceptDetail = (): Cypress.Chainable => + cy.intercept('GET', this.repository.getDetailEndpoint()).as('assistantDetail'); + + interceptDelete = (): Cypress.Chainable => + cy.intercept('POST', this.repository.getDeleteEndpoint()).as('assistantDelete'); + + openHistories = (): Cypress.Chainable => { + this.getWidgetHistoryButton().click(); + + return this.getWidgetHistories().should('not.have.attr', 'hidden'); + }; + + newChat = (): Cypress.Chainable => this.getWidgetNewChat().click(); + + deleteFirstHistoryItem = (): Cypress.Chainable => this.getWidgetFirstHistoryItemDelete().click(); + + clickFirstHistoryItem = (): Cypress.Chainable => this.getWidgetHistoryItem().first().click(); + + getWidgetAiMessage = (options?: Partial): Cypress.Chainable => + this.getWidgetMessages().find(this.repository.getWidgetAiMessageSelector(), options); + + getWidgetUserMessage = (): Cypress.Chainable => + this.getWidgetMessages().find(this.repository.getWidgetUserMessageSelector()); + + getWidgetReasoningMessage = (): Cypress.Chainable => + this.getWidgetMessages().find(this.repository.getWidgetReasoningMessageSelector()); + + getWidgetToolCallMessage = (options?: Partial): Cypress.Chainable => + this.getWidgetMessages().find(this.repository.getWidgetToolCallMessageSelector(), options); + + getWidgetToolCallNameSelector = (): string => this.repository.getWidgetToolCallNameSelector(); + + getWidgetToolCallResultToggle = (): Cypress.Chainable => + this.getWidgetToolCallMessage().find(this.repository.getWidgetToolCallResultToggleSelector()); + + getWidgetHistoriesEmpty = (): Cypress.Chainable => cy.get(this.repository.getWidgetHistoriesEmptySelector()); + + removeFirstAttachmentChip = (): Cypress.Chainable => + this.getWidgetAttachmentChip().first().find(this.repository.getWidgetAttachmentChipRemoveSelector()).click(); + + interceptHistoriesWith = ( + histories: Array>, + availableAgents: string[] = [] + ): Cypress.Chainable => + cy + .intercept('GET', this.repository.getHistoriesEndpoint(), { + statusCode: 200, + body: { histories, available_agents: availableAgents.map((name) => ({ name, description: '' })) }, + }) + .as('assistantHistories'); + + interceptDeleteSuccess = (): Cypress.Chainable => + cy + .intercept('POST', this.repository.getDeleteEndpoint(), { statusCode: 200, body: { success: true } }) + .as('assistantDelete'); + + getWidgetHistories = (): Cypress.Chainable => cy.get(this.repository.getWidgetHistoriesSelector()); + + getWidgetHistoriesList = (): Cypress.Chainable => cy.get(this.repository.getWidgetHistoriesListSelector()); + + getWidgetHistoryItem = (): Cypress.Chainable => + this.getWidgetHistoriesList().find(this.repository.getWidgetHistoryItemSelector()); + + getWidgetFirstHistoryItemDelete = (): Cypress.Chainable => + this.getWidgetHistoryItem().first().find(this.repository.getWidgetHistoryItemDeleteSelector()); + + getFormFillTargetField = (fieldName: string): Cypress.Chainable => + cy.get(this.repository.getFormFillTargetFieldSelector(fieldName)); + + expandFormFillTargetFieldLocale = (fieldName: string): Cypress.Chainable => + this.getFormFillTargetField(fieldName).then(($field): void => { + if ($field.is(':visible')) { + return; + } + + cy.wrap($field).closest('.ibox').find(this.repository.getIboxCollapseLinkSelector()).first().click(); + }); +} diff --git a/cypress/support/pages/backoffice/backoffice-assistant/backoffice-assistant-repository.ts b/cypress/support/pages/backoffice/backoffice-assistant/backoffice-assistant-repository.ts new file mode 100644 index 000000000..e9f460fc4 --- /dev/null +++ b/cypress/support/pages/backoffice/backoffice-assistant/backoffice-assistant-repository.ts @@ -0,0 +1,121 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class BackofficeAssistantRepository { + private static readonly ENABLE_SETTING_KEY = 'ai_commerce:backoffice_assistant:general:is_enabled'; + + getWidgetStateStorageKey = (): string => 'backoffice_assistant_state'; + + getWidgetToggleLabel = (): string => 'Assistant'; + + getGreetingText = (): string => 'How can I help you today?'; + + getInputPlaceholder = (): string => 'Ask me anything...'; + + getAutoAgentLabel = (): string => 'Auto'; + + getOrderManagementAgentLabel = (): string => 'Order Management'; + + getTransportFailureText = (): string => 'Request failed with status 503'; + + getUnsupportedFileTypeText = (): string => 'Unsupported file type'; + + getHistoriesEmptyText = (): string => 'No conversations yet'; + + getValidationGlossaryKey = (): string => 'backoffice_assistant.validation'; + + getInvalidCsrfToken = (): string => 'invalid-token'; + + getEnableToggleSelector = (): string => + `.setting-row[data-setting-key="${BackofficeAssistantRepository.ENABLE_SETTING_KEY}"] .config-input`; + + getSaveButtonSelector = (): string => '[data-qa="save-configuration"]'; + + getWidgetToggleSelector = (): string => '.js-backoffice-assistant__toggle'; + + getWidgetPanelSelector = (): string => '.js-backoffice-assistant__panel'; + + getWidgetPanelOpenClass = (): string => 'backoffice-assistant__panel--open'; + + getWidgetAgentSelectSelector = (): string => '.js-backoffice-assistant__agent-select'; + + getWidgetAgentBadgeSelector = (): string => '.js-backoffice-assistant__agent-badge'; + + getWidgetInputSelector = (): string => '.js-backoffice-assistant__input'; + + getWidgetSendSelector = (): string => '.js-backoffice-assistant__send'; + + getWidgetHistoryButtonSelector = (): string => '.js-backoffice-assistant__history-btn'; + + getWidgetNewChatSelector = (): string => '.js-backoffice-assistant__new-chat'; + + getWidgetAttachSelector = (): string => '.js-backoffice-assistant__attach'; + + getWidgetMessagesSelector = (): string => '.js-backoffice-assistant__messages'; + + getWidgetRetryButtonSelector = (): string => '.backoffice-assistant__retry-btn'; + + getWidgetFileInputSelector = (): string => '.js-backoffice-assistant__file-input'; + + getWidgetAttachmentsPreviewSelector = (): string => '.js-backoffice-assistant__attachments-preview'; + + getWidgetAttachmentChipSelector = (): string => '.backoffice-assistant__attachment-chip'; + + getWidgetAttachmentChipNameSelector = (): string => '.backoffice-assistant__attachment-chip-name'; + + getWidgetMessageAttachmentPillSelector = (): string => '.backoffice-assistant__message-attachment-pill'; + + getWidgetContextSuggestionsSelector = (): string => '.js-backoffice-assistant__page-context-suggestions'; + + getWidgetFormContextSuggestionLabelSelector = (): string => '.js-backoffice-assistant__form-context-suggestion-label'; + + getWidgetContextChipSelector = (): string => '.backoffice-assistant__context-chip'; + + getWidgetContextChipNameSelector = (): string => '.js-backoffice-assistant__context-chip-name'; + + getWidgetAiMessageSelector = (): string => '.backoffice-assistant__message--ai'; + + getWidgetReasoningMessageSelector = (): string => '.backoffice-assistant__message--reasoning'; + + getWidgetUserMessageSelector = (): string => '.backoffice-assistant__message--user'; + + getWidgetToolCallMessageSelector = (): string => '.backoffice-assistant__message--tool-call'; + + getWidgetToolCallNameSelector = (): string => '.backoffice-assistant__tool-call-name'; + + getWidgetToolCallResultToggleSelector = (): string => '.backoffice-assistant__tool-call-toggle'; + + getWidgetAttachmentChipRemoveSelector = (): string => '.backoffice-assistant__attachment-chip-remove'; + + getWidgetHistoriesSelector = (): string => '.js-backoffice-assistant__histories'; + + getWidgetHistoriesListSelector = (): string => '.js-backoffice-assistant__histories-list'; + + getWidgetHistoryItemSelector = (): string => '.backoffice-assistant__histories-item'; + + getWidgetHistoryItemDeleteSelector = (): string => '.backoffice-assistant__histories-item-delete'; + + getWidgetHistoriesEmptySelector = (): string => '.js-backoffice-assistant__histories-empty'; + + getFormFillTargetFieldSelector = (fieldName: string): string => `[name="${fieldName}"]`; + + getIboxCollapseLinkSelector = (): string => '.ibox-title .collapse-link'; + + getPromptEndpoint = (): string => '**/ai-commerce/backoffice-assistant-prompt/index'; + + getHistoriesEndpoint = (): string => '**/ai-commerce/backoffice-assistant-conversation/index'; + + getDetailEndpoint = (): string => '**/ai-commerce/backoffice-assistant-conversation/detail**'; + + getDeleteEndpoint = (): string => '**/ai-commerce/backoffice-assistant-conversation/delete'; + + getPromptPath = (): string => '/ai-commerce/backoffice-assistant-prompt/index'; + + getHistoriesPath = (): string => '/ai-commerce/backoffice-assistant-conversation/index'; + + getDetailPath = (): string => '/ai-commerce/backoffice-assistant-conversation/detail'; + + getDeletePath = (): string => '/ai-commerce/backoffice-assistant-conversation/delete'; +} diff --git a/cypress/support/pages/backoffice/backoffice-page.ts b/cypress/support/pages/backoffice/backoffice-page.ts index 30905edf3..03d5f647a 100644 --- a/cypress/support/pages/backoffice/backoffice-page.ts +++ b/cypress/support/pages/backoffice/backoffice-page.ts @@ -5,6 +5,10 @@ import Chainable = Cypress.Chainable; @injectable() export class BackofficePage extends AbstractPage { + private static readonly PRODUCT_TABLE_COL_ID = 0; + + private static readonly PRODUCT_TABLE_COL_SKU = 2; + visit = (options?: Partial): void => { cy.visitBackoffice(this.PAGE_URL, options); }; @@ -13,6 +17,35 @@ export class BackofficePage extends AbstractPage { cy.clearCookie(Cypress.env('backofficeSessionCookieName')); }; + getBackofficeAbsoluteUrl = (path: string): string => `${Cypress.env('backofficeUrl')}${path}`; + + /** + * Resolves a product abstract's numeric id from its SKU. + * + * `id_product_abstract` is an auto-increment value assigned during data import, so it is NOT + * stable across environments: the same demo CSV yields different ids on a freshly seeded CI + * database than on a locally reused one. Specs must therefore address products by SKU (stable, + * authored in the CSV) and resolve the id at runtime rather than hard-coding the pairing. + */ + resolveProductAbstractIdBySku = (sku: string): Cypress.Chainable => + cy + .request({ + url: this.getBackofficeAbsoluteUrl( + `/product-management/index/table?length=25&search[value]=${encodeURIComponent(sku)}` + ), + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }) + .then((response) => { + const rows: Array> = response.body.data ?? []; + // Column order comes from ProductTable::configure(): [id_product_abstract, name, sku, ...]. + const row = rows.find((columns) => columns[BackofficePage.PRODUCT_TABLE_COL_SKU] === sku); + + // eslint-disable-next-line spryker-cypress/no-assertions-in-page-objects -- Lookup guard: fails fast with a readable message before the row is dereferenced below. + expect(row, `product abstract table row for SKU "${sku}"`).to.not.be.undefined; + + return Number((row as Array)[BackofficePage.PRODUCT_TABLE_COL_ID].replace(/\D/g, '')); + }); + public interceptTable = (params: InterceptGuiTableParams, callback?: () => void): Chainable => { const expectedCount = params.expectedCount ?? 1; const interceptAlias = this.faker.string.uuid(); @@ -62,7 +95,6 @@ export class BackofficePage extends AbstractPage { const clearInterceptAlias = this.faker.string.uuid(); const searchInterceptAlias = this.faker.string.uuid(); - // Intercept clear request (empty search value) cy.intercept('GET', params.interceptTableUrl, (req) => { const searchValue = req.query['search[value]']; @@ -71,7 +103,6 @@ export class BackofficePage extends AbstractPage { } }); - // Intercept search request (matching search query) cy.intercept('GET', params.interceptTableUrl, (req) => { const searchValue = req.query['search[value]']; @@ -144,7 +175,7 @@ export class BackofficePage extends AbstractPage { }); }); }); - }) as unknown as Cypress.Chainable // subject is `TableRowGetter | null`, which is what callers actually receive. // Cypress's `.then()` overloads infer a nested Chainable here; the runtime + }) as unknown as Cypress.Chainable ); }; @@ -198,11 +229,6 @@ export class BackofficePage extends AbstractPage { }; } -/** - * Lazily re-queries the matched table row from the live DOM at call time. - * Returned by {@link BackofficePage.find} instead of a captured jQuery element, - * so a DataTables re-render between search and click cannot detach the reference. - */ export type TableRowGetter = () => Cypress.Chainable>; export enum ActionEnum { diff --git a/cypress/support/pages/backoffice/cost-price/cost-price-page.ts b/cypress/support/pages/backoffice/cost-price/cost-price-page.ts new file mode 100644 index 000000000..0c0ee7421 --- /dev/null +++ b/cypress/support/pages/backoffice/cost-price/cost-price-page.ts @@ -0,0 +1,63 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { BackofficePage } from '@pages/backoffice'; +import { CostPriceRepository } from './cost-price-repository'; + +@injectable() +@autoWired +export class CostPricePage extends BackofficePage { + @inject(CostPriceRepository) private repository: CostPriceRepository; + + protected PAGE_URL = '/product-management'; + + visitProductEdit = (idProductAbstract: number): Cypress.Chainable => { + cy.intercept('GET', '**/product-management/edit?id-product-abstract=*').as('productEditDocument'); + cy.visitBackoffice(`${this.PAGE_URL}/edit?id-product-abstract=${idProductAbstract}`); + + return cy.wait('@productEditDocument'); + }; + + visitProductView = (idProductAbstract: number): Cypress.Chainable => { + cy.intercept('GET', '**/product-management/view?id-product-abstract=*').as('productViewDocument'); + cy.visitBackoffice(`${this.PAGE_URL}/view?id-product-abstract=${idProductAbstract}`); + + return cy.wait('@productViewDocument'); + }; + + getPriceTable = (): Cypress.Chainable => cy.get(this.repository.getPriceTableSelector()); + + getPriceTableHeaders = (): Cypress.Chainable => cy.get(this.repository.getPriceTableHeaderSelector()); + + getCostAmountInputs = (): Cypress.Chainable => cy.get(this.repository.getCostAmountInputSelector()); + + getPriceTaxWidget = (): Cypress.Chainable => + cy.get(this.repository.getPriceTaxWidgetSelector()).contains('Price & Taxes'); + + getCostPriceViewRow = (): Cypress.Chainable => + cy.get(this.repository.getCostPriceViewRowSelector()).filter(':contains("Cost price")'); + + openPriceTaxTab = (): void => { + this.repository.getPriceTaxTab().click(); + }; + + setFirstCostAmount = (value: string): void => { + this.repository + .getFirstVisibleCostAmountInput() + .scrollIntoView() + .clear({ force: true }) + .type(value, { force: true, delay: 50 }); + }; + + save = (): void => { + this.repository.getSaveButton().click(); + }; + + getSaveSuccessMessage = (sku: string): Cypress.Chainable => this.repository.getSaveSuccessMessage(sku); + + getFirstCostAmountValue = (): Cypress.Chainable => this.repository.getFirstCostAmountHiddenInput().invoke('val'); + + getFirstCostAmountInput = (): Cypress.Chainable => this.repository.getFirstCostAmountHiddenInput(); + + getCostPriceViewValues = (): Cypress.Chainable => this.repository.getCostPriceViewValueCells(); +} diff --git a/cypress/support/pages/backoffice/cost-price/cost-price-repository.ts b/cypress/support/pages/backoffice/cost-price/cost-price-repository.ts new file mode 100644 index 000000000..f359e3134 --- /dev/null +++ b/cypress/support/pages/backoffice/cost-price/cost-price-repository.ts @@ -0,0 +1,32 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class CostPriceRepository { + getPriceTableSelector = (): string => '#price-table-collection'; + + getPriceTableHeaderSelector = (): string => '#price-table-collection thead th'; + + getCostAmountInputSelector = (): string => '#price-table-collection input[name*="cost_amount"]'; + + getCostPriceViewRowSelector = (): string => 'b'; + + getPriceTaxWidgetSelector = (): string => '.ibox-title h5'; + + getPriceTaxTab = (): Cypress.Chainable => cy.get('[data-tab-content-id="tab-content-price_and_tax"]'); + + getFirstVisibleCostAmountInput = (): Cypress.Chainable => + cy.get('#price-table-collection input.js-formatted-money-input[data-target*="cost_amount"]').first(); + + getFirstCostAmountHiddenInput = (): Cypress.Chainable => + cy.get(`#price-table-collection input[type="hidden"][name*="cost_amount"]`).first(); + + getSaveButton = (): Cypress.Chainable => cy.get('[name="product_form_edit"] [value="Save"]'); + + getSaveSuccessMessage = (sku: string): Cypress.Chainable => + cy.contains(`The product [${sku}] was saved successfully`, { timeout: 20000 }); + + getCostPriceViewValueCells = (): Cypress.Chainable => + cy.get('.row').filter(':contains("Cost price")').find('.col-xs-8'); +} diff --git a/cypress/support/pages/backoffice/index.ts b/cypress/support/pages/backoffice/index.ts index 6ce611e35..245018cf2 100644 --- a/cypress/support/pages/backoffice/index.ts +++ b/cypress/support/pages/backoffice/index.ts @@ -100,5 +100,13 @@ export * from './workflow/workflow-management-page'; export * from './workflow/workflow-instance-lifecycle-page'; export * from './order-experience-management/list/recurring-schedule-list-page'; export * from './order-experience-management/view/recurring-schedule-view-page'; +export * from './quicksight-analytics/quicksight-analytics-page'; +export * from './ai-configuration/ai-configuration-page'; +export * from './audit-logs/audit-logs-page'; +export * from './ai-workflows/ai-workflows-page'; +export * from './backoffice-assistant/backoffice-assistant-page'; +export * from './smart-pim/smart-pim-page'; +export * from './smart-cms/smart-cms-page'; +export * from './cost-price/cost-price-page'; export * from './service-point/list/service-point-list-page'; export * from './service-point/view/service-point-view-page'; diff --git a/cypress/support/pages/backoffice/quicksight-analytics/quicksight-analytics-page.ts b/cypress/support/pages/backoffice/quicksight-analytics/quicksight-analytics-page.ts new file mode 100644 index 000000000..f9876f76e --- /dev/null +++ b/cypress/support/pages/backoffice/quicksight-analytics/quicksight-analytics-page.ts @@ -0,0 +1,39 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { BackofficePage } from '@pages/backoffice'; +import { QuicksightAnalyticsRepository } from './quicksight-analytics-repository'; + +@injectable() +@autoWired +export class QuicksightAnalyticsPage extends BackofficePage { + @inject(QuicksightAnalyticsRepository) private repository: QuicksightAnalyticsRepository; + + protected PAGE_URL = '/analytics-gui/analytics'; + + visitAnalytics = (): Cypress.Chainable => { + cy.intercept('GET', '**/analytics-gui/analytics').as('analyticsDocument'); + cy.visitBackoffice(this.PAGE_URL); + + return cy.wait('@analyticsDocument'); + }; + + getSectionTitle = (): Cypress.Chainable => cy.get(this.repository.getSectionTitleSelector()); + + getSectionTitleText = (): string => this.repository.getSectionTitleText(); + + getSynchronizeUsersButton = (): Cypress.Chainable => cy.get(this.repository.getSynchronizeUsersButtonSelector()); + + getSynchronizeUsersLabel = (): string => this.repository.getSynchronizeUsersLabel(); + + getInfoAlert = (): Cypress.Chainable => cy.get(this.repository.getInfoAlertSelector()); + + getNoPermissionText = (): string => this.repository.getNoPermissionText(); + + getSynchronizeUsersForm = (): Cypress.Chainable => cy.get(this.repository.getSynchronizeUsersFormSelector()); + + getSynchronizeUsersFormAction = (): string => this.repository.getSynchronizeUsersFormAction(); + + getSynchronizeUsersCsrfToken = (): Cypress.Chainable => + cy.get(this.repository.getSynchronizeUsersCsrfTokenSelector()); +} diff --git a/cypress/support/pages/backoffice/quicksight-analytics/quicksight-analytics-repository.ts b/cypress/support/pages/backoffice/quicksight-analytics/quicksight-analytics-repository.ts new file mode 100644 index 000000000..d73dac007 --- /dev/null +++ b/cypress/support/pages/backoffice/quicksight-analytics/quicksight-analytics-repository.ts @@ -0,0 +1,24 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class QuicksightAnalyticsRepository { + getSectionTitleSelector = (): string => '.page-title-head h2'; + + getSynchronizeUsersFormAction = (): string => '/amazon-quicksight/user/synchronize-quicksight-users'; + + getSynchronizeUsersFormSelector = (): string => `form[action="${this.getSynchronizeUsersFormAction()}"]`; + + getSynchronizeUsersButtonSelector = (): string => `${this.getSynchronizeUsersFormSelector()} button`; + + getSynchronizeUsersCsrfTokenSelector = (): string => `${this.getSynchronizeUsersFormSelector()} input[name="_token"]`; + + getInfoAlertSelector = (): string => '.alert-info'; + + getNoPermissionText = (): string => 'No Analytics permission has been granted to the current user.'; + + getSynchronizeUsersLabel = (): string => 'Synchronize Users'; + + getSectionTitleText = (): string => 'Analytics'; +} diff --git a/cypress/support/pages/backoffice/smart-cms/smart-cms-page.ts b/cypress/support/pages/backoffice/smart-cms/smart-cms-page.ts new file mode 100644 index 000000000..3bb05b128 --- /dev/null +++ b/cypress/support/pages/backoffice/smart-cms/smart-cms-page.ts @@ -0,0 +1,163 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { BackofficePage } from '@pages/backoffice'; +import { SmartCmsRepository } from './smart-cms-repository'; + +@injectable() +@autoWired +export class SmartCmsPage extends BackofficePage { + @inject(SmartCmsRepository) private repository: SmartCmsRepository; + + protected PAGE_URL = '/cms-gui/create-glossary?id-cms-page=6'; + + private CONFIGURATION_URL = '/configuration/manage?feature=ai_commerce&tab=smart_cms'; + + private CMS_BLOCK_EDITOR_URL = '/cms-block-gui/edit-glossary?id-cms-block=24'; + + enableSmartCms = (): Cypress.Chainable => this.setSmartCmsEnabled(true); + + disableSmartCms = (): Cypress.Chainable => this.setSmartCmsEnabled(false); + + private setSmartCmsEnabled = (enabled: boolean): Cypress.Chainable => { + cy.visitBackoffice(this.CONFIGURATION_URL); + + cy.get(this.repository.getEnableToggleSelector()).then(($toggle) => { + if (($toggle[0] as HTMLInputElement).checked === enabled) { + return; + } + + cy.wrap($toggle)[enabled ? 'check' : 'uncheck']({ force: true }); + + cy.intercept('POST', '**/configuration/manage/save').as('saveConfiguration'); + cy.get(this.repository.getSaveButtonSelector()).click(); + cy.wait('@saveConfiguration').its('response.statusCode').should('eq', 200); + }); + + return cy.get(this.repository.getEnableToggleSelector()).should(enabled ? 'be.checked' : 'not.be.checked'); + }; + + visitCmsPageEditor = (): Cypress.Chainable => { + cy.intercept('GET', '**/cms-gui/create-glossary**').as('cmsPageEditorDocument'); + cy.visitBackoffice(this.PAGE_URL); + + return cy.wait('@cmsPageEditorDocument'); + }; + + visitCmsBlockEditor = (): Cypress.Chainable => { + cy.intercept('GET', '**/cms-block-gui/edit-glossary**').as('cmsBlockEditorDocument'); + cy.visitBackoffice(this.CMS_BLOCK_EDITOR_URL); + + return cy.wait('@cmsBlockEditorDocument'); + }; + + getPanelToggleTitle = (): string => this.repository.getPanelToggleTitle(); + + getPanelInputPlaceholder = (): string => this.repository.getPanelInputPlaceholder(); + + getPanelAskLabel = (): string => this.repository.getPanelAskLabel(); + + getPanelMessageErrorClass = (): string => this.repository.getPanelMessageErrorClass(); + + getPanelMessageVisibleClass = (): string => this.repository.getPanelMessageVisibleClass(); + + getContentConfigWindowKey = (): string => this.repository.getContentConfigWindowKey(); + + getHeroPrompt = (): string => this.repository.getHeroPrompt(); + + getBlockPrompt = (): string => this.repository.getBlockPrompt(); + + getProbeImageFileName = (): string => this.repository.getProbeImageFileName(); + + getPanel = (): Cypress.Chainable => cy.get(this.repository.getPanelSelector()); + + getPanelCollapsedClass = (): string => this.repository.getPanelCollapsedClass(); + + getPanelToggle = (): Cypress.Chainable => cy.get(this.repository.getPanelToggleSelector()); + + getPanelInput = (): Cypress.Chainable => cy.get(this.repository.getPanelInputSelector()); + + getPanelAsk = (): Cypress.Chainable => cy.get(this.repository.getPanelAskSelector()); + + getPanelAttach = (): Cypress.Chainable => cy.get(this.repository.getPanelAttachSelector()); + + getPanelAttachmentName = (): Cypress.Chainable => cy.get(this.repository.getPanelAttachmentNameSelector()); + + getPanelAttachmentRemove = (): Cypress.Chainable => cy.get(this.repository.getPanelAttachmentRemoveSelector()); + + getGlossaryEditor = (): Cypress.Chainable => cy.get(this.repository.getGlossaryEditorSelector()); + + getPanelMessage = (): Cypress.Chainable => cy.get(this.repository.getPanelMessageSelector()); + + expandPanel = (): void => { + this.getPanelToggle().then(($toggle): void => { + if ($toggle.attr('aria-expanded') !== 'true') { + this.getPanelToggle().click(); + } + }); + this.getPanelInput().should('be.visible'); + }; + + typePrompt = (prompt: string): void => { + this.getPanelInput().clear().type(prompt).should('have.value', prompt); + }; + + attachFile = (file: string | Cypress.FileReference): void => { + cy.get(this.repository.getPanelFileInputSelector()).selectFile(file, { force: true }); + }; + + attachUnsupportedFile = (mediaType: string): void => { + this.attachFile({ + contents: Cypress.Buffer.from('MZ unsupported binary'), + fileName: 'unsupported.bin', + mimeType: mediaType, + }); + }; + + interceptGenerateWithProviderFailure = (): void => { + cy.intercept('POST', this.repository.getGenerateEndpointGlob(), { + statusCode: 503, + body: { error: 'AI provider unavailable' }, + }).as('generateRequest'); + }; + + interceptRealGenerate = (): void => { + cy.intercept('POST', this.repository.getGenerateEndpointGlob()).as('generateRequest'); + }; + + clickAskAi = (): void => { + this.getPanelAsk().click(); + }; + + removeFirstAttachment = (): void => { + this.getPanelAttachmentRemove().first().click(); + }; + + getPanelSuccessMessage = (): Cypress.Chainable => cy.get(this.repository.getPanelSuccessMessageSelector()); + + getGlossaryEditorValue = (): Cypress.Chainable => + this.getGlossaryEditor() + .invoke('val') + .then((value): string => String(value ?? '')); + + getInjectedCsrfToken = (): Cypress.Chainable => + cy + .window() + .then((win) => + String( + (win as unknown as { SmartCmsContentConfig?: { csrfToken?: string } }).SmartCmsContentConfig?.csrfToken ?? '' + ) + ); + + requestGenerate = ( + endpointPath: string, + options: { method?: string; body?: Record } = {} + ): Cypress.Chainable> => + cy.request({ + method: options.method ?? 'POST', + url: this.getBackofficeAbsoluteUrl(endpointPath), + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + body: options.body, + failOnStatusCode: false, + }); +} diff --git a/cypress/support/pages/backoffice/smart-cms/smart-cms-repository.ts b/cypress/support/pages/backoffice/smart-cms/smart-cms-repository.ts new file mode 100644 index 000000000..0b826c96f --- /dev/null +++ b/cypress/support/pages/backoffice/smart-cms/smart-cms-repository.ts @@ -0,0 +1,58 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class SmartCmsRepository { + private static readonly ENABLE_SETTING_KEY = 'ai_commerce:smart_cms:general:is_enabled'; + + getEnableToggleSelector = (): string => + `.setting-row[data-setting-key="${SmartCmsRepository.ENABLE_SETTING_KEY}"] .config-input`; + + getSaveButtonSelector = (): string => '[data-qa="save-configuration"]'; + + getPanelSelector = (): string => '.js-smart-cms-panel'; + + getPanelCollapsedClass = (): string => 'smart-cms-panel--collapsed'; + + getPanelToggleSelector = (): string => '.js-smart-cms-panel__toggle'; + + getPanelInputSelector = (): string => '.js-smart-cms-panel__input'; + + getPanelAskSelector = (): string => '.js-smart-cms-panel__ask'; + + getPanelAttachSelector = (): string => '.js-smart-cms-panel__attach'; + + getPanelFileInputSelector = (): string => '.js-smart-cms-panel__file-input'; + + getPanelAttachmentNameSelector = (): string => '.js-smart-cms-panel__attachment-name'; + + getPanelAttachmentRemoveSelector = (): string => '.js-smart-cms-panel__attachment-remove'; + + getGlossaryEditorSelector = (): string => 'textarea.html-editor'; + + getPanelMessageSelector = (): string => '.js-smart-cms-panel__message'; + + getPanelMessageErrorClass = (): string => 'smart-cms-panel__message--error'; + + getPanelMessageVisibleClass = (): string => 'smart-cms-panel__message--visible'; + + getPanelSuccessMessageSelector = (): string => + '.js-smart-cms-panel__message.smart-cms-panel__message--success:not(.smart-cms-panel__message--error)'; + + getGenerateEndpointGlob = (): string => '**/ai-commerce/smart-cms-content/generate'; + + getPanelToggleTitle = (): string => 'Smart CMS Content Assistant'; + + getPanelInputPlaceholder = (): string => 'Ask AI to generate or edit the title and content…'; + + getPanelAskLabel = (): string => 'Ask AI'; + + getContentConfigWindowKey = (): string => 'SmartCmsContentConfig'; + + getHeroPrompt = (): string => 'Write a punchy hero title and a short intro paragraph for this landing page'; + + getBlockPrompt = (): string => 'Write a short promotional block heading and a matching one-line description'; + + getProbeImageFileName = (): string => 'search-by-image-probe.png'; +} diff --git a/cypress/support/pages/backoffice/smart-pim/smart-pim-page.ts b/cypress/support/pages/backoffice/smart-pim/smart-pim-page.ts new file mode 100644 index 000000000..3f1940386 --- /dev/null +++ b/cypress/support/pages/backoffice/smart-pim/smart-pim-page.ts @@ -0,0 +1,191 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { BackofficePage } from '@pages/backoffice'; +import { SmartPimRepository } from './smart-pim-repository'; + +@injectable() +@autoWired +export class SmartPimPage extends BackofficePage { + @inject(SmartPimRepository) private repository: SmartPimRepository; + + protected PAGE_URL = '/product-management/edit'; + + visitProductEdit = (idProductAbstract: number): Cypress.Chainable => { + cy.intercept('GET', '**/product-management/edit?id-product-abstract=*').as('productEditDocument'); + cy.visitBackoffice(`${this.PAGE_URL}?id-product-abstract=${idProductAbstract}`); + + return cy.wait('@productEditDocument'); + }; + + getRequestBuilderTriggers = (): Cypress.Chainable => cy.get(this.repository.getRequestBuilderTriggerSelector()); + + getCategoryTrigger = (): Cypress.Chainable => cy.get(this.repository.getCategoryTriggerSelector()); + + getAltImageTriggers = (): Cypress.Chainable => cy.get(this.repository.getAltImageTriggerSelector()); + + getCategoryModal = (): Cypress.Chainable => cy.get(this.repository.getCategoryModalSelector()); + + getAltTextModal = (): Cypress.Chainable => cy.get(this.repository.getAltTextModalSelector()); + + getTranslationModal = (): Cypress.Chainable => cy.get(this.repository.getTranslationModalSelector()); + + getAllActionsPopover = (): Cypress.Chainable => cy.get(this.repository.getAllActionsPopoverSelector()); + + getLocaleSelectorPopover = (): Cypress.Chainable => cy.get(this.repository.getLocaleSelectorPopoverSelector()); + + getSmartPimScript = (): Cypress.Chainable => cy.get(this.repository.getSmartPimScriptSelector()); + + getRequestBuilderScript = (): Cypress.Chainable => cy.get(this.repository.getRequestBuilderScriptSelector()); + + getTranslateActionButton = (): Cypress.Chainable => cy.get(this.repository.getTranslateActionButtonSelector()); + + getImproveContentButton = (): Cypress.Chainable => cy.get(this.repository.getImproveContentButtonSelector()); + + getLocaleButtons = (): Cypress.Chainable => cy.get(this.repository.getLocaleButtonSelector()); + + getCategoryModalEmptyText = (): Cypress.Chainable => + this.getCategoryModal().find(this.repository.getModalEmptyTextSelector()); + + getAltTextModalEmptyText = (): Cypress.Chainable => + this.getAltTextModal().find(this.repository.getModalEmptyTextSelector()); + + getCategorySelect = (): Cypress.Chainable => + this.getCategoryModal().find(this.repository.getCategorySelectSelector()); + + getCategorySelectOptions = (): Cypress.Chainable => + this.getCategoryModal().find(this.repository.getCategorySelectOptionSelector()); + + getOuterCategorySelect = (): Cypress.Chainable => cy.get(this.repository.getOuterCategorySelectSelector()); + + clickCategoryModalApply = (): Cypress.Chainable => + this.getCategoryModal().find(this.repository.getModalApplyButtonSelector()).click({ force: true }); + + clickAltTextModalApply = (): Cypress.Chainable => + this.getAltTextModal().find(this.repository.getModalApplyButtonSelector()).click({ force: true }); + + getFirstTriggeredAltTextField = (): Cypress.Chainable => + this.getAltImageTriggers().first().parent().find(this.repository.getImageRowAltTextFieldSelector()).first(); + + getAltTextInput = (): Cypress.Chainable => this.getAltTextModal().find(this.repository.getAltTextInputSelector()); + + getResponsePopover = (): Cypress.Chainable => cy.get(this.repository.getResponsePopoverSelector()); + + getResponseField = (): Cypress.Chainable => cy.get(this.repository.getResponseFieldSelector()); + + getResponseErrorBlock = (): Cypress.Chainable => cy.get(this.repository.getResponseErrorBlockSelector()); + + getResponseAgainButton = (): Cypress.Chainable => cy.get(this.repository.getResponseAgainButtonSelector()); + + getResponseApplyButton = (): Cypress.Chainable => cy.get(this.repository.getResponseApplyButtonSelector()); + + getCategoryModalErrorHolder = (): Cypress.Chainable => + this.getCategoryModal().find(this.repository.getModalErrorHolderSelector()); + + getAltTextModalErrorHolder = (): Cypress.Chainable => + this.getAltTextModal().find(this.repository.getModalErrorHolderSelector()); + + getAltTriggerTemplate = (): Cypress.Chainable => cy.get(this.repository.getAltTriggerTemplateSelector()); + + getContentImproverUrl = (): string => this.getBackofficeAbsoluteUrl(this.repository.getContentImproverPath()); + + getCategorySuggestionUrl = (): string => this.getBackofficeAbsoluteUrl(this.repository.getCategorySuggestionPath()); + + getImageAltTextUrl = (): string => this.getBackofficeAbsoluteUrl(this.repository.getImageAltTextPath()); + + getTranslateUrl = (): string => this.getBackofficeAbsoluteUrl(this.repository.getTranslatePath()); + + openAllActionsPopover = (): Cypress.Chainable => this.getRequestBuilderTriggers().first().click({ force: true }); + + openLocaleSelectorPopover = (): Cypress.Chainable => this.getTranslateActionButton().click({ force: true }); + + clickCategoryTrigger = (): Cypress.Chainable => this.getCategoryTrigger().click({ force: true }); + + clickFirstAltImageTrigger = (): Cypress.Chainable => this.getAltImageTriggers().first().click({ force: true }); + + clickImproveContent = (): Cypress.Chainable => this.getImproveContentButton().click({ force: true }); + + clickFirstLocaleButton = (): Cypress.Chainable => this.getLocaleButtons().first().click({ force: true }); + + clearInformationalFields = (): Cypress.Chainable => + cy.get(this.repository.getInformationalFieldSelector()).each(($field) => cy.wrap($field).invoke('val', '')); + + clearImageUrlFields = (): Cypress.Chainable => + cy.get(this.repository.getImageUrlFieldSelector()).each(($field) => cy.wrap($field).invoke('val', '')); + + closeAllActionsPopover = (): Cypress.Chainable => + this.getAllActionsPopover().find(this.repository.getClosePopoverButtonSelector()).first().click({ force: true }); + + clickResponseAgain = (): Cypress.Chainable => this.getResponseAgainButton().click({ force: true }); + + clickResponseApply = (): Cypress.Chainable => this.getResponseApplyButton().click({ force: true }); + + clickLocaleButtonFor = (locale: string): Cypress.Chainable => + this.getLocaleButtons().filter(`[data-locale="${locale}"]`).first().click({ force: true }); + + getFirstRequestBuilderLocale = (): Cypress.Chainable => + this.getRequestBuilderTriggers().first().invoke('attr', 'data-current-locale'); + + getLocaleButtonFor = (locale: string): Cypress.Chainable => + this.getLocaleButtons().filter(`[data-locale="${locale}"]`); + + getFirstRequestBuilderTargetField = (): Cypress.Chainable => + this.getRequestBuilderTriggers() + .first() + .invoke('attr', 'data-target-field') + .then((selector) => cy.get(selector as string).first()); + + appendImageAltTextWrapper = (marker: string): Cypress.Chainable => + cy.document().then((document): void => { + const wrapper = document.createElement('div'); + wrapper.className = `form-group ${this.repository.getImageAltTextWrapperSelector().replace('.', '')} ${marker}`; + document.body.appendChild(wrapper); + }); + + getInjectedWrapper = (marker: string): Cypress.Chainable => cy.get(`.${marker}`); + + getInjectedAltTrigger = (marker: string): Cypress.Chainable => + this.getInjectedWrapper(marker).find(this.repository.getInjectedAltTriggerSelector()); + + getAffixClass = (): string => this.repository.getAffixClass(); + + getLoadingClass = (): string => this.repository.getLoadingClass(); + + getEmptyClass = (): string => this.repository.getEmptyClass(); + + getCategoryModalId = (): string => this.repository.getCategoryModalId(); + + getContentImproverPath = (): string => this.repository.getContentImproverPath(); + + getTranslatePath = (): string => this.repository.getTranslatePath(); + + getImageAltTextPath = (): string => this.repository.getImageAltTextPath(); + + getCategorySuggestionPath = (): string => this.repository.getCategorySuggestionPath(); + + getProviderEndpointGlobs = (): Array => this.repository.getProviderEndpointGlobs(); + + getCategoryEmptyText = (): string => this.repository.getCategoryEmptyText(); + + getAltTextEmptyText = (): string => this.repository.getAltTextEmptyText(); + + getProviderUnavailableMessage = (): string => this.repository.getProviderUnavailableMessage(); + + getMissingParamsMessage = (endpoint: 'content-improver' | 'image-alt-text' | 'translate'): string => + this.repository.getMissingParamsMessage(endpoint); + + /** + * Popover open/closed state is a repository-owned CSS-state selector rather than + * plain visibility, so the check is encapsulated here instead of leaking the + * selector into every spec. Returns the chainable for the caller to continue. + */ + /* eslint-disable spryker-cypress/no-assertions-in-page-objects -- Encapsulates a repository-owned popover state selector; asserting IS these helpers' contract. */ + shouldBeOpenPopover = (chainable: Cypress.Chainable): Cypress.Chainable => + chainable + .should('be.visible') + .and(($el) => expect($el[0].matches(this.repository.getOpenPopoverState())).to.eq(true)); + + shouldBeClosedPopover = (chainable: Cypress.Chainable): Cypress.Chainable => + chainable.should(($el) => expect($el[0].matches(this.repository.getOpenPopoverState())).to.eq(false)); + /* eslint-enable spryker-cypress/no-assertions-in-page-objects */ +} diff --git a/cypress/support/pages/backoffice/smart-pim/smart-pim-repository.ts b/cypress/support/pages/backoffice/smart-pim/smart-pim-repository.ts new file mode 100644 index 000000000..23d3a66b1 --- /dev/null +++ b/cypress/support/pages/backoffice/smart-pim/smart-pim-repository.ts @@ -0,0 +1,114 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class SmartPimRepository { + getRequestBuilderTriggerSelector = (): string => '.ai-query-builder__trigger'; + + getCategoryTriggerSelector = (): string => '.js-ai-category-trigger'; + + getAltImageTriggerSelector = (): string => '.js-ai-alt-image-trigger'; + + getCategoryModalSelector = (): string => '#ai-category-modal'; + + getAltTextModalSelector = (): string => '#ai-alt-text-modal'; + + getTranslationModalSelector = (): string => '#ai-translation-modal'; + + getAllActionsPopoverSelector = (): string => '.all-ai-actions-popover'; + + getLocaleSelectorPopoverSelector = (): string => '.locale-selector-popover'; + + getSmartPimScriptSelector = (): string => 'script[src*="spryker-ai-commerce-smart-product-management.js"]'; + + getRequestBuilderScriptSelector = (): string => 'script[src*="spryker-ai-commerce-request-builder.js"]'; + + getTranslateActionButtonSelector = (): string => + '.all-ai-actions-popover .ai-query-builder__button[data-target-popover=".locale-selector-popover"]'; + + getImproveContentButtonSelector = (): string => + '.all-ai-actions-popover .ai-query-builder__button[data-request-url="/ai-commerce/content-improver"]'; + + getLocaleButtonSelector = (): string => '.locale-selector-popover .ai-query-builder__button[data-locale]'; + + getModalEmptyTextSelector = (): string => '.ai-product-management-modal__empty'; + + getCategorySelectSelector = (): string => '.js-ai-category-select'; + + getOuterCategorySelectSelector = (): string => '.js-ai-categories select'; + + getCategorySelectOptionSelector = (): string => '.js-ai-category-select option'; + + getAltTextInputSelector = (): string => '.js-ai-alt-text-input'; + + getImageRowAltTextFieldSelector = (): string => '[name*="[alt_text_small]"], [name*="[alt_text_large]"]'; + + getInformationalFieldSelector = (): string => '.js-infomational-field'; + + getImageUrlFieldSelector = (): string => '[name*="[external_url_small]"], [name*="[external_url_large]"]'; + + getOpenPopoverState = (): string => ':popover-open'; + + getResponsePopoverSelector = (): string => '.response-popover'; + + getResponseFieldSelector = (): string => '#response-field'; + + getResponseErrorBlockSelector = (): string => '.js-ai-product-management-modal__error-block'; + + getClosePopoverButtonSelector = (): string => '[data-close-popover]'; + + getResponseAgainButtonSelector = (): string => '.js-ai-builder-again'; + + getResponseApplyButtonSelector = (): string => '.js-ai-builder-apply'; + + getModalErrorHolderSelector = (): string => '.js-ai-product-management-modal__error'; + + getModalApplyButtonSelector = (): string => '.js-ai-product-management-apply'; + + getImageAltTextWrapperSelector = (): string => '.js-image-alt-text-wrapper'; + + getAltTriggerTemplateSelector = (): string => '#ai-alt-text-trigger-template'; + + getInjectedAltTriggerSelector = (): string => '.js-ai-alt-image-trigger'; + + getAffixClass = (): string => 'form-wrapper-clickable-affix'; + + getLoadingClass = (): string => 'is-loading'; + + getEmptyClass = (): string => 'is-empty'; + + getCategoryModalId = (): string => 'ai-category-modal'; + + getContentImproverPath = (): string => '/ai-commerce/content-improver'; + + getCategorySuggestionPath = (): string => '/ai-commerce/category-suggestion'; + + getImageAltTextPath = (): string => '/ai-commerce/image-alt-text'; + + getTranslatePath = (): string => '/ai-commerce/translate'; + + getProviderEndpointGlobs = (): Array => + [ + this.getContentImproverPath(), + this.getCategorySuggestionPath(), + this.getImageAltTextPath(), + this.getTranslatePath(), + ].map((path) => `**${path}`); + + getCategoryEmptyText = (): string => 'Please fill in the product name'; + + getAltTextEmptyText = (): string => 'Please fill in the product image url'; + + getProviderUnavailableMessage = (): string => 'AI provider unavailable'; + + getMissingParamsMessage = (endpoint: 'content-improver' | 'image-alt-text' | 'translate'): string => { + const messages = { + 'content-improver': 'Text is missing from request.', + 'image-alt-text': 'ImageUrl and/or target locale are missing from request.', + translate: 'Text and/or target locales are missing from request.', + }; + + return messages[endpoint]; + }; +} diff --git a/cypress/support/pages/mp/products/products-page.ts b/cypress/support/pages/mp/products/products-page.ts index 4da2b81c9..492c4f72b 100644 --- a/cypress/support/pages/mp/products/products-page.ts +++ b/cypress/support/pages/mp/products/products-page.ts @@ -13,7 +13,7 @@ export class ProductsPage extends MpPage { find = (params: FindParams): Cypress.Chainable => { const searchSelector = this.repository.getSearchSelector(); - cy.get(searchSelector).clear(); + cy.get(searchSelector, { timeout: 20000 }).should('be.visible').clear(); cy.get(searchSelector).type(params.query, { delay: 0 }); cy.get(searchSelector).type('{enter}'); @@ -76,6 +76,88 @@ export class ProductsPage extends MpPage { return this.repository.getTaxIdOptionSelector(); }; + openProductBySku = (sku: string): void => { + cy.intercept('GET', this.repository.getUpdateProductAbstractUrl()).as('updateProductAbstract'); + cy.intercept('GET', this.repository.getPriceTableDataUrl()).as('openPriceTableReload'); + this.repository.getFirstTableRow().should('contain.text', sku); + this.repository.getFirstTableRow().contains(sku).click(); + cy.wait('@updateProductAbstract'); + cy.wait('@openPriceTableReload').its('response.statusCode').should('eq', 200); + this.repository.getPriceTable().should('exist'); + this.repository.getPriceTable().find('tbody tr').should('have.length.at.least', 1); + }; + + getPriceTableHeaders = (): Cypress.Chainable => cy.get(this.repository.getPriceTableHeaderSelector()); + + getCostPriceColumnTitle = (): string => this.repository.getCostPriceColumnTitle(); + + getCostColumnIndex = (): Cypress.Chainable => + this.repository + .getPriceTable() + .find('thead th') + .then(($headers) => + $headers.toArray().findIndex((th) => th.textContent?.trim() === this.getCostPriceColumnTitle()) + ); + + getCostColumnValues = (): Cypress.Chainable => + this.getCostColumnIndex().then((costColumnIndex) => + this.repository + .getPriceTable() + .find('tbody tr') + .then(($rows) => + $rows.toArray().map((tr) => tr.querySelectorAll('td')[costColumnIndex]?.textContent?.trim() ?? '') + ) + ); + + editFirstCostAmount = (value: string): void => { + this.getCostColumnIndex().then((costColumnIndex) => { + this.repository.getPriceTable().find('tbody tr').first().find('td').eq(costColumnIndex).as('costCell'); + + this.applyCostCellEdit('@costCell', value); + }); + }; + + editCostAmountByCurrentValue = (currentValue: string, newValue: string): void => { + this.getCostColumnIndex().then((costColumnIndex) => { + this.repository + .getPriceTable() + .find('tbody tr') + .filter( + (_index, row) => (row.querySelectorAll('td')[costColumnIndex]?.textContent?.trim() ?? '') === currentValue + ) + .first() + .find('td') + .eq(costColumnIndex) + .as('costCell'); + + this.applyCostCellEdit('@costCell', newValue); + }); + }; + + private applyCostCellEdit = (cellAlias: string, value: string): void => { + cy.get(cellAlias).find(this.repository.getEditableCellOverlaySelector()).scrollIntoView(); + cy.get(cellAlias).find(this.repository.getEditableCellOverlaySelector()).click({ force: true }); + + cy.get(this.repository.getEditableCellInputSelector()).should('be.visible').clear(); + cy.get(this.repository.getEditableCellInputSelector()).type(value); + + cy.intercept('POST', this.repository.getSavePriceProductAbstractUrl()).as('savePriceProductAbstract'); + cy.intercept('GET', this.repository.getPriceTableDataUrl()).as('priceTableReload'); + + cy.contains(this.repository.getEditableCellSaveButtonSelector(), 'Save').click({ force: true }); + + cy.wait('@savePriceProductAbstract').its('response.statusCode').should('eq', 200); + cy.wait('@priceTableReload').its('response.statusCode').should('eq', 200); + cy.get(this.repository.getEditableCellInputSelector()).should('not.exist'); + }; + + saveDrawer = (): void => { + cy.intercept('POST', this.repository.getUpdateProductAbstractUrl()).as('saveProductAbstract'); + cy.contains(this.repository.getDrawerSaveButtonSelector(), 'Save').should('be.enabled').scrollIntoView(); + cy.contains(this.repository.getDrawerSaveButtonSelector(), 'Save').click(); + cy.wait('@saveProductAbstract').its('response.statusCode').should('eq', 200); + }; + getDrawerAlias = (): Cypress.Chainable => cy.get('@drawer'); selectTaxIdSetOption = (value: string | number | string[]): Cypress.Chainable => diff --git a/cypress/support/pages/mp/products/products-repository.ts b/cypress/support/pages/mp/products/products-repository.ts index 9d17f68dc..1acd6cb11 100644 --- a/cypress/support/pages/mp/products/products-repository.ts +++ b/cypress/support/pages/mp/products/products-repository.ts @@ -12,4 +12,20 @@ export class ProductsRepository { getDrawer = (): Cypress.Chainable => cy.get('.spy-drawer-wrapper'); getAddAttributeButton = (): Cypress.Chainable => cy.get('.product-attributes-table button:contains("Add")'); getAttributesTableSelector = (): string => '.product-attributes-table'; + getPriceTableHeaderSelector = (): string => '.spy-drawer-wrapper th'; + getCostPriceColumnTitle = (): string => 'Cost Default'; + + getPriceTable = (): Cypress.Chainable => + cy.get('.spy-drawer-wrapper table').filter(':has(th:contains("Cost Default"))').first(); + + getEditableCellOverlaySelector = (): string => '.spy-table-editable-feature__wrapper'; + getEditableCellInputSelector = (): string => '.spy-table-editable-feature__float-col input[type="number"]'; + getEditableCellSaveButtonSelector = (): string => + '.spy-table-editable-feature__float-cell button.spy-button-core__btn'; + getDrawerSaveButtonSelector = (): string => '.mp-edit-abstract-product__header button.spy-button-core__btn'; + + getSavePriceProductAbstractUrl = (): string => '/product-merchant-portal-gui/save-price-product-abstract**'; + getPriceTableDataUrl = (): string => '/product-merchant-portal-gui/update-product-abstract/table-data**'; + getUpdateProductAbstractUrl = (): string => + '/product-merchant-portal-gui/update-product-abstract?product-abstract-id=*'; } diff --git a/cypress/support/pages/yves/agent-quote-request/agent-quote-request-page.ts b/cypress/support/pages/yves/agent-quote-request/agent-quote-request-page.ts new file mode 100644 index 000000000..552c92c0a --- /dev/null +++ b/cypress/support/pages/yves/agent-quote-request/agent-quote-request-page.ts @@ -0,0 +1,68 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { YvesPage } from '@pages/yves'; +import { AgentQuoteRequestRepository } from './agent-quote-request-repository'; + +@injectable() +@autoWired +export class AgentQuoteRequestPage extends YvesPage { + @inject(AgentQuoteRequestRepository) private repository: AgentQuoteRequestRepository; + + protected PAGE_URL = '/agent/quote-request'; + + visitEdit = (reference: string): Cypress.Chainable => { + cy.intercept('GET', '**/agent/quote-request/edit/**').as('agentQuoteRequestEditDocument'); + cy.visit(`${this.PAGE_URL}/edit/${reference}`); + + return cy.wait('@agentQuoteRequestEditDocument'); + }; + + visitDetails = (reference: string): Cypress.Chainable => { + cy.intercept('GET', '**/agent/quote-request/details/**').as('agentQuoteRequestDetailsDocument'); + cy.visit(`${this.PAGE_URL}/details/${reference}`); + + return cy.wait('@agentQuoteRequestDetailsDocument'); + }; + + visitCustomerDetails = (reference: string): Cypress.Chainable => { + cy.intercept('GET', '**/quote-request/details/**').as('customerQuoteRequestDetailsDocument'); + cy.visit(`/quote-request/details/${reference}`); + + return cy.wait('@customerQuoteRequestDetailsDocument'); + }; + + getCartItems = (): Cypress.Chainable => cy.get(this.repository.getCartItemSelector()); + + getCostPriceMolecules = (): Cypress.Chainable => cy.get(this.repository.getCostPriceMoleculeSelector()); + + getCostPriceValues = (): Cypress.Chainable => cy.get(this.repository.getCostPriceValueSelector()); + + getGrossMargins = (): Cypress.Chainable => cy.get(this.repository.getGrossMarginSelector()); + + getGrossMarginUnavailable = (): Cypress.Chainable => + cy.get('body').then(($body) => $body.find(this.repository.getGrossMarginUnavailableSelector())); + + getCostPriceWarningIcon = (): Cypress.Chainable => + cy.get('body').then(($body) => $body.find(this.repository.getCostPriceWarningIconSelector())); + + isGrossMarginUnavailable = ($margin: JQuery): boolean => + $margin.hasClass(this.repository.getGrossMarginUnavailableClass()); + + getCostPriceDataAttribute = (): string => this.repository.getCostPriceDataAttribute(); + + getUnavailableCostPriceText = (): string => this.repository.getUnavailableCostPriceText(); + + save = (): void => { + cy.intercept('POST', '**/agent/quote-request/edit/**').as('agentQuoteRequestSave'); + this.repository.getSaveButton().click(); + cy.wait('@agentQuoteRequestSave'); + }; + + recalculate = (reference: string): void => { + this.visitEdit(reference); + // eslint-disable-next-line spryker-cypress/no-assertions-in-page-objects -- Readiness guard: the cart must be rendered before the save below is clicked. + this.getCartItems().should('have.length.at.least', 1); + this.save(); + }; +} diff --git a/cypress/support/pages/yves/agent-quote-request/agent-quote-request-repository.ts b/cypress/support/pages/yves/agent-quote-request/agent-quote-request-repository.ts new file mode 100644 index 000000000..fdac2033b --- /dev/null +++ b/cypress/support/pages/yves/agent-quote-request/agent-quote-request-repository.ts @@ -0,0 +1,28 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class AgentQuoteRequestRepository { + getCartItemSelector = (): string => '.quote-request-cart-item'; + + getCostPriceMoleculeSelector = (): string => 'cost-price'; + + getCostPriceListSelector = (): string => '.cost-price__list'; + + getCostPriceValueSelector = (): string => '.cost-price__value[data-has-cost-price]'; + + getGrossMarginSelector = (): string => '.cost-price__gross-margin'; + + getGrossMarginUnavailableClass = (): string => 'cost-price__gross-margin--unavailable'; + + getGrossMarginUnavailableSelector = (): string => `.${this.getGrossMarginUnavailableClass()}`; + + getCostPriceDataAttribute = (): string => 'data-has-cost-price'; + + getUnavailableCostPriceText = (): string => '—'; + + getCostPriceWarningIconSelector = (): string => '.font-icon--cost-price-warning'; + + getSaveButton = (): Cypress.Chainable => cy.get('button[name="save"], input[name="save"]').first(); +} diff --git a/cypress/support/pages/yves/index.ts b/cypress/support/pages/yves/index.ts index f183b3810..f304ca96e 100644 --- a/cypress/support/pages/yves/index.ts +++ b/cypress/support/pages/yves/index.ts @@ -3,6 +3,7 @@ export * from './home/home-page'; export * from './cart/cart-page'; export * from './cart/cart-up-selling-products-page'; export * from './agent-login/agent-login-page'; +export * from './agent-quote-request/agent-quote-request-page'; export * from './checkout/address/checkout-address-page'; export * from './checkout/customer/checkout-customer-page'; export * from './checkout/payment/checkout-payment-page'; @@ -53,6 +54,8 @@ export * from './purchasing-control/budget/list/budget-list-page'; export * from './purchasing-control/budget/create/budget-create-page'; export * from './purchasing-control/budget/update/budget-update-page'; export * from './purchasing-control/checkout-summary/checkout-summary-budget-page'; +export * from './quick-order-image-to-cart/quick-order-image-to-cart-page'; +export * from './search-by-image/search-by-image-page'; export * from './recurring-orders/checkout-summary/checkout-summary-recurring-order-page'; export * from './recurring-orders/list/recurring-order-list-page'; export * from './recurring-orders/detail/recurring-order-detail-page'; diff --git a/cypress/support/pages/yves/quick-order-image-to-cart/quick-order-image-to-cart-page.ts b/cypress/support/pages/yves/quick-order-image-to-cart/quick-order-image-to-cart-page.ts new file mode 100644 index 000000000..30d90b01b --- /dev/null +++ b/cypress/support/pages/yves/quick-order-image-to-cart/quick-order-image-to-cart-page.ts @@ -0,0 +1,155 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { YvesPage } from '@pages/yves'; +import { QuickOrderImageToCartRepository } from './quick-order-image-to-cart-repository'; + +@injectable() +@autoWired +export class QuickOrderImageToCartPage extends YvesPage { + @inject(QuickOrderImageToCartRepository) private repository: QuickOrderImageToCartRepository; + + protected PAGE_URL = '/quick-order'; + + private CONFIGURATION_URL = '/configuration/manage?feature=ai_commerce&tab=quick_order'; + + private SYNC_CONFIGURATION_COMMAND = 'console sync:data configuration'; + + enableQuickAddByImage = (): Cypress.Chainable => { + cy.visitBackoffice(this.CONFIGURATION_URL); + + cy.get(this.repository.getEnableToggleSelector()).then(($toggle) => { + if (!($toggle[0] as HTMLInputElement).checked) { + cy.wrap($toggle).check({ force: true }); + + cy.intercept('POST', '**/configuration/manage/save').as('saveConfiguration'); + cy.get(this.repository.getSaveButtonSelector()).click(); + cy.wait('@saveConfiguration').its('response.statusCode').should('eq', 200); + } + }); + + cy.get(this.repository.getEnableToggleSelector()).should('be.checked'); + + cy.runQueueWorker(); + cy.runCliCommands([this.SYNC_CONFIGURATION_COMMAND]); + + return cy.get(this.repository.getEnableToggleSelector()).should('be.checked'); + }; + + visitQuickOrder = (): Cypress.Chainable => { + cy.intercept('GET', '**/quick-order').as('quickOrderDocument'); + this.visit(); + + return cy.wait('@quickOrderDocument'); + }; + + getPageTitle = (): Cypress.Chainable => cy.get(this.repository.getPageTitleSelector()); + + getPageTitleText = (): string => this.repository.getPageTitleText(); + + getImageToCartSection = (): Cypress.Chainable => cy.get(this.repository.getImageToCartSectionSelector()); + + getImageToCartTitle = (): Cypress.Chainable => cy.get(this.repository.getImageToCartTitleSelector()); + + getImageToCartTitleText = (): string => this.repository.getImageToCartTitleText(); + + getBrowseFileText = (): string => this.repository.getBrowseFileText(); + + getUploadButtonText = (): string => this.repository.getUploadButtonText(); + + getImageUploadInputName = (): string => this.repository.getImageUploadInputName(); + + getAcceptedImageMimeTypes = (): string => this.repository.getAcceptedImageMimeTypes(); + + getImageUploadInput = (): Cypress.Chainable => cy.get(this.repository.getImageUploadInputSelector()); + + getBrowseFileLabel = (): Cypress.Chainable => cy.get(this.repository.getBrowseFileLabelSelector()); + + getUploadSubmitButton = (): Cypress.Chainable => cy.get(this.repository.getUploadSubmitButtonSelector()); + + getFileSelectLabel = (): Cypress.Chainable => cy.get(this.repository.getFileSelectLabelSelector()); + + getBrowseFileToggleLabel = (): Cypress.Chainable => cy.get(this.repository.getBrowseFileToggleLabelSelector()); + + getErrorDropzone = (): Cypress.Chainable => cy.get(this.repository.getErrorDropzoneSelector()); + + getErrorMessage = (): Cypress.Chainable => cy.get(this.repository.getErrorMessageSelector()); + + getFileSelectError = (): Cypress.Chainable => cy.get(this.repository.getFileSelectErrorSelector()); + + getRemoveFileIcon = (): Cypress.Chainable => cy.get(this.repository.getRemoveFileIconSelector()); + + attachImage = (imageFilePath: string): Cypress.Chainable => + this.getImageUploadInput().selectFile(imageFilePath, { force: true }); + + attachSyntheticFile = (params: { fileName: string; sizeInBytes: number; mimeType: string }): Cypress.Chainable => + this.getImageUploadInput().selectFile( + { + contents: Cypress.Buffer.alloc(params.sizeInBytes), + fileName: params.fileName, + mimeType: params.mimeType, + }, + { force: true } + ); + + removeAttachedFile = (): Cypress.Chainable => this.getRemoveFileIcon().click({ force: true }); + + submitEmptyImageOrder = (): Cypress.Chainable => { + cy.intercept('POST', '**/quick-order').as('emptyImageOrderSubmit'); + this.getUploadSubmitButton().click(); + + return cy.wait('@emptyImageOrderSubmit'); + }; + + submitNonImageFileViaRequest = (params: { + fileName: string; + contents: string; + mimeType: string; + }): Cypress.Chainable => { + const pageUrl = this.PAGE_URL; + + return cy + .visit(pageUrl) + .get(this.repository.getImageOrderCsrfTokenSelector()) + .invoke('attr', 'value') + .then((csrfToken) => { + const formData = new FormData(); + formData.append( + 'image_order_form[uploadImageOrder]', + new Blob([params.contents], { type: params.mimeType }), + params.fileName + ); + formData.append('image_order_form[_token]', String(csrfToken)); + formData.append('uploadImage', ''); + + return cy.window().then( + (win) => + new Cypress.Promise((resolve) => { + const xhr = new win.XMLHttpRequest(); + xhr.open('POST', pageUrl); + xhr.onload = (): void => resolve({ status: xhr.status, body: xhr.responseText }); + xhr.send(formData); + }) + ); + }); + }; + + submitImageOrder = (): Cypress.Chainable => { + cy.intercept('POST', '**/quick-order').as('imageOrderSubmit'); + this.getUploadSubmitButton().click(); + + return cy.wait('@imageOrderSubmit'); + }; + + submitImageOrderReal = (): Cypress.Chainable => { + cy.intercept('POST', '**/quick-order').as('imageOrderSubmitReal'); + this.getUploadSubmitButton().click(); + + return cy.wait('@imageOrderSubmitReal', { timeout: 30000 }); + }; + + getQuickOrderRows = (): Cypress.Chainable => cy.get(this.repository.getQuickOrderRowsSelector(), { timeout: 30000 }); + + getRecognizedSkuInputs = (): Cypress.Chainable => + cy.get(this.repository.getRecognizedSkuInputSelector(), { timeout: 30000 }); +} diff --git a/cypress/support/pages/yves/quick-order-image-to-cart/quick-order-image-to-cart-repository.ts b/cypress/support/pages/yves/quick-order-image-to-cart/quick-order-image-to-cart-repository.ts new file mode 100644 index 000000000..12d10d90a --- /dev/null +++ b/cypress/support/pages/yves/quick-order-image-to-cart/quick-order-image-to-cart-repository.ts @@ -0,0 +1,61 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class QuickOrderImageToCartRepository { + private static readonly ENABLE_SETTING_KEY = 'ai_commerce:quick_order:visual_add_to_cart:enabled'; + + getEnableToggleSelector = (): string => + `.setting-row[data-setting-key="${QuickOrderImageToCartRepository.ENABLE_SETTING_KEY}"] .config-input`; + + getSaveButtonSelector = (): string => '[data-qa="save-configuration"]'; + + getPageTitleSelector = (): string => 'h1.page-info__title'; + + getImageToCartSectionSelector = (): string => '[data-qa="component quick-order-image-to-cart"]'; + + getImageToCartTitleSelector = (): string => '.quick-order-image-to-cart__title'; + + getPageTitleText = (): string => 'Quick Order'; + + getImageToCartTitleText = (): string => 'Add to cart from image'; + + getBrowseFileText = (): string => 'Browse file'; + + getUploadButtonText = (): string => 'Upload'; + + getImageUploadInputName = (): string => 'image_order_form[uploadImageOrder]'; + + getAcceptedImageMimeTypes = (): string => 'image/jpeg,image/jpg,image/png'; + + getImageUploadInputSelector = (): string => '#image_order_form_uploadImageOrder'; + + getBrowseFileLabelSelector = (): string => 'label[for="image_order_form_uploadImageOrder"]'; + + getUploadSubmitButtonSelector = (): string => 'button[name="uploadImage"]'; + + getImageOrderCsrfTokenSelector = (): string => 'input[name="image_order_form[_token]"]'; + + getFileSelectLabelSelector = (): string => + '[data-qa="component quick-order-image-to-cart"] .quick-order-image-to-cart__file-select'; + + getFileSelectErrorSelector = (): string => + '[data-qa="component quick-order-image-to-cart"] .quick-order-image-to-cart__file-select--error'; + + getBrowseFileToggleLabelSelector = (): string => + '[data-qa="component quick-order-image-to-cart"] .js-quick-order-image-to-cart__browse-file'; + + getRemoveFileIconSelector = (): string => + '[data-qa="component quick-order-image-to-cart"] .js-quick-order-image-to-cart__remove-file'; + + getErrorDropzoneSelector = (): string => '[data-qa="component quick-order-image-to-cart"] .input-dropzone--error'; + + getErrorMessageSelector = (): string => + '[data-qa="component quick-order-image-to-cart"] :is(.list--error, .list--alert) .list__item'; + + getQuickOrderRowsSelector = (): string => '.js-quick-order-form__rows'; + + getRecognizedSkuInputSelector = (): string => + '.js-quick-order-form__rows [data-qa="component product-search-autocomplete-form"] .js-product-search-autocomplete-form__input-hidden'; +} diff --git a/cypress/support/pages/yves/search-by-image/search-by-image-page.ts b/cypress/support/pages/yves/search-by-image/search-by-image-page.ts new file mode 100644 index 000000000..fd59a50f8 --- /dev/null +++ b/cypress/support/pages/yves/search-by-image/search-by-image-page.ts @@ -0,0 +1,209 @@ +import { autoWired } from '@utils'; +import { inject, injectable } from 'inversify'; + +import { YvesPage } from '@pages/yves'; +import { SearchByImageRepository } from './search-by-image-repository'; + +interface SearchByImageInterception { + response?: { + statusCode?: number; + body?: { isSuccessful?: boolean; redirectUrl?: string }; + }; +} + +@injectable() +@autoWired +export class SearchByImagePage extends YvesPage { + @inject(SearchByImageRepository) private repository: SearchByImageRepository; + + protected PAGE_URL = '/en/search?q=cable'; + + private CONFIGURATION_URL = '/configuration/manage?feature=ai_commerce&tab=search_by_image'; + + private SYNC_CONFIGURATION_COMMAND = 'console sync:data configuration'; + + enableSearchByImage = (): Cypress.Chainable => this.setSearchByImageEnabled(true); + + setSearchByImageEnabled = (enabled: boolean): Cypress.Chainable => { + cy.visitBackoffice(this.CONFIGURATION_URL); + + cy.get(this.repository.getEnableToggleSelector()).then(($toggle) => { + if (($toggle[0] as HTMLInputElement).checked === enabled) { + return; + } + + if (enabled) { + cy.wrap($toggle).check({ force: true }); + } else { + cy.wrap($toggle).uncheck({ force: true }); + } + + cy.intercept('POST', '**/configuration/manage/save').as('saveConfiguration'); + cy.get(this.repository.getSaveButtonSelector()).click(); + cy.wait('@saveConfiguration').its('response.statusCode').should('eq', 200); + }); + + cy.runQueueWorker(); + cy.runCliCommands([this.SYNC_CONFIGURATION_COMMAND]); + + return cy.get(this.repository.getEnableToggleSelector()).should(enabled ? 'be.checked' : 'not.be.checked'); + }; + + visitSearchResults = (): Cypress.Chainable => { + cy.intercept('GET', '**/search**').as('searchResultsDocument'); + this.visit(); + + return cy.wait('@searchResultsDocument'); + }; + + getWrapper = (): Cypress.Chainable => cy.get(this.repository.getWrapperSelector()); + + getFileButton = (): Cypress.Chainable => cy.get(this.repository.getFileButtonSelector()); + + getPhotoButton = (): Cypress.Chainable => cy.get(this.repository.getPhotoButtonSelector()); + + getFileInput = (): Cypress.Chainable => cy.get(this.repository.getFileInputSelector()); + + getToken = (): Cypress.Chainable => cy.get(this.repository.getTokenSelector()); + + getDesktopInstance = (): Cypress.Chainable => cy.get(this.repository.getDesktopInstanceSelector()); + + clickFileTrigger = (): void => { + this.getDesktopInstance().find(this.repository.getFileButtonSelector()).click({ force: true }); + }; + + getOpenFilePopupUploadButton = (): Cypress.Chainable => + cy.get(this.repository.getOpenFilePopupSelector()).find(this.repository.getUploadFileButtonSelector()); + + interceptSearchByImageRequest = (): void => { + cy.intercept('POST', '**/search-by-image').as('searchByImageRequest'); + }; + + stubSearchByImageFailure = ( + errors: string[] = ['Search by Image is currently unavailable. Please try again later.'] + ): void => { + cy.intercept('POST', '**/search-by-image', { + statusCode: 200, + body: { isSuccessful: false, errors }, + }).as('searchByImageRequest'); + }; + + stubSearchByImageServerError = (): void => { + cy.intercept('POST', '**/search-by-image', { + statusCode: 503, + body: {}, + }).as('searchByImageRequest'); + }; + + getFilePopupError = (): Cypress.Chainable => + cy.get(this.repository.getOpenFilePopupSelector()).find(this.repository.getFilePopupErrorSelector()); + + getFilePopupErrorItems = (): Cypress.Chainable => + cy.get(this.repository.getOpenFilePopupSelector()).find(this.repository.getFilePopupErrorItemSelector()); + + getInstances = (): Cypress.Chainable => cy.get(this.repository.getSearchByImageInstanceSelector()); + + getDesktopFileSearchButton = (): Cypress.Chainable => + this.getDesktopInstance().find(this.repository.getFileButtonSelector()); + + getFileInputSelector = (): string => this.repository.getFileInputSelector(); + + getTokenSelector = (): string => this.repository.getTokenSelector(); + + attachImage = (filePath: string): void => { + this.getDesktopInstance().find(this.repository.getFileInputSelector()).selectFile(filePath, { force: true }); + }; + + submitImageThroughFilePopup = (filePath: string): void => { + this.getDesktopInstance().find(this.repository.getFileButtonSelector()).click({ force: true }); + this.getOpenFilePopupUploadButton().should('be.visible').and('not.be.disabled'); + this.attachImage(filePath); + }; + + getResultsPageSurface = (): Cypress.Chainable => + cy.get(this.repository.getResultsPageSurfaceSelector(), { timeout: 30000 }); + + getEndpointUrl = (): string => `${Cypress.config('baseUrl')}${this.repository.getEndpointPath()}`; + + getNoImageErrorText = (): string => this.repository.getNoImageErrorText(); + + getCsrfErrorMarker = (): string => this.repository.getCsrfErrorMarker(); + + getUnsupportedTypeErrorMarker = (): string => this.repository.getUnsupportedTypeErrorMarker(); + + buildMultipartBody = (params: { + boundary: string; + fileName: string; + contentType: string; + contents: string; + token: string; + }): string => + `--${params.boundary}\r\n` + + `Content-Disposition: form-data; name="search_by_image[image]"; filename="${params.fileName}"\r\n` + + `Content-Type: ${params.contentType}\r\n\r\n` + + `${params.contents}\r\n` + + `--${params.boundary}\r\n` + + 'Content-Disposition: form-data; name="search_by_image[_token]"\r\n\r\n' + + `${params.token}\r\n` + + `--${params.boundary}--\r\n`; + + requestEndpoint = ( + method: string, + options: { body?: string; contentType?: string } = {} + ): Cypress.Chainable> => + cy.request({ + method, + url: this.getEndpointUrl(), + failOnStatusCode: false, + headers: options.contentType ? { 'content-type': options.contentType } : undefined, + body: options.body, + }); + + submitImageThroughFilePopupUntilSuccessful = ( + filePath: string, + { maxAttempts = 3, timeout = 30000 }: { maxAttempts?: number; timeout?: number } = {} + ): Cypress.Chainable => { + const isSuccessful = (interception: SearchByImageInterception | undefined): boolean => + ((interception?.response?.statusCode ?? 0) >= 200 && + (interception?.response?.statusCode ?? 0) < 300 && + interception?.response?.body?.isSuccessful === true) || + false; + + const pollForSuccess = (deadline: number): Cypress.Chainable => + cy.get('@searchByImageRequest.all').then((subject) => { + const calls = subject as unknown as Array; + const successful = calls.filter(isSuccessful).pop(); + + if (successful) { + return cy.wrap(successful, { log: false }); + } + + if (Date.now() >= deadline) { + return cy.wrap(undefined, { log: false }); + } + + // eslint-disable-next-line cypress/no-unnecessary-waiting + return cy.wait(pollIntervalMs, { log: false }).then(() => pollForSuccess(deadline)); + }); + + const pollIntervalMs = 1000; + + const attempt = (remainingAttempts: number): Cypress.Chainable => { + this.visitSearchResults(); + this.interceptSearchByImageRequest(); + this.submitImageThroughFilePopup(filePath); + + return cy + .then(() => pollForSuccess(Date.now() + timeout)) + .then((successful: SearchByImageInterception | undefined) => { + if (successful || remainingAttempts <= 1) { + return successful ?? cy.wait('@searchByImageRequest', { timeout }); + } + + return attempt(remainingAttempts - 1); + }); + }; + + return attempt(maxAttempts); + }; +} diff --git a/cypress/support/pages/yves/search-by-image/search-by-image-repository.ts b/cypress/support/pages/yves/search-by-image/search-by-image-repository.ts new file mode 100644 index 000000000..774af1ba3 --- /dev/null +++ b/cypress/support/pages/yves/search-by-image/search-by-image-repository.ts @@ -0,0 +1,46 @@ +import { autoWired } from '@utils'; +import { injectable } from 'inversify'; + +@injectable() +@autoWired +export class SearchByImageRepository { + private static readonly ENABLE_SETTING_KEY = 'ai_commerce:search_by_image:search_by_image:enabled'; + + getEnableToggleSelector = (): string => + `.setting-row[data-setting-key="${SearchByImageRepository.ENABLE_SETTING_KEY}"] .config-input`; + + getSaveButtonSelector = (): string => '[data-qa="save-configuration"]'; + + getWrapperSelector = (): string => '.search-form__search-by-image'; + + getFileButtonSelector = (): string => '.js-search-by-image__btn-search-by-file'; + + getPhotoButtonSelector = (): string => '.js-search-by-image__btn-search-by-photo'; + + getFileInputSelector = (): string => '.search-by-image__file-input'; + + getTokenSelector = (): string => '.search-by-image__token'; + + getDesktopInstanceSelector = (): string => '.js-search-by-image--desktop'; + + getOpenFilePopupSelector = (): string => '.search-by-image__search-by-file-popup.main-popup--open'; + + getUploadFileButtonSelector = (): string => '.js-search-by-file-image__upload-file-button'; + + getEndpointPath = (): string => '/search-by-image'; + + getNoImageErrorText = (): string => 'Please select an image to search.'; + + getCsrfErrorMarker = (): string => 'CSRF'; + + getUnsupportedTypeErrorMarker = (): string => 'not supported'; + + getResultsPageSurfaceSelector = (): string => + '[data-qa="component product-item"], [data-qa="component product-detail"], [data-qa="component empty-catalog-state"], [data-qa="component search-tabs"]'; + + getFilePopupErrorSelector = (): string => '.js-search-by-file-image__error'; + + getFilePopupErrorItemSelector = (): string => '.js-search-by-file-image__error-text'; + + getSearchByImageInstanceSelector = (): string => 'search-by-image'; +} diff --git a/cypress/support/types/demo/ai-configuration.ts b/cypress/support/types/demo/ai-configuration.ts new file mode 100644 index 000000000..f012dad5d --- /dev/null +++ b/cypress/support/types/demo/ai-configuration.ts @@ -0,0 +1,6 @@ +export interface AiConfigurationDemoStaticFixtures { + defaultPassword: string; + rootUser: { + username: string; + }; +} diff --git a/cypress/support/types/demo/ai-workflows.ts b/cypress/support/types/demo/ai-workflows.ts new file mode 100644 index 000000000..cdd8f0b41 --- /dev/null +++ b/cypress/support/types/demo/ai-workflows.ts @@ -0,0 +1,7 @@ +export interface AiWorkflowsDemoStaticFixtures { + defaultPassword: string; + rootUser: { + username: string; + }; + aiWorkflowsPageUrl: string; +} diff --git a/cypress/support/types/demo/audit-logs.ts b/cypress/support/types/demo/audit-logs.ts new file mode 100644 index 000000000..97deaa956 --- /dev/null +++ b/cypress/support/types/demo/audit-logs.ts @@ -0,0 +1,10 @@ +export interface AuditLogsDemoStaticFixtures { + defaultPassword: string; + rootUser: { + username: string; + }; + auditLogsPageUrl: string; + product: { + sku: string; + }; +} diff --git a/cypress/support/types/demo/backoffice-assistant.ts b/cypress/support/types/demo/backoffice-assistant.ts new file mode 100644 index 000000000..8ab50e30b --- /dev/null +++ b/cypress/support/types/demo/backoffice-assistant.ts @@ -0,0 +1,30 @@ +export interface BackofficeAssistantDemoStaticFixtures { + defaultPassword: string; + rootUser: { + username: string; + }; + contextPageUrl: string; + contextFormName: string; + attachmentImagePath: string; + formFill: { + agentName: string; + prompt: string; + targetFieldName: string; + }; + orderManagement: { + agentName: string; + prompt: string; + }; + simplePrompt: string; + unknownConversationReference: string; + enabledAgents: string[]; + promptRequiredValidationMessage: string; + attachmentUnsupportedMediaTypeMessage: string; + attachmentCountExceededMessage: string; + attachmentMaxCount: number; + formFillTargetValue: string; + xssPromptPayload: string; + stubbedAgentName: string; + historyEntryName: string; + unsupportedAttachmentPath: string; +} diff --git a/cypress/support/types/demo/cost-price.ts b/cypress/support/types/demo/cost-price.ts new file mode 100644 index 000000000..db4326e85 --- /dev/null +++ b/cypress/support/types/demo/cost-price.ts @@ -0,0 +1,25 @@ +export interface CostPriceDemoStaticFixtures { + defaultPassword: string; + rootUser: { + username: string; + }; + agent: { + username: string; + }; + merchant: { + username: string; + }; + customer: { + email: string; + }; + product: { + sku: string; + }; + merchantProduct: { + sku: string; + }; + quoteRequest: { + calculatedReference: string; + uncalculatedReference: string; + }; +} diff --git a/cypress/support/types/demo/index.ts b/cypress/support/types/demo/index.ts new file mode 100644 index 000000000..b365f7665 --- /dev/null +++ b/cypress/support/types/demo/index.ts @@ -0,0 +1,10 @@ +export * from './ai-configuration'; +export * from './quicksight-analytics'; +export * from './audit-logs'; +export * from './ai-workflows'; +export * from './backoffice-assistant'; +export * from './smart-pim'; +export * from './smart-cms'; +export * from './quick-add-by-image'; +export * from './search-by-image'; +export * from './cost-price'; diff --git a/cypress/support/types/demo/quick-add-by-image.ts b/cypress/support/types/demo/quick-add-by-image.ts new file mode 100644 index 000000000..13bc51620 --- /dev/null +++ b/cypress/support/types/demo/quick-add-by-image.ts @@ -0,0 +1,12 @@ +export interface QuickAddByImageDemoStaticFixtures { + defaultPassword: string; + customer: { + email: string; + }; + rootUser: { + username: string; + }; + quickOrderPageUrl: string; + imageFilePath: string; + imageFileName: string; +} diff --git a/cypress/support/types/demo/quicksight-analytics.ts b/cypress/support/types/demo/quicksight-analytics.ts new file mode 100644 index 000000000..cc924bed0 --- /dev/null +++ b/cypress/support/types/demo/quicksight-analytics.ts @@ -0,0 +1,7 @@ +export interface QuicksightAnalyticsDemoStaticFixtures { + defaultPassword: string; + rootUser: { + username: string; + }; + analyticsPageUrl: string; +} diff --git a/cypress/support/types/demo/search-by-image.ts b/cypress/support/types/demo/search-by-image.ts new file mode 100644 index 000000000..675c8e6d7 --- /dev/null +++ b/cypress/support/types/demo/search-by-image.ts @@ -0,0 +1,11 @@ +export interface SearchByImageDemoStaticFixtures { + defaultPassword: string; + rootUser: { + username: string; + }; + customer: { + email: string; + }; + searchResultsUrl: string; + probeImagePath: string; +} diff --git a/cypress/support/types/demo/smart-cms.ts b/cypress/support/types/demo/smart-cms.ts new file mode 100644 index 000000000..ea4187ab3 --- /dev/null +++ b/cypress/support/types/demo/smart-cms.ts @@ -0,0 +1,19 @@ +export interface SmartCmsDemoStaticFixtures { + defaultPassword: string; + rootUser: { + username: string; + }; + cmsPageEditorUrl: string; + cmsBlockEditorUrl: string; + cmsPageEntityType: string; + cmsPageIdEntity: number; + cmsBlockEntityType: string; + cmsBlockIdEntity: number; + generateEndpointPath: string; + invalidCsrfErrorKey: string; + disabledErrorKey: string; + unsupportedAttachmentMediaType: string; + unsupportedAttachmentError: string; + probeImagePath: string; + probeImageMediaType: string; +} diff --git a/cypress/support/types/demo/smart-pim.ts b/cypress/support/types/demo/smart-pim.ts new file mode 100644 index 000000000..d9f745ab0 --- /dev/null +++ b/cypress/support/types/demo/smart-pim.ts @@ -0,0 +1,9 @@ +export interface SmartPimDemoStaticFixtures { + defaultPassword: string; + rootUser: { + username: string; + }; + product: { + sku: string; + }; +} diff --git a/cypress/support/utils/demo/ai-provider.ts b/cypress/support/utils/demo/ai-provider.ts new file mode 100644 index 000000000..a13685f97 --- /dev/null +++ b/cypress/support/utils/demo/ai-provider.ts @@ -0,0 +1,5 @@ +export function skipUnlessAiProviderEnabled(context: Mocha.Context): void { + if (!Cypress.env('DEMO_AI_PROVIDER_ENABLED')) { + context.skip(); + } +} diff --git a/cypress/support/utils/index.ts b/cypress/support/utils/index.ts index 52058cfdc..5759a835f 100644 --- a/cypress/support/utils/index.ts +++ b/cypress/support/utils/index.ts @@ -1,4 +1,5 @@ export * from './api'; export * from './checkout'; +export * from './demo/ai-provider'; export * from './inversify'; export * from './nestable'; diff --git a/package.json b/package.json index f1365f860..14bb40f02 100644 --- a/package.json +++ b/package.json @@ -15,14 +15,16 @@ "prettier:check": "prettier . --check", "prettier:write": "prettier . --write", "cy:open": "cypress open", - "cy:run": "cypress run --spec \"cypress/e2e/!(smoke)/**/*.ts\"", - "cy:ci": "cypress run --spec \"cypress/e2e/!(smoke)/**/!(ssp)*.ts\" --headless --browser chrome", + "cy:run": "cypress run --spec \"cypress/e2e/!(smoke|demo)/**/*.ts\"", + "cy:ci": "cypress run --spec \"cypress/e2e/!(smoke|demo)/**/!(ssp)*.ts\" --headless --browser chrome", "cy:ci:backoffice": "cypress run --spec \"cypress/e2e/backoffice/**/!(ssp)*.ts\" --headless --browser chrome", "cy:ci:api": "cypress run --spec \"cypress/e2e/api/**/!(ssp)*.ts\" --headless --browser chrome", "cy:ci:mp": "cypress run --spec \"cypress/e2e/mp/**/!(ssp)*.ts\" --headless --browser chrome", "cy:ci:yves": "cypress run --spec \"cypress/e2e/yves/**/!(ssp)*.ts\" --headless --browser chrome", "cy:ci:ssp": "cypress run --spec \"cypress/e2e/!(smoke)/**/(ssp)*.ts\" --headless --browser chrome", "cy:smoke": "cypress run --spec \"cypress/e2e/smoke/**/*.ts\" --headless --browser chrome", + "cy:demo": "ENV_REPOSITORY_ID=b2b-mp cypress run --spec \"cypress/e2e/demo/**/*.ts\" --env grepTags=@demo-smoke --headless --browser chrome", + "cy:demo:full": "ENV_REPOSITORY_ID=b2b-mp cypress run --spec \"cypress/e2e/demo/**/*.ts\" --env grepTags=\"@demo-smoke @demo-full\" --headless --browser chrome", "cy:dms": "cypress run --spec \"cypress/e2e/!(smoke)/**/*dms.cy.ts\" --headless --browser chrome", "cy:smoke:demo": "cypress run --spec \"cypress/e2e/smoke/!(product)/*.ts\" --headless --browser chrome", "cy:repack": "node tools/scripts/cypress-shards/repack.mjs"