Skip to content

Commit ccd0112

Browse files
A tour chunk that fails twice no longer drops the first run
Co-authored-by: Gáborjáni-Szabó Ádám <Programcode@users.noreply.github.com>
1 parent dd18519 commit ccd0112

8 files changed

Lines changed: 280 additions & 20 deletions

File tree

‎docs/architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b
141141

142142
- Google OAuth, six-digit email OTP, and demo-code token-hash handoffs all finish as ordinary Supabase sessions and use the same store hydration path.
143143
- Google and any emailed sign-in link return to `/auth/callback` (with `?next=/app/...` for a deep link; only `/app` paths are accepted). [`AuthProvider`](../src/lib/supabase/AuthProvider.tsx) exchanges the code there and keeps `loading` true until the exchange settles. The client sets `experimental.appendPkceFlowIdToRedirects`, so each sign-in keeps its own PKCE verifier and its redirect carries `sb_flow_id`; another tab's sign-in or a failed resend no longer breaks a pending link. An `error`/`error_code` from Auth, or a failed exchange, opens `/login` with a localized message. Links that still point at `/?code=` or `/app?code=` are moved onto the callback route before the first render ([`src/lib/auth-callback.ts`](../src/lib/auth-callback.ts)). Each project's Auth redirect allowlist needs `<site origin>/auth/callback**`; a redirect to the Site URL's own host passes without it.
144-
- Sign-out ends only this device's session (`scope: 'local'`). After a sign-out, `/login` returns only the same account to the page it left; another account starts on `/app`. The onboarding tour is tracked per account on each browser. The one-time DebateOwl welcome ([`src/lib/debateowl-intro.ts`](../src/lib/debateowl-intro.ts)) opens once per browser inside the signed-in app, is remembered in localStorage (`debateowl_intro_seen_v1`), and closing it continues straight into the tour's Start debate step. `?intro=show` opens the welcome again; a finished tour stays finished.
144+
- Sign-out ends only this device's session (`scope: 'local'`). After a sign-out, `/login` returns only the same account to the page it left; another account starts on `/app`. The onboarding tour is tracked per account on each browser. The one-time DebateOwl welcome ([`src/lib/debateowl-intro.ts`](../src/lib/debateowl-intro.ts)) opens once per browser inside the signed-in app, is remembered in localStorage (`debateowl_intro_seen_v1`), and closing it continues straight into the tour's Start debate step. If that chunk fails twice, the app logs once, remembers the welcome, and continues without the tour. `?intro=show` opens the welcome again; a finished tour stays finished.
145145
- RLS owns row visibility and mutation rights.
146146
- Shared backend helpers validate bearer tokens and admin roles.
147147
- Admin endpoints verify server-side roles.

‎docs/testing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ The server is never reused. CI uses retries and two workers. On failure, traces,
129129

130130
Playwright starts that server with `VITE_E2E=1`. The client then keeps the environment switcher, but the production fallback is `http://e2e.invalid` instead of the production Supabase project, and a request that still names that project is refused in the browser. `playwright.config.ts` also refuses to start when `VITE_SUPABASE_ENV` or `SUPABASE_URL` already points at production. `E2E_ALLOW_PROD=1` is the explicit override. The production canary uses `playwright.production.config.ts` and does not set `VITE_E2E`. Plain `npm run dev` is unchanged and still defaults to production.
131131

132-
The one-time DebateOwl welcome stays closed whenever `VITE_E2E` or `VITE_LOCAL_PREVIEW` is set, so Playwright, the verify stack and agentic tests never meet it; `?intro=show` opens it anyway and `?intro=skip` keeps it closed in any build. Closing it continues into the tour's Start debate step when that account has not finished the tour. The production canary and the service worker offline test (`npm run test:sw`), which drive production builds, seed `debateowl_intro_seen_v1` instead.
132+
The one-time DebateOwl welcome stays closed whenever `VITE_E2E` or `VITE_LOCAL_PREVIEW` is set, so Playwright, the verify stack and agentic tests never meet it; `?intro=show` opens it anyway and `?intro=skip` keeps it closed in any build. Closing it continues into the tour's Start debate step when that account has not finished the tour. If that chunk fails twice, the app logs once, remembers the welcome, and continues without the tour. The production canary and the service worker offline test (`npm run test:sw`), which drive production builds, seed `debateowl_intro_seen_v1` instead.
133133

