refactor(onboarding): modularize onboarding flow logic and improve testability

Extract onboarding flow step resolution and async runner logic into a
dedicated module for better separation of concerns and maintainability.
Refactor context/provider to use new helpers and simplify settings modal
control. Add unit tests for onboarding step resolution and async runner
coalescing. Update e2e helpers and selectors for onboarding modals.
Adjust environment file handling for CI and Docker contexts.

- Add .dockerignore for build hygiene
- Refactor onboarding context and modal logic
- Add src/lib/client/onboarding-flow.ts with core onboarding helpers
- Add onboarding-flow unit tests
- Update Playwright config and scripts for CI env loading
- Improve test selectors for claim modal
- Update .gitignore to exclude all .env* except .env.example

This change increases onboarding logic modularity and reliability, and improves CI/test environment handling.
This commit is contained in:
Richard R 2026-05-29 18:03:17 -06:00
parent c6047ffaa4
commit 5428a21b67
12 changed files with 294 additions and 139 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
.env
.env.*
README.md
.next
node_modules
**/node_modules
docstore

View file

@ -10,7 +10,6 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-latest
env:
FFMPEG_BIN: /usr/bin/ffmpeg
USE_EMBEDDED_WEED_MINI: true
BASE_URL: http://127.0.0.1:3003
S3_ENDPOINT: http://127.0.0.1:8333

5
.gitignore vendored
View file

@ -32,9 +32,8 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env
.env.prod
.dockerignore
.env*
!.env.example
*.creds
# vercel

View file

@ -12,6 +12,7 @@
"start:raw": "next start -p 3003",
"lint": "next lint",
"test": "playwright test",
"test:ci-env": "CI=true playwright test",
"migrate": "node drizzle/scripts/migrate.mjs",
"migrate-fs": "node scripts/migrate-fs-v2.mjs",
"migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true",

View file

