Skip first-run education in dev launches (#4489)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
90fc65803d
commit
5a48af49f3
|
|
@ -40,4 +40,4 @@ trap cleanup EXIT
|
|||
|
||||
echo "[dev-fresh-profile] using userData=$PROFILE_DIR"
|
||||
# Don't exec — we need the EXIT trap to fire so the temp profile gets cleaned up.
|
||||
ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" pnpm dev
|
||||
ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" ORCA_DEV_SHOW_FIRST_RUN_EDUCATION=1 pnpm dev
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ import {
|
|||
patchPackagedProcessPath,
|
||||
shouldInstallManagedHooks
|
||||
} from './startup/configure-process'
|
||||
import {
|
||||
shouldSuppressDevEducation,
|
||||
suppressDevEducationForStore
|
||||
} from './startup/dev-education-suppression'
|
||||
import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect'
|
||||
import { startFirstWindowStartupServices } from './startup/first-window-startup-services'
|
||||
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
|
||||
|
|
@ -1035,6 +1039,9 @@ app.whenReady().then(async () => {
|
|||
}
|
||||
|
||||
store = new Store()
|
||||
if (shouldSuppressDevEducation({ isDev: is.dev })) {
|
||||
suppressDevEducationForStore(store)
|
||||
}
|
||||
try {
|
||||
// Why: Dock/Launchpad launches do not inherit shell proxy env vars, so the
|
||||
// persisted proxy must be applied before any app-owned network fetchers run.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getDefaultOnboardingState,
|
||||
getDefaultUIState,
|
||||
ONBOARDING_FINAL_STEP,
|
||||
ONBOARDING_FLOW_VERSION
|
||||
} from '../../shared/constants'
|
||||
import { CONTEXTUAL_TOUR_IDS } from '../../shared/contextual-tours'
|
||||
import { FEATURE_INTERACTION_IDS } from '../../shared/feature-interactions'
|
||||
import { FEATURE_TIP_IDS } from '../../shared/feature-tips'
|
||||
import type { OnboardingState, PersistedUIState } from '../../shared/types'
|
||||
import {
|
||||
DEV_SHOW_FIRST_RUN_EDUCATION_ENV,
|
||||
shouldSuppressDevEducation,
|
||||
suppressDevEducationForStore
|
||||
} from './dev-education-suppression'
|
||||
|
||||
function createStoreState(overrides?: {
|
||||
onboarding?: Partial<OnboardingState>
|
||||
ui?: Partial<PersistedUIState>
|
||||
}) {
|
||||
let onboarding: OnboardingState = {
|
||||
...getDefaultOnboardingState(),
|
||||
...overrides?.onboarding,
|
||||
checklist: {
|
||||
...getDefaultOnboardingState().checklist,
|
||||
...overrides?.onboarding?.checklist
|
||||
}
|
||||
}
|
||||
let ui: PersistedUIState = {
|
||||
...getDefaultUIState(),
|
||||
...overrides?.ui
|
||||
}
|
||||
|
||||
return {
|
||||
get onboarding() {
|
||||
return onboarding
|
||||
},
|
||||
get ui() {
|
||||
return ui
|
||||
},
|
||||
store: {
|
||||
getOnboarding: vi.fn(() => onboarding),
|
||||
updateOnboarding: vi.fn((updates: Partial<OnboardingState>) => {
|
||||
onboarding = {
|
||||
...onboarding,
|
||||
...updates,
|
||||
checklist: {
|
||||
...onboarding.checklist,
|
||||
...updates.checklist
|
||||
}
|
||||
}
|
||||
return onboarding
|
||||
}),
|
||||
getUI: vi.fn(() => ui),
|
||||
updateUI: vi.fn((updates: Partial<PersistedUIState>) => {
|
||||
ui = { ...ui, ...updates }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('shouldSuppressDevEducation', () => {
|
||||
it('suppresses first-run education for normal dev launches', () => {
|
||||
expect(shouldSuppressDevEducation({ isDev: true, env: {} })).toBe(true)
|
||||
})
|
||||
|
||||
it('does not suppress packaged launches', () => {
|
||||
expect(shouldSuppressDevEducation({ isDev: false, env: {} })).toBe(false)
|
||||
})
|
||||
|
||||
it('respects the first-run education env escape hatch', () => {
|
||||
expect(
|
||||
shouldSuppressDevEducation({
|
||||
isDev: true,
|
||||
env: { [DEV_SHOW_FIRST_RUN_EDUCATION_ENV]: '1' }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not suppress E2E-controlled profiles', () => {
|
||||
expect(
|
||||
shouldSuppressDevEducation({
|
||||
isDev: true,
|
||||
env: { ORCA_E2E_USER_DATA_DIR: '/tmp/orca-e2e' }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('suppressDevEducationForStore', () => {
|
||||
it('marks onboarding and first-run education complete', () => {
|
||||
const state = createStoreState()
|
||||
|
||||
suppressDevEducationForStore(state.store, 1234)
|
||||
|
||||
expect(state.onboarding).toMatchObject({
|
||||
flowVersion: ONBOARDING_FLOW_VERSION,
|
||||
closedAt: 1234,
|
||||
outcome: 'completed',
|
||||
lastCompletedStep: ONBOARDING_FINAL_STEP
|
||||
})
|
||||
expect(state.ui.featureTipsSeenIds).toEqual(FEATURE_TIP_IDS)
|
||||
expect(state.ui.contextualToursSeenIds).toEqual(CONTEXTUAL_TOUR_IDS)
|
||||
expect(state.ui.contextualToursAutoEligible).toBe(false)
|
||||
expect(Object.keys(state.ui.featureInteractions ?? {}).sort()).toEqual(
|
||||
[...FEATURE_INTERACTION_IDS].sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves completed onboarding and existing education history', () => {
|
||||
const state = createStoreState({
|
||||
onboarding: {
|
||||
closedAt: 99,
|
||||
outcome: 'dismissed',
|
||||
lastCompletedStep: 1
|
||||
},
|
||||
ui: {
|
||||
featureTipsSeenIds: ['voice-dictation'],
|
||||
contextualToursSeenIds: ['tasks'],
|
||||
featureInteractions: {
|
||||
tasks: { firstInteractedAt: 77, interactionCount: 3 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
suppressDevEducationForStore(state.store, 1234)
|
||||
|
||||
expect(state.store.updateOnboarding).not.toHaveBeenCalled()
|
||||
expect(state.onboarding).toMatchObject({
|
||||
closedAt: 99,
|
||||
outcome: 'dismissed',
|
||||
lastCompletedStep: 1
|
||||
})
|
||||
expect(state.ui.featureTipsSeenIds).toEqual(['voice-dictation', 'orca-cli'])
|
||||
expect(state.ui.contextualToursSeenIds).toEqual([
|
||||
'tasks',
|
||||
...CONTEXTUAL_TOUR_IDS.filter((id) => id !== 'tasks')
|
||||
])
|
||||
expect(state.ui.featureInteractions?.tasks).toEqual({
|
||||
firstInteractedAt: 77,
|
||||
interactionCount: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('does not write UI when education state is already suppressed', () => {
|
||||
const featureInteractions = Object.fromEntries(
|
||||
FEATURE_INTERACTION_IDS.map((id) => [id, { firstInteractedAt: 1, interactionCount: 1 }])
|
||||
)
|
||||
const state = createStoreState({
|
||||
onboarding: {
|
||||
closedAt: 1,
|
||||
outcome: 'completed',
|
||||
lastCompletedStep: ONBOARDING_FINAL_STEP
|
||||
},
|
||||
ui: {
|
||||
featureTipsSeenIds: [...FEATURE_TIP_IDS],
|
||||
contextualToursSeenIds: [...CONTEXTUAL_TOUR_IDS],
|
||||
contextualToursAutoEligible: false,
|
||||
featureInteractions
|
||||
}
|
||||
})
|
||||
|
||||
suppressDevEducationForStore(state.store, 1234)
|
||||
|
||||
expect(state.store.updateOnboarding).not.toHaveBeenCalled()
|
||||
expect(state.store.updateUI).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../shared/constants'
|
||||
import { CONTEXTUAL_TOUR_IDS } from '../../shared/contextual-tours'
|
||||
import {
|
||||
FEATURE_INTERACTION_IDS,
|
||||
type FeatureInteractionState
|
||||
} from '../../shared/feature-interactions'
|
||||
import { FEATURE_TIP_IDS } from '../../shared/feature-tips'
|
||||
import type { PersistedUIState } from '../../shared/types'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
export const DEV_SHOW_FIRST_RUN_EDUCATION_ENV = 'ORCA_DEV_SHOW_FIRST_RUN_EDUCATION'
|
||||
|
||||
type DevEducationStore = Pick<Store, 'getOnboarding' | 'updateOnboarding' | 'getUI' | 'updateUI'>
|
||||
|
||||
export function shouldSuppressDevEducation(args: {
|
||||
isDev: boolean
|
||||
env?: NodeJS.ProcessEnv
|
||||
}): boolean {
|
||||
const env = args.env ?? process.env
|
||||
return (
|
||||
args.isDev &&
|
||||
env.ORCA_E2E_USER_DATA_DIR === undefined &&
|
||||
env[DEV_SHOW_FIRST_RUN_EDUCATION_ENV] !== '1'
|
||||
)
|
||||
}
|
||||
|
||||
export function suppressDevEducationForStore(store: DevEducationStore, now = Date.now()): void {
|
||||
const onboarding = store.getOnboarding()
|
||||
if (onboarding.closedAt === null) {
|
||||
// Why: default dev launches should behave like an already-productive
|
||||
// profile, while the env escape hatch keeps first-run surfaces testable.
|
||||
store.updateOnboarding({
|
||||
flowVersion: ONBOARDING_FLOW_VERSION,
|
||||
closedAt: now,
|
||||
outcome: 'completed',
|
||||
lastCompletedStep: ONBOARDING_FINAL_STEP
|
||||
})
|
||||
}
|
||||
|
||||
const ui = store.getUI()
|
||||
const nextFeatureTipsSeenIds = mergeUnique(ui.featureTipsSeenIds, FEATURE_TIP_IDS)
|
||||
const nextContextualToursSeenIds = mergeUnique(ui.contextualToursSeenIds, CONTEXTUAL_TOUR_IDS)
|
||||
const nextFeatureInteractions = fillFeatureInteractions(ui.featureInteractions, now)
|
||||
|
||||
const updates: Partial<PersistedUIState> = {}
|
||||
if (!sameArray(ui.featureTipsSeenIds, nextFeatureTipsSeenIds)) {
|
||||
updates.featureTipsSeenIds = nextFeatureTipsSeenIds
|
||||
}
|
||||
if (!sameArray(ui.contextualToursSeenIds, nextContextualToursSeenIds)) {
|
||||
updates.contextualToursSeenIds = nextContextualToursSeenIds
|
||||
}
|
||||
if (ui.contextualToursAutoEligible !== false) {
|
||||
updates.contextualToursAutoEligible = false
|
||||
}
|
||||
if (
|
||||
Object.keys(nextFeatureInteractions).length !== Object.keys(ui.featureInteractions ?? {}).length
|
||||
) {
|
||||
updates.featureInteractions = nextFeatureInteractions
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
store.updateUI(updates)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeUnique<const T extends string>(
|
||||
current: readonly T[] | undefined,
|
||||
additions: readonly T[]
|
||||
): T[] {
|
||||
return [...new Set([...(current ?? []), ...additions])]
|
||||
}
|
||||
|
||||
function fillFeatureInteractions(
|
||||
current: FeatureInteractionState | undefined,
|
||||
now: number
|
||||
): FeatureInteractionState {
|
||||
const next: FeatureInteractionState = { ...current }
|
||||
for (const id of FEATURE_INTERACTION_IDS) {
|
||||
next[id] ??= {
|
||||
firstInteractedAt: now,
|
||||
interactionCount: 1
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function sameArray<T>(a: readonly T[] | undefined, b: readonly T[]): boolean {
|
||||
return (a ?? []).length === b.length && (a ?? []).every((value, index) => value === b[index])
|
||||
}
|
||||
Loading…
Reference in New Issue