Merge pull request #32968 from overleaf/worktree-labs-feature-preview

Add labs preview modal to editor

GitOrigin-RevId: 0df33135febc8e94129bcdfdfb5c4981326dfab0
This commit is contained in:
Malik Glossop
2026-05-22 10:51:18 +02:00
committed by Copybot
parent 24ba0b86b1
commit eb9d586bdb
20 changed files with 761 additions and 69 deletions
@@ -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,
@@ -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 => ({
@@ -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) {
@@ -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,
}
@@ -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)
+2
View File
@@ -1083,6 +1083,8 @@ module.exports = {
referenceIndices: [],
railEntries: [],
railPopovers: [],
railActions: [],
railModals: [],
},
moduleImportSequence: [
@@ -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": "",
@@ -14,6 +14,7 @@ type RailActionButton = {
icon: AvailableUnfilledIcon
title: string
action: () => void
indicator?: ReactElement
hide?: boolean
ref?: React.Ref<HTMLButtonElement>
}
@@ -23,6 +24,7 @@ type RailDropdown = {
icon: AvailableUnfilledIcon
title: string
dropdown: ReactElement
indicator?: ReactElement
hide?: boolean
ref?: React.Ref<HTMLButtonElement>
}
@@ -57,10 +59,9 @@ const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
as="button"
aria-label={action.title}
>
<MaterialIcon
className="ide-rail-tab-link-icon"
<RailActionIcon
type={action.icon}
unfilled
indicator={action.indicator}
/>
</DropdownToggle>
</span>
@@ -81,11 +82,7 @@ const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
className="ide-rail-tab-link ide-rail-tab-button"
aria-label={action.title}
>
<MaterialIcon
className="ide-rail-tab-link-icon"
type={action.icon}
unfilled
/>
<RailActionIcon type={action.icon} indicator={action.indicator} />
</button>
</OLTooltip>
)
@@ -93,6 +90,21 @@ const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
}
)
function RailActionIcon({
type,
indicator,
}: {
type: AvailableUnfilledIcon
indicator?: ReactElement
}) {
return (
<>
<MaterialIcon className="ide-rail-tab-link-icon" type={type} unfilled />
{indicator}
</>
)
}
RailActionElement.displayName = 'RailActionElement'
export default RailActionElement
@@ -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
}
@@ -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() {
@@ -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: <RailHelpDropdown />,
},
...moduleRailActions,
{
key: 'settings',
icon: 'settings',
@@ -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<React.PropsWithChildren> = ({ children }) => {
}, [handlePaneCollapse, selectedTab, isOpen, openTab])
)
useEventListener(
'ui:open-rail-modal',
useCallback(
(event: Event) => {
setActiveModal((event as CustomEvent<RailModalKey>).detail)
},
[setActiveModal]
)
)
useEventListener(
'keydown',
useCallback(
@@ -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 (
<OLButton
variant={ghost ? 'danger-ghost' : 'secondary'}
onClick={handleDisable}
disabled={isDisabled}
>
{t('disable')}
</OLButton>
)
}
return (
<OLButton
variant={ghost ? 'secondary' : 'primary'}
onClick={handleEnable}
disabled={isDisabled}
>
{t('enable')}
</OLButton>
)
}
@@ -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({
</div>
<div>
{labsEnabled && (
<ActionButton
<LabsEnableButton
optedIn={optedIn}
handleDisable={handleDisable}
handleEnable={handleEnable}
disabled={disabled}
handleEnable={handleEnable}
handleDisable={handleDisable}
/>
)}
</div>
@@ -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 (
<OLButton variant="secondary" onClick={handleDisable}>
{t('disable')}
</OLButton>
)
} else if (disabled) {
return (
<OLButton variant="primary" disabled>
{t('enable')}
</OLButton>
)
} else {
return (
<OLButton variant="primary" onClick={handleEnable}>
{t('enable')}
</OLButton>
)
}
}
export default LabsExperimentWidget
+1
View File
@@ -206,6 +206,7 @@ export interface Meta {
surveyLink: string
isFull: boolean
optedIn: boolean
versionCreatedAt: string | null
}>
'ol-languages': SpellCheckLanguage[]
'ol-learnedWords': string[]
@@ -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;
}
}
}
+5
View File
@@ -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</0> 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{...}</0> command. This makes it easy to reference figures without needing to manually remember the figure numbering. <1>Learn more</1>",
"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{...}</0> command. This makes it easy to reference tables without manually remembering the table numbering. <1>Read about labels and cross-references</1>.",
"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. Its 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": "Youll 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 Overleafs PDF download location, <0>__compilesUserContentDomain__</0>. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide</1>.",
"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</0>",
"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</0> and help us understand your needs better",
"thank_you_for_participating_feedback": "Thank you for participating in this experiment. Wed 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",
@@ -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({
@@ -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)
})
})
})
+10
View File
@@ -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
}