diff --git a/e2e/page-objects/screens/SetupWizard.ts b/e2e/page-objects/screens/SetupWizard.ts index 12a7d76d..0315db93 100644 --- a/e2e/page-objects/screens/SetupWizard.ts +++ b/e2e/page-objects/screens/SetupWizard.ts @@ -19,6 +19,8 @@ export class SetupWizardPage extends BasePage { readonly pythonSetupStep: Locator; + readonly runnerSetupStep: Locator; + readonly completionStep: Locator; // Navigation buttons @@ -36,6 +38,7 @@ export class SetupWizardPage extends BasePage { this.welcomeStep = this.getByTestId('setup-step-welcome'); this.cliInstallStep = this.getByTestId('setup-step-cli'); this.pythonSetupStep = this.getByTestId('setup-step-python'); + this.runnerSetupStep = this.getByTestId('setup-step-runner'); this.completionStep = this.getByTestId('setup-step-complete'); this.nextButton = this.getByTestId('setup-next-btn'); this.backButton = this.getByTestId('setup-back-btn'); @@ -168,12 +171,13 @@ export class SetupWizardPage extends BasePage { * Expect a specific step to be the current/visible step */ async expectCurrentStep( - step: 'welcome' | 'cli' | 'python' | 'complete', + step: 'welcome' | 'cli' | 'python' | 'runner' | 'complete', ): Promise { const stepMap = { welcome: this.welcomeStep, cli: this.cliInstallStep, python: this.pythonSetupStep, + runner: this.runnerSetupStep, complete: this.completionStep, }; await expect(stepMap[step]).toBeVisible(); diff --git a/e2e/tests/setup/first-run.spec.ts b/e2e/tests/setup/first-run.spec.ts index 0138063a..3d04a012 100644 --- a/e2e/tests/setup/first-run.spec.ts +++ b/e2e/tests/setup/first-run.spec.ts @@ -1,6 +1,38 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { Page, ElectronApplication } from '@playwright/test'; -import { test, expect } from '../../fixtures/electron.fixture'; +import { test as base, expect } from '../../fixtures/electron.fixture'; import { SetupWizardPage } from '../../page-objects/screens/SetupWizard'; +import { CURRENT_SCHEMA_VERSION } from '../../../src/main/database/migrations'; + +const test = base.extend<{ seedSetupStep: 'runner' | null }>({ + seedSetupStep: [null, { option: true }], + + // Seed database.json before the app launches so the wizard resumes at the + // requested step (isSetup stays 'false' so the setup window still opens). + userData: async ({ userData, seedSetupStep }, use) => { + if (seedSetupStep === 'runner') { + // The wizard only checks that these paths are non-empty, but main.ts + // re-embeds Python when pythonPath doesn't exist on disk, so point at a + // binary that is guaranteed to exist to avoid a download during tests. + const settings = { + schemaVersion: CURRENT_SCHEMA_VERSION, + settings: { + isSetup: 'false', + pythonPath: process.execPath, + dbtPath: process.execPath, + }, + projects: [], + connections: [], + }; + fs.writeFileSync( + path.join(userData, 'database.json'), + JSON.stringify(settings, null, 2), + ); + } + await use(userData); + }, +}); test.use({ autoSkipSetup: false }); @@ -81,3 +113,34 @@ test.describe('First Run Experience', () => { // TODO: Add test for completing wizard once we have a mock strategy for installation }); + +test.describe('First Run Experience - Local Runner step', () => { + test.use({ seedSetupStep: 'runner' }); + + test('should show local runner install step after python and dbt are set', async ({ + electronApp, + }) => { + const setupWindow = await findSetupWindow(electronApp); + if (!setupWindow) throw new Error('Setup window not found'); + await setupWindow.waitForLoadState('domcontentloaded'); + + const setupWizard = new SetupWizardPage(setupWindow); + await setupWizard.expectToBeVisible(); + + // With pythonPath and dbtPath seeded, the wizard resumes at the runner step + await setupWizard.expectCurrentStep('runner'); + await expect(setupWizard.cliInstallStep).toBeHidden(); + await expect(setupWizard.completionStep).toBeHidden(); + + // Verify runner install button is present + const installBtn = setupWindow.getByTestId('setup-install-runner-btn'); + await expect(installBtn).toBeVisible(); + + // Next button is present but disabled until the runner is installed + await expect(setupWizard.nextButton).toBeVisible(); + await expect(setupWizard.nextButton).toBeDisabled(); + + // Skip is still available as an escape hatch + await expect(setupWizard.skipButton).toBeVisible(); + }); +}); diff --git a/src/renderer/components/finishSetup/index.tsx b/src/renderer/components/finishSetup/index.tsx index 3b067b8c..c45914b6 100644 --- a/src/renderer/components/finishSetup/index.tsx +++ b/src/renderer/components/finishSetup/index.tsx @@ -19,5 +19,11 @@ export const FinishSetup: React.FC = ({ settings }) => ( All set! dbt path: {settings.dbtPath} )} + {settings.runnerPath && ( + + Local runner (version {settings.runnerVersion || 'Unknown'}) is + successfully installed at: {settings.runnerPath} + + )} ); diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index af13ca12..85e60901 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -23,6 +23,7 @@ export * from './errorMessage'; export * from './settings'; export * from './dbtSetup'; export * from './pythonSetup'; +export * from './runnerSetup'; export * from './finishSetup'; export * from './GetStartedModal'; export * from './sidebarConnections'; diff --git a/src/renderer/components/runnerSetup/index.tsx b/src/renderer/components/runnerSetup/index.tsx new file mode 100644 index 00000000..3253052b --- /dev/null +++ b/src/renderer/components/runnerSetup/index.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { + Box, + Typography, + Button, + Alert, + CircularProgress, +} from '@mui/material'; +import { toast } from 'react-toastify'; +import { SettingsType } from '../../../types/backend'; +import { + useCheckRunnerVersions, + useInstallRunnerVersion, +} from '../../controllers'; + +type Props = { + settings: SettingsType; + onInstallComplete: (runnerPath: string) => void; +}; + +export const RunnerSetup: React.FC = ({ + settings, + onInstallComplete, +}) => { + const [latestVersion, setLatestVersion] = React.useState(null); + + const checkVersions = useCheckRunnerVersions({ + onSuccess: (data) => { + setLatestVersion(data.latestStable || null); + }, + onError: (error) => { + toast.error(`Failed to check runner versions: ${error.message}`); + }, + }); + + const installRunner = useInstallRunnerVersion({ + onSuccess: (result) => { + if (result.success) { + toast.info(`Local runner ${result.version} installation completed`); + result.warnings?.forEach((warning) => toast.warning(warning)); + onInstallComplete(result.path); + } else { + toast.error(`Local runner installation failed: ${result.error}`); + } + }, + onError: (error) => { + toast.error(`Local runner installation failed: ${error.message}`); + }, + }); + + React.useEffect(() => { + checkVersions.mutate(); + }, []); + + const isInstalled = Boolean(settings.runnerPath); + let installButtonLabel = isInstalled + ? 'Reinstall Local Runner' + : 'Install Local Runner'; + if (installRunner.isLoading) installButtonLabel = 'Installing...'; + + return ( + + + The local runner executes rosetta pipelines on this machine, the same + way the cloud runner does on the server. + + + {isInstalled ? ( + + Local runner (version {settings.runnerVersion || 'Unknown'}) is + already installed at: {settings.runnerPath} + + ) : ( + + The local runner is not installed yet. Click Install to set it up now. + + )} + + {latestVersion && !installRunner.isLoading && ( + + Latest version: {latestVersion} + + )} + + + + + + ); +}; diff --git a/src/renderer/screens/setup/index.tsx b/src/renderer/screens/setup/index.tsx index 42e4cd7e..15dbc0f8 100644 --- a/src/renderer/screens/setup/index.tsx +++ b/src/renderer/screens/setup/index.tsx @@ -2,7 +2,13 @@ import React from 'react'; import { Box, Button } from '@mui/material'; import { toast } from 'react-toastify'; import { useGetSettings, useUpdateSettings } from '../../controllers'; -import { Loader, FinishSetup, DbtSetup, PythonSetup } from '../../components'; +import { + Loader, + FinishSetup, + DbtSetup, + PythonSetup, + RunnerSetup, +} from '../../components'; import { client } from '../../config/client'; const ADAPTERS = [ @@ -18,6 +24,8 @@ const ADAPTERS = [ }, ]; +const FINAL_STEP = 3; + const Setup: React.FC = () => { const { data: settings, isLoading } = useGetSettings(); const { mutateAsync: updateSettings } = useUpdateSettings(); @@ -44,7 +52,11 @@ const Setup: React.FC = () => { if (settings && !isInitialized) { if (settings.pythonPath && settings.pythonPath !== '') { if (settings.dbtPath && settings.dbtPath !== '') { - setCurrentStep(2); + if (settings.runnerPath && settings.runnerPath !== '') { + setCurrentStep(FINAL_STEP); + } else { + setCurrentStep(2); + } } else { setCurrentStep(1); } @@ -97,6 +109,16 @@ const Setup: React.FC = () => { )} {currentStep === 2 && ( +
+ { + setCurrentStep(currentStep + 1); + }} + /> +
+ )} + {currentStep === FINAL_STEP && (
@@ -113,21 +135,22 @@ const Setup: React.FC = () => { variant="contained" disabled={ (currentStep === 0 && !settings.pythonPath) || - (currentStep === 1 && !settings.dbtPath) + (currentStep === 1 && !settings.dbtPath) || + (currentStep === 2 && !settings.runnerPath) } style={{ marginLeft: 'auto' }} data-testid={ - currentStep === 2 ? 'setup-finish-btn' : 'setup-next-btn' + currentStep === FINAL_STEP ? 'setup-finish-btn' : 'setup-next-btn' } onClick={() => { - if (currentStep === 2) { + if (currentStep === FINAL_STEP) { handleSkip(); return; } setCurrentStep(currentStep + 1); }} > - {currentStep === 2 ? 'Finish' : 'Next'} + {currentStep === FINAL_STEP ? 'Finish' : 'Next'}