Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ const failResponse: StepValidationResponse = {
checkedAt: '2026-07-15T10:00:00.000Z',
};

const passResponse: StepValidationResponse = {
roadmapId: roadmap.id,
stepId: 'create-web-server',
stepPassed: true,
results: [
{
index: 0,
type: 'container_running',
status: 'pass',
message: 'The container "web" is running.',
},
],
checkedAt: '2026-07-15T10:01:00.000Z',
};

function jsonResponse(ok: boolean, body: unknown): Response {
return { ok, json: () => Promise.resolve(body) } as Response;
}
Expand Down Expand Up @@ -149,7 +164,7 @@ describe('LearningPanel', () => {
expect(screen.getByText('Step 1 of 2')).toBeInTheDocument();
});

it('validates the current step and renders the raw results', async () => {
it('validates the current step and renders pedagogical feedback', async () => {
vi.stubGlobal('fetch', buildFetchMock({}));
render(<LearningPanel projectId="p1" onClose={() => {}} />);
await openRoadmapFromCatalog();
Expand All @@ -161,6 +176,50 @@ describe('LearningPanel', () => {
screen.getByText('No container named "web" exists in this project yet.')
).toBeInTheDocument();
expect(screen.getByText(/a running container named "web"/)).toBeInTheDocument();
// The step list reflects the failure, and the raw status/type strings are gone.
expect(screen.getByTitle('Failed')).toBeInTheDocument();
expect(screen.queryByText('[fail]')).not.toBeInTheDocument();
expect(screen.queryByText('container_running')).not.toBeInTheDocument();
});

it('replaces failure feedback cleanly when a revalidation passes', async () => {
let firstAttempt = true;
vi.stubGlobal(
'fetch',
buildFetchMock({
validate: () => {
const body = firstAttempt ? failResponse : passResponse;
firstAttempt = false;
return jsonResponse(true, body);
},
})
);
render(<LearningPanel projectId="p1" onClose={() => {}} />);
await openRoadmapFromCatalog();

fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
expect(await screen.findByText('Not yet — see the results below')).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
expect(await screen.findByText('Step passed')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Next step' })).toBeInTheDocument();
expect(screen.getByTitle('Passed')).toBeInTheDocument();
// No artifacts from the failed attempt survive.
expect(screen.queryByText('Not yet — see the results below')).not.toBeInTheDocument();
expect(
screen.queryByText('No container named "web" exists in this project yet.')
).not.toBeInTheDocument();
});

it('advances to the next step from the success banner', async () => {
vi.stubGlobal('fetch', buildFetchMock({ validate: () => jsonResponse(true, passResponse) }));
render(<LearningPanel projectId="p1" onClose={() => {}} />);
await openRoadmapFromCatalog();

fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
fireEvent.click(await screen.findByRole('button', { name: 'Next step' }));

expect(screen.getByText('Step 2 of 2')).toBeInTheDocument();
});

it('shows an understandable error with retry when the backend is unreachable during validation', async () => {
Expand Down
33 changes: 20 additions & 13 deletions frontend/src/features/learning/components/RoadmapPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { useTranslation } from 'react-i18next';
import { ArrowLeft, ChevronLeft, ChevronRight, Globe, Copy, Check } from 'lucide-react';
import StepValidationResults from './StepValidationResults';
import { outcomePreset } from '../validationStatus';
import type { StepValidationResponse } from '../../../shared/types/roadmap';
import type { useLearningPlayer } from '../hooks/useLearningPlayer';
import type { ContainerData } from '../../../shared/types';
import type { NetworkConfig } from '../../../shared/types/network';
Expand Down Expand Up @@ -49,6 +51,16 @@
);
}

function StepMarker({ response }: { response: StepValidationResponse }) {
const { t } = useTranslation();
const { icon: Icon, color, labelKey } = outcomePreset(response);
return (
<span style={styles.stepMarker} title={t(labelKey)}>
<Icon size={13} color={color} role="img" aria-label={t(labelKey)} />
</span>
);
}

function renderInstruction(text: string) {
if (!text) return null;

Expand Down Expand Up @@ -183,16 +195,7 @@
>
<span style={styles.stepIndex}>{index + 1}.</span>
<span style={styles.stepItemTitle}>{step.title}</span>
{result && (
<span
style={{
...styles.stepMarker,
color: result.stepPassed ? 'var(--color-success)' : 'var(--color-danger)',
}}
>
{result.stepPassed ? '✓' : '✗'}
</span>
)}
{result && <StepMarker response={result} />}
</button>
);
})}
Expand All @@ -213,7 +216,7 @@

const sgRules = networkConfig.nodeSecurityGroups?.[container.id] || [];
const allowsPort80 = sgRules.some(
(r: any) =>

Check warning on line 219 in frontend/src/features/learning/components/RoadmapPlayer.tsx

View workflow job for this annotation

GitHub Actions / frontend-build-and-test

Unexpected any. Specify a different type
r.type === 'inbound' &&
r.action === 'ALLOW' &&
(r.port === '80' || r.port === 'ALL') &&
Expand Down Expand Up @@ -291,7 +294,11 @@
)}