134134
`e2e/hungarian-parity.spec.ts` is the focused Hungarian browser contract. On desktop and mobile Chromium it checks persisted `<html lang="hu">`, public and authenticated page chrome, route titles, onboarding, saved-chat empty state, Library controls, Timer semantics, localized close labels, and runtime errors. It deliberately does not reject arbitrary English text because source titles, user content, model names, and citations may legitimately remain in their source language.
135135

‎src/App.tsx‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ThemeProvider } from "@/components/ThemeProvider";
1515
import { useSessionStorageSync } from "@/hooks/use-session-storage-sync";
1616
import { hasCompletedOnboarding, useOnboardingStore } from "@/hooks/use-onboarding";
1717
import { browserStorage, markIntroSeen, shouldShowIntro } from "@/lib/debateowl-intro";
18+
import { focusCurrentDebate, noteTourChunkFailure, tourChunkGaveUp } from "@/lib/onboarding-tour-load";
1819
import { OfflineIndicator } from "@/components/OfflineIndicator";
1920
import { StagingBanner } from "@/components/StagingBanner";
2021
import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability";
@@ -97,12 +98,15 @@ let onboardingTourPromise: Promise<OnboardingTourComponent> | null = null;
9798
function loadOnboardingTour(): Promise<OnboardingTourComponent> {
9899
if (onboardingTourComponent) return Promise.resolve(onboardingTourComponent);
99100
// A failed fetch has to be retryable. Keeping the rejected promise would
100-
// leave the Start debate step closed for the rest of the page.
101+
// leave the Start debate step closed for the rest of the page. The second
102+
// failure is the last: `noteTourChunkFailure` logs once and the tour owner
103+
// continues without it.
101104
onboardingTourPromise ??= import("@/components/features/onboarding/OnboardingTour").then((mod) => {
102105
onboardingTourComponent = mod.OnboardingTour;
103106
return mod.OnboardingTour;
104107
}).catch((error: unknown) => {
105108
onboardingTourPromise = null;
109+
noteTourChunkFailure(error);
106110
throw error;
107111
});
108112
return onboardingTourPromise;
@@ -307,18 +311,17 @@ export function RouteContent({ children }: { children: ReactNode }) {
307311
* One first-run moment. The owl welcome, when it is due, opens over the signed-in
308312
* app once its layout has loaded. Closing it continues straight into the tour's
309313
* Start debate step. With no welcome, the tour mounts as before.
314+
* Rendered only under RequireAuth, so an account is already signed in.
310315
*/
311-
function AppWelcome() {
316+
export function AppWelcome() {
312317
const [showIntro] = useState(() => shouldShowIntro({
313318
search: window.location.search,
314319
storage: browserStorage(),
315320
env: import.meta.env,
316321
}));
317322
const [layoutReady, setLayoutReady] = useState(false);
318323
const userId = useAuth().user?.id;
319-
// A missing account is not a finished tour. `hasCompletedOnboarding` says so
320-
// only because there is nobody to show it to yet.
321-
const tourCompleted = useOnboardingStore((state) => Boolean(userId) && hasCompletedOnboarding(state, userId));
324+
const tourCompleted = useOnboardingStore((state) => hasCompletedOnboarding(state, userId));
322325

323326
useEffect(() => {
324327
if (!showIntro) return;
@@ -364,15 +367,38 @@ function OptionalOnboardingTour({ continueFromIntro }: { continueFromIntro: bool
364367
function TimedOnboardingTour({ userId, continueFromIntro }: { userId: string; continueFromIntro: boolean }) {
365368
const [eligibleAt] = useState(() => typeof performance === "undefined" ? 0 : performance.now());
366369
const [Tour, setTour] = useState<OnboardingTourComponent | null>(() => onboardingTourComponent);
370+
const [gaveUp, setGaveUp] = useState(false);
367371

368372
useEffect(() => {
369-
if (Tour) return;
373+
if (Tour || gaveUp) return;
370374
let live = true;
371-
loadOnboardingTour().then((Loaded) => { if (live) setTour(() => Loaded); }, (): void => undefined);
375+
const giveUp = () => {
376+
setGaveUp(true);
377+
focusCurrentDebate();
378+
};
379+
// The welcome's preload counts as the first try when it ran. One more
380+
// failure after that, and the page continues without the tour.
381+
if (tourChunkGaveUp()) {
382+
giveUp();
383+
return;
384+
}
385+
const load = () => {
386+
loadOnboardingTour().then((Loaded) => {
387+
if (live) setTour(() => Loaded);
388+
}, () => {
389+
if (!live) return;
390+
if (tourChunkGaveUp()) {
391+
giveUp();
392+
return;
393+
}
394+
load();
395+
});
396+
};
397+
load();
372398
return () => { live = false; };
373-
}, [Tour]);
399+
}, [Tour, gaveUp]);
374400

375-
if (!Tour) return null;
401+
if (gaveUp || !Tour) return null;
376402
return <Tour eligibleAt={eligibleAt} userId={userId} continueFromIntro={continueFromIntro} />;
377403
}
378404

‎src/components/features/onboarding/DebateOwlIntro.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ export function DebateOwlIntro({ onClose, owlName = OWL_NAME }: { onClose: () =>
6363
event.preventDefault();
6464
doneButton.current?.focus();
6565
}}
66+
// The tour's Next button takes focus as this dialog goes. Restoring
67+
// focus here would put it back on the page body.
68+
onCloseAutoFocus={(event) => {
69+
event.preventDefault();
70+
}}
6671
>
6772
<div className="flex flex-col items-center gap-5 px-6 pt-7 sm:flex-row sm:items-center sm:gap-6 sm:px-8 sm:pt-8">
6873
<motion.div

‎src/components/features/onboarding/OnboardingTour.tsx‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Joyride, { ACTIONS, CallBackProps, EVENTS, STATUS, Step } from 'react-joy
55
import { useLocation } from 'react-router-dom';
66
import { Button } from '@/components/ui/button';
77
import { hasCompletedOnboarding as completedFor, useOnboardingStore } from '@/hooks/use-onboarding';
8+
import { focusCurrentDebate, focusTourPrimary } from '@/lib/onboarding-tour-load';
89
import { useDebateStore } from '@/lib/stores/debate-store';
910

1011
// Below `lg` the header's menu button is the navigation. There the tour draws no
@@ -128,14 +129,28 @@ export function OnboardingTour({
128129
setScrollOffset((document.querySelector('header')?.getBoundingClientRect().height ?? 0) + 24);
129130
}, [resolveTourSteps]);
130131

131-
// The welcome and this step are one moment: open the spotlight before paint,
132-
// while the dialog's close is still the thing the student just did.
132+
// The parent leaves `continueFromIntro` true for the whole Start debate step.
133+
// Consume it once the spotlight has opened. `startTour` does not reset
134+
// `stepIndex`, and the create-dialog pause still depends on `debateDialog`,
135+
// so a later language or debate change must not run this handoff again.
136+
// If the card is not in the document yet, leave it pending for the next pass.
137+
const handoffPending = useRef(continueFromIntro);
133138
useLayoutEffect(() => {
134-
if (!continueFromIntro || hasCompletedOnboarding || location.pathname !== '/app') return;
139+
if (!handoffPending.current || hasCompletedOnboarding || location.pathname !== '/app') return;
135140
if (!document.querySelector('[data-tour="current-debate"]')) return;
141+
handoffPending.current = false;
136142
prepareTour();
137143
startTour();
138-
}, [continueFromIntro, hasCompletedOnboarding, location.pathname, prepareTour, startTour]);
144+
}, [hasCompletedOnboarding, location.pathname, prepareTour, startTour]);
145+
146+
// Joyride also focuses a hidden beacon. That runs in its mount timeout, so
147+
// the visible Next button has to take focus after it. The welcome dialog
148+
// has already given up restoring focus to the page body.
149+
useEffect(() => {
150+
if (!continueFromIntro || hasCompletedOnboarding || !isTourRunning) return;
151+
const timer = window.setTimeout(() => { focusTourPrimary(); }, 0);
152+
return () => window.clearTimeout(timer);
153+
}, [continueFromIntro, hasCompletedOnboarding, isTourRunning]);
139154

140155
// Start tour automatically for new users
141156
useEffect(() => {
@@ -193,6 +208,9 @@ export function OnboardingTour({
193208
if (finishedStatuses.includes(status)) {
194209
setOnboardingComplete(userId);
195210
setStepIndex(0);
211+
// The tooltip is about to unmount. The card it was pointing at is the
212+
// control the student can use next.
213+
requestAnimationFrame(() => { focusCurrentDebate(); });
196214
} else if (type === EVENTS.STEP_AFTER || type === EVENTS.TARGET_NOT_FOUND) {
197215
setStepIndex(index + (action === ACTIONS.PREV ? -1 : 1));
198216
}

‎src/lib/onboarding-tour-load.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { browserStorage, markIntroSeen } from '@/lib/debateowl-intro';
2+
3+
/** The welcome preloads the tour once. The tour owner tries once more, then stops. */
4+
const TOUR_CHUNK_ATTEMPTS = 2;
5+
6+
let attempts = 0;
7+
let logged = false;
8+
9+
export function resetTourChunkLoadForTests(): void {
10+
attempts = 0;
11+
logged = false;
12+
}
13+
14+
export function tourChunkGaveUp(): boolean {
15+
return attempts >= TOUR_CHUNK_ATTEMPTS;
16+
}
17+
18+
/**
19+
* Count one failed fetch of the tour chunk. The second failure logs once,
20+
* remembers the welcome, and lets the page continue without the tour.
21+
*/
22+
export function noteTourChunkFailure(error: unknown): 'retry' | 'give-up' {
23+
attempts += 1;
24+
if (attempts < TOUR_CHUNK_ATTEMPTS) return 'retry';
25+
if (!logged) {
26+
logged = true;
27+
const detail = error instanceof Error ? error.message : 'unknown error';
28+
console.warn('[Onboarding] Could not load the tour. Continuing without it.', detail);
29+
markIntroSeen(browserStorage());
30+
}
31+
return 'give-up';
32+
}
33+
34+
/** The Current Debate card is what the first step was pointing at. */
35+
export function focusCurrentDebate(): void {
36+
if (typeof document === 'undefined') return;
37+
const card = document.querySelector<HTMLElement>('[data-tour="current-debate"]');
38+
if (!card) return;
39+
const control = card.querySelector<HTMLElement>('button:not([disabled]), a[href]');
40+
(control ?? card).focus();
41+
}
42+
43+
/** Joyride's Next button, the action the welcome just handed the student. */
44+
export function focusTourPrimary(): void {
45+
if (typeof document === 'undefined') return;
46+
document.querySelector<HTMLElement>('[data-action="primary"]')?.focus();
47+
}

‎src/test/onboarding/debateowl-intro.test.tsx‎

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { fireEvent, render, screen } from '@testing-library/react';
2-
import { beforeEach, describe, expect, it, vi } from 'vitest';
1+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import type { User } from '@supabase/supabase-js';
34
import { MemoryRouter } from 'react-router-dom';
5+
import { AppWelcome } from '@/App';
46
import { DebateOwlIntro } from '@/components/features/onboarding/DebateOwlIntro';
57
import { FirstRun } from '@/components/features/onboarding/FirstRun';
68
import { OnboardingTour } from '@/components/features/onboarding/OnboardingTour';
79
import { useOnboardingStore } from '@/hooks/use-onboarding';
8-
import { useDebateStore } from '@/lib/stores/debate-store';
910
import { INTRO_SEEN_KEY, firstRunStep, markIntroSeen, shouldShowIntro } from '@/lib/debateowl-intro';
11+
import { noteTourChunkFailure, resetTourChunkLoadForTests } from '@/lib/onboarding-tour-load';
12+
import { AuthContext, type AuthContextValue } from '@/lib/supabase/auth-context';
13+
import { useDebateStore } from '@/lib/stores/debate-store';
1014

1115
function memoryStorage(initial: Record<string, string> = {}) {
1216
const values = new Map(Object.entries(initial));
@@ -128,17 +132,24 @@ describe('OnboardingTour continuing from the welcome', () => {
128132
function renderTour(continueFromIntro: boolean) {
129133
return render(
130134
<MemoryRouter initialEntries={['/app']}>
131-
<div data-tour="current-debate">Current debate</div>
135+
<div data-tour="current-debate"><button type="button">Open current debate</button></div>
132136
<OnboardingTour userId="student-1" continueFromIntro={continueFromIntro} eligibleAt={0} />
133137
</MemoryRouter>,
134138
);
135139
}
136140

137-
it('opens the Start debate step and not a second welcome dialog', () => {
141+
it('opens the Start debate step and not a second welcome dialog', async () => {
138142
renderTour(true);
139143
expect(screen.getByText(/Start your first debate here/)).toBeTruthy();
140144
expect(screen.getByRole('button', { name: 'Start debate' })).toBeTruthy();
141145
expect(screen.queryByRole('dialog', { name: 'DebateLab is slowly becoming DebateOwl' })).toBeNull();
146+
await waitFor(() => {
147+
expect(document.activeElement).toBe(document.querySelector('[data-action="primary"]'));
148+
});
149+
fireEvent.click(screen.getByRole('button', { name: 'Skip tour' }));
150+
await waitFor(() => {
151+
expect(document.activeElement).toBe(document.querySelector('[data-tour="current-debate"] button'));
152+
});
142153
});
143154

144155
it('stays closed when that account has already finished the tour', () => {
@@ -149,6 +160,85 @@ describe('OnboardingTour continuing from the welcome', () => {
149160
});
150161
});
151162

163+
const signedIn: AuthContextValue = {
164+
session: null,
165+
user: { id: 'student-1' } as User,
166+
loading: false,
167+
callbackError: null,
168+
clearCallbackError: () => undefined,
169+
signInWithGoogle: async () => undefined,
170+
signInWithEmailOtp: async () => undefined,
171+
verifyEmailOtp: async () => undefined,
172+
signInWithDemoCode: async () => undefined,
173+
signOut: async () => undefined,
174+
leaveEnvironment: async () => undefined,
175+
discardTabState: () => undefined,
176+
};
177+
178+
describe('AppWelcome hands the welcome to the Start debate step', () => {
179+
beforeEach(() => {
180+
localStorage.clear();
181+
resetTourChunkLoadForTests();
182+
window.history.pushState({}, '', '/app?intro=show');
183+
useOnboardingStore.setState({ completedUserIds: [], legacyCompleted: false, isTourRunning: false });
184+
useDebateStore.setState({ currentDebateId: null, dialog: null });
185+
});
186+
187+
afterEach(() => {
188+
window.history.pushState({}, '', '/');
189+
});
190+
191+
it('Got it closes the welcome into the tour, with focus on the tour and then the card', async () => {
192+
render(
193+
<AuthContext.Provider value={signedIn}>
194+
<MemoryRouter initialEntries={['/app?intro=show']}>
195+
<div data-tour="current-debate"><button type="button">Open current debate</button></div>
196+
<AppWelcome />
197+
</MemoryRouter>
198+
</AuthContext.Provider>,
199+
);
200+
201+
const gotIt = await screen.findByRole('button', { name: 'Got it' }, { timeout: 5000 });
202+
expect(gotIt).toHaveFocus();
203+
fireEvent.click(gotIt);
204+
205+
expect(await screen.findByText(/Start your first debate here/)).toBeTruthy();
206+
expect(screen.queryByRole('dialog', { name: 'DebateLab is slowly becoming DebateOwl' })).toBeNull();
207+
expect(localStorage.getItem(INTRO_SEEN_KEY)).toBeTruthy();
208+
await waitFor(() => {
209+
expect(document.activeElement).toBe(document.querySelector('[data-action="primary"]'));
210+
});
211+
212+
fireEvent.click(screen.getByRole('button', { name: 'Skip tour' }));
213+
await waitFor(() => {
214+
expect(document.activeElement).toBe(document.querySelector('[data-tour="current-debate"] button'));
215+
});
216+
});
217+
});
218+
219+
describe('a tour chunk that fails to load', () => {
220+
beforeEach(() => {
221+
localStorage.clear();
222+
resetTourChunkLoadForTests();
223+
});
224+
225+
it('retries once, then logs once and remembers the welcome', () => {
226+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
227+
expect(noteTourChunkFailure(new Error('offline'))).toBe('retry');
228+
expect(localStorage.getItem(INTRO_SEEN_KEY)).toBeNull();
229+
expect(warn).not.toHaveBeenCalled();
230+
231+
expect(noteTourChunkFailure(new Error('offline'))).toBe('give-up');
232+
expect(localStorage.getItem(INTRO_SEEN_KEY)).toBeTruthy();
233+
expect(warn).toHaveBeenCalledTimes(1);
234+
expect(String(warn.mock.calls[0]?.[0])).toContain('[Onboarding] Could not load the tour');
235+
236+
expect(noteTourChunkFailure(new Error('offline'))).toBe('give-up');
237+
expect(warn).toHaveBeenCalledTimes(1);
238+
warn.mockRestore();
239+
});
240+
});
241+
152242
describe('DebateOwlIntro', () => {
153243
function renderIntro(owlName?: string) {
154244
const storage = memoryStorage();

0 commit comments

Comments
 (0)