Repository: getsentry/sentry
Pull request #88781: feat(demo-mode): multi-tour context
Author: login: obostjancic
Labels: name: Scope: Frontend
description: Automatically applied to PRs that change frontend components
Created: 2025-04-04T09:10:52Z | Merged: 2025-04-11T07:20:15Z

Closes [TET-81](https://linear.app/getsentry/issue/TET-81/guided-tours-improvements)

Introduces a `DemoToursContext` that manages multiple demo tours (Sidebar, Issues, Releases, Performance) with shared state management. Each tour maintains its own progress while using common components described in [storybook](https://sentry.sentry.io/stories/?name=app%2Fcomponents%2Ftours%2Ftour.stories.tsx&query=tour) for state persistence and step navigation. 

Actual tours will be ported over to this in the followup PRs

[TET-81]: https://getsentry.atlassian.net/browse/TET-81?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/static/app/components/tours/components.tsx b/static/app/components/tours/components.tsx
index 9f0c1f54dd66..33b4acc113d4 100644
--- a/static/app/components/tours/components.tsx
+++ b/static/app/components/tours/components.tsx
@@ -210,7 +210,7 @@ interface TourElementContentProps<T extends TourEnumType>
   tourContextValue: TourContextType<T>;
 }
 
-function TourElementContent<T extends TourEnumType>({
+export function TourElementContent<T extends TourEnumType>({
   children,
   id,
   title,
diff --git a/static/app/utils/demoMode/demoTours.spec.tsx b/static/app/utils/demoMode/demoTours.spec.tsx
new file mode 100644
index 000000000000..99ee895d5fab
--- /dev/null
+++ b/static/app/utils/demoMode/demoTours.spec.tsx
@@ -0,0 +1,282 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {makeTestQueryClient} from 'sentry-test/queryClient';
+import {act, render, renderHook, screen} from 'sentry-test/reactTestingLibrary';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import type {TourState} from 'sentry/components/tours/tourContext';
+import type {Organization} from 'sentry/types/organization';
+import {
+  DEMO_TOURS_STATE_KEY,
+  DemoTour,
+  DemoTourElement,
+  DemoToursProvider,
+  DemoTourStep,
+  useDemoTours,
+} from 'sentry/utils/demoMode/demoTours';
+import {QueryClientProvider} from 'sentry/utils/queryClient';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+
+jest.mock('sentry/actionCreators/guides', () => ({
+  recordFinish: jest.fn(),
+}));
+
+jest.mock('sentry/utils/useLocalStorageState', () => ({
+  useLocalStorageState: jest.fn(),
+}));
+
+interface MockToursState {
+  [DemoTour.SIDEBAR]: TourState<DemoTourStep>;
+  [DemoTour.ISSUES]: TourState<DemoTourStep>;
+  [DemoTour.RELEASES]: TourState<DemoTourStep>;
+  [DemoTour.PERFORMANCE]: TourState<DemoTourStep>;
+}
+
+const mockUseLocalStorageState = useLocalStorageState as jest.Mock;
+
+function createWrapper(organization: Organization) {
+  return function ({children}: {children?: React.ReactNode}) {
+    return (
+      <QueryClientProvider client={makeTestQueryClient()}>
+        <OrganizationContext value={organization}>
+          <DemoToursProvider>{children}</DemoToursProvider>
+        </OrganizationContext>
+      </QueryClientProvider>
+    );
+  };
+}
+
+describe('DemoTours', () => {
+  let mockState: MockToursState;
+  let mockSetState: jest.Mock;
+  const organization = OrganizationFixture();
+
+  beforeEach(() => {
+    mockState = {
+      [DemoTour.SIDEBAR]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.SIDEBAR_PROJECTS, DemoTourStep.SIDEBAR_ISSUES],
+        isRegistered: true,
+        tourKey: DemoTour.SIDEBAR,
+      },
+      [DemoTour.ISSUES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.ISSUES_STREAM, DemoTourStep.ISSUES_TAGS],
+        isRegistered: true,
+        tourKey: DemoTour.ISSUES,
+      },
+      [DemoTour.RELEASES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.RELEASES,
+      },
+      [DemoTour.PERFORMANCE]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.PERFORMANCE,
+      },
+    };
+
+    mockSetState = jest.fn(
+      (stateOrUpdater: MockToursState | ((prev: MockToursState) => MockToursState)) => {
+        if (typeof stateOrUpdater === 'function') {
+          mockState = stateOrUpdater(mockState);
+        } else {
+          mockState = stateOrUpdater;
+        }
+        return mockState;
+      }
+    );
+
+    mockUseLocalStorageState.mockImplementation(() => [mockState, mockSetState]);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  describe('DemoToursProvider', () => {
+    it('initializes with correct state', () => {
+      render(
+        <DemoToursProvider>
+          <div data-test-id="child" />
+        </DemoToursProvider>
+      );
+
+      expect(mockUseLocalStorageState).toHaveBeenCalledWith(
+        DEMO_TOURS_STATE_KEY,
+        expect.any(Object)
+      );
+    });
+  });
+
+  describe('useDemoTours', () => {
+    it('throws error when used outside provider', () => {
+      jest.spyOn(console, 'error').mockImplementation(() => {});
+
+      expect(() => {
+        renderHook(() => useDemoTours(DemoTour.SIDEBAR));
+      }).toThrow('Must be used within a TourContextProvider');
+
+      jest.restoreAllMocks();
+    });
+
+    it('provides tour context when used inside provider', () => {
+      const {result} = renderHook(() => useDemoTours(DemoTour.SIDEBAR), {
+        wrapper: createWrapper(organization),
+      });
+
+      const tour = result.current;
+
+      expect(tour.currentStepId).toBeNull();
+      expect(tour.isCompleted).toBe(false);
+      expect(tour.orderedStepIds).toEqual([
+        DemoTourStep.SIDEBAR_PROJECTS,
+        DemoTourStep.SIDEBAR_ISSUES,
+      ]);
+    });
+
+    it('handles tour actions', () => {
+      const {result} = renderHook(() => useDemoTours(DemoTour.SIDEBAR), {
+        wrapper: createWrapper(organization),
+      });
+
+      const tour = result.current;
+
+      act(() => {
+        tour.startTour(DemoTourStep.SIDEBAR_PROJECTS);
+      });
+
+      expect(mockState[DemoTour.SIDEBAR].currentStepId).toBe(
+        DemoTourStep.SIDEBAR_PROJECTS
+      );
+
+      act(() => {
+        tour.setStep(DemoTourStep.SIDEBAR_ISSUES);
+      });
+
+      expect(mockState[DemoTour.SIDEBAR].currentStepId).toBe(DemoTourStep.SIDEBAR_ISSUES);
+
+      act(() => {
+        tour.endTour();
+      });
+
+      expect(mockState[DemoTour.SIDEBAR].isCompleted).toBe(true);
+      expect(mockState[DemoTour.SIDEBAR].currentStepId).toBeUndefined();
+
+      expect(recordFinish).toHaveBeenCalledWith(
+        DemoTour.SIDEBAR,
+        expect.any(String),
+        expect.any(String),
+        expect.anything()
+      );
+    });
+
+    it('maintains separate state for different tours', () => {
+      const {result: sideBarResult} = renderHook(() => useDemoTours(DemoTour.SIDEBAR), {
+        wrapper: createWrapper(organization),
+      });
+
+      const sidebarTour = sideBarResult.current;
+
+      const {result: issuesResult} = renderHook(() => useDemoTours(DemoTour.ISSUES), {
+        wrapper: createWrapper(organization),
+      });
+
+      const issuesTour = issuesResult.current;
+
+      act(() => {
+        sidebarTour.startTour(DemoTourStep.SIDEBAR_PROJECTS);
+      });
+
+      expect(mockState[DemoTour.SIDEBAR].currentStepId).toBe(
+        DemoTourStep.SIDEBAR_PROJECTS
+      );
+      expect(mockState[DemoTour.ISSUES].currentStepId).toBeNull();
+
+      act(() => {
+        issuesTour.startTour(DemoTourStep.ISSUES_STREAM);
+      });
+
+      expect(mockState[DemoTour.SIDEBAR].currentStepId).toBe(
+        DemoTourStep.SIDEBAR_PROJECTS
+      );
+      expect(mockState[DemoTour.ISSUES].currentStepId).toBe(DemoTourStep.ISSUES_STREAM);
+
+      act(() => {
+        sidebarTour.setStep(DemoTourStep.SIDEBAR_ISSUES);
+      });
+
+      expect(mockState[DemoTour.SIDEBAR].currentStepId).toBe(DemoTourStep.SIDEBAR_ISSUES);
+      expect(mockState[DemoTour.ISSUES].currentStepId).toBe(DemoTourStep.ISSUES_STREAM);
+
+      act(() => {
+        sidebarTour.endTour();
+      });
+
+      expect(mockState[DemoTour.SIDEBAR].isCompleted).toBe(true);
+      expect(mockState[DemoTour.SIDEBAR].currentStepId).toBeUndefined();
+      expect(mockState[DemoTour.ISSUES].isCompleted).toBe(false);
+      expect(mockState[DemoTour.ISSUES].currentStepId).toBe(DemoTourStep.ISSUES_STREAM);
+
+      expect(recordFinish).toHaveBeenCalledWith(
+        DemoTour.SIDEBAR,
+        organization.id,
+        organization.slug,
+        organization
+      );
+    });
+
+    it('correctly advances through tour steps', () => {
+      const {result} = renderHook(() => useDemoTours(DemoTour.SIDEBAR), {
+        wrapper: createWrapper(organization),
+      });
+
+      const sidebarTour = result.current;
+
+      act(() => {
+        sidebarTour.startTour(DemoTourStep.SIDEBAR_PROJECTS);
+      });
+      expect(mockState[DemoTour.SIDEBAR].currentStepId).toBe(
+        DemoTourStep.SIDEBAR_PROJECTS
+      );
+
+      act(() => {
+        sidebarTour.setStep(DemoTourStep.SIDEBAR_ISSUES);
+      });
+      expect(mockState[DemoTour.SIDEBAR].currentStepId).toBe(DemoTourStep.SIDEBAR_ISSUES);
+
+      act(() => {
+        sidebarTour.endTour();
+      });
+      expect(mockState[DemoTour.SIDEBAR].currentStepId).toBeUndefined();
+      expect(mockState[DemoTour.SIDEBAR].isCompleted).toBe(true);
+    });
+  });
+
+  describe('DemoTourElement', () => {
+    it('renders children correctly', () => {
+      render(
+        <DemoToursProvider>
+          <DemoTourElement
+            id={DemoTourStep.SIDEBAR_PROJECTS}
+            title="Test Title"
+            description="Test Description"
+          >
+            <div data-test-id="element-content">Element Content</div>
+          </DemoTourElement>
+        </DemoToursProvider>
+      );
+
+      expect(screen.getByTestId('element-content')).toBeInTheDocument();
+      expect(screen.getByText('Element Content')).toBeInTheDocument();
+    });
+  });
+});
diff --git a/static/app/utils/demoMode/demoTours.tsx b/static/app/utils/demoMode/demoTours.tsx
new file mode 100644
index 000000000000..fbfa852f87fa
--- /dev/null
+++ b/static/app/utils/demoMode/demoTours.tsx
@@ -0,0 +1,232 @@
+import {createContext, useCallback, useContext, useMemo} from 'react';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import {TourElementContent} from 'sentry/components/tours/components';
+import {
+  type TourContextType,
+  type TourState,
+  useTourReducer,
+} from 'sentry/components/tours/tourContext';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {useLocalStorageState} from '../useLocalStorageState';
+
+export const DEMO_TOURS_STATE_KEY = 'demo-mode:tours';
+
+export const enum DemoTour {
+  SIDEBAR = 'sidebar',
+  ISSUES = 'issues',
+  RELEASES = 'releases',
+  PERFORMANCE = 'performance',
+}
+
+export const enum DemoTourStep {
+  // Sidebar steps
+  SIDEBAR_PROJECTS = 'demo-tour-sidebar-projects',
+  SIDEBAR_ISSUES = 'demo-tour-sidebar-issues',
+  SIDEBAR_PERFORMANCE = 'demo-tour-sidebar-performance',
+  SIDEBAR_RELEASES = 'demo-tour-sidebar-releases',
+  SIDEBAR_DISCOVER = 'demo-tour-sidebar-discover',
+  // Issues steps
+  ISSUES_STREAM = 'demo-tour-issues-stream',
+  ISSUES_TAGS = 'demo-tour-issues-tags',
+  ISSUES_STACKTRACE = 'demo-tour-issues-stacktrace',
+  ISSUES_BREADCRUMBS = 'demo-tour-issues-breadcrumbs',
+  // Releases steps
+  RELEASES_COMPARE = 'demo-tour-releases-compare',
+  RELEASES_DETAILS = 'demo-tour-releases-details',
+  RELEASES_STATES = 'demo-tour-releases-states',
+  // Performance steps
+  PERFORMANCE_TABLE = 'demo-tour-performance-table',
+  PERFORMANCE_TRANSACTION_SUMMARY = 'demo-tour-performance-transaction-summary',
+  PERFORMANCE_TRANSACTIONS_TABLE = 'demo-tour-performance-transactions-table',
+  PERFORMANCE_SPAN_TREE = 'demo-tour-performance-span-tree',
+}
+
+type DemoToursContextType = {
+  issues: TourContextType<DemoTourStep>;
+  performance: TourContextType<DemoTourStep>;
+  releases: TourContextType<DemoTourStep>;
+  sidebar: TourContextType<DemoTourStep>;
+};
+
+const DemoToursContext = createContext<DemoToursContextType | null>(null);
+
+export function useDemoTours(tourKey: DemoTour): TourContextType<DemoTourStep> {
+  const tourContext = useContext(DemoToursContext);
+
+  if (!tourContext) {
+    throw new Error('Must be used within a TourContextProvider');
+  }
+  return tourContext[tourKey];
+}
+
+const TOUR_STEPS: Record<DemoTour, DemoTourStep[]> = {
+  [DemoTour.SIDEBAR]: [
+    DemoTourStep.SIDEBAR_PROJECTS,
+    DemoTourStep.SIDEBAR_ISSUES,
+    DemoTourStep.SIDEBAR_PERFORMANCE,
+    DemoTourStep.SIDEBAR_RELEASES,
+    DemoTourStep.SIDEBAR_DISCOVER,
+  ],
+  [DemoTour.ISSUES]: [
+    DemoTourStep.ISSUES_STREAM,
+    DemoTourStep.ISSUES_TAGS,
+    DemoTourStep.ISSUES_STACKTRACE,
+    DemoTourStep.ISSUES_BREADCRUMBS,
+  ],
+  [DemoTour.RELEASES]: [
+    DemoTourStep.RELEASES_COMPARE,
+    DemoTourStep.RELEASES_DETAILS,
+    DemoTourStep.RELEASES_STATES,
+  ],
+  [DemoTour.PERFORMANCE]: [
+    DemoTourStep.PERFORMANCE_TABLE,
+    DemoTourStep.PERFORMANCE_TRANSACTION_SUMMARY,
+    DemoTourStep.PERFORMANCE_TRANSACTIONS_TABLE,
+    DemoTourStep.PERFORMANCE_SPAN_TREE,
+  ],
+};
+
+const emptyTourState = {
+  currentStepId: undefined,
+  isCompleted: false,
+  isRegistered: true,
+  orderedStepIds: [],
+};
+
+const TOUR_STATE_INITIAL_VALUE: Record<DemoTour, TourState<any>> = {
+  [DemoTour.SIDEBAR]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.SIDEBAR],
+    tourKey: DemoTour.SIDEBAR,
+  },
+  [DemoTour.ISSUES]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.ISSUES],
+    tourKey: DemoTour.ISSUES,
+  },
+  [DemoTour.RELEASES]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.RELEASES],
+    tourKey: DemoTour.RELEASES,
+  },
+  [DemoTour.PERFORMANCE]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.PERFORMANCE],
+    tourKey: DemoTour.PERFORMANCE,
+  },
+};
+
+export function DemoToursProvider({children}: {children: React.ReactNode}) {
+  const org = useOrganization();
+  const [tourState, setTourState] = useLocalStorageState<
+    Record<DemoTour, TourState<any>>
+  >(DEMO_TOURS_STATE_KEY, TOUR_STATE_INITIAL_VALUE);
+
+  const handleStepChange = useCallback(
+    (tourKey: DemoTour, stepId: DemoTourStep) => {
+      setTourState(prev => ({
+        ...prev,
+        [tourKey]: {...prev[tourKey], currentStepId: stepId},
+      }));
+    },
+    [setTourState]
+  );
+
+  const handleEndTour = useCallback(
+    (tourKey: DemoTour) => {
+      setTourState(prev => ({
+        ...prev,
+        [tourKey]: {
+          ...prev[tourKey],
+          currentStepId: undefined,
+          isCompleted: true,
+        },
+      }));
+      recordFinish(tourKey, org.id, org.slug, org);
+    },
+    [setTourState, org]
+  );
+
+  const getTourOptions = useCallback(
+    (tourKey: DemoTour) => ({
+      onEndTour: () => handleEndTour(tourKey),
+      onStepChange: (stepId: DemoTourStep) => handleStepChange(tourKey, stepId),
+      requireAllStepsRegistered: false,
+    }),
+    [handleEndTour, handleStepChange]
+  );
+
+  const sidebarTour = useTourReducer<DemoTourStep>(
+    tourState[DemoTour.SIDEBAR],
+    getTourOptions(DemoTour.SIDEBAR)
+  );
+
+  const issuesTour = useTourReducer<DemoTourStep>(
+    tourState[DemoTour.ISSUES],
+    getTourOptions(DemoTour.ISSUES)
+  );
+
+  const releasesTour = useTourReducer<DemoTourStep>(
+    tourState[DemoTour.RELEASES],
+    getTourOptions(DemoTour.RELEASES)
+  );
+
+  const performanceTour = useTourReducer<DemoTourStep>(
+    tourState[DemoTour.PERFORMANCE],
+    getTourOptions(DemoTour.PERFORMANCE)
+  );
+
+  const tours = useMemo(
+    () => ({
+      [DemoTour.SIDEBAR]: sidebarTour,
+      [DemoTour.ISSUES]: issuesTour,
+      [DemoTour.RELEASES]: releasesTour,
+      [DemoTour.PERFORMANCE]: performanceTour,
+    }),
+    [issuesTour, releasesTour, performanceTour, sidebarTour]
+  );
+
+  return <DemoToursContext value={tours}>{children}</DemoToursContext>;
+}
+
+// Helper to get tour category from step remains the same
+const getTourFromStep = (step: DemoTourStep): DemoTour => {
+  for (const [category, steps] of Object.entries(TOUR_STEPS)) {
+    if (steps.includes(step)) {
+      return category as DemoTour;
+    }
+  }
+  throw new Error(`Unknown tour step: ${step}`);
+};
+
+export function DemoTourElement({
+  id,
+  title,
+  description,
+  children,
+}: {
+  children: React.ReactNode;
+  description: string;
+  id: DemoTourStep;
+  title: string;
+}) {
+  const tourKey = getTourFromStep(id);
+  const tourContextValue = useDemoTours(tourKey);
+
+  if (!tourContextValue) {
+    return children;
+  }
+
+  return (
+    <TourElementContent
+      id={id}
+      title={title}
+      description={description}
+      tourContextValue={tourContextValue}
+    >
+      {children}
+    </TourElementContent>
+  );
+}
diff --git a/static/app/utils/demoMode/guides.tsx b/static/app/utils/demoMode/guides.tsx
index e505ad8e736c..bc85a1a12c2e 100644
--- a/static/app/utils/demoMode/guides.tsx
+++ b/static/app/utils/demoMode/guides.tsx
@@ -47,12 +47,22 @@ export function getTourTask(
   guide: string
 ): {task: OnboardingTaskKey; tour: string} | undefined {
   switch (guide) {
+    case 'sidebar':
+      return {tour: 'tabs', task: OnboardingTaskKey.SIDEBAR_GUIDE};
+    // TODO(ogi): Remove this once we merge the new sidebar tour
     case 'sidebar_v2':
       return {tour: 'tabs', task: OnboardingTaskKey.SIDEBAR_GUIDE};
+    case 'issues':
+      return {tour: 'issues', task: OnboardingTaskKey.ISSUE_GUIDE};
+    // TODO(ogi): Remove this once we merge the new issues tour
     case 'issues_v3':
       return {tour: 'issues', task: OnboardingTaskKey.ISSUE_GUIDE};
+    case 'releases':
+      return {tour: 'releases', task: OnboardingTaskKey.RELEASE_GUIDE};
+    // TODO(ogi): Remove this once we merge the new releases tour
     case 'release-details_v2':
       return {tour: 'releases', task: OnboardingTaskKey.RELEASE_GUIDE};
+
     case 'performance':
       return {tour: 'performance', task: OnboardingTaskKey.PERFORMANCE_GUIDE};
     default:


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T11:25:28Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**static/app/utils/demoMode/demoTours.tsx:92**
* Consider using null instead of undefined for currentStepId to match the test expectations (e.g. in endTour, tests expect currentStepId to be null).
```
currentStepId: undefined,
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T12:23:14Z
user: login: scttcper
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T22:50:13Z
user: login: scttcper
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T22:53:18Z
user: login: scttcper
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T22:53:35Z
user: login: scttcper
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T22:56:17Z
user: login: ryan953
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-08T22:57:35Z
user: login: obostjancic
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T14:50:05Z
user: login: obostjancic
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T14:50:43Z
user: login: scttcper
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T15:01:51Z
user: login: scttcper
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T15:03:09Z
user: login: obostjancic
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T07:28:10Z
user: login: obostjancic
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T07:28:47Z
user: login: obostjancic
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T10:34:46Z
user: login: ArthurKnaus
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T07:19:44Z

Review comments:
diff_hunk: @@ -0,0 +1,232 @@
+import {createContext, useCallback, useContext, useMemo} from 'react';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import {TourElementContent} from 'sentry/components/tours/components';
+import {
+  type TourContextType,
+  type TourState,
+  useTourReducer,
+} from 'sentry/components/tours/tourContext';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {useLocalStorageState} from '../useLocalStorageState';
+
+export const DEMO_TOURS_STATE_KEY = 'demo-mode:tours';
+
+export const enum DemoTour {
+  SIDEBAR = 'sidebar',
+  ISSUES = 'issues',
+  RELEASES = 'releases',
+  PERFORMANCE = 'performance',
+}
+
+export const enum DemoTourStep {
+  // Sidebar steps
+  SIDEBAR_PROJECTS = 'demo-tour-sidebar-projects',
+  SIDEBAR_ISSUES = 'demo-tour-sidebar-issues',
+  SIDEBAR_PERFORMANCE = 'demo-tour-sidebar-performance',
+  SIDEBAR_RELEASES = 'demo-tour-sidebar-releases',
+  SIDEBAR_DISCOVER = 'demo-tour-sidebar-discover',
+  // Issues steps
+  ISSUES_STREAM = 'demo-tour-issues-stream',
+  ISSUES_TAGS = 'demo-tour-issues-tags',
+  ISSUES_STACKTRACE = 'demo-tour-issues-stacktrace',
+  ISSUES_BREADCRUMBS = 'demo-tour-issues-breadcrumbs',
+  // Releases steps
+  RELEASES_COMPARE = 'demo-tour-releases-compare',
+  RELEASES_DETAILS = 'demo-tour-releases-details',
+  RELEASES_STATES = 'demo-tour-releases-states',
+  // Performance steps
+  PERFORMANCE_TABLE = 'demo-tour-performance-table',
+  PERFORMANCE_TRANSACTION_SUMMARY = 'demo-tour-performance-transaction-summary',
+  PERFORMANCE_TRANSACTIONS_TABLE = 'demo-tour-performance-transactions-table',
+  PERFORMANCE_SPAN_TREE = 'demo-tour-performance-span-tree',
+}
+
+type DemoToursContextType = {
+  issues: TourContextType<DemoTourStep>;
+  performance: TourContextType<DemoTourStep>;
+  releases: TourContextType<DemoTourStep>;
+  sidebar: TourContextType<DemoTourStep>;
+};
+
+export const DemoToursContext = createContext<DemoToursContextType | null>(null);
+
+export function useDemoTours(tourKey: DemoTour): TourContextType<DemoTourStep> {
+  const tourContext = useContext(DemoToursContext);
+
+  if (!tourContext) {
+    throw new Error('Must be used within a TourContextProvider');
+  }
+  return tourContext[tourKey];
+}
+
+const TOUR_STEPS: Record<DemoTour, DemoTourStep[]> = {
+  [DemoTour.SIDEBAR]: [
+    DemoTourStep.SIDEBAR_PROJECTS,
+    DemoTourStep.SIDEBAR_ISSUES,
+    DemoTourStep.SIDEBAR_PERFORMANCE,
+    DemoTourStep.SIDEBAR_RELEASES,
+    DemoTourStep.SIDEBAR_DISCOVER,
+  ],
+  [DemoTour.ISSUES]: [
+    DemoTourStep.ISSUES_STREAM,
+    DemoTourStep.ISSUES_TAGS,
+    DemoTourStep.ISSUES_STACKTRACE,
+    DemoTourStep.ISSUES_BREADCRUMBS,
+  ],
+  [DemoTour.RELEASES]: [
+    DemoTourStep.RELEASES_COMPARE,
+    DemoTourStep.RELEASES_DETAILS,
+    DemoTourStep.RELEASES_STATES,
+  ],
+  [DemoTour.PERFORMANCE]: [
+    DemoTourStep.PERFORMANCE_TABLE,
+    DemoTourStep.PERFORMANCE_TRANSACTION_SUMMARY,
+    DemoTourStep.PERFORMANCE_TRANSACTIONS_TABLE,
+    DemoTourStep.PERFORMANCE_SPAN_TREE,
+  ],
+};
+
+const emptyTourState = {
+  currentStepId: undefined,
+  isCompleted: false,
+  isRegistered: true,
+  orderedStepIds: [],
+};
+
+const TOUR_STATE_INITIAL_VALUE: Record<DemoTour, TourState<any>> = {
+  [DemoTour.SIDEBAR]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.SIDEBAR],
+    tourKey: DemoTour.SIDEBAR,
+  },
+  [DemoTour.ISSUES]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.ISSUES],
+    tourKey: DemoTour.ISSUES,
+  },
+  [DemoTour.RELEASES]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.RELEASES],
+    tourKey: DemoTour.RELEASES,
+  },
+  [DemoTour.PERFORMANCE]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.PERFORMANCE],
+    tourKey: DemoTour.PERFORMANCE,
+  },
+};
+
+export function DemoToursProvider({children}: {children: React.ReactNode}) {
+  const org = useOrganization();
+  const [tourState, setTourState] = useLocalStorageState<
+    Record<DemoTour, TourState<any>>
+  >(DEMO_TOURS_STATE_KEY, TOUR_STATE_INITIAL_VALUE);
+
+  const handleStepChange = useCallback(
+    (tourKey: DemoTour, stepId: DemoTourStep) => {
+      setTourState(prev => ({
+        ...prev,
+        [tourKey]: {...prev[tourKey], currentStepId: stepId},
+      }));
+    },
+    [setTourState]
+  );
+
+  const handleEndTour = useCallback(
+    (tourKey: DemoTour) => {
+      setTourState(prev => ({
+        ...prev,
+        [tourKey]: {
+          ...prev[tourKey],
+          currentStepId: null,
path: static/app/utils/demoMode/demoTours.tsx
user: login: Copilot
body: The initial tour state defines 'currentStepId' as undefined while the tour end handler sets it to null. Consider unifying these values to maintain consistent type expectations.
```suggestion
          currentStepId: undefined,
```
created_at: 2025-04-08T11:25:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,341 @@
+import {cleanup, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import type {TourState} from 'sentry/components/tours/tourContext';
+import {
+  DemoTour,
+  DemoTourElement,
+  DemoToursProvider,
+  DemoTourStep,
+  useDemoTours,
+} from 'sentry/utils/demoMode/demoTours';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+
+jest.mock('sentry/actionCreators/guides', () => ({
+  recordFinish: jest.fn(),
+}));
+
+jest.mock('sentry/utils/useLocalStorageState', () => ({
+  useLocalStorageState: jest.fn(),
+}));
+
+function TourHookTester({tourKey}: {tourKey: DemoTour}) {
+  const tour = useDemoTours(tourKey);
+
+  return (
+    <div>
+      <div data-test-id="tour-active">{tour.currentStepId || 'none'}</div>
+      <div data-test-id="tour-completed">{String(tour.isCompleted)}</div>
+      <button
+        data-test-id="start-tour"
+        onClick={() =>
+          tour.startTour(
+            tourKey === DemoTour.SIDEBAR
+              ? DemoTourStep.SIDEBAR_PROJECTS
+              : DemoTourStep.ISSUES_STREAM
+          )
+        }
+      >
+        Start Tour
+      </button>
+      <button data-test-id="end-tour" onClick={() => tour.endTour()}>
+        End Tour
+      </button>
+      <button
+        data-test-id="set-step"
+        onClick={() =>
+          tour.setStep(
+            tourKey === DemoTour.SIDEBAR
+              ? DemoTourStep.SIDEBAR_ISSUES
+              : DemoTourStep.ISSUES_TAGS
+          )
+        }
+      >
+        Set Step
+      </button>
+    </div>
+  );
+}
+
+// Helper component for testing multiple tours
+function MultiTourTester() {
+  const sidebarTour = useDemoTours(DemoTour.SIDEBAR);
+  const issuesTour = useDemoTours(DemoTour.ISSUES);
+
+  return (
+    <div>
+      <DemoTourElement
+        id={DemoTourStep.SIDEBAR_PROJECTS}
+        title="Sidebar Projects"
+        description="Learn about projects"
+      >
+        <div data-test-id="sidebar-projects-anchor">
+          {sidebarTour.currentStepId === DemoTourStep.SIDEBAR_PROJECTS
+            ? 'Active'
+            : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <DemoTourElement
+        id={DemoTourStep.SIDEBAR_ISSUES}
+        title="Sidebar Issues"
+        description="Learn about issues"
+      >
+        <div data-test-id="sidebar-issues-anchor">
+          {sidebarTour.currentStepId === DemoTourStep.SIDEBAR_ISSUES
+            ? 'Active'
+            : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <DemoTourElement
+        id={DemoTourStep.ISSUES_STREAM}
+        title="Issues Stream"
+        description="Learn about the issues stream"
+      >
+        <div data-test-id="issues-stream-anchor">
+          {issuesTour.currentStepId === DemoTourStep.ISSUES_STREAM
+            ? 'Active'
+            : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <DemoTourElement
+        id={DemoTourStep.ISSUES_TAGS}
+        title="Issues Tags"
+        description="Learn about issues tags"
+      >
+        <div data-test-id="issues-tags-anchor">
+          {issuesTour.currentStepId === DemoTourStep.ISSUES_TAGS ? 'Active' : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <button
+        data-test-id="start-sidebar"
+        onClick={() => sidebarTour.startTour(DemoTourStep.SIDEBAR_PROJECTS)}
+      >
+        Start Sidebar
+      </button>
+      <button
+        data-test-id="start-issues"
+        onClick={() => issuesTour.startTour(DemoTourStep.ISSUES_STREAM)}
+      >
+        Start Issues
+      </button>
+      <button data-test-id="end-sidebar" onClick={() => sidebarTour.endTour()}>
+        End Sidebar
+      </button>
+      <button data-test-id="end-issues" onClick={() => issuesTour.endTour()}>
+        End Issues
+      </button>
+      <button data-test-id="next-step-sidebar" onClick={() => sidebarTour.nextStep()}>
+        Next Sidebar Step
+      </button>
+      <button data-test-id="next-step-issues" onClick={() => issuesTour.nextStep()}>
+        Next Issues Step
+      </button>
+    </div>
+  );
+}
+
+interface MockToursState {
+  [DemoTour.SIDEBAR]: TourState<DemoTourStep>;
+  [DemoTour.ISSUES]: TourState<DemoTourStep>;
+  [DemoTour.RELEASES]: TourState<DemoTourStep>;
+  [DemoTour.PERFORMANCE]: TourState<DemoTourStep>;
+}
+
+const mockUseLocalStorageState = useLocalStorageState as jest.Mock;
+
+describe('DemoTours', () => {
+  let mockState: MockToursState;
+  let mockSetState: jest.Mock;
+
+  beforeEach(() => {
+    mockState = {
+      [DemoTour.SIDEBAR]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.SIDEBAR_PROJECTS, DemoTourStep.SIDEBAR_ISSUES],
+        isRegistered: true,
+        tourKey: DemoTour.SIDEBAR,
+      },
+      [DemoTour.ISSUES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.ISSUES_STREAM, DemoTourStep.ISSUES_TAGS],
+        isRegistered: true,
+        tourKey: DemoTour.ISSUES,
+      },
+      [DemoTour.RELEASES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.RELEASES,
+      },
+      [DemoTour.PERFORMANCE]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.PERFORMANCE,
+      },
+    };
+
+    mockSetState = jest.fn(
+      (stateOrUpdater: MockToursState | ((prev: MockToursState) => MockToursState)) => {
+        if (typeof stateOrUpdater === 'function') {
+          mockState = stateOrUpdater(mockState);
+        } else {
+          mockState = stateOrUpdater;
+        }
+        return mockState;
+      }
+    );
+
+    mockUseLocalStorageState.mockImplementation(() => [mockState, mockSetState]);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+    cleanup();
path: static/app/utils/demoMode/demoTours.spec.tsx
user: login: scttcper
body: cleanup should be called for you in our jest tests
created_at: 2025-04-08T22:50:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,232 @@
+import {createContext, useCallback, useContext, useMemo} from 'react';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import {TourElementContent} from 'sentry/components/tours/components';
+import {
+  type TourContextType,
+  type TourState,
+  useTourReducer,
+} from 'sentry/components/tours/tourContext';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {useLocalStorageState} from '../useLocalStorageState';
+
+export const DEMO_TOURS_STATE_KEY = 'demo-mode:tours';
+
+export const enum DemoTour {
+  SIDEBAR = 'sidebar',
+  ISSUES = 'issues',
+  RELEASES = 'releases',
+  PERFORMANCE = 'performance',
+}
+
+export const enum DemoTourStep {
+  // Sidebar steps
+  SIDEBAR_PROJECTS = 'demo-tour-sidebar-projects',
+  SIDEBAR_ISSUES = 'demo-tour-sidebar-issues',
+  SIDEBAR_PERFORMANCE = 'demo-tour-sidebar-performance',
+  SIDEBAR_RELEASES = 'demo-tour-sidebar-releases',
+  SIDEBAR_DISCOVER = 'demo-tour-sidebar-discover',
+  // Issues steps
+  ISSUES_STREAM = 'demo-tour-issues-stream',
+  ISSUES_TAGS = 'demo-tour-issues-tags',
+  ISSUES_STACKTRACE = 'demo-tour-issues-stacktrace',
+  ISSUES_BREADCRUMBS = 'demo-tour-issues-breadcrumbs',
+  // Releases steps
+  RELEASES_COMPARE = 'demo-tour-releases-compare',
+  RELEASES_DETAILS = 'demo-tour-releases-details',
+  RELEASES_STATES = 'demo-tour-releases-states',
+  // Performance steps
+  PERFORMANCE_TABLE = 'demo-tour-performance-table',
+  PERFORMANCE_TRANSACTION_SUMMARY = 'demo-tour-performance-transaction-summary',
+  PERFORMANCE_TRANSACTIONS_TABLE = 'demo-tour-performance-transactions-table',
+  PERFORMANCE_SPAN_TREE = 'demo-tour-performance-span-tree',
+}
+
+type DemoToursContextType = {
+  issues: TourContextType<DemoTourStep>;
+  performance: TourContextType<DemoTourStep>;
+  releases: TourContextType<DemoTourStep>;
+  sidebar: TourContextType<DemoTourStep>;
+};
+
+export const DemoToursContext = createContext<DemoToursContextType | null>(null);
path: static/app/utils/demoMode/demoTours.tsx
user: login: scttcper
body: exported but not used?
created_at: 2025-04-08T22:53:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,232 @@
+import {createContext, useCallback, useContext, useMemo} from 'react';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import {TourElementContent} from 'sentry/components/tours/components';
+import {
+  type TourContextType,
+  type TourState,
+  useTourReducer,
+} from 'sentry/components/tours/tourContext';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {useLocalStorageState} from '../useLocalStorageState';
+
+export const DEMO_TOURS_STATE_KEY = 'demo-mode:tours';
+
+export const enum DemoTour {
+  SIDEBAR = 'sidebar',
+  ISSUES = 'issues',
+  RELEASES = 'releases',
+  PERFORMANCE = 'performance',
+}
+
+export const enum DemoTourStep {
+  // Sidebar steps
+  SIDEBAR_PROJECTS = 'demo-tour-sidebar-projects',
+  SIDEBAR_ISSUES = 'demo-tour-sidebar-issues',
+  SIDEBAR_PERFORMANCE = 'demo-tour-sidebar-performance',
+  SIDEBAR_RELEASES = 'demo-tour-sidebar-releases',
+  SIDEBAR_DISCOVER = 'demo-tour-sidebar-discover',
+  // Issues steps
+  ISSUES_STREAM = 'demo-tour-issues-stream',
+  ISSUES_TAGS = 'demo-tour-issues-tags',
+  ISSUES_STACKTRACE = 'demo-tour-issues-stacktrace',
+  ISSUES_BREADCRUMBS = 'demo-tour-issues-breadcrumbs',
+  // Releases steps
+  RELEASES_COMPARE = 'demo-tour-releases-compare',
+  RELEASES_DETAILS = 'demo-tour-releases-details',
+  RELEASES_STATES = 'demo-tour-releases-states',
+  // Performance steps
+  PERFORMANCE_TABLE = 'demo-tour-performance-table',
+  PERFORMANCE_TRANSACTION_SUMMARY = 'demo-tour-performance-transaction-summary',
+  PERFORMANCE_TRANSACTIONS_TABLE = 'demo-tour-performance-transactions-table',
+  PERFORMANCE_SPAN_TREE = 'demo-tour-performance-span-tree',
+}
+
+type DemoToursContextType = {
+  issues: TourContextType<DemoTourStep>;
+  performance: TourContextType<DemoTourStep>;
+  releases: TourContextType<DemoTourStep>;
+  sidebar: TourContextType<DemoTourStep>;
+};
+
+export const DemoToursContext = createContext<DemoToursContextType | null>(null);
+
+export function useDemoTours(tourKey: DemoTour): TourContextType<DemoTourStep> {
+  const tourContext = useContext(DemoToursContext);
+
+  if (!tourContext) {
+    throw new Error('Must be used within a TourContextProvider');
+  }
+  return tourContext[tourKey];
+}
+
+const TOUR_STEPS: Record<DemoTour, DemoTourStep[]> = {
+  [DemoTour.SIDEBAR]: [
+    DemoTourStep.SIDEBAR_PROJECTS,
+    DemoTourStep.SIDEBAR_ISSUES,
+    DemoTourStep.SIDEBAR_PERFORMANCE,
+    DemoTourStep.SIDEBAR_RELEASES,
+    DemoTourStep.SIDEBAR_DISCOVER,
+  ],
+  [DemoTour.ISSUES]: [
+    DemoTourStep.ISSUES_STREAM,
+    DemoTourStep.ISSUES_TAGS,
+    DemoTourStep.ISSUES_STACKTRACE,
+    DemoTourStep.ISSUES_BREADCRUMBS,
+  ],
+  [DemoTour.RELEASES]: [
+    DemoTourStep.RELEASES_COMPARE,
+    DemoTourStep.RELEASES_DETAILS,
+    DemoTourStep.RELEASES_STATES,
+  ],
+  [DemoTour.PERFORMANCE]: [
+    DemoTourStep.PERFORMANCE_TABLE,
+    DemoTourStep.PERFORMANCE_TRANSACTION_SUMMARY,
+    DemoTourStep.PERFORMANCE_TRANSACTIONS_TABLE,
+    DemoTourStep.PERFORMANCE_SPAN_TREE,
+  ],
+};
+
+const emptyTourState = {
+  currentStepId: undefined,
+  isCompleted: false,
+  isRegistered: true,
+  orderedStepIds: [],
+};
+
+const TOUR_STATE_INITIAL_VALUE: Record<DemoTour, TourState<any>> = {
+  [DemoTour.SIDEBAR]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.SIDEBAR],
+    tourKey: DemoTour.SIDEBAR,
+  },
+  [DemoTour.ISSUES]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.ISSUES],
+    tourKey: DemoTour.ISSUES,
+  },
+  [DemoTour.RELEASES]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.RELEASES],
+    tourKey: DemoTour.RELEASES,
+  },
+  [DemoTour.PERFORMANCE]: {
+    ...emptyTourState,
+    orderedStepIds: TOUR_STEPS[DemoTour.PERFORMANCE],
+    tourKey: DemoTour.PERFORMANCE,
+  },
+};
+
+export function DemoToursProvider({children}: {children: React.ReactNode}) {
+  const org = useOrganization();
+  const [tourState, setTourState] = useLocalStorageState<
+    Record<DemoTour, TourState<any>>
+  >(DEMO_TOURS_STATE_KEY, TOUR_STATE_INITIAL_VALUE);
+
+  const handleStepChange = useCallback(
+    (tourKey: DemoTour, stepId: DemoTourStep) => {
+      setTourState(prev => ({
+        ...prev,
+        [tourKey]: {...prev[tourKey], currentStepId: stepId},
+      }));
+    },
+    [setTourState]
+  );
+
+  const handleEndTour = useCallback(
+    (tourKey: DemoTour) => {
+      setTourState(prev => ({
+        ...prev,
+        [tourKey]: {
+          ...prev[tourKey],
+          currentStepId: undefined,
+          isCompleted: true,
+        },
+      }));
+      recordFinish(tourKey, org.id, org.slug, org);
+    },
+    [setTourState, org]
+  );
+
+  const getTourOptions = useCallback(
+    (tourKey: DemoTour) => ({
+      onEndTour: () => handleEndTour(tourKey),
+      onStepChange: (stepId: DemoTourStep) => handleStepChange(tourKey, stepId),
+      requireAllStepsRegistered: false,
+    }),
+    [handleEndTour, handleStepChange]
+  );
+
+  const sidebarTour = useTourReducer<DemoTourStep>(
+    tourState[DemoTour.SIDEBAR],
+    getTourOptions(DemoTour.SIDEBAR)
+  );
+
+  const issuesTour = useTourReducer<DemoTourStep>(
+    tourState[DemoTour.ISSUES],
+    getTourOptions(DemoTour.ISSUES)
+  );
+
+  const releasesTour = useTourReducer<DemoTourStep>(
+    tourState[DemoTour.RELEASES],
+    getTourOptions(DemoTour.RELEASES)
+  );
+
+  const performanceTour = useTourReducer<DemoTourStep>(
+    tourState[DemoTour.PERFORMANCE],
+    getTourOptions(DemoTour.PERFORMANCE)
+  );
+
+  const tours = useMemo(
+    () => ({
+      [DemoTour.SIDEBAR]: sidebarTour,
+      [DemoTour.ISSUES]: issuesTour,
+      [DemoTour.RELEASES]: releasesTour,
+      [DemoTour.PERFORMANCE]: performanceTour,
+    }),
+    [issuesTour, releasesTour, performanceTour, sidebarTour]
+  );
+
+  return <DemoToursContext.Provider value={tours}>{children}</DemoToursContext.Provider>;
path: static/app/utils/demoMode/demoTours.tsx
user: login: scttcper
body: react 19 lets us use them directly
```suggestion
  return <DemoToursContext value={tours}>{children}</DemoToursContext>;
```
created_at: 2025-04-08T22:53:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,341 @@
+import {cleanup, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import type {TourState} from 'sentry/components/tours/tourContext';
+import {
+  DemoTour,
+  DemoTourElement,
+  DemoToursProvider,
+  DemoTourStep,
+  useDemoTours,
+} from 'sentry/utils/demoMode/demoTours';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+
+jest.mock('sentry/actionCreators/guides', () => ({
+  recordFinish: jest.fn(),
+}));
+
+jest.mock('sentry/utils/useLocalStorageState', () => ({
+  useLocalStorageState: jest.fn(),
+}));
+
+function TourHookTester({tourKey}: {tourKey: DemoTour}) {
+  const tour = useDemoTours(tourKey);
+
+  return (
+    <div>
+      <div data-test-id="tour-active">{tour.currentStepId || 'none'}</div>
+      <div data-test-id="tour-completed">{String(tour.isCompleted)}</div>
+      <button
+        data-test-id="start-tour"
+        onClick={() =>
+          tour.startTour(
+            tourKey === DemoTour.SIDEBAR
+              ? DemoTourStep.SIDEBAR_PROJECTS
+              : DemoTourStep.ISSUES_STREAM
+          )
+        }
+      >
+        Start Tour
+      </button>
+      <button data-test-id="end-tour" onClick={() => tour.endTour()}>
+        End Tour
+      </button>
+      <button
+        data-test-id="set-step"
+        onClick={() =>
+          tour.setStep(
+            tourKey === DemoTour.SIDEBAR
+              ? DemoTourStep.SIDEBAR_ISSUES
+              : DemoTourStep.ISSUES_TAGS
+          )
+        }
+      >
+        Set Step
+      </button>
+    </div>
+  );
+}
+
+// Helper component for testing multiple tours
+function MultiTourTester() {
+  const sidebarTour = useDemoTours(DemoTour.SIDEBAR);
+  const issuesTour = useDemoTours(DemoTour.ISSUES);
+
+  return (
+    <div>
+      <DemoTourElement
+        id={DemoTourStep.SIDEBAR_PROJECTS}
+        title="Sidebar Projects"
+        description="Learn about projects"
+      >
+        <div data-test-id="sidebar-projects-anchor">
+          {sidebarTour.currentStepId === DemoTourStep.SIDEBAR_PROJECTS
+            ? 'Active'
+            : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <DemoTourElement
+        id={DemoTourStep.SIDEBAR_ISSUES}
+        title="Sidebar Issues"
+        description="Learn about issues"
+      >
+        <div data-test-id="sidebar-issues-anchor">
+          {sidebarTour.currentStepId === DemoTourStep.SIDEBAR_ISSUES
+            ? 'Active'
+            : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <DemoTourElement
+        id={DemoTourStep.ISSUES_STREAM}
+        title="Issues Stream"
+        description="Learn about the issues stream"
+      >
+        <div data-test-id="issues-stream-anchor">
+          {issuesTour.currentStepId === DemoTourStep.ISSUES_STREAM
+            ? 'Active'
+            : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <DemoTourElement
+        id={DemoTourStep.ISSUES_TAGS}
+        title="Issues Tags"
+        description="Learn about issues tags"
+      >
+        <div data-test-id="issues-tags-anchor">
+          {issuesTour.currentStepId === DemoTourStep.ISSUES_TAGS ? 'Active' : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <button
+        data-test-id="start-sidebar"
+        onClick={() => sidebarTour.startTour(DemoTourStep.SIDEBAR_PROJECTS)}
+      >
+        Start Sidebar
+      </button>
+      <button
+        data-test-id="start-issues"
+        onClick={() => issuesTour.startTour(DemoTourStep.ISSUES_STREAM)}
+      >
+        Start Issues
+      </button>
+      <button data-test-id="end-sidebar" onClick={() => sidebarTour.endTour()}>
+        End Sidebar
+      </button>
+      <button data-test-id="end-issues" onClick={() => issuesTour.endTour()}>
+        End Issues
+      </button>
+      <button data-test-id="next-step-sidebar" onClick={() => sidebarTour.nextStep()}>
+        Next Sidebar Step
+      </button>
+      <button data-test-id="next-step-issues" onClick={() => issuesTour.nextStep()}>
+        Next Issues Step
+      </button>
+    </div>
+  );
+}
+
+interface MockToursState {
+  [DemoTour.SIDEBAR]: TourState<DemoTourStep>;
+  [DemoTour.ISSUES]: TourState<DemoTourStep>;
+  [DemoTour.RELEASES]: TourState<DemoTourStep>;
+  [DemoTour.PERFORMANCE]: TourState<DemoTourStep>;
+}
+
+const mockUseLocalStorageState = useLocalStorageState as jest.Mock;
+
+describe('DemoTours', () => {
+  let mockState: MockToursState;
+  let mockSetState: jest.Mock;
+
+  beforeEach(() => {
+    mockState = {
+      [DemoTour.SIDEBAR]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.SIDEBAR_PROJECTS, DemoTourStep.SIDEBAR_ISSUES],
+        isRegistered: true,
+        tourKey: DemoTour.SIDEBAR,
+      },
+      [DemoTour.ISSUES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.ISSUES_STREAM, DemoTourStep.ISSUES_TAGS],
+        isRegistered: true,
+        tourKey: DemoTour.ISSUES,
+      },
+      [DemoTour.RELEASES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.RELEASES,
+      },
+      [DemoTour.PERFORMANCE]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.PERFORMANCE,
+      },
+    };
+
+    mockSetState = jest.fn(
+      (stateOrUpdater: MockToursState | ((prev: MockToursState) => MockToursState)) => {
+        if (typeof stateOrUpdater === 'function') {
+          mockState = stateOrUpdater(mockState);
+        } else {
+          mockState = stateOrUpdater;
+        }
+        return mockState;
+      }
+    );
+
+    mockUseLocalStorageState.mockImplementation(() => [mockState, mockSetState]);
path: static/app/utils/demoMode/demoTours.spec.tsx
user: login: scttcper
body: feels like we could simplify this. i think localstorage is already mocked
created_at: 2025-04-08T22:56:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,341 @@
+import {cleanup, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import type {TourState} from 'sentry/components/tours/tourContext';
+import {
+  DemoTour,
+  DemoTourElement,
+  DemoToursProvider,
+  DemoTourStep,
+  useDemoTours,
+} from 'sentry/utils/demoMode/demoTours';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+
+jest.mock('sentry/actionCreators/guides', () => ({
+  recordFinish: jest.fn(),
+}));
+
+jest.mock('sentry/utils/useLocalStorageState', () => ({
+  useLocalStorageState: jest.fn(),
+}));
+
+function TourHookTester({tourKey}: {tourKey: DemoTour}) {
+  const tour = useDemoTours(tourKey);
+
+  return (
+    <div>
+      <div data-test-id="tour-active">{tour.currentStepId || 'none'}</div>
+      <div data-test-id="tour-completed">{String(tour.isCompleted)}</div>
+      <button
+        data-test-id="start-tour"
+        onClick={() =>
+          tour.startTour(
+            tourKey === DemoTour.SIDEBAR
+              ? DemoTourStep.SIDEBAR_PROJECTS
+              : DemoTourStep.ISSUES_STREAM
+          )
+        }
+      >
+        Start Tour
+      </button>
+      <button data-test-id="end-tour" onClick={() => tour.endTour()}>
+        End Tour
+      </button>
+      <button
+        data-test-id="set-step"
+        onClick={() =>
+          tour.setStep(
+            tourKey === DemoTour.SIDEBAR
+              ? DemoTourStep.SIDEBAR_ISSUES
+              : DemoTourStep.ISSUES_TAGS
+          )
+        }
+      >
+        Set Step
+      </button>
+    </div>
+  );
+}
path: static/app/utils/demoMode/demoTours.spec.tsx
user: login: ryan953
body: This is not the way to test a hook. You can get `import {renderHook} from 'sentry-test/reactTestingLibrary';` and just call your `startTour`, `setStep` and `endTour` methods directly. You might have to also grab `import {waitFor} from 'sentry-test/reactTestingLibrary';` to wait in between steps.

I think with the `renderHook` helper you'll find that the test file overall is much smaller, and it'll be easier to read the tests. Specifically it'll be easier to see "call the function, check new state" instead of inferring the state through these different tester mini-apps.
created_at: 2025-04-08T22:57:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,341 @@
+import {cleanup, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import type {TourState} from 'sentry/components/tours/tourContext';
+import {
+  DemoTour,
+  DemoTourElement,
+  DemoToursProvider,
+  DemoTourStep,
+  useDemoTours,
+} from 'sentry/utils/demoMode/demoTours';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+
+jest.mock('sentry/actionCreators/guides', () => ({
+  recordFinish: jest.fn(),
+}));
+
+jest.mock('sentry/utils/useLocalStorageState', () => ({
+  useLocalStorageState: jest.fn(),
+}));
+
+function TourHookTester({tourKey}: {tourKey: DemoTour}) {
+  const tour = useDemoTours(tourKey);
+
+  return (
+    <div>
+      <div data-test-id="tour-active">{tour.currentStepId || 'none'}</div>
+      <div data-test-id="tour-completed">{String(tour.isCompleted)}</div>
+      <button
+        data-test-id="start-tour"
+        onClick={() =>
+          tour.startTour(
+            tourKey === DemoTour.SIDEBAR
+              ? DemoTourStep.SIDEBAR_PROJECTS
+              : DemoTourStep.ISSUES_STREAM
+          )
+        }
+      >
+        Start Tour
+      </button>
+      <button data-test-id="end-tour" onClick={() => tour.endTour()}>
+        End Tour
+      </button>
+      <button
+        data-test-id="set-step"
+        onClick={() =>
+          tour.setStep(
+            tourKey === DemoTour.SIDEBAR
+              ? DemoTourStep.SIDEBAR_ISSUES
+              : DemoTourStep.ISSUES_TAGS
+          )
+        }
+      >
+        Set Step
+      </button>
+    </div>
+  );
+}
path: static/app/utils/demoMode/demoTours.spec.tsx
user: login: obostjancic
body: Changed this to use the `renderHook` in [0673045](https://github.com/getsentry/sentry/pull/88781/commits/0673045ba7b3a08324cb1e2fe77632e94dd82ec2). Much nicer! Thanks! 
created_at: 2025-04-09T14:50:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,232 @@
+import {createContext, useCallback, useContext, useMemo} from 'react';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import {TourElementContent} from 'sentry/components/tours/components';
+import {
+  type TourContextType,
+  type TourState,
+  useTourReducer,
+} from 'sentry/components/tours/tourContext';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {useLocalStorageState} from '../useLocalStorageState';
+
+export const DEMO_TOURS_STATE_KEY = 'demo-mode:tours';
+
+export const enum DemoTour {
+  SIDEBAR = 'sidebar',
+  ISSUES = 'issues',
+  RELEASES = 'releases',
+  PERFORMANCE = 'performance',
+}
+
+export const enum DemoTourStep {
+  // Sidebar steps
+  SIDEBAR_PROJECTS = 'demo-tour-sidebar-projects',
+  SIDEBAR_ISSUES = 'demo-tour-sidebar-issues',
+  SIDEBAR_PERFORMANCE = 'demo-tour-sidebar-performance',
+  SIDEBAR_RELEASES = 'demo-tour-sidebar-releases',
+  SIDEBAR_DISCOVER = 'demo-tour-sidebar-discover',
+  // Issues steps
+  ISSUES_STREAM = 'demo-tour-issues-stream',
+  ISSUES_TAGS = 'demo-tour-issues-tags',
+  ISSUES_STACKTRACE = 'demo-tour-issues-stacktrace',
+  ISSUES_BREADCRUMBS = 'demo-tour-issues-breadcrumbs',
+  // Releases steps
+  RELEASES_COMPARE = 'demo-tour-releases-compare',
+  RELEASES_DETAILS = 'demo-tour-releases-details',
+  RELEASES_STATES = 'demo-tour-releases-states',
+  // Performance steps
+  PERFORMANCE_TABLE = 'demo-tour-performance-table',
+  PERFORMANCE_TRANSACTION_SUMMARY = 'demo-tour-performance-transaction-summary',
+  PERFORMANCE_TRANSACTIONS_TABLE = 'demo-tour-performance-transactions-table',
+  PERFORMANCE_SPAN_TREE = 'demo-tour-performance-span-tree',
+}
+
+type DemoToursContextType = {
+  issues: TourContextType<DemoTourStep>;
+  performance: TourContextType<DemoTourStep>;
+  releases: TourContextType<DemoTourStep>;
+  sidebar: TourContextType<DemoTourStep>;
+};
+
+export const DemoToursContext = createContext<DemoToursContextType | null>(null);
path: static/app/utils/demoMode/demoTours.tsx
user: login: obostjancic
body: Its used in a followup PR I have, I'll move the change there. 
created_at: 2025-04-09T14:50:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,279 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {act, render, renderHook, screen} from 'sentry-test/reactTestingLibrary';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import type {TourState} from 'sentry/components/tours/tourContext';
+import {
+  DemoTour,
+  DemoTourElement,
+  DemoToursProvider,
+  DemoTourStep,
+  useDemoTours,
+} from 'sentry/utils/demoMode/demoTours';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+import * as useOrganization from 'sentry/utils/useOrganization';
+
+jest.mock('sentry/actionCreators/guides', () => ({
+  recordFinish: jest.fn(),
+}));
+
+jest.mock('sentry/utils/useLocalStorageState', () => ({
+  useLocalStorageState: jest.fn(),
+}));
+
+interface MockToursState {
+  [DemoTour.SIDEBAR]: TourState<DemoTourStep>;
+  [DemoTour.ISSUES]: TourState<DemoTourStep>;
+  [DemoTour.RELEASES]: TourState<DemoTourStep>;
+  [DemoTour.PERFORMANCE]: TourState<DemoTourStep>;
+}
+
+const mockUseLocalStorageState = useLocalStorageState as jest.Mock;
+
+describe('DemoTours', () => {
+  let mockState: MockToursState;
+  let mockSetState: jest.Mock;
+  const organization = OrganizationFixture();
+
+  beforeEach(() => {
+    mockState = {
+      [DemoTour.SIDEBAR]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.SIDEBAR_PROJECTS, DemoTourStep.SIDEBAR_ISSUES],
+        isRegistered: true,
+        tourKey: DemoTour.SIDEBAR,
+      },
+      [DemoTour.ISSUES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.ISSUES_STREAM, DemoTourStep.ISSUES_TAGS],
+        isRegistered: true,
+        tourKey: DemoTour.ISSUES,
+      },
+      [DemoTour.RELEASES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.RELEASES,
+      },
+      [DemoTour.PERFORMANCE]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.PERFORMANCE,
+      },
+    };
+
+    mockSetState = jest.fn(
+      (stateOrUpdater: MockToursState | ((prev: MockToursState) => MockToursState)) => {
+        if (typeof stateOrUpdater === 'function') {
+          mockState = stateOrUpdater(mockState);
+        } else {
+          mockState = stateOrUpdater;
+        }
+        return mockState;
+      }
+    );
+
+    mockUseLocalStorageState.mockImplementation(() => [mockState, mockSetState]);
+
+    jest.spyOn(useOrganization, 'default').mockReturnValue(organization);
path: static/app/utils/demoMode/demoTours.spec.tsx
user: login: scttcper
body: avoid mocking useOrganization. with react hook tests a wrapper can be provided like you've already done https://github.com/getsentry/sentry/blob/master/static/app/views/explore/hooks/useTraceSpans.spec.tsx#L37-L45 with organization context

for render organization is already mocked
created_at: 2025-04-09T15:01:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,279 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {act, render, renderHook, screen} from 'sentry-test/reactTestingLibrary';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import type {TourState} from 'sentry/components/tours/tourContext';
+import {
+  DemoTour,
+  DemoTourElement,
+  DemoToursProvider,
+  DemoTourStep,
+  useDemoTours,
+} from 'sentry/utils/demoMode/demoTours';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+import * as useOrganization from 'sentry/utils/useOrganization';
+
+jest.mock('sentry/actionCreators/guides', () => ({
+  recordFinish: jest.fn(),
+}));
+
+jest.mock('sentry/utils/useLocalStorageState', () => ({
+  useLocalStorageState: jest.fn(),
+}));
+
+interface MockToursState {
+  [DemoTour.SIDEBAR]: TourState<DemoTourStep>;
+  [DemoTour.ISSUES]: TourState<DemoTourStep>;
+  [DemoTour.RELEASES]: TourState<DemoTourStep>;
+  [DemoTour.PERFORMANCE]: TourState<DemoTourStep>;
+}
+
+const mockUseLocalStorageState = useLocalStorageState as jest.Mock;
+
+describe('DemoTours', () => {
+  let mockState: MockToursState;
+  let mockSetState: jest.Mock;
+  const organization = OrganizationFixture();
+
+  beforeEach(() => {
+    mockState = {
+      [DemoTour.SIDEBAR]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.SIDEBAR_PROJECTS, DemoTourStep.SIDEBAR_ISSUES],
+        isRegistered: true,
+        tourKey: DemoTour.SIDEBAR,
+      },
+      [DemoTour.ISSUES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.ISSUES_STREAM, DemoTourStep.ISSUES_TAGS],
+        isRegistered: true,
+        tourKey: DemoTour.ISSUES,
+      },
+      [DemoTour.RELEASES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.RELEASES,
+      },
+      [DemoTour.PERFORMANCE]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.PERFORMANCE,
+      },
+    };
+
+    mockSetState = jest.fn(
+      (stateOrUpdater: MockToursState | ((prev: MockToursState) => MockToursState)) => {
+        if (typeof stateOrUpdater === 'function') {
+          mockState = stateOrUpdater(mockState);
+        } else {
+          mockState = stateOrUpdater;
+        }
+        return mockState;
+      }
+    );
+
+    mockUseLocalStorageState.mockImplementation(() => [mockState, mockSetState]);
+
+    jest.spyOn(useOrganization, 'default').mockReturnValue(organization);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  describe('DemoToursProvider', () => {
+    it('renders children', () => {
+      render(
+        <DemoToursProvider>
+          <div data-test-id="child-component">Test Child</div>
+        </DemoToursProvider>
+      );
+
+      expect(screen.getByTestId('child-component')).toBeInTheDocument();
+      expect(screen.getByText('Test Child')).toBeInTheDocument();
+    });
path: static/app/utils/demoMode/demoTours.spec.tsx
user: login: scttcper
body: is this worth testing
created_at: 2025-04-09T15:03:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,279 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {act, render, renderHook, screen} from 'sentry-test/reactTestingLibrary';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import type {TourState} from 'sentry/components/tours/tourContext';
+import {
+  DemoTour,
+  DemoTourElement,
+  DemoToursProvider,
+  DemoTourStep,
+  useDemoTours,
+} from 'sentry/utils/demoMode/demoTours';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+import * as useOrganization from 'sentry/utils/useOrganization';
+
+jest.mock('sentry/actionCreators/guides', () => ({
+  recordFinish: jest.fn(),
+}));
+
+jest.mock('sentry/utils/useLocalStorageState', () => ({
+  useLocalStorageState: jest.fn(),
+}));
+
+interface MockToursState {
+  [DemoTour.SIDEBAR]: TourState<DemoTourStep>;
+  [DemoTour.ISSUES]: TourState<DemoTourStep>;
+  [DemoTour.RELEASES]: TourState<DemoTourStep>;
+  [DemoTour.PERFORMANCE]: TourState<DemoTourStep>;
+}
+
+const mockUseLocalStorageState = useLocalStorageState as jest.Mock;
+
+describe('DemoTours', () => {
+  let mockState: MockToursState;
+  let mockSetState: jest.Mock;
+  const organization = OrganizationFixture();
+
+  beforeEach(() => {
+    mockState = {
+      [DemoTour.SIDEBAR]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.SIDEBAR_PROJECTS, DemoTourStep.SIDEBAR_ISSUES],
+        isRegistered: true,
+        tourKey: DemoTour.SIDEBAR,
+      },
+      [DemoTour.ISSUES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.ISSUES_STREAM, DemoTourStep.ISSUES_TAGS],
+        isRegistered: true,
+        tourKey: DemoTour.ISSUES,
+      },
+      [DemoTour.RELEASES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.RELEASES,
+      },
+      [DemoTour.PERFORMANCE]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.PERFORMANCE,
+      },
+    };
+
+    mockSetState = jest.fn(
+      (stateOrUpdater: MockToursState | ((prev: MockToursState) => MockToursState)) => {
+        if (typeof stateOrUpdater === 'function') {
+          mockState = stateOrUpdater(mockState);
+        } else {
+          mockState = stateOrUpdater;
+        }
+        return mockState;
+      }
+    );
+
+    mockUseLocalStorageState.mockImplementation(() => [mockState, mockSetState]);
+
+    jest.spyOn(useOrganization, 'default').mockReturnValue(organization);
path: static/app/utils/demoMode/demoTours.spec.tsx
user: login: obostjancic
body: Switched in [05036fd](https://github.com/getsentry/sentry/pull/88781/commits/05036fd6a7a9b5040665af936e4a02545b7f8d79)
created_at: 2025-04-10T07:28:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,279 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {act, render, renderHook, screen} from 'sentry-test/reactTestingLibrary';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import type {TourState} from 'sentry/components/tours/tourContext';
+import {
+  DemoTour,
+  DemoTourElement,
+  DemoToursProvider,
+  DemoTourStep,
+  useDemoTours,
+} from 'sentry/utils/demoMode/demoTours';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+import * as useOrganization from 'sentry/utils/useOrganization';
+
+jest.mock('sentry/actionCreators/guides', () => ({
+  recordFinish: jest.fn(),
+}));
+
+jest.mock('sentry/utils/useLocalStorageState', () => ({
+  useLocalStorageState: jest.fn(),
+}));
+
+interface MockToursState {
+  [DemoTour.SIDEBAR]: TourState<DemoTourStep>;
+  [DemoTour.ISSUES]: TourState<DemoTourStep>;
+  [DemoTour.RELEASES]: TourState<DemoTourStep>;
+  [DemoTour.PERFORMANCE]: TourState<DemoTourStep>;
+}
+
+const mockUseLocalStorageState = useLocalStorageState as jest.Mock;
+
+describe('DemoTours', () => {
+  let mockState: MockToursState;
+  let mockSetState: jest.Mock;
+  const organization = OrganizationFixture();
+
+  beforeEach(() => {
+    mockState = {
+      [DemoTour.SIDEBAR]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.SIDEBAR_PROJECTS, DemoTourStep.SIDEBAR_ISSUES],
+        isRegistered: true,
+        tourKey: DemoTour.SIDEBAR,
+      },
+      [DemoTour.ISSUES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.ISSUES_STREAM, DemoTourStep.ISSUES_TAGS],
+        isRegistered: true,
+        tourKey: DemoTour.ISSUES,
+      },
+      [DemoTour.RELEASES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.RELEASES,
+      },
+      [DemoTour.PERFORMANCE]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.PERFORMANCE,
+      },
+    };
+
+    mockSetState = jest.fn(
+      (stateOrUpdater: MockToursState | ((prev: MockToursState) => MockToursState)) => {
+        if (typeof stateOrUpdater === 'function') {
+          mockState = stateOrUpdater(mockState);
+        } else {
+          mockState = stateOrUpdater;
+        }
+        return mockState;
+      }
+    );
+
+    mockUseLocalStorageState.mockImplementation(() => [mockState, mockSetState]);
+
+    jest.spyOn(useOrganization, 'default').mockReturnValue(organization);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  describe('DemoToursProvider', () => {
+    it('renders children', () => {
+      render(
+        <DemoToursProvider>
+          <div data-test-id="child-component">Test Child</div>
+        </DemoToursProvider>
+      );
+
+      expect(screen.getByTestId('child-component')).toBeInTheDocument();
+      expect(screen.getByText('Test Child')).toBeInTheDocument();
+    });
path: static/app/utils/demoMode/demoTours.spec.tsx
user: login: obostjancic
body: It's covered by other tests so i removed it.
created_at: 2025-04-10T07:28:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,341 @@
+import {cleanup, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {recordFinish} from 'sentry/actionCreators/guides';
+import type {TourState} from 'sentry/components/tours/tourContext';
+import {
+  DemoTour,
+  DemoTourElement,
+  DemoToursProvider,
+  DemoTourStep,
+  useDemoTours,
+} from 'sentry/utils/demoMode/demoTours';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+
+jest.mock('sentry/actionCreators/guides', () => ({
+  recordFinish: jest.fn(),
+}));
+
+jest.mock('sentry/utils/useLocalStorageState', () => ({
+  useLocalStorageState: jest.fn(),
+}));
+
+function TourHookTester({tourKey}: {tourKey: DemoTour}) {
+  const tour = useDemoTours(tourKey);
+
+  return (
+    <div>
+      <div data-test-id="tour-active">{tour.currentStepId || 'none'}</div>
+      <div data-test-id="tour-completed">{String(tour.isCompleted)}</div>
+      <button
+        data-test-id="start-tour"
+        onClick={() =>
+          tour.startTour(
+            tourKey === DemoTour.SIDEBAR
+              ? DemoTourStep.SIDEBAR_PROJECTS
+              : DemoTourStep.ISSUES_STREAM
+          )
+        }
+      >
+        Start Tour
+      </button>
+      <button data-test-id="end-tour" onClick={() => tour.endTour()}>
+        End Tour
+      </button>
+      <button
+        data-test-id="set-step"
+        onClick={() =>
+          tour.setStep(
+            tourKey === DemoTour.SIDEBAR
+              ? DemoTourStep.SIDEBAR_ISSUES
+              : DemoTourStep.ISSUES_TAGS
+          )
+        }
+      >
+        Set Step
+      </button>
+    </div>
+  );
+}
+
+// Helper component for testing multiple tours
+function MultiTourTester() {
+  const sidebarTour = useDemoTours(DemoTour.SIDEBAR);
+  const issuesTour = useDemoTours(DemoTour.ISSUES);
+
+  return (
+    <div>
+      <DemoTourElement
+        id={DemoTourStep.SIDEBAR_PROJECTS}
+        title="Sidebar Projects"
+        description="Learn about projects"
+      >
+        <div data-test-id="sidebar-projects-anchor">
+          {sidebarTour.currentStepId === DemoTourStep.SIDEBAR_PROJECTS
+            ? 'Active'
+            : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <DemoTourElement
+        id={DemoTourStep.SIDEBAR_ISSUES}
+        title="Sidebar Issues"
+        description="Learn about issues"
+      >
+        <div data-test-id="sidebar-issues-anchor">
+          {sidebarTour.currentStepId === DemoTourStep.SIDEBAR_ISSUES
+            ? 'Active'
+            : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <DemoTourElement
+        id={DemoTourStep.ISSUES_STREAM}
+        title="Issues Stream"
+        description="Learn about the issues stream"
+      >
+        <div data-test-id="issues-stream-anchor">
+          {issuesTour.currentStepId === DemoTourStep.ISSUES_STREAM
+            ? 'Active'
+            : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <DemoTourElement
+        id={DemoTourStep.ISSUES_TAGS}
+        title="Issues Tags"
+        description="Learn about issues tags"
+      >
+        <div data-test-id="issues-tags-anchor">
+          {issuesTour.currentStepId === DemoTourStep.ISSUES_TAGS ? 'Active' : 'Inactive'}
+        </div>
+      </DemoTourElement>
+
+      <button
+        data-test-id="start-sidebar"
+        onClick={() => sidebarTour.startTour(DemoTourStep.SIDEBAR_PROJECTS)}
+      >
+        Start Sidebar
+      </button>
+      <button
+        data-test-id="start-issues"
+        onClick={() => issuesTour.startTour(DemoTourStep.ISSUES_STREAM)}
+      >
+        Start Issues
+      </button>
+      <button data-test-id="end-sidebar" onClick={() => sidebarTour.endTour()}>
+        End Sidebar
+      </button>
+      <button data-test-id="end-issues" onClick={() => issuesTour.endTour()}>
+        End Issues
+      </button>
+      <button data-test-id="next-step-sidebar" onClick={() => sidebarTour.nextStep()}>
+        Next Sidebar Step
+      </button>
+      <button data-test-id="next-step-issues" onClick={() => issuesTour.nextStep()}>
+        Next Issues Step
+      </button>
+    </div>
+  );
+}
+
+interface MockToursState {
+  [DemoTour.SIDEBAR]: TourState<DemoTourStep>;
+  [DemoTour.ISSUES]: TourState<DemoTourStep>;
+  [DemoTour.RELEASES]: TourState<DemoTourStep>;
+  [DemoTour.PERFORMANCE]: TourState<DemoTourStep>;
+}
+
+const mockUseLocalStorageState = useLocalStorageState as jest.Mock;
+
+describe('DemoTours', () => {
+  let mockState: MockToursState;
+  let mockSetState: jest.Mock;
+
+  beforeEach(() => {
+    mockState = {
+      [DemoTour.SIDEBAR]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.SIDEBAR_PROJECTS, DemoTourStep.SIDEBAR_ISSUES],
+        isRegistered: true,
+        tourKey: DemoTour.SIDEBAR,
+      },
+      [DemoTour.ISSUES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [DemoTourStep.ISSUES_STREAM, DemoTourStep.ISSUES_TAGS],
+        isRegistered: true,
+        tourKey: DemoTour.ISSUES,
+      },
+      [DemoTour.RELEASES]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.RELEASES,
+      },
+      [DemoTour.PERFORMANCE]: {
+        currentStepId: null,
+        isCompleted: false,
+        orderedStepIds: [],
+        isRegistered: true,
+        tourKey: DemoTour.PERFORMANCE,
+      },
+    };
+
+    mockSetState = jest.fn(
+      (stateOrUpdater: MockToursState | ((prev: MockToursState) => MockToursState)) => {
+        if (typeof stateOrUpdater === 'function') {
+          mockState = stateOrUpdater(mockState);
+        } else {
+          mockState = stateOrUpdater;
+        }
+        return mockState;
+      }
+    );
+
+    mockUseLocalStorageState.mockImplementation(() => [mockState, mockSetState]);
path: static/app/utils/demoMode/demoTours.spec.tsx
user: login: obostjancic
body: I gave this a shot but wasn't able to get it to easily do what we need here since `useLocalStorageState` uses `useState` as its primary state storage and also keeps localStorage in sync with a micro task. So we would have to mock both :/ 
created_at: 2025-04-10T10:34:46Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-04T09:15:17Z
body: ## [Codecov](https://app.codecov.io/gh/getsentry/sentry/pull/88781?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
Attention: Patch coverage is `0%` with `3 lines` in your changes missing coverage. Please review.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/getsentry/sentry/pull/88781?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) | Patch % | Lines |
|---|---|---|
| [static/app/utils/demoMode/guides.tsx](https://app.codecov.io/gh/getsentry/sentry/pull/88781?src=pr&el=tree&filepath=static%2Fapp%2Futils%2FdemoMode%2Fguides.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c3RhdGljL2FwcC91dGlscy9kZW1vTW9kZS9ndWlkZXMudHN4) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/getsentry/sentry/pull/88781?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master   #88781      +/-   ##
==========================================
+ Coverage   87.69%   87.76%   +0.06%     
==========================================
  Files       10089    10031      -58     
  Lines      570583   567241    -3342     
  Branches    22444    22261     -183     
==========================================
- Hits       500396   497820    -2576     
+ Misses      69776    68998     -778     
- Partials      411      423      +12     
```

</details>
author_association: NONE
user: login: sentry[bot]
created_at: 2025-04-11T09:38:32Z
body: ## Suspect Issues
This pull request was deployed and Sentry observed the following issues:

- ‼️ **TypeError: Cannot read properties of undefined (reading 'id')** `/issues/` [View Issue](https://sentry.sentry.io/issues/6543396808/?referrer=github-pr-bot)
- ‼️ **TypeError: undefined is not an object (evaluating 'Z[X]')** `/releases/com.airshape.cleaner@1.38+1` [View Issue](https://sentry.sentry.io/issues/6543553249/?referrer=github-pr-bot)
- ‼️ **TypeError: undefined is not an object (evaluating 'i[0].coord')** `/releases/com.airshape.cleaner@1.38+1` [View Issue](https://sentry.sentry.io/issues/6543553274/?referrer=github-pr-bot)
- ‼️ **TypeError: null is not an object (evaluating 'e.toFixed')** `/stats/` [View Issue](https://sentry.sentry.io/issues/6549962416/?referrer=github-pr-bot)

<sub>Did you find this useful? React with a 👍 or 👎</sub>
author_association: CONTRIBUTOR