diff --git a/nala/assets/guddy.png b/nala/assets/guddy.png new file mode 100644 index 00000000..88ef72c7 Binary files /dev/null and b/nala/assets/guddy.png differ diff --git a/nala/features/photoshop/unitywidget1/unitywidget.page.cjs b/nala/features/photoshop/unitywidget1/unitywidget.page.cjs new file mode 100644 index 00000000..16e7017a --- /dev/null +++ b/nala/features/photoshop/unitywidget1/unitywidget.page.cjs @@ -0,0 +1,14 @@ +export default class psUnityWidget { + constructor(page) { + this.page = page; + this.unityWidgetContainer = page.locator('.upload.upload-block.con-block.unity-enabled'); + this.unityVideo = this.unityWidgetContainer.locator('.video-container.video-holder').nth(0); + this.dropZone = this.unityWidgetContainer.locator('.drop-zone-container').nth(0); + this.dropZoneText = this.dropZone.locator('//div[@class="drop-zone-container"]/div[@class="drop-zone"]/p[1]').nth(2); + this.dropZoneFileText = this.dropZone.locator('//div[@class="drop-zone-container"]/div[@class="drop-zone"]/p[2]').nth(2); + this.fileUploadCta = this.unityWidgetContainer.locator('.con-button.blue.action-button.button-xl').nth(2); + this.legelTerms = this.unityWidgetContainer.locator('//a[@daa-ll="Terms of Use-11--"]'); + this.privacyPolicy = this.unityWidgetContainer.locator('//a[@daa-ll="Privacy Policy-12--"]'); + this.splashScreen = this.unityWidgetContainer.locator('//div[@class="fragment splash -loader show" and @style="display: none"]'); + } +} diff --git a/nala/features/photoshop/unitywidget1/unitywidget.spec.cjs b/nala/features/photoshop/unitywidget1/unitywidget.spec.cjs new file mode 100644 index 00000000..6fc0223b --- /dev/null +++ b/nala/features/photoshop/unitywidget1/unitywidget.spec.cjs @@ -0,0 +1,31 @@ +module.exports = { + FeatureName: 'PS Unity Widget', + features: [ + { + tcid: '0', + name: '@ps-unityUI', + path: '/drafts/nala/unity/remove-background?georouting=off', + data: { + CTATxt: 'Upload your photo', + fileFormatTxt: 'File must be JPEG, JPG or PNG and up to 40MB', + dropZoneTxt: 'Drag and drop an image to try it today.', + }, + tags: '@ps-unity @smoke @regression @unity', + }, + + { + tcid: '1', + name: '@ps-unityFileUpload', + path: '/drafts/nala/unity/remove-background?georouting=off', + tags: '@ps-unity @smoke @regression @unity', + }, + + { + tcid: '2', + name: '@ps-unityPSProductpage', + path: '/drafts/nala/unity/remove-background?georouting=off', + url: 'stage.try.photoshop.adobe.com', + tags: '@ps-unity @smoke @regression @unity', + }, + ], +}; diff --git a/nala/features/photoshop/unitywidget1/unitywidget.test.cjs b/nala/features/photoshop/unitywidget1/unitywidget.test.cjs new file mode 100644 index 00000000..3583db08 --- /dev/null +++ b/nala/features/photoshop/unitywidget1/unitywidget.test.cjs @@ -0,0 +1,75 @@ +import path from 'path'; +import { expect, test } from '@playwright/test'; +import { features } from './unitywidget.spec.cjs'; +import UnityWidget from './unitywidget.page.cjs'; + +const imageFilePath = path.resolve(__dirname, '../../../assets/guddy.png'); +console.log(__dirname); + +let unityWidget; +const unityLibs = process.env.UNITY_LIBS || ''; + +test.describe('Unity Widget PS test suite', () => { + test.beforeEach(async ({ page }) => { + unityWidget = new UnityWidget(page); + await page.setViewportSize({ width: 1250, height: 850 }); + await page.context().clearCookies(); + }); + + // Test 0 : Unity Widget PS UI checks + test(`${features[0].name},${features[0].tags}`, async ({ page, baseURL }) => { + const ccBaseURL = baseURL.replace('--dc--', '--cc--'); + console.info(`[Test Page]: ${ccBaseURL}${features[0].path}${unityLibs}`); + + await test.step('step-1: Go to Unity Widget PS test page', async () => { + await page.goto(`${ccBaseURL}${features[0].path}${unityLibs}`); + await page.waitForLoadState('domcontentloaded'); + await expect(page).toHaveURL(`${ccBaseURL}${features[0].path}${unityLibs}`); + }); + + await test.step('step-2: Verify Unity Widget PS verb user interface', async () => { + await page.waitForTimeout(3000); + await expect(await unityWidget.unityWidgetContainer).toBeTruthy(); + await expect(await unityWidget.unityVideo).toBeTruthy(); + await expect(await unityWidget.dropZone).toBeTruthy(); + await expect(await unityWidget.dropZoneText).toBeTruthy(); + }); + }); + // Test 1 : Unity Widget File Upload & splash screen display + test(`${features[1].name},${features[1].tags}`, async ({ page, baseURL }) => { + const ccBaseURL = baseURL.replace('--dc--', '--cc--'); + console.info(`[Test Page]: ${ccBaseURL}${features[1].path}${unityLibs}`); + + await test.step('check photoshop file upload', async () => { + await page.goto(`${ccBaseURL}${features[1].path}${unityLibs}`); + await page.waitForLoadState('domcontentloaded'); + await expect(page).toHaveURL(`${ccBaseURL}${features[1].path}${unityLibs}`); + }); + await test.step('png image file upload and splash screen display', async () => { + const fileInput = page.locator('//input[@type="file" and @id="file-upload"]').nth(0); + await page.waitForTimeout(10000); + await fileInput.setInputFiles(imageFilePath); + await page.waitForTimeout(3000); + await expect(unityWidget.splashScreen).toBeTruthy(); + }); + }); + // Test 2 : Unity Widget user navigation to Photoshop Product Page + test(`${features[2].name},${features[2].tags}`, async ({ page, baseURL }) => { + const ccBaseURL = baseURL.replace('--dc--', '--cc--'); + console.info(`[Test Page]: ${ccBaseURL}${features[2].path}${unityLibs}`); + + await test.step('check user landing on PS product page post file upload', async () => { + await page.goto(`${ccBaseURL}${features[2].path}${unityLibs}`); + await page.waitForLoadState('domcontentloaded'); + await expect(page).toHaveURL(`${ccBaseURL}${features[2].path}${unityLibs}`); + }); + await test.step('png image file upload and user navigation to product page', async () => { + const fileInput = page.locator('//input[@type="file" and @id="file-upload"]').nth(0); + await page.waitForTimeout(10000); + await fileInput.setInputFiles(imageFilePath); + await page.waitForTimeout(10000); + const productPageUrl = await page.url(); + expect(productPageUrl).toContain(features[2].url); + }); + }); +}); diff --git a/test/core/workflow/workflow-acrobat/action-binder.test.js b/test/core/workflow/workflow-acrobat/action-binder.test.js index ad6c0383..8d0ab125 100644 --- a/test/core/workflow/workflow-acrobat/action-binder.test.js +++ b/test/core/workflow/workflow-acrobat/action-binder.test.js @@ -51,7 +51,11 @@ describe('ActionBinder', () => { mockWorkflowCfg = { productName: 'test-product', enabledFeatures: ['test-feature'], - targetCfg: { sendSplunkAnalytics: true }, + targetCfg: { + sendSplunkAnalytics: true, + experimentationOn: [], + showSplashScreen: false, + }, errors: { 'test-error': 'Test error message' }, }; @@ -764,6 +768,11 @@ describe('ActionBinder', () => { beforeEach(() => { actionBinder.getRedirectUrl = sinon.stub().resolves(); actionBinder.redirectUrl = 'https://test-redirect-url.com'; + actionBinder.workflowCfg.targetCfg = { + sendSplunkAnalytics: true, + experimentationOn: [], + showSplashScreen: false, + }; localStorage.clear(); }); @@ -1466,11 +1475,11 @@ describe('ActionBinder', () => { it('should return correct mime type for .ai file', async () => { const file = { name: 'test.ai' }; const originalImport = window.import; - window.import = () => Promise.resolve({ getMimeType: () => 'application/illustrator' }); + window.import = () => Promise.resolve({ getMimeType: () => 'application/pdf' }); const result = await actionBinder.getMimeType(file); - expect(result).to.equal('application/illustrator'); + expect(result).to.equal('application/pdf'); // Restore original import window.import = originalImport; @@ -1547,6 +1556,11 @@ describe('ActionBinder', () => { beforeEach(() => { actionBinder.workflowCfg = { enabledFeatures: ['compress-pdf'], + targetCfg: { + sendSplunkAnalytics: true, + experimentationOn: [], + showSplashScreen: false, + }, errors: { error_generic: 'Generic error occurred' }, }; actionBinder.signedOut = false; @@ -1576,5 +1590,383 @@ describe('ActionBinder', () => { actionBinder.getRedirectUrl.restore(); }); }); + + describe('Experiment Data Integration', () => { + beforeEach(() => { + // Mock priorityLoad + window.priorityLoad = sinon.stub().resolves(); + + // Mock getUnityLibs + window.getUnityLibs = sinon.stub().returns('/test/libs'); + }); + + afterEach(() => { + delete window.priorityLoad; + delete window.getUnityLibs; + }); + + describe('handlePreloads with Experimentation', () => { + it('should load experiment data when experimentation is enabled for the feature', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: { + experimentationOn: ['add-comment'], + showSplashScreen: true, + }, + }; + + // Mock the dynamic import by stubbing the handlePreloads method + const mockGetExperimentData = sinon.stub().resolves({ variationId: 'test-variant' }); + sinon.stub(actionBinder, 'handlePreloads').callsFake(async function mockHandlePreloadsWithExperiment() { + if (this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0])) { + this.experimentData = await mockGetExperimentData(); + } + const parr = []; + if (this.workflowCfg.targetCfg?.showSplashScreen) { + parr.push(`${window.getUnityLibs()}/core/styles/splash-screen.css`); + } + await window.priorityLoad(parr); + }); + + await actionBinder.handlePreloads(); + + expect(actionBinder.experimentData).to.deep.equal({ variationId: 'test-variant' }); + expect(window.priorityLoad.called).to.be.true; + }); + + it('should not load experiment data when experimentation is disabled', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: { + experimentationOn: ['other-feature'], + showSplashScreen: true, + }, + }; + + // Mock the handlePreloads method + sinon.stub(actionBinder, 'handlePreloads').callsFake(async function mockHandlePreloadsDisabled() { + if (this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0])) { + this.experimentData = { variationId: 'should-not-load' }; + } else { + this.experimentData = {}; + } + const parr = []; + if (this.workflowCfg.targetCfg?.showSplashScreen) { + parr.push(`${window.getUnityLibs()}/core/styles/splash-screen.css`); + } + await window.priorityLoad(parr); + }); + + await actionBinder.handlePreloads(); + + expect(actionBinder.experimentData).to.deep.equal({}); + expect(window.priorityLoad.called).to.be.true; + }); + + it('should not load experiment data when targetCfg is missing', async () => { + actionBinder.workflowCfg = { enabledFeatures: ['add-comment'] }; + + // Mock the handlePreloads method + sinon.stub(actionBinder, 'handlePreloads').callsFake(async function mockHandlePreloadsNoTargetCfg() { + if (this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0])) { + this.experimentData = { variationId: 'should-not-load' }; + } else { + this.experimentData = {}; + } + const parr = []; + if (this.workflowCfg.targetCfg?.showSplashScreen) { + parr.push(`${window.getUnityLibs()}/core/styles/splash-screen.css`); + } + if (parr.length > 0) { + await window.priorityLoad(parr); + } + }); + + await actionBinder.handlePreloads(); + + expect(actionBinder.experimentData).to.deep.equal({}); + expect(window.priorityLoad.called).to.be.false; + }); + + it('should not load experiment data when experimentationOn is missing', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: { showSplashScreen: true }, + }; + + // Mock the handlePreloads method + sinon.stub(actionBinder, 'handlePreloads').callsFake(async function mockHandlePreloadsNoExperimentationOn() { + if (this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0])) { + this.experimentData = { variationId: 'should-not-load' }; + } else { + this.experimentData = {}; + } + const parr = []; + if (this.workflowCfg.targetCfg?.showSplashScreen) { + parr.push(`${window.getUnityLibs()}/core/styles/splash-screen.css`); + } + await window.priorityLoad(parr); + }); + + await actionBinder.handlePreloads(); + + expect(actionBinder.experimentData).to.deep.equal({}); + expect(window.priorityLoad.called).to.be.true; + }); + }); + + describe('handleRedirect with Experiment Data', () => { + beforeEach(() => { + actionBinder.getRedirectUrl = sinon.stub().resolves(); + actionBinder.redirectUrl = 'https://test-redirect-url.com'; + actionBinder.dispatchAnalyticsEvent = sinon.stub(); + localStorage.clear(); + }); + + it('should add variationId to payload when experiment data is available', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: { experimentationOn: ['add-comment'] }, + }; + actionBinder.experimentData = { variationId: 'test-variant' }; + + const cOpts = { payload: {} }; + const filesData = { test: 'data' }; + + const result = await actionBinder.handleRedirect(cOpts, filesData); + + expect(cOpts.payload.variationId).to.equal('test-variant'); + expect(actionBinder.getRedirectUrl.calledWith(cOpts)).to.be.true; + expect(result).to.be.true; + }); + + it('should not add variationId when experimentation is disabled for the feature', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: { experimentationOn: ['other-feature'] }, + }; + actionBinder.experimentData = { variationId: 'test-variant' }; + + const cOpts = { payload: {} }; + const filesData = { test: 'data' }; + + const result = await actionBinder.handleRedirect(cOpts, filesData); + + expect(cOpts.payload.variationId).to.be.undefined; + expect(actionBinder.getRedirectUrl.calledWith(cOpts)).to.be.true; + expect(result).to.be.true; + }); + + it('should not add variationId when experiment data is not available', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: { experimentationOn: ['add-comment'] }, + }; + actionBinder.experimentData = {}; + + const cOpts = { payload: {} }; + const filesData = { test: 'data' }; + + const result = await actionBinder.handleRedirect(cOpts, filesData); + + expect(cOpts.payload.variationId).to.be.undefined; + expect(actionBinder.getRedirectUrl.calledWith(cOpts)).to.be.true; + expect(result).to.be.true; + }); + + it('should not add variationId when targetCfg is missing', async () => { + actionBinder.workflowCfg = { enabledFeatures: ['add-comment'] }; + actionBinder.experimentData = { variationId: 'test-variant' }; + + const cOpts = { payload: {} }; + const filesData = { test: 'data' }; + + const result = await actionBinder.handleRedirect(cOpts, filesData); + + expect(cOpts.payload.variationId).to.be.undefined; + expect(actionBinder.getRedirectUrl.calledWith(cOpts)).to.be.true; + expect(result).to.be.true; + }); + + it('should not add variationId when experimentationOn is missing', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: {}, + }; + actionBinder.experimentData = { variationId: 'test-variant' }; + + const cOpts = { payload: {} }; + const filesData = { test: 'data' }; + + const result = await actionBinder.handleRedirect(cOpts, filesData); + + expect(cOpts.payload.variationId).to.be.undefined; + expect(actionBinder.getRedirectUrl.calledWith(cOpts)).to.be.true; + expect(result).to.be.true; + }); + + it('should preserve existing payload properties when adding variationId', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: { experimentationOn: ['add-comment'] }, + }; + actionBinder.experimentData = { variationId: 'test-variant' }; + + const cOpts = { + payload: { + existingProp: 'value', + newUser: true, + attempts: '1st', + }, + }; + const filesData = { test: 'data' }; + + const result = await actionBinder.handleRedirect(cOpts, filesData); + + expect(cOpts.payload).to.deep.include({ + existingProp: 'value', + newUser: true, + attempts: '1st', + variationId: 'test-variant', + }); + expect(actionBinder.getRedirectUrl.calledWith(cOpts)).to.be.true; + expect(result).to.be.true; + }); + }); + + describe('Integration Tests', () => { + it('should handle complete flow with experiment data', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: { + experimentationOn: ['add-comment'], + showSplashScreen: true, + }, + }; + + // Mock the handlePreloads method + const mockGetExperimentData = sinon.stub().resolves({ variationId: 'integration-test-variant' }); + sinon.stub(actionBinder, 'handlePreloads').callsFake(async function mockHandlePreloadsIntegration() { + if (this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0])) { + this.experimentData = await mockGetExperimentData(); + } + const parr = []; + if (this.workflowCfg.targetCfg?.showSplashScreen) { + parr.push(`${window.getUnityLibs()}/core/styles/splash-screen.css`); + } + await window.priorityLoad(parr); + }); + + // First, load experiment data + await actionBinder.handlePreloads(); + expect(actionBinder.experimentData).to.deep.equal({ variationId: 'integration-test-variant' }); + + // Then, use it in redirect + actionBinder.getRedirectUrl = sinon.stub().resolves(); + actionBinder.redirectUrl = 'https://test-redirect-url.com'; + actionBinder.dispatchAnalyticsEvent = sinon.stub(); + + const cOpts = { payload: {} }; + const filesData = { test: 'data' }; + + const result = await actionBinder.handleRedirect(cOpts, filesData); + + expect(cOpts.payload.variationId).to.equal('integration-test-variant'); + expect(result).to.be.true; + }); + + it('should handle flow without experiment data', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: { + experimentationOn: ['other-feature'], + showSplashScreen: true, + }, + }; + + // Mock the handlePreloads method + sinon.stub(actionBinder, 'handlePreloads').callsFake(async function mockHandlePreloadsWithoutExperiment() { + if (this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0])) { + this.experimentData = { variationId: 'should-not-load' }; + } else { + this.experimentData = {}; + } + const parr = []; + if (this.workflowCfg.targetCfg?.showSplashScreen) { + parr.push(`${window.getUnityLibs()}/core/styles/splash-screen.css`); + } + await window.priorityLoad(parr); + }); + + // Load preloads (should not load experiment data) + await actionBinder.handlePreloads(); + expect(actionBinder.experimentData).to.deep.equal({}); + + // Use in redirect (should not add variationId) + actionBinder.getRedirectUrl = sinon.stub().resolves(); + actionBinder.redirectUrl = 'https://test-redirect-url.com'; + actionBinder.dispatchAnalyticsEvent = sinon.stub(); + + const cOpts = { payload: {} }; + const filesData = { test: 'data' }; + + const result = await actionBinder.handleRedirect(cOpts, filesData); + + expect(cOpts.payload.variationId).to.be.undefined; + expect(result).to.be.true; + }); + + it('should handle experiment provider exceptions and log warnings', async () => { + actionBinder.workflowCfg = { + enabledFeatures: ['add-comment'], + targetCfg: { + experimentationOn: ['add-comment'], + showSplashScreen: true, + }, + }; + + // Mock the handlePreloads method to simulate experiment provider exception + sinon.stub(actionBinder, 'handlePreloads').callsFake(async function mockHandlePreloadsWithException() { + if (this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0])) { + // Simulate the experiment provider throwing an error + const getExperimentData = () => Promise.reject(new Error('Target proposition fetch failed: Test error')); + try { + this.experimentData = await getExperimentData(); + } catch (error) { + await this.dispatchErrorToast('warn_fetch_experiment', null, error.message, true, true, { + code: 'warn_fetch_experiment', + desc: error.message, + }); + this.experimentData = {}; + } + } + const parr = []; + if (this.workflowCfg.targetCfg?.showSplashScreen) { + parr.push(`${window.getUnityLibs()}/core/styles/splash-screen.css`); + } + await window.priorityLoad(parr); + }); + + // Mock dispatchErrorToast to verify it's called + const dispatchErrorToastSpy = sinon.stub(actionBinder, 'dispatchErrorToast').resolves(); + + // Load preloads (should catch exception and log warning) + await actionBinder.handlePreloads(); + + expect(actionBinder.experimentData).to.deep.equal({}); + expect(dispatchErrorToastSpy.calledWith( + 'warn_fetch_experiment', + null, + 'Target proposition fetch failed: Test error', + true, + true, + { + code: 'warn_fetch_experiment', + desc: 'Target proposition fetch failed: Test error', + }, + )).to.be.true; + }); + }); + }); }); }); diff --git a/test/utils/experiment-provider.test.js b/test/utils/experiment-provider.test.js new file mode 100644 index 00000000..0d74b789 --- /dev/null +++ b/test/utils/experiment-provider.test.js @@ -0,0 +1,147 @@ +/* eslint-disable no-underscore-dangle */ +import { expect } from '@esm-bundle/chai'; +import { getExperimentData, getDecisionScopesForVerb } from '../../unitylibs/utils/experiment-provider.js'; + +describe('getExperimentData', () => { + // Helper function to setup mock with result and error + const setupMock = (result, error = null) => { + window._satellite.track = (event, options) => { + setTimeout(() => { + if (typeof options.done === 'function') options.done(result, error); + }, 0); + }; + }; + + // Helper function to setup mock that throws an exception + const setupMockWithException = (error) => { + window._satellite.track = () => { + throw error; + }; + }; + + // Helper function to create mock target result structure + const createMockResult = (content = null, customDecisions = null) => { + let decisions; + if (customDecisions !== null) { + decisions = customDecisions; + } else if (content) { + decisions = [{ items: [{ data: { content } }] }]; + } else { + decisions = []; + } + return { decisions, propositions: ['test-proposition'] }; + }; + + // Helper function to test error scenarios + const testErrorScenario = async (expectedErrorMessage, mockSetup) => { + mockSetup(); + try { + await getExperimentData(['acom_unity_acrobat_add-comment_us']); + expect.fail('Should have rejected'); + } catch (error) { + expect(error.message).to.equal(expectedErrorMessage); + } + }; + + beforeEach(() => { + // Mock window._satellite + window._satellite = { track: () => {} }; + }); + + afterEach(() => { + delete window._satellite; + }); + + it('should reject when no decision scopes provided', async () => { + try { + await getExperimentData([]); + expect.fail('Should have rejected'); + } catch (error) { + expect(error.message).to.equal('No decision scopes provided for experiment data fetch'); + } + }); + + it('should reject when target fetch fails', async () => { + await testErrorScenario( + 'Target proposition fetch failed: Test error', + () => setupMock(null, new Error('Test error')), + ); + }); + + it('should fetch target data when target returns valid data', async () => { + const mockTargetData = { + experience: 'test-experience', + verb: 'add-comment', + }; + + setupMock(createMockResult(mockTargetData)); + + const result = await getExperimentData(['ACOM_UNITY_ACROBAT_EDITPDF_POC']); + expect(result).to.deep.equal(mockTargetData); + }); + + it('should reject when target returns empty decisions', async () => { + await testErrorScenario( + 'Target proposition returned but no valid data for scopes: acom_unity_acrobat_add-comment_us', + () => setupMock(createMockResult(null, [])), + ); + }); + + it('should reject when target returns empty items', async () => { + await testErrorScenario( + 'Target proposition returned but no valid data for scopes: acom_unity_acrobat_add-comment_us', + () => setupMock({ decisions: [{ items: [] }] }), + ); + }); + + it('should reject when target returns invalid structure', async () => { + await testErrorScenario( + 'Target proposition returned but no valid data for scopes: acom_unity_acrobat_add-comment_us', + () => setupMock({ invalid: 'structure' }), + ); + }); + + it('should reject when satellite track throws exception', async () => { + await testErrorScenario( + 'Exception during Target proposition fetch: Satellite error', + () => setupMockWithException(new Error('Satellite error')), + ); + }); + + it('should reject when target result is null', async () => { + await testErrorScenario( + 'Target proposition returned but no valid data for scopes: acom_unity_acrobat_add-comment_us', + () => setupMock(null), + ); + }); + + it('should reject when target result is undefined', async () => { + await testErrorScenario( + 'Target proposition returned but no valid data for scopes: acom_unity_acrobat_add-comment_us', + () => setupMock(undefined), + ); + }); +}); + +describe('getDecisionScopesForVerb', () => { + let originalFetch; + + beforeEach(() => { + originalFetch = window.fetch; + window.fetch = async () => ({ ok: true, json: async () => ({ country: 'US' }) }); + }); + + afterEach(() => { + window.fetch = originalFetch; + }); + + it('should return decision scopes for known verb', async () => { + const result = await getDecisionScopesForVerb('add-comment'); + expect(result).to.deep.equal(['acom_unity_acrobat_add-comment_us']); + }); + + it('should return decision scopes for unknown verb using region', async () => { + const result = await getDecisionScopesForVerb('unknown-verb'); + expect(result).to.deep.equal(['acom_unity_acrobat_unknown-verb_us']); + }); +}); diff --git a/unitylibs/core/workflow/workflow-acrobat/action-binder.js b/unitylibs/core/workflow/workflow-acrobat/action-binder.js index 729920cb..816a0bb1 100644 --- a/unitylibs/core/workflow/workflow-acrobat/action-binder.js +++ b/unitylibs/core/workflow/workflow-acrobat/action-binder.js @@ -108,6 +108,7 @@ export default class ActionBinder { pre_upload_warn_renamed_invalid_file_name: -602, upload_warn_delete_asset: -603, validation_warn_validate_files: -604, + warn_fetch_experiment: -605, }; static NEW_TO_OLD_ERROR_KEY_MAP = { @@ -141,6 +142,7 @@ export default class ActionBinder { upload_warn_chunk_upload: 'verb_upload_warn_chunk_upload', pre_upload_warn_renamed_invalid_file_name: 'verb_warn_renamed_invalid_file_name', warn_delete_asset: 'verb_upload_warn_delete_asset', + warn_fetch_experiment: 'verb_warn_fetch_experiment', }; constructor(unityEl, workflowCfg, wfblock, canvasArea, actionMap = {}) { @@ -172,6 +174,7 @@ export default class ActionBinder { this.showInfoToast = false; this.multiFileValidationFailure = false; this.initialize(); + this.experimentData = null; } async initialize() { @@ -232,6 +235,19 @@ export default class ActionBinder { } async handlePreloads() { + + if ( !this.experimentData && this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0])) { + const { getExperimentData, getDecisionScopesForVerb } = await import('../../../utils/experiment-provider.js'); + try { + const decisionScopes = await getDecisionScopesForVerb(this.workflowCfg.enabledFeatures[0]); + this.experimentData = await getExperimentData(decisionScopes); + } catch (error) { + await this.dispatchErrorToast('warn_fetch_experiment', null, error.message, true, true, { + code: 'warn_fetch_experiment', + desc: error.message, + }); + } + } const parr = []; if (this.workflowCfg.targetCfg.showSplashScreen) { parr.push( @@ -449,6 +465,9 @@ export default class ActionBinder { if (this.multiFileValidationFailure) cOpts.payload.feedback = 'uploaderror'; if (this.showInfoToast) cOpts.payload.feedback = 'nonpdf'; } + if (this.workflowCfg.targetCfg?.experimentationOn?.includes(this.workflowCfg.enabledFeatures[0]) && this.experimentData) { + cOpts.payload.variationId = this.experimentData.variationId; + } await this.getRedirectUrl(cOpts); if (!this.redirectUrl) return false; const [baseUrl, queryString] = this.redirectUrl.split('?'); @@ -459,7 +478,7 @@ export default class ActionBinder { } async handleSingleFileUpload(files) { - this.filesData = { ...this.filesData, uploadType: 'sfu' }; + this.filesData = { ...files, uploadType: 'sfu' }; if (this.signedOut) await this.uploadHandler.singleFileGuestUpload(files[0], this.filesData); else await this.uploadHandler.singleFileUserUpload(files[0], this.filesData); } @@ -467,7 +486,7 @@ export default class ActionBinder { async handleMultiFileUpload(files) { this.MULTI_FILE = true; this.LOADER_LIMIT = 65; - this.filesData = { ...this.filesData, uploadType: 'mfu' }; + this.filesData = { ...files, uploadType: 'mfu' }; this.dispatchAnalyticsEvent('multifile', this.filesData); if (this.signedOut) await this.uploadHandler.multiFileGuestUpload(files, this.filesData); else await this.uploadHandler.multiFileUserUpload(files, this.filesData); diff --git a/unitylibs/core/workflow/workflow-acrobat/limits.json b/unitylibs/core/workflow/workflow-acrobat/limits.json index 8a19f6fc..61974ba9 100644 --- a/unitylibs/core/workflow/workflow-acrobat/limits.json +++ b/unitylibs/core/workflow/workflow-acrobat/limits.json @@ -30,6 +30,8 @@ "application/x-tika-msworks-spreadsheet", "application/vnd.adobe.form.fillsign", "application/illustrator", + "application/postscript", + "application/vnd.adobe.illustrator", "application/rtf", "application/x-indesign", "image/jpeg", @@ -103,6 +105,8 @@ "application/x-tika-msworks-spreadsheet", "application/vnd.adobe.form.fillsign", "application/illustrator", + "application/postscript", + "application/vnd.adobe.illustrator", "application/rtf", "application/x-indesign", "image/jpeg", diff --git a/unitylibs/core/workflow/workflow-acrobat/target-config.json b/unitylibs/core/workflow/workflow-acrobat/target-config.json index 504169c5..d9c1a06b 100644 --- a/unitylibs/core/workflow/workflow-acrobat/target-config.json +++ b/unitylibs/core/workflow/workflow-acrobat/target-config.json @@ -24,6 +24,8 @@ "nonpdfSfuProductScreen": ["word-to-pdf", "jpg-to-pdf", "ppt-to-pdf", "excel-to-pdf", "png-to-pdf", "createpdf", "chat-pdf", "chat-pdf-student", "summarize-pdf"], "mfuUploadAllowed": ["combine-pdf", "rotate-pages", "chat-pdf", "chat-pdf-student", "summarize-pdf"], "mfuUploadOnlyPdfAllowed": ["combine-pdf"], + "experimentationOn": ["add-comment"], + "fetchApiConfig": { "finalizeAsset": { "retryType": "polling", diff --git a/unitylibs/utils/FileUtils.js b/unitylibs/utils/FileUtils.js index 73588229..bf68a52c 100644 --- a/unitylibs/utils/FileUtils.js +++ b/unitylibs/utils/FileUtils.js @@ -17,7 +17,7 @@ export function removeExtension(name) { export function getMimeType(fileName) { const extToTypeMap = { indd: 'application/x-indesign', - ai: 'application/illustrator', + ai: 'application/pdf', psd: 'image/vnd.adobe.photoshop', form: 'application/vnd.adobe.form.fillsign', }; diff --git a/unitylibs/utils/experiment-provider.js b/unitylibs/utils/experiment-provider.js new file mode 100644 index 00000000..4987cdfe --- /dev/null +++ b/unitylibs/utils/experiment-provider.js @@ -0,0 +1,46 @@ +/* eslint-disable no-underscore-dangle */ + +export async function getDecisionScopesForVerb(verb) { + const region = await getRegion().catch(() => undefined); + return [`acom_unity_acrobat_${verb}${region ? `_${region}` : ''}`]; +} + +export async function getRegion() { + const resp = await fetch('https://geo2.adobe.com/json/', { cache: 'no-cache' }); + if (!resp.ok) throw new Error(`Failed to resolve region: ${resp.statusText}`); + const { country } = await resp.json(); + if (!country) throw new Error('Failed to resolve region: missing country'); + return country.toLowerCase(); +} + +export async function getExperimentData(decisionScopes) { + if (!decisionScopes || decisionScopes.length === 0) { + throw new Error('No decision scopes provided for experiment data fetch'); + } + + return new Promise((resolve, reject) => { + try { + + window._satellite.track('propositionFetch', { + decisionScopes, + data: {}, + done: (TargetPropositionResult, error) => { + if (error) { + reject(new Error(`Target proposition fetch failed: ${error.message || 'Unknown error'}`)); + return; + } + + const targetData = TargetPropositionResult?.decisions?.[0]?.items?.[0]?.data?.content; + if (targetData) { + window._satellite.track('propositionDisplay', TargetPropositionResult.propositions); + resolve(targetData); + } else { + reject(new Error(`Target proposition returned but no valid data for scopes: ${Array.isArray(decisionScopes) ? decisionScopes.join(', ') : decisionScopes}`)); + } + }, + }); + } catch (e) { + reject(new Error(`Exception during Target proposition fetch: ${e.message || 'Unknown exception'}`)); + } + }); +}