@ -1,5 +1,15 @@
import { defineConfig, devices } from '@playwright/test';
import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import dotenv from 'dotenv';
if (process.env.CI === 'true') {
const envCiPath = path.join(process.cwd(), '.env.ci');
if (fs.existsSync(envCiPath)) {
dotenv.config({ path: envCiPath, override: true });
}
}
/**
* See https://playwright.dev/docs/test-configuration.

View file

@ -11,6 +11,15 @@ import * as dotenv from 'dotenv';
function loadEnvFiles() {
const cwd = process.cwd();
const isCi = isTrue(process.env.CI, false);
if (isCi) {
const envCiPath = path.join(cwd, '.env.ci');
if (fs.existsSync(envCiPath)) {
dotenv.config({ path: envCiPath });
}
return;
}
const envPath = path.join(cwd, '.env');
const envLocalPath = path.join(cwd, '.env.local');

View file

@ -178,7 +178,7 @@ export function SettingsModal({
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
const { data: session } = useAuthSession();
const { requestOpenSettings, registerSettingsController } = useOnboardingFlow();
const { changelogOpenSignal } = useOnboardingFlow();
const router = useRouter();
const isBusy = isImportingLibrary;
const {
@ -211,25 +211,11 @@ export function SettingsModal({
const isSharedSelected = Boolean(selectedSharedProvider);
const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0];
const closeSettings = useCallback(() => {
setIsOpen(false);
setIsChangelogOpen(false);
}, []);
const openSettings = useCallback((options?: { changelog?: boolean }) => {
setIsOpen(true);
setIsChangelogOpen(Boolean(options?.changelog));
}, []);
useEffect(() => {
registerSettingsController({
open: openSettings,
close: closeSettings,
});
return () => {
registerSettingsController(null);
};
}, [closeSettings, openSettings, registerSettingsController]);
if (changelogOpenSignal <= 0) return;
setIsOpen(true);
setIsChangelogOpen(true);
}, [changelogOpenSignal]);
useEffect(() => {
setLocalApiKey(apiKey);
@ -505,7 +491,8 @@ export function SettingsModal({
<>
<Button
onClick={() => {
void requestOpenSettings();
setIsOpen(true);
setIsChangelogOpen(false);
}}
className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.01] ${className}`}
aria-label="Settings"

View file

@ -99,7 +99,7 @@ export default function ClaimDataModal({
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogPanel data-testid="claim-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground mb-4"
@ -128,6 +128,7 @@ export default function ClaimDataModal({
<div className="flex justify-end gap-3">
<Button
data-testid="claim-dismiss-button"
type="button"
onClick={onDismiss}
disabled={isClaiming}
@ -140,6 +141,7 @@ export default function ClaimDataModal({
Dismiss
</Button>
<Button
data-testid="claim-submit-button"
type="button"
onClick={handleClaim}
disabled={isClaiming}

View file

@ -11,20 +11,11 @@ import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppCon
import { listDocuments } from '@/lib/client/api/documents';
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
import { scheduleChangelogCheck } from '@/lib/client/changelog-check';
import { createCoalescedAsyncRunner, resolveNextOnboardingStep } from '@/lib/client/onboarding-flow';
import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
type SettingsOpenOptions = {
changelog?: boolean;
};
type SettingsController = {
open: (options?: SettingsOpenOptions) => void;
close: () => void;
};
type OnboardingFlowContextValue = {
requestOpenSettings: (options?: SettingsOpenOptions) => Promise<boolean>;
registerSettingsController: (controller: SettingsController | null) => void;
changelogOpenSignal: number;
};
const OnboardingFlowContext = createContext<OnboardingFlowContextValue | null>(null);
@ -61,9 +52,9 @@ async function fetchClaimableCounts(): Promise<ClaimableCounts> {
return toClaimableCounts(data);
}
async function getMigrationPromptState(): Promise<MigrationPromptState> {
async function getMigrationPromptState(privacyGateSatisfied: boolean): Promise<MigrationPromptState> {
const cfg = await getAppConfig();
if (!cfg?.privacyAccepted || cfg.documentsMigrationPrompted) {
if (!privacyGateSatisfied || cfg?.documentsMigrationPrompted) {
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
}
@ -127,144 +118,125 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
localCount: 0,
missingCount: 0,
});
const [changelogOpenSignal, setChangelogOpenSignal] = useState(0);
const settingsControllerRef = useRef<SettingsController | null>(null);
const pendingChangelogOpenRef = useRef(false);
const runningAdvanceRef = useRef(false);
const claimDismissedUsersRef = useRef<Set<string>>(new Set());
const changelogVersionCheckKeyRef = useRef<string | null>(null);
const changelogVersionCheckInFlightRef = useRef<string | null>(null);
const openSettingsNow = useCallback((options?: SettingsOpenOptions) => {
settingsControllerRef.current?.open(options);
}, []);
const runOnceFlowRef = useRef<() => Promise<void>>(async () => {});
const advanceFlow = useCallback(async () => {
if (runningAdvanceRef.current) {
return;
}
runningAdvanceRef.current = true;
try {
const local = await readLocalOnboardingSnapshot();
if (authEnabled && !local.privacyAccepted) {
setActiveBlockingModal('privacy');
return;
}
if (activeBlockingModal === 'privacy') {
setActiveBlockingModal(null);
} else if (activeBlockingModal) {
return;
}
const runFlow = useMemo(
() => createCoalescedAsyncRunner(async () => {
await runOnceFlowRef.current();
}),
[],
);
if (authEnabled && userId && !isAnonymous && !claimDismissedUsersRef.current.has(userId)) {
const counts = await fetchClaimableCounts();
const total = counts.documents + counts.audiobooks + counts.preferences + counts.progress;
if (total > 0) {
setClaimableCounts(counts);
setActiveBlockingModal('claim');
return;
}
const runOnceFlow = useCallback(async () => {
const local = await readLocalOnboardingSnapshot();
const privacyRequired = authEnabled;
const privacyAccepted = !privacyRequired || local.privacyAccepted;
const isClaimEligible = Boolean(
authEnabled
&& userId
&& !isAnonymous
&& !claimDismissedUsersRef.current.has(userId),
);
let claimCounts = EMPTY_CLAIM_COUNTS;
let claimHasData = false;
if (isClaimEligible) {
claimCounts = await fetchClaimableCounts();
const total = claimCounts.documents + claimCounts.audiobooks + claimCounts.preferences + claimCounts.progress;
claimHasData = total > 0;
if (!claimHasData && userId) {
claimDismissedUsersRef.current.add(userId);
}
const migrationState = await getMigrationPromptState();
if (migrationState.shouldPrompt) {
setMigrationCounts({
localCount: migrationState.localCount,
missingCount: migrationState.missingCount,
});
setActiveBlockingModal('migration');
return;
}
if (!local.firstVisitSettingsOpened) {
await setFirstVisit(true);
// In no-auth mode (used by local/CI e2e), avoid background modal opens
// that can race with interactions and steal pointer events.
if (authEnabled) {
openSettingsNow();
}
return;
}
if (pendingChangelogOpenRef.current) {
pendingChangelogOpenRef.current = false;
openSettingsNow({ changelog: true });
}
} finally {
runningAdvanceRef.current = false;
}
}, [activeBlockingModal, authEnabled, isAnonymous, openSettingsNow, userId]);
const requestOpenSettings = useCallback(async (options?: SettingsOpenOptions): Promise<boolean> => {
const local = await readLocalOnboardingSnapshot();
if (authEnabled && !local.privacyAccepted) {
if (options?.changelog) {
pendingChangelogOpenRef.current = true;
}
settingsControllerRef.current?.close();
return false;
}
if (activeBlockingModal) {
if (options?.changelog) {
pendingChangelogOpenRef.current = true;
}
return false;
const migrationState = await getMigrationPromptState(privacyAccepted);
const nextStep = resolveNextOnboardingStep({
privacyRequired,
privacyAccepted,
claimEligible: isClaimEligible,
claimHasData,
migrationRequired: migrationState.shouldPrompt,
changelogPending: pendingChangelogOpenRef.current,
});
if (nextStep === 'privacy') {
setActiveBlockingModal('privacy');
return;
}
if (!settingsControllerRef.current) {
if (options?.changelog) {
pendingChangelogOpenRef.current = true;
}
return false;
if (nextStep === 'claim') {
setClaimableCounts(claimCounts);
setActiveBlockingModal('claim');
return;
}
settingsControllerRef.current.open(options);
return true;
}, [activeBlockingModal, authEnabled]);
const registerSettingsController = useCallback((controller: SettingsController | null) => {
settingsControllerRef.current = controller;
if (controller) {
void advanceFlow();
if (nextStep === 'migration') {
setMigrationCounts({
localCount: migrationState.localCount,
missingCount: migrationState.missingCount,
});
setActiveBlockingModal('migration');
return;
}
}, [advanceFlow]);
setActiveBlockingModal(null);
if (!local.firstVisitSettingsOpened) {
await setFirstVisit(true);
}
if (nextStep === 'changelog') {
pendingChangelogOpenRef.current = false;
setChangelogOpenSignal((value) => value + 1);
}
}, [authEnabled, isAnonymous, userId]);
runOnceFlowRef.current = runOnceFlow;
const handleClaimComplete = useCallback(() => {
if (userId) {
claimDismissedUsersRef.current.add(userId);
}
setActiveBlockingModal(null);
void advanceFlow();
}, [advanceFlow, userId]);
void runFlow();
}, [runFlow, userId]);
const handleMigrationComplete = useCallback(() => {
setActiveBlockingModal(null);
void advanceFlow();
}, [advanceFlow]);
void runFlow();
}, [runFlow]);
const handlePrivacyAccepted = useCallback(() => {
setActiveBlockingModal(null);
void advanceFlow();
}, [advanceFlow]);
void runFlow();
}, [runFlow]);
useEffect(() => {
void advanceFlow();
}, [advanceFlow, authEnabled, isAnonymous, userId]);
void runFlow();
}, [authEnabled, isAnonymous, runFlow, userId]);
useEffect(() => {
if (!authEnabled) {
return;
}
const onPrivacyAccepted = () => {
void advanceFlow();
void runFlow();
};
window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted);
return () => {
window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted);
};
}, [advanceFlow, authEnabled]);
}, [authEnabled, runFlow]);
useEffect(() => {
if (!authEnabled) {
@ -281,17 +253,16 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
postCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion),
onShouldOpen: () => {
pendingChangelogOpenRef.current = true;
void advanceFlow();
void runFlow();
},
delayMs: 120,
retryDelayMs: 400,
});
}, [advanceFlow, authEnabled, isSessionPending, runtimeConfig.appVersion, userId]);
}, [authEnabled, isSessionPending, runFlow, runtimeConfig.appVersion, userId]);
const contextValue = useMemo<OnboardingFlowContextValue>(() => ({
requestOpenSettings,
registerSettingsController,
}), [registerSettingsController, requestOpenSettings]);
changelogOpenSignal,
}), [changelogOpenSignal]);
return (
<OnboardingFlowContext.Provider value={contextValue}>

View file

@ -0,0 +1,52 @@
export type OnboardingStep = 'privacy' | 'claim' | 'migration' | 'changelog' | 'done';
export type OnboardingStepSnapshot = {
privacyRequired: boolean;
privacyAccepted: boolean;
claimEligible: boolean;
claimHasData: boolean;
migrationRequired: boolean;
changelogPending: boolean;
};
export function resolveNextOnboardingStep(snapshot: OnboardingStepSnapshot): OnboardingStep {
if (snapshot.privacyRequired && !snapshot.privacyAccepted) {
return 'privacy';
}
if (snapshot.claimEligible && snapshot.claimHasData) {
return 'claim';
}
if (snapshot.migrationRequired) {
return 'migration';
}
if (snapshot.changelogPending) {
return 'changelog';
}
return 'done';
}
export function createCoalescedAsyncRunner(runOnce: () => Promise<void>): () => Promise<void> {
let running = false;
let rerunRequested = false;
return async () => {
if (running) {
rerunRequested = true;
return;
}
running = true;
try {
do {
rerunRequested = false;
await runOnce();
} while (rerunRequested);
} finally {
running = false;
}
};
}

View file

@ -111,6 +111,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
async function dismissOnboardingModals(page: Page): Promise<void> {
const privacyDialog = page.getByTestId('privacy-modal');
const claimDialog = page.getByTestId('claim-modal');
const migrationDialog = page.getByTestId('migration-modal');
const settingsDialog = page.getByTestId('settings-modal');
@ -135,6 +136,16 @@ async function dismissOnboardingModals(page: Page): Promise<void> {
continue;
}
if (await claimDialog.isVisible().catch(() => false)) {
const dismissBtn = page.getByTestId('claim-dismiss-button');
await expect(dismissBtn).toBeEnabled({ timeout: 10000 });
await dismissBtn.click();
await claimDialog.waitFor({ state: 'hidden', timeout: 15000 });
await page.waitForTimeout(100);
settledWithoutDialog = 0;
continue;
}
if (await settingsDialog.isVisible().catch(() => false)) {
const backToSettingsBtn = settingsDialog.getByRole('button', { name: /back to settings/i });
if (await backToSettingsBtn.isVisible().catch(() => false)) {

View file

@ -0,0 +1,107 @@
import { expect, test } from '@playwright/test';
import { createCoalescedAsyncRunner, resolveNextOnboardingStep } from '../../src/lib/client/onboarding-flow';
test.describe('onboarding flow resolver', () => {
test('resolves deterministic order with privacy first', () => {
const step = resolveNextOnboardingStep({
privacyRequired: true,
privacyAccepted: false,
claimEligible: true,
claimHasData: true,
migrationRequired: true,
changelogPending: true,
});
expect(step).toBe('privacy');
});
test('resolves claim after privacy is accepted', () => {
const step = resolveNextOnboardingStep({
privacyRequired: true,
privacyAccepted: true,
claimEligible: true,
claimHasData: true,
migrationRequired: true,
changelogPending: true,
});
expect(step).toBe('claim');
});
test('resolves migration when no claim is needed', () => {
const step = resolveNextOnboardingStep({
privacyRequired: true,
privacyAccepted: true,
claimEligible: true,
claimHasData: false,
migrationRequired: true,
changelogPending: true,
});
expect(step).toBe('migration');
});
test('resolves changelog when prior steps are clear', () => {
const step = resolveNextOnboardingStep({
privacyRequired: true,
privacyAccepted: true,
claimEligible: true,
claimHasData: false,
migrationRequired: false,
changelogPending: true,
});
expect(step).toBe('changelog');
});
test('resolves done when no steps are pending (auth and no-auth parity)', () => {
const authStep = resolveNextOnboardingStep({
privacyRequired: true,
privacyAccepted: true,
claimEligible: true,
claimHasData: false,
migrationRequired: false,
changelogPending: false,
});
const noAuthStep = resolveNextOnboardingStep({
privacyRequired: false,
privacyAccepted: false,
claimEligible: false,
claimHasData: false,
migrationRequired: false,
changelogPending: false,
});
expect(authStep).toBe('done');
expect(noAuthStep).toBe('done');
});
});
test.describe('coalesced onboarding runner', () => {
test('coalesces concurrent triggers into one extra rerun', async () => {
let runs = 0;
let nestedRequested = false;
const run = createCoalescedAsyncRunner(async () => {
runs += 1;
if (!nestedRequested) {
nestedRequested = true;
await run();
}
});
await run();
expect(runs).toBe(2);
});
test('does not rerun when no trigger arrives during execution', async () => {
let runs = 0;
const run = createCoalescedAsyncRunner(async () => {
runs += 1;
});
await run();
expect(runs).toBe(1);
});
});