diff --git a/services/web/app/src/Features/SplitTests/SplitTestHandler.js b/services/web/app/src/Features/SplitTests/SplitTestHandler.js index d450e72f08..4ed14d6c7e 100644 --- a/services/web/app/src/Features/SplitTests/SplitTestHandler.js +++ b/services/web/app/src/Features/SplitTests/SplitTestHandler.js @@ -58,6 +58,8 @@ async function getAssignment(req, res, splitTestName, { sync = false } = {}) { if (!Features.hasFeature('saas')) { assignment = _getNonSaasAssignment(splitTestName) } else { + await _loadSplitTestInfoInLocals(res.locals, splitTestName, req.session) + // Check the query string for an override, ignoring an invalid value const queryVariant = query[splitTestName] if (queryVariant) { @@ -91,7 +93,7 @@ async function getAssignment(req, res, splitTestName, { sync = false } = {}) { splitTestName, assignment.variant ) - await _loadSplitTestInfoInLocals(res.locals, splitTestName) + return assignment } @@ -244,6 +246,14 @@ async function _getAssignment( const splitTest = await _getSplitTest(splitTestName) const currentVersion = SplitTestUtils.getCurrentVersion(splitTest) + + if (Settings.splitTest.devToolbar.enabled) { + const override = session?.splitTestOverrides?.[splitTestName] + if (override) { + return _makeAssignment(splitTest, override, currentVersion) + } + } + if (!currentVersion?.active) { return DEFAULT_ASSIGNMENT } @@ -352,6 +362,20 @@ function getPercentile(analyticsId, splitTestName, splitTestPhase) { ) } +function setOverrideInSession(session, splitTestName, variantName) { + if (!Settings.splitTest.devToolbar.enabled) { + return + } + if (!session.splitTestOverrides) { + session.splitTestOverrides = {} + } + session.splitTestOverrides[splitTestName] = variantName +} + +function clearOverridesInSession(session) { + delete session.splitTestOverrides +} + function _getVariantFromPercentile(variants, percentile) { for (const variant of variants) { for (const stripe of variant.rolloutStripes) { @@ -425,12 +449,14 @@ function _makeAssignment(splitTest, variant, currentVersion) { return { variant, analytics: { - segmentation: { - splitTest: splitTest.name, - variant, - phase: currentVersion.phase, - versionNumber: currentVersion.versionNumber, - }, + segmentation: splitTest + ? { + splitTest: splitTest.name, + variant, + phase: currentVersion.phase, + versionNumber: currentVersion.versionNumber, + } + : {}, }, } } @@ -492,18 +518,33 @@ async function _getUser(id, splitTestName) { return user } -async function _loadSplitTestInfoInLocals(locals, splitTestName) { +async function _loadSplitTestInfoInLocals(locals, splitTestName, session) { const splitTest = await _getSplitTest(splitTestName) if (splitTest) { + const override = session?.splitTestOverrides?.[splitTestName] + const currentVersion = SplitTestUtils.getCurrentVersion(splitTest) - if (!currentVersion.active) { + if (!currentVersion.active && !Settings.splitTest.devToolbar.enabled) { return } const phase = currentVersion.phase - LocalsHelper.setSplitTestInfo(locals, splitTestName, { + const info = { phase, badgeInfo: splitTest.badgeInfo?.[phase], + } + if (Settings.splitTest.devToolbar.enabled) { + info.active = currentVersion.active + info.variants = currentVersion.variants.map(variant => ({ + name: variant.name, + rolloutPercent: variant.rolloutPercent, + })) + info.hasOverride = !!override + } + LocalsHelper.setSplitTestInfo(locals, splitTestName, info) + } else if (Settings.splitTest.devToolbar.enabled) { + LocalsHelper.setSplitTestInfo(locals, splitTestName, { + missing: true, }) } } @@ -555,6 +596,8 @@ module.exports = { getAssignmentForUser: callbackify(getAssignmentForUser), getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser), sessionMaintenance: callbackify(sessionMaintenance), + setOverrideInSession, + clearOverridesInSession, promises: { getAssignment, getAssignmentForMongoUser, diff --git a/services/web/app/src/Features/SplitTests/SplitTestManager.js b/services/web/app/src/Features/SplitTests/SplitTestManager.js index b1818d3025..665840e8da 100644 --- a/services/web/app/src/Features/SplitTests/SplitTestManager.js +++ b/services/web/app/src/Features/SplitTests/SplitTestManager.js @@ -1,7 +1,9 @@ const { SplitTest } = require('../../models/SplitTest') const SplitTestUtils = require('./SplitTestUtils') const OError = require('@overleaf/o-error') +const Settings = require('@overleaf/settings') const _ = require('lodash') +const { CacheFlow } = require('cache-flow') const ALPHA_PHASE = 'alpha' const BETA_PHASE = 'beta' @@ -315,6 +317,10 @@ async function archive(name, userId) { return _saveSplitTest(splitTest) } +async function clearCache() { + await CacheFlow.reset('split-test') +} + function _checkNewVariantsConfiguration(variants, newVariantsConfiguration) { const totalRolloutPercentage = _getTotalRolloutPercentage( newVariantsConfiguration @@ -410,7 +416,7 @@ async function _saveSplitTest(splitTest) { * since deleting all records in staging or prod would be very bad... */ function _checkEnvIsSafe(operation) { - if (process.env.NODE_ENV !== 'development') { + if (Settings.splitTest.devToolbar.enabled) { throw OError.tag( `attempted to ${operation} all feature flags outside of local env` ) @@ -459,4 +465,5 @@ module.exports = { archive, replaceSplitTests, mergeSplitTests, + clearCache, } diff --git a/services/web/app/views/layout-base.pug b/services/web/app/views/layout-base.pug index a1882d4a54..bd31e71e27 100644 --- a/services/web/app/views/layout-base.pug +++ b/services/web/app/views/layout-base.pug @@ -79,9 +79,15 @@ html( block body + if (settings.splitTest.devToolbar.enabled) + div#dev-toolbar + block foot-scripts each file in entrypointScripts(entrypoint) script(type="text/javascript", nonce=scriptNonce, src=file) + if (settings.splitTest.devToolbar.enabled) + each file in entrypointScripts("devToolbar") + script(type="text/javascript", nonce=scriptNonce, src=file) script(type="text/javascript", nonce=scriptNonce). //- Look for bundle diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 226ba797b2..39af36af28 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -271,6 +271,12 @@ module.exports = { algorithm: process.env.OT_JWT_AUTH_ALG || 'HS256', }, + splitTest: { + devToolbar: { + enabled: false, + }, + }, + splitTests: [], // Where your instance of ShareLaTeX can be found publically. Used in emails @@ -828,6 +834,7 @@ module.exports = { overleafModuleImports: { // modules to import (an empty array for each set of modules) createFileModes: [], + devToolbar: [], gitBridge: [], publishModal: [], tprFileViewInfo: [], diff --git a/services/web/frontend/js/dev-toolbar.js b/services/web/frontend/js/dev-toolbar.js new file mode 100644 index 0000000000..f973c97173 --- /dev/null +++ b/services/web/frontend/js/dev-toolbar.js @@ -0,0 +1,5 @@ +import importOverleafModules from '../macros/import-overleaf-module.macro' + +if (process.env.NODE_ENV === 'development') { + importOverleafModules('devToolbar') +} diff --git a/services/web/frontend/stylesheets/_style_includes.less b/services/web/frontend/stylesheets/_style_includes.less index 8e15d35c86..2a8f986f78 100644 --- a/services/web/frontend/stylesheets/_style_includes.less +++ b/services/web/frontend/stylesheets/_style_includes.less @@ -24,6 +24,7 @@ @import 'components/card.less'; //@import "components/code.less"; @import 'components/component-animations.less'; +@import 'components/dev-toolbar.less'; @import 'components/dropdowns.less'; @import 'components/button-groups.less'; @import 'components/input-groups.less'; diff --git a/services/web/frontend/stylesheets/components/dev-toolbar.less b/services/web/frontend/stylesheets/components/dev-toolbar.less new file mode 100644 index 0000000000..a504e97efd --- /dev/null +++ b/services/web/frontend/stylesheets/components/dev-toolbar.less @@ -0,0 +1,78 @@ +.dev-toolbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + height: 40px; + background-color: @neutral-90; + padding: 5px 12px; + + button.widget { + color: @neutral-10; + margin: 0 4px; + padding: 0 4px; + border: none; + text-decoration: none; + } +} + +.dev-toolbar-tooltip { + a { + color: @blue-20; + &.btn { + color: @neutral-10; + } + } + + &.tooltip.top { + margin-top: -10px; + opacity: 1; + } + .tooltip-inner { + padding: 2px 8px 8px 8px; + text-align: left; + min-width: 300px; + max-height: 800px; + overflow-y: auto; + } + + .title { + margin-top: 4px; + } + + .test-card { + text-align: left; + color: @neutral-10; + padding: 6px; + border: 2px solid @neutral-70; + background-color: @neutral-80; + border-radius: 4px; + margin-top: 8px; + + &.override { + border-color: @blue-40; + } + + .test-name { + font-family: monospace; + font-size: @font-size-extra-small; + font-weight: bold; + } + + ul { + li.variant-row { + line-height: 24px; + } + } + } + + .actions { + margin-top: 8px; + text-align: right; + } + + ul { + margin-bottom: 0; + } +} diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index cf6a519c1e..6a5e628720 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -41,6 +41,7 @@ @import 'components/buttons.less'; @import 'components/card.less'; @import 'components/component-animations.less'; +@import 'components/dev-toolbar.less'; @import 'components/dropdowns.less'; @import 'components/button-groups.less'; @import 'components/input-groups.less'; diff --git a/services/web/test/acceptance/config/settings.test.defaults.js b/services/web/test/acceptance/config/settings.test.defaults.js index f5e2d79864..2a1c3f0ff1 100644 --- a/services/web/test/acceptance/config/settings.test.defaults.js +++ b/services/web/test/acceptance/config/settings.test.defaults.js @@ -267,6 +267,12 @@ module.exports = { test: { counterInit: 0, }, + + splitTest: { + devToolbar: { + enabled: false, + }, + }, } module.exports.mergeWith = function (overrides) { diff --git a/services/web/test/unit/src/SplitTests/SplitTestHandlerTests.js b/services/web/test/unit/src/SplitTests/SplitTestHandlerTests.js index faa3de36d7..bf30d289a1 100644 --- a/services/web/test/unit/src/SplitTests/SplitTestHandlerTests.js +++ b/services/web/test/unit/src/SplitTests/SplitTestHandlerTests.js @@ -46,6 +46,11 @@ describe('SplitTestHandler', function () { this.Settings = { moduleImportSequence: [], overleaf: {}, + splitTest: { + devToolbar: { + enabled: false, + }, + }, } this.AnalyticsManager = { getIdsFromSession: sinon.stub(), diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js index ee6728bc7a..7189660e6a 100644 --- a/services/web/webpack.config.js +++ b/services/web/webpack.config.js @@ -13,6 +13,7 @@ const PackageVersions = require('./app/src/infrastructure/PackageVersions') // Generate a hash of entry points, including modules const entryPoints = { tracing: './frontend/js/tracing.js', + devToolbar: './frontend/js/dev-toolbar.js', main: './frontend/js/main.js', ide: './frontend/js/ide.js', 'ide-detached': './frontend/js/ide-detached.js',