diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 360972fd5f..c7bc982b85 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -902,6 +902,10 @@ const _ProjectController = { userSettings?.overallTheme ) + if (user.labsProgram) { + await Modules.promises.hooks.fire('assignLabsSplitTests', req, res) + } + res.render(template, { title: project.name, priority_title: true, @@ -943,7 +947,6 @@ const _ProjectController = { }, initialLoadingScreenTheme, userSettings, - labsExperiments: user.labsExperiments ?? [], privilegeLevel, anonymous, isTokenMember, diff --git a/services/web/app/src/Features/SplitTests/SplitTestHandler.mjs b/services/web/app/src/Features/SplitTests/SplitTestHandler.mjs index 351f0d9ef8..3c81825aec 100644 --- a/services/web/app/src/Features/SplitTests/SplitTestHandler.mjs +++ b/services/web/app/src/Features/SplitTests/SplitTestHandler.mjs @@ -828,6 +828,22 @@ async function _loadSplitTestInfoInLocals(locals, splitTestName, session) { phase, badgeInfo: splitTest.badgeInfo?.[phase], } + + if (phase === 'labs') { + const variant = currentVersion.variants?.[0] + info.labsDetails = { + title: splitTest.labsTitle || '', + description: splitTest.labsDescription || '', + icon: splitTest.labsIcon || '', + surveyLink: splitTest.badgeInfo?.labs?.url || '', + isFull: SplitTestUtils.isExperimentFull(variant), + versionCreatedAt: + currentVersion.createdAt instanceof Date + ? currentVersion.createdAt.toISOString() + : currentVersion.createdAt, + } + } + if (Settings.devToolbar.enabled) { info.active = currentVersion.active info.variants = currentVersion.variants.map(variant => ({ diff --git a/services/web/app/src/Features/SplitTests/SplitTestManager.mjs b/services/web/app/src/Features/SplitTests/SplitTestManager.mjs index 1d67149f82..166ee14e65 100644 --- a/services/web/app/src/Features/SplitTests/SplitTestManager.mjs +++ b/services/web/app/src/Features/SplitTests/SplitTestManager.mjs @@ -329,6 +329,17 @@ async function switchToNextPhase( ) } + // Labs phase requires labs config to be set + if (lastVersionCopy.phase === LABS_PHASE) { + if (!splitTest.labsTitle || !splitTest.labsDescription) { + // Cannot be a required fields as we do not always promote to labs + throw new Errors.InvalidError( + 'Labs phase requires Labs Title and Labs Description to be set', + { name } + ) + } + } + // Labs phase requires exactly one variant if (lastVersionCopy.phase === LABS_PHASE) { if (lastVersionCopy.variants.length !== 1) { diff --git a/services/web/app/src/Features/SplitTests/SplitTestUtils.mjs b/services/web/app/src/Features/SplitTests/SplitTestUtils.mjs index 7627fa48ad..c95d3c80c5 100644 --- a/services/web/app/src/Features/SplitTests/SplitTestUtils.mjs +++ b/services/web/app/src/Features/SplitTests/SplitTestUtils.mjs @@ -14,7 +14,17 @@ function getVersion(splitTest, versionNumber) { }) } +function isExperimentFull(variant) { + const { userLimit, userCount } = variant + if (typeof userLimit === 'number') { + const currentCount = userCount ?? 0 + return currentCount >= userLimit + } + return false +} + export default { getCurrentVersion, getVersion, + isExperimentFull, } diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index 87b188eafa..3131779382 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -3,7 +3,6 @@ meta(name="ol-projectName" content=projectName) meta(name="ol-canUseClsiCache" data-type="boolean" content=canUseClsiCache) meta(name="ol-userSettings" data-type="json" content=userSettings) meta(name="ol-user" data-type="json" content=user) -meta(name="ol-labsExperiments" data-type="json" content=labsExperiments) meta(name="ol-learnedWords" data-type="json" content=learnedWords) meta(name="ol-anonymous" data-type="boolean" content=anonymous) meta(name="ol-brandVariation" data-type="json" content=brandVariation) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index b349f29336..02dc00b158 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1083,6 +1083,8 @@ module.exports = { referenceIndices: [], railEntries: [], railPopovers: [], + railActions: [], + railModals: [], }, moduleImportSequence: [ diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 536ffda1bb..10bc8cd0c7 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -641,6 +641,7 @@ "example_project": "", "existing_plan_active_until_term_end": "", "expand": "", + "experiment_enabled_refresh_page": "", "experiment_full_check_back_soon": "", "expired": "", "expired_confirmation_code": "", @@ -1036,6 +1037,8 @@ "knowledge_base": "", "labels_help_you_to_easily_reference_your_figures": "", "labels_help_you_to_reference_your_tables": "", + "labs_ongoing_experiments": "", + "labs_settings": "", "language": "", "last_active": "", "last_active_description": "", @@ -1230,6 +1233,7 @@ "need_to_add_new_primary_before_remove": "", "need_to_leave": "", "neither_agree_nor_disagree": "", + "new": "", "new_compile_domain_notice": "", "new_compiles_in_this_project_will_automatically_use_the_newest_version": "", "new_file": "", @@ -1988,6 +1992,7 @@ "tex_live_version": "", "thank_you": "", "thank_you_exclamation": "", + "thank_you_for_participating_feedback": "", "thank_you_for_your_feedback": "", "thanks_for_confirming_your_email_address": "", "thanks_for_getting_in_touch": "", diff --git a/services/web/frontend/js/features/ide-react/components/rail/rail-action-element.tsx b/services/web/frontend/js/features/ide-react/components/rail/rail-action-element.tsx index 76ecb4c610..14d959ab0d 100644 --- a/services/web/frontend/js/features/ide-react/components/rail/rail-action-element.tsx +++ b/services/web/frontend/js/features/ide-react/components/rail/rail-action-element.tsx @@ -14,6 +14,7 @@ type RailActionButton = { icon: AvailableUnfilledIcon title: string action: () => void + indicator?: ReactElement hide?: boolean ref?: React.Ref } @@ -23,6 +24,7 @@ type RailDropdown = { icon: AvailableUnfilledIcon title: string dropdown: ReactElement + indicator?: ReactElement hide?: boolean ref?: React.Ref } @@ -57,10 +59,9 @@ const RailActionElement = forwardRef( as="button" aria-label={action.title} > - @@ -81,11 +82,7 @@ const RailActionElement = forwardRef( className="ide-rail-tab-link ide-rail-tab-button" aria-label={action.title} > - + ) @@ -93,6 +90,21 @@ const RailActionElement = forwardRef( } ) +function RailActionIcon({ + type, + indicator, +}: { + type: AvailableUnfilledIcon + indicator?: ReactElement +}) { + return ( + <> + + {indicator} + + ) +} + RailActionElement.displayName = 'RailActionElement' export default RailActionElement diff --git a/services/web/frontend/js/features/ide-react/components/rail/rail-indicator.tsx b/services/web/frontend/js/features/ide-react/components/rail/rail-indicator.tsx index 845120a217..55802173e9 100644 --- a/services/web/frontend/js/features/ide-react/components/rail/rail-indicator.tsx +++ b/services/web/frontend/js/features/ide-react/components/rail/rail-indicator.tsx @@ -1,7 +1,7 @@ import OLBadge from '@/shared/components/ol/ol-badge' type RailIndicatorProps = { - type: 'danger' | 'warning' | 'info' + type: 'danger' | 'warning' | 'info' | 'success' count: number } diff --git a/services/web/frontend/js/features/ide-react/components/rail/rail-modals.tsx b/services/web/frontend/js/features/ide-react/components/rail/rail-modals.tsx index 9fc5539b35..2ca7a8f560 100644 --- a/services/web/frontend/js/features/ide-react/components/rail/rail-modals.tsx +++ b/services/web/frontend/js/features/ide-react/components/rail/rail-modals.tsx @@ -6,11 +6,21 @@ import { import { RailHelpContactUsModal } from './contact-us' import { RailHelpShowHotkeysModal } from './keyboard-shortcuts' import DictionarySettingsModal from '@/features/settings/components/editor-settings/dictionary-settings-modal' +import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' -const RAIL_MODALS: { +type RailModalEntry = { key: RailModalKey modalComponentFunction: FC<{ show: boolean }> -}[] = [ +} + +const moduleRailModals = ( + importOverleafModules('railModals') as { + import: { default: RailModalEntry } + path: string + }[] +).map(({ import: { default: entry } }) => entry) + +const RAIL_MODALS: RailModalEntry[] = [ { key: 'keyboard-shortcuts', modalComponentFunction: RailHelpShowHotkeysModal, @@ -23,6 +33,7 @@ const RAIL_MODALS: { key: 'dictionary', modalComponentFunction: DictionarySettingsModal, }, + ...moduleRailModals, ] export default function RailModals() { diff --git a/services/web/frontend/js/features/ide-react/components/rail/rail.tsx b/services/web/frontend/js/features/ide-react/components/rail/rail.tsx index 0c49f69c8d..db28ded7e8 100644 --- a/services/web/frontend/js/features/ide-react/components/rail/rail.tsx +++ b/services/web/frontend/js/features/ide-react/components/rail/rail.tsx @@ -56,6 +56,13 @@ const moduleRailPopovers = ( }[] ).map(({ import: { default: element } }) => element) +const moduleRailActions = ( + importOverleafModules('railActions') as { + import: { default: RailAction } + path: string + }[] +).map(({ import: { default: action } }) => action) + export const RailLayout = () => { const { sendEvent } = useEditorAnalytics() const { t } = useTranslation() @@ -152,6 +159,7 @@ export const RailLayout = () => { title: t('help'), dropdown: , }, + ...moduleRailActions, { key: 'settings', icon: 'settings', diff --git a/services/web/frontend/js/features/ide-react/context/rail-context.tsx b/services/web/frontend/js/features/ide-react/context/rail-context.tsx index bdbf95097c..39f25a5c42 100644 --- a/services/web/frontend/js/features/ide-react/context/rail-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/rail-context.tsx @@ -25,7 +25,15 @@ export type RailTabKey = | 'full-project-search' | 'workbench' -export type RailModalKey = 'keyboard-shortcuts' | 'contact-us' | 'dictionary' +export type RailModalKey = + | 'keyboard-shortcuts' + | 'contact-us' + | 'dictionary' + | 'labs' + +export function dispatchOpenRailModal(key: RailModalKey) { + window.dispatchEvent(new CustomEvent('ui:open-rail-modal', { detail: key })) +} const RailContext = createContext< | { @@ -119,6 +127,16 @@ export const RailProvider: FC = ({ children }) => { }, [handlePaneCollapse, selectedTab, isOpen, openTab]) ) + useEventListener( + 'ui:open-rail-modal', + useCallback( + (event: Event) => { + setActiveModal((event as CustomEvent).detail) + }, + [setActiveModal] + ) + ) + useEventListener( 'keydown', useCallback( diff --git a/services/web/frontend/js/shared/components/labs/labs-enable-button.tsx b/services/web/frontend/js/shared/components/labs/labs-enable-button.tsx new file mode 100644 index 0000000000..e078e2239c --- /dev/null +++ b/services/web/frontend/js/shared/components/labs/labs-enable-button.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next' +import OLButton from '@/shared/components/ol/ol-button' + +type LabsEnableButtonProps = { + optedIn: boolean + disabled: boolean + loading?: boolean + ghost?: boolean + handleEnable: () => void + handleDisable: () => void +} + +export function LabsEnableButton({ + optedIn, + disabled, + loading, + ghost, + handleEnable, + handleDisable, +}: LabsEnableButtonProps) { + const { t } = useTranslation() + const isDisabled = loading || disabled + + if (optedIn) { + return ( + + {t('disable')} + + ) + } + + return ( + + {t('enable')} + + ) +} diff --git a/services/web/frontend/js/shared/components/labs/labs-experiments-widget.tsx b/services/web/frontend/js/shared/components/labs/labs-experiments-widget.tsx index 7a9f882b41..10a2443692 100644 --- a/services/web/frontend/js/shared/components/labs/labs-experiments-widget.tsx +++ b/services/web/frontend/js/shared/components/labs/labs-experiments-widget.tsx @@ -5,6 +5,7 @@ import { postJSON } from '@/infrastructure/fetch-json' import OLButton from '@/shared/components/ol/ol-button' import getMeta from '@/utils/meta' import Notification from '../notification' +import { LabsEnableButton } from '@/shared/components/labs/labs-enable-button' export type LabsExperimentWidgetProps = { logo: ReactNode @@ -96,11 +97,11 @@ export function LabsExperimentWidget({
{labsEnabled && ( - )}
@@ -119,40 +120,4 @@ export function LabsExperimentWidget({ ) } -type ActionButtonProps = { - optedIn?: boolean - disabled?: boolean - handleEnable: () => void - handleDisable: () => void -} - -function ActionButton({ - optedIn, - disabled, - handleEnable, - handleDisable, -}: ActionButtonProps) { - const { t } = useTranslation() - - if (optedIn) { - return ( - - {t('disable')} - - ) - } else if (disabled) { - return ( - - {t('enable')} - - ) - } else { - return ( - - {t('enable')} - - ) - } -} - export default LabsExperimentWidget diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 1f0e1a60ff..ea71ed2358 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -206,6 +206,7 @@ export interface Meta { surveyLink: string isFull: boolean optedIn: boolean + versionCreatedAt: string | null }> 'ol-languages': SpellCheckLanguage[] 'ol-learnedWords': string[] diff --git a/services/web/frontend/stylesheets/modules/labs.scss b/services/web/frontend/stylesheets/modules/labs.scss index 77d9b802a2..903bee7156 100644 --- a/services/web/frontend/stylesheets/modules/labs.scss +++ b/services/web/frontend/stylesheets/modules/labs.scss @@ -9,10 +9,6 @@ } #experiments-container { - .title-row { - display: flex; - } - .labs-experiment-widget-container { border: 1px solid var(--border-divider); } @@ -60,13 +56,12 @@ } .title-row { - margin: 0; - margin-bottom: var(--spacing-04); + display: flex; align-items: center; + margin: 0 0 var(--spacing-04) 0; > h3 { - margin: 0; - margin-right: var(--spacing-04); + margin: 0 var(--spacing-04) 0 0; } } @@ -96,14 +91,229 @@ } } -.labs-workbench-logo { - width: 32px; - height: 32px; - padding: var(--spacing-01); +// Feature Preview Modal +.labs-feature-preview-modal { + .modal-header { + padding-bottom: var(--spacing-04); + border-bottom: 1px solid var(--border-divider); + } - img { - max-width: 100%; - max-height: 100%; - object-fit: contain; + .modal-body { + padding: 0; + } +} + +.labs-feature-preview-empty { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-09); + color: var(--content-secondary); +} + +// Experiment Browser (two-panel layout) +.lab-experiment-modal-content { + container-type: inline-size; + container-name: lab-experiment-modal-content; +} + +.lab-experiment-modal-content-inner { + display: flex; + height: 100%; +} + +.labs-experiment-tab-content { + flex: 1; + min-width: 0; +} + +// Fixed height in the feature preview modal, where the modal body constrains the layout +.labs-feature-preview-modal .lab-experiment-modal-content-inner { + height: 500px; +} + +.labs-experiment-sidebar { + width: 320px; + border-right: 1px solid var(--border-divider); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.labs-experiment-sidebar-item { + display: flex; + align-items: center; + gap: var(--spacing-04); + padding: var(--spacing-04) var(--spacing-05); + text-align: left; + color: var(--content-primary); + font-size: var(--font-size-02); + min-height: 44px; + text-decoration: none; + + &:hover { + background: var(--bg-light-secondary); + color: var(--content-primary); + } + + &.active { + background: var(--bg-accent-03); + color: var(--content-primary); + + .labs-experiment-sidebar-title { + color: var(--green-70); + font-weight: 600; + } + } +} + +.labs-experiment-sidebar-icon { + font-size: 20px; + color: var(--content-primary); +} + +.labs-experiment-sidebar-title { + flex: 1; + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; +} + +.labs-experiment-sidebar-badge { + // Reserve a fixed width so layout is stable whether badge is shown or not + min-width: 60px; + text-align: right; +} + +.labs-settings-link { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--spacing-04); + padding: var(--spacing-04) var(--spacing-05); + text-align: left; + color: var(--content-primary); + font-size: var(--font-size-02); + min-height: 44px; + width: 100%; + text-decoration: none; + + &:visited { + color: var(--content-primary); + } + + &:hover { + background: var(--bg-light-secondary); + color: var(--content-primary); + } +} + +.labs-settings-link-external-icon { + font-size: 20px; + color: var(--content-secondary); +} + +.labs-badge { + font-size: 14px; + font-weight: 700; + line-height: 1; + padding: 3px 8px; +} + +.labs-experiment-sidebar-arrow { + display: none; + font-size: 18px; + color: var(--content-secondary); +} + +// !important needed to override Bootstrap's d-inline-grid utility on OLIconButton +.labs-experiment-detail-back { + display: none !important; +} + +.labs-experiment-detail { + padding: var(--spacing-06); + overflow-y: auto; + + > .notification { + margin-bottom: var(--spacing-06); + + .notification-cta { + align-self: center; + height: auto; + } + } +} + +.labs-experiment-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-06); + + h3 { + margin: 0; + font-size: var(--font-size-05); + } +} + +.labs-experiment-detail-actions { + display: flex; + align-items: center; + gap: var(--spacing-04); + margin-bottom: var(--spacing-06); +} + +.labs-experiment-detail-description { + margin-bottom: var(--spacing-06); + color: var(--content-secondary); + font-size: var(--font-size-02); + line-height: var(--line-height-03); +} + +// Narrow layout: drill-down navigation instead of side-by-side panels +// Uses container queries so this triggers based on the browser component's own width, +// not the viewport — works correctly whether embedded in the labs modal or the settings modal. +@container lab-experiment-modal-content (width < 600px) { + .lab-experiment-modal-content-inner { + flex-direction: column; + min-height: auto; + } + + .labs-experiment-sidebar { + width: 100%; + border-right: none; + } + + .labs-experiment-sidebar-item { + padding: var(--spacing-05) var(--spacing-06); + } + + .labs-experiment-sidebar-arrow { + display: block; + } + + .labs-experiment-sidebar-badge { + min-width: auto; + } + + .labs-experiment-tab-content { + display: none; + } + + .labs-experiment-detail-back { + display: inline-flex !important; + margin-bottom: var(--spacing-05); + } + + // When an experiment is selected, show detail and hide sidebar + .lab-experiment-modal-content.mobile-detail-active { + .labs-experiment-sidebar { + display: none; + } + + .labs-experiment-tab-content { + display: block; + } } } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 6b74e30bec..0b3bd6ec06 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -844,6 +844,7 @@ "exclusive_access_with_labs": "Exclusive access to early-stage experiments", "existing_plan_active_until_term_end": "Your existing plan and its features will remain active until the end of the current billing period.", "expand": "Expand", + "experiment_enabled_refresh_page": "Experiment is enabled! You need to refresh the page to see the changes.", "experiment_full_check_back_soon": "Sorry, this experiment is full. Spaces may become available, so check back soon.", "expired": "Expired", "expired_confirmation_code": "Your confirmation code has expired. Click <0>Resend confirmation code to get a new one.", @@ -1351,7 +1352,9 @@ "labels_help_you_to_easily_reference_your_figures": "Labels help you to easily reference your figures throughout your document. To reference a figure within the text, reference the label using the <0>\\ref{...} command. This makes it easy to reference figures without needing to manually remember the figure numbering. <1>Learn more", "labels_help_you_to_reference_your_tables": "Labels help you to reference your tables throughout your document easily. To reference a table within the text, reference the label using the <0>\\ref{...} command. This makes it easy to reference tables without manually remembering the table numbering. <1>Read about labels and cross-references.", "labs": "Labs", + "labs_ongoing_experiments": "Overleaf Labs - ongoing experiments", "labs_program_benefits": "By signing up for Overleaf Labs you can get your hands on in-development features and try them out as much as you like. All we ask in return is your honest feedback to help us develop and improve. It’s important to note that features available in this program are still being tested and actively developed. This means they could change, be removed, or become part of a premium plan.", + "labs_settings": "Labs settings", "language": "Language", "language_suggestions": "Language suggestions", "larger_discounts_available": "Larger discounts available", @@ -1619,6 +1622,7 @@ "need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.", "need_to_leave": "Need to leave?", "neither_agree_nor_disagree": "Neither agree nor disagree", + "new": "New", "new_compile_domain_notice": "Something might be blocking your browser from accessing Overleaf’s PDF download location, <0>__compilesUserContentDomain__. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide.", "new_compiles_in_this_project_will_automatically_use_the_newest_version": "New compiles in this project will automatically use the newest version. <0>Learn how to change compiler settings", "new_file": "New file", @@ -2593,6 +2597,7 @@ "thank_you_email_confirmed": "Thank you, your email is now confirmed", "thank_you_exclamation": "Thank you!", "thank_you_for_being_part_of_our_beta_program": "Thank you for being part of our beta program, where you can have <0>early access to new features and help us understand your needs better", + "thank_you_for_participating_feedback": "Thank you for participating in this experiment. We’d appreciate your feedback.", "thank_you_for_your_feedback": "Thank you for your feedback!", "thanks": "Thanks", "thanks_for_confirming_your_email_address": "Thanks for confirming your email address", diff --git a/services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs b/services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs index 2d19b128ff..ccd208b579 100644 --- a/services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs +++ b/services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs @@ -664,6 +664,255 @@ describe('SplitTestHandler', function () { }) }) + describe('_loadSplitTestInfoInLocals labsDetails population', function () { + beforeEach(function (ctx) { + ctx.AnalyticsManager.getIdsFromSession.returns({ + userId: 'abc123abc123', + }) + }) + + it('populates labsDetails for a labs-phase split test', async function (ctx) { + const createdAt = new Date('2024-06-15T12:00:00.000Z') + ctx.cachedSplitTests.set('labs-info-test', { + name: 'labs-info-test', + labsTitle: 'My Labs Feature', + labsDescription: 'A great feature', + labsIcon: 'star', + badgeInfo: { labs: { url: 'https://example.com/survey' } }, + versions: [ + { + active: true, + analyticsEnabled: true, + phase: 'labs', + versionNumber: 1, + createdAt, + variants: [ + { + name: 'variant-1', + rolloutPercent: 100, + rolloutStripes: [{ start: 0, end: 100 }], + }, + ], + }, + ], + }) + + await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, + 'labs-info-test' + ) + + expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith( + ctx.res.locals, + 'labs-info-test', + sinon.match({ + phase: 'labs', + labsDetails: sinon.match({ + isFull: false, + versionCreatedAt: createdAt.toISOString(), + title: 'My Labs Feature', + description: 'A great feature', + icon: 'star', + surveyLink: 'https://example.com/survey', + }), + }) + ) + }) + + it('handles missing labsTitle defensively', async function (ctx) { + const createdAt = new Date('2024-01-01T00:00:00.000Z') + ctx.cachedSplitTests.set('my-labs-experiment', { + name: 'my-labs-experiment', + versions: [ + { + active: true, + analyticsEnabled: true, + phase: 'labs', + versionNumber: 1, + createdAt, + variants: [ + { + name: 'variant-1', + rolloutPercent: 100, + rolloutStripes: [{ start: 0, end: 100 }], + }, + ], + }, + ], + }) + + await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, + 'my-labs-experiment' + ) + + expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith( + ctx.res.locals, + 'my-labs-experiment', + sinon.match({ + labsDetails: sinon.match({ title: '' }), + }) + ) + }) + + it('sets description to empty string when labsDescription is absent', async function (ctx) { + ctx.cachedSplitTests.set('labs-nodesc', { + name: 'labs-nodesc', + labsTitle: 'No Description', + versions: [ + { + active: true, + analyticsEnabled: true, + phase: 'labs', + versionNumber: 1, + createdAt: new Date(), + variants: [ + { + name: 'variant-1', + rolloutPercent: 100, + rolloutStripes: [{ start: 0, end: 100 }], + }, + ], + }, + ], + }) + + await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, + 'labs-nodesc' + ) + + expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith( + ctx.res.locals, + 'labs-nodesc', + sinon.match({ labsDetails: sinon.match({ description: '' }) }) + ) + }) + + it('marks isFull true when userCount >= userLimit', async function (ctx) { + ctx.cachedSplitTests.set('labs-full', { + name: 'labs-full', + versions: [ + { + active: true, + analyticsEnabled: true, + phase: 'labs', + versionNumber: 1, + createdAt: new Date(), + variants: [ + { + name: 'variant-1', + rolloutPercent: 100, + rolloutStripes: [{ start: 0, end: 100 }], + userLimit: 10, + userCount: 10, + }, + ], + }, + ], + }) + + await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, + 'labs-full' + ) + + expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith( + ctx.res.locals, + 'labs-full', + sinon.match({ labsDetails: sinon.match({ isFull: true }) }) + ) + }) + + it('marks isFull false when userCount < userLimit', async function (ctx) { + ctx.cachedSplitTests.set('labs-not-full', { + name: 'labs-not-full', + versions: [ + { + active: true, + analyticsEnabled: true, + phase: 'labs', + versionNumber: 1, + createdAt: new Date(), + variants: [ + { + name: 'variant-1', + rolloutPercent: 100, + rolloutStripes: [{ start: 0, end: 100 }], + userLimit: 10, + userCount: 5, + }, + ], + }, + ], + }) + + await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, + 'labs-not-full' + ) + + expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith( + ctx.res.locals, + 'labs-not-full', + sinon.match({ labsDetails: sinon.match({ isFull: false }) }) + ) + }) + + it('marks isFull false when no userLimit is set', async function (ctx) { + ctx.cachedSplitTests.set('labs-no-limit', { + name: 'labs-no-limit', + versions: [ + { + active: true, + analyticsEnabled: true, + phase: 'labs', + versionNumber: 1, + createdAt: new Date(), + variants: [ + { + name: 'variant-1', + rolloutPercent: 100, + rolloutStripes: [{ start: 0, end: 100 }], + }, + ], + }, + ], + }) + + await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, + 'labs-no-limit' + ) + + expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith( + ctx.res.locals, + 'labs-no-limit', + sinon.match({ labsDetails: sinon.match({ isFull: false }) }) + ) + }) + + it('does not set labsDetails for a release-phase split test', async function (ctx) { + await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, + 'active-test' + ) + + expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith( + ctx.res.locals, + 'active-test', + sinon.match(info => info.labsDetails === undefined) + ) + }) + }) + describe('labs phase assignment (gradual rollout)', function () { beforeEach(function (ctx) { ctx.AnalyticsManager.getIdsFromSession.returns({ diff --git a/services/web/test/unit/src/SplitTests/SplitTestUtils.test.mjs b/services/web/test/unit/src/SplitTests/SplitTestUtils.test.mjs new file mode 100644 index 0000000000..b6c24152d2 --- /dev/null +++ b/services/web/test/unit/src/SplitTests/SplitTestUtils.test.mjs @@ -0,0 +1,112 @@ +import { expect } from 'vitest' +import SplitTestUtils from '../../../../app/src/Features/SplitTests/SplitTestUtils.mjs' + +describe('SplitTestUtils', function () { + describe('isExperimentFull', function () { + describe('when userLimit is null or undefined', function () { + it('should return false when userLimit is null', function () { + const variant = { + userLimit: null, + userCount: 5, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(false) + }) + + it('should return false when userLimit is undefined', function () { + const variant = { + userCount: 5, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(false) + }) + }) + + describe('when userLimit is not a number', function () { + it.each([ + { label: 'NaN', userLimit: NaN }, + { label: 'an object', userLimit: {} }, + { label: 'a string', userLimit: 'string' }, + ])( + 'should return false when userLimit is $label', + function ({ userLimit }) { + const variant = { + userLimit, + userCount: 50, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(false) + } + ) + }) + + it('should return true when userLimit is 0', function () { + const variant = { + userLimit: 0, + userCount: 5, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(true) + }) + + it('should treat undefined userCount as 0 and return false when under limit', function () { + const variant = { + userLimit: 100, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(false) + }) + + it('should treat null userCount as 0 and return false when under limit', function () { + const variant = { + userLimit: 100, + userCount: null, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(false) + }) + + it('should treat undefined userCount as 0 and return true when limit is 0', function () { + const variant = { + userLimit: 0, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(true) + }) + + describe('when userCount is below the limit', function () { + it('should return false when userCount is less than userLimit', function () { + const variant = { + userLimit: 100, + userCount: 50, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(false) + }) + + it('should return false when userCount is 1 and userLimit is 100', function () { + const variant = { + userLimit: 100, + userCount: 1, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(false) + }) + + it('should return false when userCount is 0 and userLimit is 100', function () { + const variant = { + userLimit: 100, + userCount: 0, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(false) + }) + }) + + it('should return true when userCount equals userLimit', function () { + const variant = { + userLimit: 100, + userCount: 100, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(true) + }) + + it('should return true when userCount is greater than userLimit', function () { + const variant = { + userLimit: 100, + userCount: 150, + } + expect(SplitTestUtils.isExperimentFull(variant)).toBe(true) + }) + }) +}) diff --git a/services/web/types/split-test.ts b/services/web/types/split-test.ts index 9002ff00f1..632027c98e 100644 --- a/services/web/types/split-test.ts +++ b/services/web/types/split-test.ts @@ -12,4 +12,14 @@ export type SplitTestInfo = { url?: string tooltipText?: string } + labsDetails?: LabsDetails +} + +export type LabsDetails = { + isFull: boolean + versionCreatedAt: string + title: string + description: string + icon: string + surveyLink: string }