{resultsByStepId[currentStep.id] && (
<StepValidationResults response={resultsByStepId[currentStep.id]} />
<StepValidationResults
response={resultsByStepId[currentStep.id]}
isLastStep={atLastStep}
onNextStep={() => goToStep(currentStepIndex + 1)}
/>
)}
</div>
);
Expand Down Expand Up @@ -356,8 +363,8 @@
flex: 1,
},
stepMarker: {
fontSize: '12px',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
},
currentStep: {
display: 'flex',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import '../../../i18n';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import StepValidationResults from './StepValidationResults';
import type { StepValidationResponse, ValidatorResult } from '../../../shared/types/roadmap';

const passResult: ValidatorResult = {
index: 0,
type: 'container_running',
status: 'pass',
message: 'The container "web" is running.',
};

const failResult: ValidatorResult = {
index: 0,
type: 'container_running',
status: 'fail',
message: 'No container named "web" exists in this project yet.',
expected: 'a running container named "web"',
observed: 'no container with that name',
};

const errorResult: ValidatorResult = {
index: 1,
type: 'table_exists',
status: 'error',
message: 'Something went wrong while talking to Docker.',
errorCode: 'DOCKER_ERROR',
};

function buildResponse(results: ValidatorResult[]): StepValidationResponse {
return {
roadmapId: 'example-first-architecture',
stepId: 'create-web-server',
stepPassed: results.every(result => result.status === 'pass'),
results,
checkedAt: '2026-07-15T10:00:00.000Z',
};
}

function renderResults(
response: StepValidationResponse,
{ isLastStep = false, onNextStep = vi.fn() } = {}
) {
render(<StepValidationResults response={response} isLastStep={isLastStep} onNextStep={onNextStep} />);
return { onNextStep };
}

describe('StepValidationResults', () => {
it('renders the success banner with a Next step button that advances', () => {
const { onNextStep } = renderResults(buildResponse([passResult]));

expect(screen.getByText('Step passed')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next step' }));
expect(onNextStep).toHaveBeenCalledOnce();
});

it('celebrates the roadmap on the last step and offers no Next step button', () => {
renderResults(buildResponse([passResult]), { isLastStep: true });

expect(
screen.getByText('Step passed — that was the last one. Roadmap complete!')
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument();
});

it('renders a pedagogical failure: banner, message, expected and observed', () => {
renderResults(buildResponse([failResult]));

expect(screen.getByText('Not yet — see the results below')).toBeInTheDocument();
expect(
screen.getByText('No container named "web" exists in this project yet.')
).toBeInTheDocument();
expect(screen.getByText('a running container named "web"')).toBeInTheDocument();
expect(screen.getByText('no container with that name')).toBeInTheDocument();
});

it('renders an infrastructure error distinctly, without the raw error code', () => {
renderResults(buildResponse([errorResult]));

expect(
screen.getByText("Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.")
).toBeInTheDocument();
expect(screen.getByText("Check couldn't run")).toBeInTheDocument();
expect(screen.queryByText('DOCKER_ERROR')).not.toBeInTheDocument();
});

it('lets an error win over a failure in the banner while still listing the failure', () => {
renderResults(buildResponse([failResult, errorResult]));

expect(
screen.getByText("Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.")
).toBeInTheDocument();
expect(screen.queryByText('Not yet — see the results below')).not.toBeInTheDocument();
expect(
screen.getByText('No container named "web" exists in this project yet.')
).toBeInTheDocument();
});

it('uses the Docker-specific wording when the daemon is unreachable', () => {
renderResults(
buildResponse([{ ...errorResult, errorCode: 'DOCKER_UNAVAILABLE' }])
);

expect(
screen.getByText("Docker wasn't running, so the checks couldn't run. Start Docker and validate again.")
).toBeInTheDocument();
});
});
Loading
Loading