diff --git a/config/scripts/dev-fresh-profile.sh b/config/scripts/dev-fresh-profile.sh index 3579c840f..7b8041477 100755 --- a/config/scripts/dev-fresh-profile.sh +++ b/config/scripts/dev-fresh-profile.sh @@ -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 diff --git a/src/main/index.ts b/src/main/index.ts index 40ea6b678..3626f0a4c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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. diff --git a/src/main/startup/dev-education-suppression.test.ts b/src/main/startup/dev-education-suppression.test.ts new file mode 100644 index 000000000..079ce7634 --- /dev/null +++ b/src/main/startup/dev-education-suppression.test.ts @@ -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 + ui?: Partial +}) { + 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) => { + onboarding = { + ...onboarding, + ...updates, + checklist: { + ...onboarding.checklist, + ...updates.checklist + } + } + return onboarding + }), + getUI: vi.fn(() => ui), + updateUI: vi.fn((updates: Partial) => { + 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() + }) +}) diff --git a/src/main/startup/dev-education-suppression.ts b/src/main/startup/dev-education-suppression.ts new file mode 100644 index 000000000..9b3897169 --- /dev/null +++ b/src/main/startup/dev-education-suppression.ts @@ -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 + +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 = {} + 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( + 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(a: readonly T[] | undefined, b: readonly T[]): boolean { + return (a ?? []).length === b.length && (a ?? []).every((value, index) => value === b[index]) +}