From e0a442a308a0e0752e5ec196852282effdd418f2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 15 Oct 2025 15:12:36 -0400 Subject: [PATCH] feat(onboarding): implement first boot completion tracking in OnboardingTracker - Added functionality to the OnboardingTracker to manage first boot completion state, ensuring accurate onboarding flow. - Introduced tests to verify the behavior of first boot completion logic, including scenarios for both new and existing states. - Updated the OnboardingService to utilize the new first boot completion check, enhancing the onboarding process by preventing redundant setups. This update improves the onboarding experience by accurately tracking first boot completion, ensuring a smoother initialization process for users. --- api/src/unraid-api/config/api-config.test.ts | 56 +++++++++++ .../config/onboarding-tracker.model.ts | 1 + .../config/onboarding-tracker.module.ts | 25 ++++- .../customization/activation-steps.util.ts | 76 +++++++++++++++ .../customization/onboarding.service.spec.ts | 71 ++++++++++---- .../customization/onboarding.service.ts | 96 +++---------------- 6 files changed, 223 insertions(+), 102 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/customization/activation-steps.util.ts diff --git a/api/src/unraid-api/config/api-config.test.ts b/api/src/unraid-api/config/api-config.test.ts index 77d2467f8..87d877009 100644 --- a/api/src/unraid-api/config/api-config.test.ts +++ b/api/src/unraid-api/config/api-config.test.ts @@ -160,6 +160,62 @@ describe('OnboardingTracker', () => { mockPathsState.activationBase = '/activation'; }); + it('marks first boot as completed when no prior state exists', async () => { + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === trackerPath) { + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + } + if (filePath === versionFilePath) { + return 'version="7.2.0"\n'; + } + if (filePath === UPGRADE_MARKER_PATH) { + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + const tracker = new OnboardingTracker(configService); + const alreadyCompleted = await tracker.ensureFirstBootCompleted(); + + expect(alreadyCompleted).toBe(false); + expect(mockAtomicWriteFile).toHaveBeenCalledWith( + trackerPath, + expect.stringContaining('"firstBootCompletedAt"'), + { mode: 0o644 } + ); + expect(setMock).toHaveBeenCalledWith( + 'onboardingTracker.firstBootCompletedAt', + expect.any(String) + ); + }); + + it('returns true when first boot was already recorded', async () => { + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === trackerPath) { + return JSON.stringify({ + firstBootCompletedAt: '2025-01-01T00:00:00.000Z', + }); + } + if (filePath === versionFilePath) { + return 'version="7.2.0"\n'; + } + if (filePath === UPGRADE_MARKER_PATH) { + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + const tracker = new OnboardingTracker(configService); + const alreadyCompleted = await tracker.ensureFirstBootCompleted(); + + expect(alreadyCompleted).toBe(true); + expect(mockAtomicWriteFile).not.toHaveBeenCalled(); + expect(setMock).toHaveBeenCalledWith( + 'onboardingTracker.firstBootCompletedAt', + '2025-01-01T00:00:00.000Z' + ); + }); + it('keeps previous version when shutting down with pending steps', async () => { mockReadFile.mockImplementation(async (filePath) => { if (filePath === versionFilePath) { diff --git a/api/src/unraid-api/config/onboarding-tracker.model.ts b/api/src/unraid-api/config/onboarding-tracker.model.ts index 41ed2ffe9..ab5ab283a 100644 --- a/api/src/unraid-api/config/onboarding-tracker.model.ts +++ b/api/src/unraid-api/config/onboarding-tracker.model.ts @@ -9,6 +9,7 @@ export type TrackerState = { lastTrackedVersion?: string; updatedAt?: string; completedSteps?: Record; + firstBootCompletedAt?: string; }; export type UpgradeStepState = { diff --git a/api/src/unraid-api/config/onboarding-tracker.module.ts b/api/src/unraid-api/config/onboarding-tracker.module.ts index ded1d6840..5ecf8f74f 100644 --- a/api/src/unraid-api/config/onboarding-tracker.module.ts +++ b/api/src/unraid-api/config/onboarding-tracker.module.ts @@ -15,7 +15,7 @@ import { compare } from 'semver'; import type { ActivationStepContext, ActivationStepDefinition, -} from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; +} from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; import { PATHS_CONFIG_MODULES } from '@app/environment.js'; import { getters } from '@app/store/index.js'; import { @@ -28,7 +28,7 @@ import { ActivationOnboardingStepId } from '@app/unraid-api/graph/resolvers/cust import { findActivationCodeFile, resolveActivationStepDefinitions, -} from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; +} from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; const TRACKER_FILE_NAME = 'onboarding-tracker.json'; const CONFIG_PREFIX = 'onboardingTracker'; @@ -119,6 +119,26 @@ export class OnboardingTracker implements OnApplicationBootstrap, OnApplicationS await this.clearUpgradeMarker(); } + async ensureFirstBootCompleted(): Promise { + await this.ensureStateLoaded(); + + if (this.state.firstBootCompletedAt) { + this.syncConfig(this.currentVersion); + return true; + } + + const timestamp = new Date().toISOString(); + const updatedState: TrackerState = { + ...this.state, + firstBootCompletedAt: timestamp, + updatedAt: timestamp, + }; + + await this.writeTrackerState(updatedState); + this.syncConfig(this.currentVersion); + return false; + } + async getUpgradeSnapshot(): Promise { const currentVersion = this.currentVersion ?? @@ -337,6 +357,7 @@ export class OnboardingTracker implements OnApplicationBootstrap, OnApplicationS this.configService.set('store.emhttp.var.version', currentVersion); this.configService.set(`${CONFIG_PREFIX}.lastTrackedVersion`, this.sessionLastTrackedVersion); this.configService.set(`${CONFIG_PREFIX}.completedSteps`, completedStepsMap); + this.configService.set(`${CONFIG_PREFIX}.firstBootCompletedAt`, this.state.firstBootCompletedAt); } private async readCurrentVersion(): Promise { diff --git a/api/src/unraid-api/graph/resolvers/customization/activation-steps.util.ts b/api/src/unraid-api/graph/resolvers/customization/activation-steps.util.ts new file mode 100644 index 000000000..c72778f2e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/customization/activation-steps.util.ts @@ -0,0 +1,76 @@ +import { Logger } from '@nestjs/common'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { ActivationOnboardingStepId } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; + +export async function findActivationCodeFile( + activationDir: string, + extension = '.activationcode', + logger?: Logger +): Promise { + try { + await fs.access(activationDir); + const files = await fs.readdir(activationDir); + const activationFile = files.find((file) => file.endsWith(extension)); + return activationFile ? path.join(activationDir, activationFile) : null; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + logger?.debug?.( + `Activation directory ${activationDir} not found when searching for activation code.` + ); + } else if (error instanceof Error) { + logger?.error?.('Error accessing activation directory or reading its content.', error); + } + return null; + } +} + +export type ActivationStepContext = { + hasActivationCode: boolean; + regState?: string; +}; + +export type ActivationStepDefinition = { + id: ActivationOnboardingStepId; + required: boolean; + introducedIn: string; + condition?: (context: ActivationStepContext) => boolean | Promise; +}; + +const activationStepDefinitions: ActivationStepDefinition[] = [ + { + id: ActivationOnboardingStepId.WELCOME, + required: false, + introducedIn: '7.0.0', + }, + { + id: ActivationOnboardingStepId.TIMEZONE, + required: true, + introducedIn: '7.0.0', + }, + { + id: ActivationOnboardingStepId.PLUGINS, + required: false, + introducedIn: '7.0.0', + }, + { + id: ActivationOnboardingStepId.ACTIVATION, + required: true, + introducedIn: '7.0.0', + condition: (context) => + context.hasActivationCode && Boolean(context.regState?.startsWith('ENOKEYFILE')), + }, +]; + +export async function resolveActivationStepDefinitions( + context: ActivationStepContext +): Promise { + const results: ActivationStepDefinition[] = []; + for (const definition of activationStepDefinitions) { + if (!definition.condition || (await definition.condition(context))) { + results.push(definition); + } + } + return results; +} diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts index 35e4871c7..b25a6e5c1 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; import { getters } from '@app/store/index.js'; +import { OnboardingTracker } from '@app/unraid-api/config/onboarding-tracker.module.js'; import { ActivationCode } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; @@ -96,6 +97,10 @@ vi.mock('@app/core/utils/misc/sleep.js', async () => { }; }); +const onboardingTrackerMock = { + ensureFirstBootCompleted: vi.fn<() => Promise>(), +}; + describe('OnboardingService', () => { let service: OnboardingService; let loggerDebugSpy; @@ -106,7 +111,6 @@ describe('OnboardingService', () => { // Resolved mock paths const activationDir = mockPaths.activationBase; const assetsDir = mockPaths.activation.assets; - const doneFlag = path.join(activationDir, 'applied.txt'); const userDynamixCfg = mockPaths['dynamix-config'][1]; const identCfg = mockPaths.identConfig; const webguiImagesDir = mockPaths.webguiImagesBase; @@ -138,9 +142,27 @@ describe('OnboardingService', () => { loggerLogSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); loggerWarnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + onboardingTrackerMock.ensureFirstBootCompleted.mockReset(); + onboardingTrackerMock.ensureFirstBootCompleted.mockResolvedValue(false); + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any); + vi.mocked(fs.access).mockReset(); + vi.mocked(fs.readdir).mockReset(); + vi.mocked(fs.readFile).mockReset(); + vi.mocked(fs.writeFile).mockReset(); + vi.mocked(fs.copyFile).mockReset(); + vi.mocked(fileExists).mockReset(); + vi.mocked(fs.access).mockResolvedValue(undefined as any); + vi.mocked(fs.readdir).mockResolvedValue([]); + vi.mocked(fs.readFile).mockResolvedValue(''); + vi.mocked(fs.writeFile).mockResolvedValue(undefined as any); + vi.mocked(fs.copyFile).mockResolvedValue(undefined as any); + vi.mocked(fileExists).mockResolvedValue(false); const module: TestingModule = await Test.createTestingModule({ - providers: [OnboardingService], + providers: [ + OnboardingService, + { provide: OnboardingTracker, useValue: onboardingTrackerMock }, + ], }).compile(); service = module.get(OnboardingService); @@ -154,6 +176,7 @@ describe('OnboardingService', () => { afterEach(() => { vi.useRealTimers(); + mockPaths['dynamix-config'] = ['/mock/default.cfg', '/mock/user/dynamix.cfg']; }); it('should be defined', () => { @@ -168,13 +191,9 @@ describe('OnboardingService', () => { await service.onModuleInit(); expect(loggerErrorSpy).toHaveBeenCalledWith( - 'Error accessing activation directory or reading its content.', - expect.objectContaining({ - message: "Cannot read properties of undefined (reading 'find')", - }) + 'User dynamix config path missing. Skipping activation setup.' ); - // The implementation actually calls writeFile to create the flag - // so we don't check that it's not called here + expect(onboardingTrackerMock.ensureFirstBootCompleted).not.toHaveBeenCalled(); mockPaths['dynamix-config'] = originalDynamixConfig; }); @@ -189,7 +208,6 @@ describe('OnboardingService', () => { 'Error during activation check/setup on init:', accessError ); - expect(fs.writeFile).not.toHaveBeenCalledWith(doneFlag, 'true'); // Should not proceed }); it('should skip setup if activation directory does not exist', async () => { @@ -204,16 +222,20 @@ describe('OnboardingService', () => { expect(loggerLogSpy).toHaveBeenCalledWith( `Activation directory ${activationDir} not found. Skipping activation setup.` ); - expect(vi.mocked(fs.writeFile)).not.toHaveBeenCalledWith(doneFlag, 'true'); // Should not create .done flag expect(fs.readdir).not.toHaveBeenCalled(); // Should not try to read dir }); - it('should skip customizations if .done flag exists', async () => { - vi.mocked(fileExists).mockImplementation(async (p) => p === doneFlag); // .done file exists + it('should skip customizations when first boot already completed', async () => { + onboardingTrackerMock.ensureFirstBootCompleted.mockResolvedValueOnce(true); await service.onModuleInit(); - expect(fs.readdir).not.toHaveBeenCalled(); // Should not read activation dir for JSON + expect(onboardingTrackerMock.ensureFirstBootCompleted).toHaveBeenCalledTimes(1); + expect(fs.readdir).not.toHaveBeenCalled(); + expect(loggerLogSpy).toHaveBeenCalledWith('First boot setup flag file already exists.'); + expect(loggerLogSpy).toHaveBeenCalledWith( + 'First boot setup previously completed, skipping customizations.' + ); }); it('should create flag and apply customizations if activation dir exists and flag is missing', async () => { @@ -238,7 +260,7 @@ describe('OnboardingService', () => { await promise; // Check .done flag creation - expect(fs.writeFile).toHaveBeenCalledWith(doneFlag, 'true'); + expect(onboardingTrackerMock.ensureFirstBootCompleted).toHaveBeenCalledTimes(1); expect(loggerLogSpy).toHaveBeenCalledWith('First boot setup flag file created.'); // Check activation data loaded @@ -307,8 +329,8 @@ describe('OnboardingService', () => { await promise; // --- Assertions --- - // 1. .done flag is still created - expect(fs.writeFile).toHaveBeenCalledWith(doneFlag, 'true'); + // 1. First boot completion is recorded + expect(onboardingTrackerMock.ensureFirstBootCompleted).toHaveBeenCalledTimes(1); expect(loggerLogSpy).toHaveBeenCalledWith('First boot setup flag file created.'); // 2. Activation data loaded @@ -469,7 +491,6 @@ describe('OnboardingService', () => { beforeEach(() => { // Setup service state as if onModuleInit ran successfully before customizations (service as any).activationDir = activationDir; - (service as any).hasRunFirstBootSetup = doneFlag; (service as any).configFile = userDynamixCfg; (service as any).caseModelCfg = caseModelCfg; (service as any).identCfg = identCfg; @@ -891,9 +912,15 @@ describe('applyActivationCustomizations specific tests', () => { loggerLogSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); loggerWarnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + onboardingTrackerMock.ensureFirstBootCompleted.mockReset(); + onboardingTrackerMock.ensureFirstBootCompleted.mockResolvedValue(false); + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any); const module: TestingModule = await Test.createTestingModule({ - providers: [OnboardingService], + providers: [ + OnboardingService, + { provide: OnboardingTracker, useValue: onboardingTrackerMock }, + ], }).compile(); service = module.get(OnboardingService); @@ -1043,10 +1070,16 @@ describe('OnboardingService - updateCfgFile', () => { vi.clearAllMocks(); loggerLogSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + onboardingTrackerMock.ensureFirstBootCompleted.mockReset(); + onboardingTrackerMock.ensureFirstBootCompleted.mockResolvedValue(false); + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any); // Need to compile a module to get an instance, even though we test a private method const module: TestingModule = await Test.createTestingModule({ - providers: [OnboardingService], + providers: [ + OnboardingService, + { provide: OnboardingTracker, useValue: onboardingTrackerMock }, + ], }).compile(); service = module.get(OnboardingService); diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts index e5d14a5bd..db2ad7a12 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts @@ -12,118 +12,52 @@ import { fileExists } from '@app/core/utils/files/file-exists.js'; import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js'; import { getters, store } from '@app/store/index.js'; import { updateDynamixConfig } from '@app/store/modules/dynamix.js'; +import { OnboardingTracker } from '@app/unraid-api/config/onboarding-tracker.module.js'; import { ActivationCode, - ActivationOnboardingStepId, PublicPartnerInfo, } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { findActivationCodeFile } from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; -export async function findActivationCodeFile( - activationDir: string, - extension = '.activationcode', - logger?: Logger -): Promise { - try { - await fs.access(activationDir); - const files = await fs.readdir(activationDir); - const activationFile = files.find((file) => file.endsWith(extension)); - return activationFile ? path.join(activationDir, activationFile) : null; - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - logger?.debug?.( - `Activation directory ${activationDir} not found when searching for activation code.` - ); - } else if (error instanceof Error) { - logger?.error?.('Error accessing activation directory or reading its content.', error); - } - return null; - } -} - -export type ActivationStepContext = { - hasActivationCode: boolean; - regState?: string; -}; - -export type ActivationStepDefinition = { - id: ActivationOnboardingStepId; - required: boolean; - introducedIn: string; - condition?: (context: ActivationStepContext) => boolean | Promise; -}; - -const activationStepDefinitions: ActivationStepDefinition[] = [ - { - id: ActivationOnboardingStepId.WELCOME, - required: false, - introducedIn: '7.0.0', - }, - { - id: ActivationOnboardingStepId.TIMEZONE, - required: true, - introducedIn: '7.0.0', - }, - { - id: ActivationOnboardingStepId.PLUGINS, - required: false, - introducedIn: '7.0.0', - }, - { - id: ActivationOnboardingStepId.ACTIVATION, - required: true, - introducedIn: '7.0.0', - condition: (context) => - context.hasActivationCode && Boolean(context.regState?.startsWith('ENOKEYFILE')), - }, -]; - -export async function resolveActivationStepDefinitions( - context: ActivationStepContext -): Promise { - const results: ActivationStepDefinition[] = []; - for (const definition of activationStepDefinitions) { - if (!definition.condition || (await definition.condition(context))) { - results.push(definition); - } - } - return results; -} - @Injectable() export class OnboardingService implements OnModuleInit { private readonly logger = new Logger(OnboardingService.name); private readonly activationJsonExtension = '.activationcode'; - private readonly activationAppliedFilename = 'applied.txt'; private activationDir!: string; - private hasRunFirstBootSetup!: string; private configFile!: string; private caseModelCfg!: string; private identCfg!: string; private activationData: ActivationCode | null = null; - async createOrGetFirstBootSetupFlag(): Promise { + constructor(private readonly onboardingTracker: OnboardingTracker) {} + + private async ensureFirstBootCompletion(): Promise { await fs.mkdir(this.activationDir, { recursive: true }); - if (await fileExists(this.hasRunFirstBootSetup)) { + const alreadyCompleted = await this.onboardingTracker.ensureFirstBootCompleted(); + if (alreadyCompleted) { this.logger.log('First boot setup flag file already exists.'); - return true; // Indicate setup was already done based on flag presence + return true; } - await fs.writeFile(this.hasRunFirstBootSetup, 'true'); this.logger.log('First boot setup flag file created.'); - return false; // Indicate setup was just marked as done + return false; } async onModuleInit() { const paths = getters.paths(); this.activationDir = paths.activationBase; - this.hasRunFirstBootSetup = path.join(this.activationDir, this.activationAppliedFilename); this.configFile = paths['dynamix-config']?.[1]; this.identCfg = paths.identConfig; this.logger.log('OnboardingService initialized with paths from store.'); + if (!this.configFile) { + this.logger.error('User dynamix config path missing. Skipping activation setup.'); + return; + } + try { // Check if activation dir exists using the initialized path try { @@ -140,7 +74,7 @@ export class OnboardingService implements OnModuleInit { } // Proceed with first boot check and activation data retrieval ONLY if dir exists - const hasRunFirstBootSetup = await this.createOrGetFirstBootSetupFlag(); + const hasRunFirstBootSetup = await this.ensureFirstBootCompletion(); if (hasRunFirstBootSetup) { this.logger.log('First boot setup previously completed, skipping customizations.'); return;