feat(onboarding): migrate source-backed setup guidance

This commit is contained in:
cesnimda
2026-10-12 01:54:24 +02:00
parent ad7b4196af
commit d21501aa7e
16 changed files with 9415 additions and 89 deletions
@@ -0,0 +1,283 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test as base, type APIRequestContext, type BrowserContext, type Page, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { email: string; id: string; userName: string; headers: Record<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
async function login(api: APIRequestContext, email: string): Promise<Owner> {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const response = await api.get(`${apiUrl}/auth/me`);
expect(response.status()).toBe(200);
const me = await response.json();
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
expect(csrf).toBeDefined();
expect(me.sessionContext).toEqual(expect.any(String));
return { email, id: me.id, userName: me.userName, headers: {
'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext,
} };
}
async function createOwner(admin: APIRequestContext, adminHeaders: Record<string, string>, suffix: string) {
const email = `p7-setup-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
return email;
}
async function localOnly(context: BrowserContext) {
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
? route.continue() : route.abort();
});
}
const test = base.extend<{ owner: Owner }>({
owner: async ({ page, context, playwright }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminOwner = await login(admin, 'e2e@example.test');
const email = await createOwner(admin, adminOwner.headers, 'a');
const owner = await login(page.request, email);
await localOnly(context);
await context.addInitScript(() => {
if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en');
if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light');
});
await provide(owner);
} finally { await admin.dispose(); }
},
});
async function enter(page: Page, owner: Owner, sample: Sample) {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers,
data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } } )).status()).toBe(204);
await page.setViewportSize({ width: sample.width, height: sample.height });
await page.goto('/dashboard');
await expect(page.getByRole('main')).toBeVisible();
await page.evaluate(({ language, theme }) => {
localStorage.setItem('uiLanguage', language);
localStorage.setItem('jobtracker.themeMode', theme);
}, sample);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.reload();
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
}
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
const geometry = await page.evaluate(() => ({
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
width: innerWidth, height: innerHeight,
}));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p7-setup-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry,
ariaSnapshot: await page.getByRole('main').ariaSnapshot(),
axe: { scope: 'All enabled rules with explicit target-size; every violation/incomplete retained',
violations: axe.violations, incomplete: axe.incomplete,
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
limits: ['No human screen reader, physical touch or software keyboard', 'No real mailbox/provider or production qualification', 'Finite sample, no actual browser zoom'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow).toBe(0);
}
async function assertDismissedAfterSettledNavigation(page: Page, navigate: () => Promise<void>) {
const statusPaths = ['/api/career/profile', '/api/gmail/status', '/api/microsoft-graph/status', '/api/imap/status'];
const reads: string[] = [];
const observe = (request: import('@playwright/test').Request) => {
const path = new URL(request.url()).pathname;
if (request.method() === 'GET' && statusPaths.includes(path)) reads.push(path);
};
page.on('request', observe);
try {
await navigate();
await expect(page.getByRole('heading', { name: translations.en.dashboardStartTitle, exact: true })).toBeVisible();
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
await expect(page.getByRole('region', { name: translations.en.onboardingTitle, exact: true })).toHaveCount(0);
expect(reads, 'a persisted dismissal skips checklist status reads after its private Dashboard has settled').toEqual([]);
} finally { page.off('request', observe); }
}
const matrix: Sample[] = [
{ language: 'nb', theme: 'dark', width: 320, height: 844 },
{ language: 'en', theme: 'light', width: 360, height: 800 },
{ language: 'nb', theme: 'light', width: 390, height: 844 },
{ language: 'en', theme: 'dark', width: 768, height: 1024 },
{ language: 'nb', theme: 'dark', width: 1024, height: 768 },
{ language: 'en', theme: 'light', width: 1280, height: 320 },
{ language: 'nb', theme: 'light', width: 1440, height: 900 },
{ language: 'en', theme: 'dark', width: 1920, height: 1080 },
];
for (const sample of matrix) {
test(`[P7-02 PRES-S02 C01 C02 E09] ${sample.language}/${sample.theme} ${sample.width}px setup counts saved manual career information without claiming CV import or email verification`, async ({ page, owner }, info) => {
test.setTimeout(120_000);
expect((await page.request.put(`${apiUrl}/auth/profile`, { headers: owner.headers, data: {
userName: owner.userName, firstName: 'Åse', lastName: 'Oppsett', displayName: 'Åse Oppsett',
} })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile: { version: '1', contact: { fullName: 'Canonical career name' }, skills: ['Manually entered C#'] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Oppsett AS' } });
expect(company.status()).toBe(201);
expect((await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers,
data: { companyId: (await company.json()).id, jobTitle: 'Recorded first job', status: 'Saved' } })).status()).toBe(201);
const beforeCareer = await (await page.request.get(`${apiUrl}/career/profile`)).json();
expect(await (await page.request.get(`${apiUrl}/cv/variants`)).json()).toEqual([]);
await enter(page, owner, sample);
const t = translations[sample.language];
const region = page.getByRole('region', { name: t.onboardingTitle, exact: true });
await expect(region.getByRole('heading', { level: 2, name: t.onboardingTitle })).toBeVisible();
await expect(region.getByRole('progressbar', { name: t.checklistProgress.replace('{completed}', '3').replace('{total}', '4') })).toHaveAttribute('aria-valuenow', '75');
await expect(region.getByText(t.onboardingStepCv, { exact: true })).toBeVisible();
await expect(region.getByText(t.onboardingStepVerify, { exact: true })).toHaveCount(0);
await expect(region.getByText(t.onboardingStepSignup, { exact: true })).toHaveCount(0);
await expect(region.getByRole('button', { name: t.onboardingStepCvAction, exact: true })).toHaveCount(0);
await capture(page, info, sample, 'manual-data-3-of-4');
await page.reload();
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '75');
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(beforeCareer);
expect(await (await page.request.get(`${apiUrl}/cv/variants`)).json()).toEqual([]);
});
}
test('[P7-02 PRES-S02 E09] dismissal survives reload and remains isolated across actual A/B logout and login', async ({ page, owner, playwright }) => {
test.setTimeout(120_000);
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
await enter(page, owner, sample);
const region = page.getByRole('region', { name: translations.en.onboardingTitle, exact: true });
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await region.getByRole('button', { name: translations.en.onboardingDismiss }).focus();
await page.keyboard.press('Enter');
await expect(region).toHaveCount(0);
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: translations.en.dashboardCustomize, exact: true })).toBeFocused();
expect(await page.evaluate(id => localStorage.getItem(`onboardingDismissed:${id}`), owner.id)).toBe('true');
await assertDismissedAfterSettledNavigation(page, async () => { await page.reload(); });
const admin = await playwright.request.newContext();
let bEmail: string;
try {
const adminOwner = await login(admin, 'e2e@example.test');
bEmail = await createOwner(admin, adminOwner.headers, 'b');
} finally { await admin.dispose(); }
async function switchTo(email: string) {
await page.getByRole('button', { name: 'User', exact: true }).click();
await page.getByRole('menuitem', { name: 'Sign out', exact: true }).click();
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
await page.getByLabel('Username or email', { exact: true }).fill(email);
await page.getByLabel('Current password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('main')).toBeVisible();
const response = await page.request.get(`${apiUrl}/auth/me`);
expect(response.status()).toBe(200);
const me = await response.json();
expect(me.email).toBe(email);
await expect.poll(() => page.evaluate(() => localStorage.getItem('authUserKey'))).toBe(me.id);
await expect(page.getByRole('heading', { name: translations.en.dashboardStartTitle, exact: true })).toBeVisible();
}
await switchTo(bEmail!);
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await page.reload();
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await assertDismissedAfterSettledNavigation(page, async () => { await switchTo(owner.email); });
expect(await page.evaluate(id => localStorage.getItem(`onboardingDismissed:${id}`), owner.id)).toBe('true');
});
test('[P7-02 PRES-S02 E08] a failed mailbox read cannot invent setup progress and retry only reads', async ({ page, owner }, info) => {
const sample: Sample = { language: 'nb', theme: 'dark', width: 320, height: 844 };
let failedReads = 0;
await page.route(`${apiUrl}/gmail/status`, route => { failedReads++; return route.fulfill({ status: 500, json: { message: 'Setup connection read unavailable' } }); });
await enter(page, owner, sample);
const t = translations.nb;
const region = page.getByRole('region', { name: t.onboardingTitle, exact: true });
await expect(region.getByRole('alert')).toBeVisible();
await expect(region.getByRole('progressbar')).toHaveCount(0);
await expect(region.getByRole('button', { name: t.onboardingStepEmailAction, exact: true })).toHaveCount(0);
expect(failedReads).toBeGreaterThan(0);
await capture(page, info, sample, 'failed-mailbox-read-unknown');
await page.unroute(`${apiUrl}/gmail/status`);
const writes: string[] = [];
page.on('request', request => { if (request.url().startsWith(apiUrl) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(request.method() + ' ' + new URL(request.url()).pathname); });
await region.getByRole('button', { name: t.retry, exact: true }).focus();
await page.keyboard.press('Enter');
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await expect(region.getByRole('alert')).toHaveCount(0);
await expect(region.getByRole('button', { name: t.onboardingDismiss, exact: true })).toBeFocused();
expect(writes).toEqual([]);
await capture(page, info, sample, 'read-only-retry-known');
});
test('[P7-02 PRES-S02 C02 E07] keyboard setup actions retain their four destinations and browser Back context', async ({ page, owner }) => {
test.setTimeout(120_000);
await enter(page, owner, { language: 'en', theme: 'light', width: 390, height: 844 });
const region = page.getByRole('region', { name: translations.en.onboardingTitle, exact: true });
for (const [name, destination] of [
[translations.en.onboardingStepProfileAction, '/profile'],
[translations.en.onboardingStepCvAction, '/career'],
[translations.en.onboardingStepEmailAction, '/settings/connected-accounts'],
[translations.en.onboardingStepJobAction, '/jobs'],
]) {
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await region.getByRole('button', { name, exact: true }).focus();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(new RegExp(destination.replaceAll('/', '\\/') + '$'));
await expect(page.getByRole('main')).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/dashboard$/);
}
});
test('[P7-02 PRES-S01 S02 E08] failed Dashboard summary keeps first-job guidance unknown until its existing read owner retries', async ({ page, owner }, info) => {
const sample: Sample = { language: 'en', theme: 'dark', width: 390, height: 844 };
await page.route(`${apiUrl}/jobapplications/stats`, route => route.fulfill({ status: 500, json: { message: 'Summary read unavailable' } }));
await enter(page, owner, sample);
const t = translations.en;
const region = page.getByRole('region', { name: t.onboardingTitle, exact: true });
await expect(region.getByText(t.onboardingJobStatusUnknown, { exact: true })).toBeVisible();
await expect(region.locator('[role="progressbar"][aria-valuenow]')).toHaveCount(0);
await expect(region.getByRole('button', { name: t.onboardingStepJobAction, exact: true })).toHaveCount(0);
await capture(page, info, sample, 'failed-summary-unknown');
await page.unroute(`${apiUrl}/jobapplications/stats`);
const summaryFailure = page.getByRole('main').getByRole('alert').filter({ hasText: t.dashboardSummaryUnavailableTitle });
await summaryFailure.getByRole('button', { name: t.retry, exact: true }).focus();
await page.keyboard.press('Enter');
await expect(region.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await expect(region.getByText(t.onboardingJobStatusUnknown, { exact: true })).toHaveCount(0);
await capture(page, info, sample, 'summary-read-recovered');
});
test('[P7-02 PRES-S02 E08] recovery to four complete setup steps removes guidance while retaining the next keyboard action', async ({ page, owner }, info) => {
expect((await page.request.put(`${apiUrl}/auth/profile`, { headers: owner.headers, data: {
userName: owner.userName, firstName: 'Åse', lastName: 'Oppsett', displayName: 'Åse Oppsett',
} })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile: { version: '1', skills: ['Manually entered SQL'] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Complete setup AS' } });
expect(company.status()).toBe(201);
expect((await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers,
data: { companyId: (await company.json()).id, jobTitle: 'Recorded job', status: 'Saved' } })).status()).toBe(201);
// Only the stored-connection response is controlled; no mailbox grant, connection or sync is attempted.
let failed = true;
await page.route(`${apiUrl}/gmail/status`, route => failed
? route.fulfill({ status: 500, json: { message: 'Connection status unavailable' } })
: route.fulfill({ json: { connected: true } }));
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
await enter(page, owner, sample);
const region = page.getByRole('region', { name: translations.en.onboardingTitle, exact: true });
await expect(region.getByRole('alert')).toBeVisible();
failed = false;
const writes: string[] = [];
page.on('request', request => { if (request.url().startsWith(apiUrl) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(request.method()); });
await region.getByRole('button', { name: translations.en.retry, exact: true }).focus();
await page.keyboard.press('Enter');
await expect(region).toHaveCount(0);
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: translations.en.dashboardCustomize, exact: true })).toBeFocused();
expect(writes).toEqual([]);
await capture(page, info, sample, 'four-complete-recovery-next-keyboard-action');
});
@@ -221,7 +221,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
return (
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
{!reporting ? <OnboardingChecklist hasJobs={hasJobs} /> : null}
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
@@ -1,15 +1,15 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, IconButton, LinearProgress, Paper, Stack, Typography } from "@mui/material";
import { Alert, Box, Button, IconButton, LinearProgress, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CloseIcon from "@mui/icons-material/Close";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { getUserKeyFromToken } from "../themePrefs";
import { PageSection } from "../layout/PageLayout";
type MeResponse = {
email?: string | null;
@@ -33,21 +33,32 @@ type CareerProfileResponse = {
};
type ConnectionStatus = { connected?: boolean };
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
const theme = useTheme();
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean | null }) {
const navigate = useNavigate();
const { t } = useI18n();
const dismissalKey = `onboardingDismissed:${getUserKeyFromToken()}`;
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissalKey) === "true");
// The session boundary disposes this owner before another account is adopted.
const [dismissalKey] = useState(() => `onboardingDismissed:${getUserKeyFromToken()}`);
const [dismissed, setDismissed] = useState(() => {
try { return window.localStorage.getItem(dismissalKey) === "true"; }
catch { return false; }
});
const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<{ cause: unknown } | null>(null);
const [readAttempt, setReadAttempt] = useState(0);
const dismissRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (dismissed) return;
let active = true;
setLoading(true);
setLoadError(null);
setStatus(null);
Promise.all([
api.get<MeResponse>("/auth/me"),
api.get<CareerProfileResponse>("/career/profile"),
Promise.all(["/gmail/status", "/microsoft-graph/status", "/imap/status"].map((url) =>
api.get<ConnectionStatus>(url).then((r) => Boolean(r.data?.connected)).catch(() => false),
api.get<ConnectionStatus>(url).then((r) => Boolean(r.data?.connected)),
)),
]).then(([me, career, connections]) => {
if (!active) return;
@@ -68,59 +79,77 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
cv: structuredCount > 0 || Boolean(career.data?.cvText?.trim()),
email: connections.some(Boolean),
});
}).catch(() => { if (active) setStatus({ profile: false, cv: false, email: false }); });
}).catch((cause: unknown) => {
if (active) setLoadError({ cause });
}).finally(() => {
if (active) setLoading(false);
});
return () => { active = false; };
}, []);
}, [dismissed, readAttempt]);
if (!status || dismissed) return null;
if (dismissed) return null;
// These four recorded steps are guidance, not a completeness score or proof of
// verified email, an imported CV, a rendered PDF, or a live mailbox grant.
const steps = [
{ done: true, label: t("onboardingStepSignup"), actionLabel: "" },
{ done: true, label: t("onboardingStepVerify"), actionLabel: "" },
{ done: status.profile, label: t("onboardingStepProfile"), action: () => navigate("/profile"), actionLabel: t("onboardingStepProfileAction") },
{ done: status.cv, label: t("onboardingStepCv"), action: () => navigate("/career"), actionLabel: t("onboardingStepCvAction") },
{ done: status.email, label: t("onboardingStepEmail"), action: () => navigate("/settings/connected-accounts"), actionLabel: t("onboardingStepEmailAction") },
{ done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") },
{ done: status?.profile ?? null, label: t("onboardingStepProfile"), path: "/profile", actionLabel: t("onboardingStepProfileAction") },
{ done: status?.cv ?? null, label: t("onboardingStepCv"), path: "/career", actionLabel: t("onboardingStepCvAction") },
{ done: status?.email ?? null, label: t("onboardingStepEmail"), path: "/settings/connected-accounts", actionLabel: t("onboardingStepEmailAction") },
{ done: hasJobs, label: t("onboardingStepJob"), path: "/jobs", actionLabel: t("onboardingStepJobAction") },
];
const completed = steps.filter((step) => step.done).length;
if (completed === steps.length) return null;
const completed = steps.filter((step) => step.done === true).length;
if (status && hasJobs !== null && completed === steps.length) return null;
return (
<Paper sx={{ p: 2.25, mb: 2, borderRadius: 4, border: "1px solid", borderColor: alpha(theme.palette.primary.main, 0.25), background: alpha(theme.palette.primary.main, 0.04) }}>
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<PageSection surface title={t("onboardingTitle")} description={t("onboardingBody")} actions={
<IconButton
ref={dismissRef}
size="small"
aria-label={t("onboardingDismiss")}
onClick={() => {
window.localStorage.setItem(dismissalKey, "true");
try { window.localStorage.setItem(dismissalKey, "true"); }
catch { /* This dismissal still applies while the page remains open. */ }
setDismissed(true);
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("onboardingBody")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{completed} / {steps.length}</Typography>
}>
{loading ? <>
<Typography role="status" variant="body2" color="text.secondary">{t("loading")}</Typography>
<LinearProgress aria-label={t("loading")} sx={{ mt: 1.5 }} />
</> : loadError ? (
<Alert severity="error" sx={{ overflowWrap: "anywhere" }} action={
<Button color="inherit" onClick={() => {
// Retry is replaced by loading feedback; keep focus inside this setup section.
dismissRef.current?.focus();
setReadAttempt((attempt) => attempt + 1);
}}>{t("retry")}</Button>
}>{getApiErrorMessage(loadError.cause, t("onboardingLoadFailed"))}</Alert>
) : status ? <>
{hasJobs === null ? (
<Typography role="status" variant="body2" color="text.secondary">{t("onboardingJobStatusUnknown")}</Typography>
) : <Typography variant="caption" color="text.secondary">{completed} / {steps.length}</Typography>}
<LinearProgress
variant="determinate"
value={(completed / steps.length) * 100}
aria-label={t("checklistProgress", { completed, total: steps.length })}
sx={{ my: 1.5, borderRadius: 2 }}
variant={hasJobs === null ? "indeterminate" : "determinate"}
value={hasJobs === null ? undefined : (completed / steps.length) * 100}
aria-label={hasJobs === null ? t("onboardingJobStatusUnknown") : t("checklistProgress", { completed, total: steps.length })}
sx={{ my: 1.5 }}
/>
<Stack spacing={1}>
<Box component="ul" sx={{ display: "grid", gap: 1, listStyle: "none", m: 0, p: 0, minWidth: 0 }}>
{steps.map((step) => (
<Box key={step.label} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{step.done ? <CheckCircleIcon fontSize="small" color="success" /> : <RadioButtonUncheckedIcon fontSize="small" sx={{ color: "text.secondary" }} />}
<Typography variant="body2" sx={{ fontWeight: step.done ? 400 : 700, color: step.done ? "text.secondary" : "text.primary", textDecoration: step.done ? "line-through" : "none" }}>
<Box component="li" key={step.path} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap", minWidth: 0 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0, flex: "1 1 12rem" }}>
{step.done === null ? null : step.done ? <CheckCircleIcon fontSize="small" color="success" titleAccess={t("checklistProgress", { completed: 1, total: 1 })} sx={{ flexShrink: 0 }} /> : <RadioButtonUncheckedIcon fontSize="small" titleAccess={t("checklistProgress", { completed: 0, total: 1 })} sx={{ color: "text.secondary", flexShrink: 0 }} />}
<Typography variant="body2" sx={{ fontWeight: step.done ? 400 : 700, color: step.done ? "text.secondary" : "text.primary", textDecoration: step.done ? "line-through" : "none", overflowWrap: "anywhere" }}>
{step.label}
</Typography>
</Box>
{!step.done && step.action ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
{step.done === false ? <Button size="small" variant="text" sx={{ maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }} onClick={() => navigate(step.path)}>{step.actionLabel}</Button> : null}
</Box>
))}
</Stack>
</Paper>
</Box>
</> : null}
</PageSection>
);
}
@@ -1,12 +1,12 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import DashboardView from './components/DashboardView';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
jest.mock('./components/OnboardingChecklist', () => () => <div>Setup retained</div>);
jest.mock('./components/OnboardingChecklist', () => ({ hasJobs }: { hasJobs: boolean | null }) => <><div>Setup retained</div><output aria-label="Setup job status">{hasJobs === null ? 'unknown' : String(hasJobs)}</output></>);
const mockedApi = api as jest.Mocked<typeof api>;
const stats = { total: 9, active: 8, deleted: 1, byStatus: { Applied: 3, Interview: 2, Offer: 1, 'Faglig vurdering': 2 }, appliedLast30Days: 4 };
const overview = { funnel: [{ label: 'Applied', count: 3 }, { label: 'Interview', count: 2 }, { label: 'Offer', count: 1 }], totalActive: 8, totalResponses: 3, responseRateBySource: [{ label: 'NAV', total: 8, responses: 3, rate: 37.5 }], topCompanies: [{ companyId: 7, company: 'Nordlys AS', count: 8, responses: 3, responseRate: 37.5 }], timeInStage: [{ stage: 'Applied', medianDays: 4, count: 3 }], salaryInsights: [{ currency: 'NOK', period: 'year', count: 2, minimum: 600000, maximum: 800000, averageMidpoint: 700000 }] };
@@ -99,3 +99,26 @@ test('[PRES-S01] activity chart uses one shared zero-based scale and a readable
const firstY = (path: Element) => Number(path.getAttribute('d')!.split(' ')[2]);
expect(firstY(paths[0])).toBeLessThan(firstY(paths[1]));
});
test('[P7-02 PRES-S01 S02] setup guidance treats pending and failed summaries as unknown until their owner retries', async () => {
let resolveStats!: (response: { data: typeof stats }) => void;
let fail = false;
const pending = new Promise<{ data: typeof stats }>(resolve => { resolveStats = resolve; });
mockedApi.get.mockImplementation((url: string) => url.endsWith('/stats')
? fail ? Promise.reject(new Error('summary unavailable')) : pending
: Promise.resolve({ data: data(url) }) as any);
const view = open();
expect(screen.getByLabelText('Setup job status')).toHaveTextContent('unknown');
await screen.findByRole('region', { name: 'Application activity' });
await act(async () => { resolveStats({ data: { ...stats, total: 0 } }); });
await waitFor(() => expect(screen.getByLabelText('Setup job status')).toHaveTextContent('false'));
view.unmount();
fail = true;
open();
await screen.findByText('Unable to load dashboard summary');
expect(screen.getByLabelText('Setup job status')).toHaveTextContent('unknown');
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: data(url) }) as any);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
await waitFor(() => expect(screen.getByLabelText('Setup job status')).toHaveTextContent('true'));
expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/stats');
});
+16 -12
View File
@@ -1488,17 +1488,19 @@ export const translations = {
dashboardOverviewTitle: "Dashboard overview",
dashboardHeroLabel: "Job search overview",
onboardingTitle: "Get set up",
onboardingBody: "A few steps to get the most out of Jobbjakt.",
onboardingBody: "Setup guidance based on your saved account details, Career information, mailbox connections and tracked jobs.",
onboardingLoadFailed: "Could not load setup status.",
onboardingJobStatusUnknown: "Tracked-job status is unavailable. Retry the dashboard summary to check your jobs.",
onboardingDismiss: "Dismiss setup checklist",
onboardingStepSignup: "Create your account",
onboardingStepVerify: "Verify your email",
onboardingStepProfile: "Complete your profile",
onboardingStepProfile: "Add account details",
onboardingStepProfileAction: "Open profile",
onboardingStepCv: "Import your CV",
onboardingStepCvAction: "Add CV",
onboardingStepJob: "Import your first job",
onboardingStepCv: "Add Career information",
onboardingStepCvAction: "Open Career Profile",
onboardingStepJob: "Track your first job",
onboardingStepJobAction: "Add job",
onboardingStepEmail: "Connect your email",
onboardingStepEmail: "Connect a mailbox",
onboardingStepEmailAction: "Connect email",
dashboardResponseRate: "{rate}% response rate",
dashboardTodayTitle: "Today",
@@ -4074,17 +4076,19 @@ export const translations = {
dashboardOverviewTitle: "Dashboard-oversikt",
dashboardHeroLabel: "Oversikt over jobbsøket",
onboardingTitle: "Kom i gang",
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
onboardingBody: "Veiledning basert på lagrede kontoopplysninger, karriereinformasjon, tilkoblede postkasser og stillinger du følger.",
onboardingLoadFailed: "Kunne ikke laste oppsettstatus.",
onboardingJobStatusUnknown: "Status for stillinger er ikke tilgjengelig. Last oversikten på dashbordet på nytt for å sjekke stillingene dine.",
onboardingDismiss: "Skjul oppstartslisten",
onboardingStepSignup: "Opprett kontoen din",
onboardingStepVerify: "Bekreft e-postadressen din",
onboardingStepProfile: "Fullfør profilen din",
onboardingStepProfile: "Legg til kontoopplysninger",
onboardingStepProfileAction: "Åpne profil",
onboardingStepCv: "Importer CV-en din",
onboardingStepCvAction: "Legg til CV",
onboardingStepJob: "Importer din første jobb",
onboardingStepCv: "Legg til karriereinformasjon",
onboardingStepCvAction: "Åpne karriereprofil",
onboardingStepJob: "Følg din første stilling",
onboardingStepJobAction: "Legg til jobb",
onboardingStepEmail: "Koble til e-post",
onboardingStepEmail: "Koble til en postkasse",
onboardingStepEmailAction: "Koble til e-post",
dashboardResponseRate: "{rate}% svarrate",
dashboardTodayTitle: "I dag",
+244 -19
View File
@@ -1,36 +1,261 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import OnboardingChecklist from "./components/OnboardingChecklist";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import { api } from "./api";
jest.mock("./api", () => ({
api: { get: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } } },
getApiErrorMessage: jest.requireActual("./api").getApiErrorMessage,
api: {
get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
}));
const mockedApi = api as jest.Mocked<typeof api>;
const sources = ["/auth/me", "/career/profile", "/gmail/status", "/microsoft-graph/status", "/imap/status"];
let account: any;
let career: any;
let connectedProvider: string | null;
function renderChecklist() {
return render(<MemoryRouter><I18nProvider><OnboardingChecklist hasJobs={false} /></I18nProvider></MemoryRouter>);
function sourceResponse(url: string) {
if (url === "/auth/me") return { data: account };
if (url === "/career/profile") return { data: career };
if (sources.includes(url)) return { data: { connected: url === connectedProvider } };
throw new Error(`Unexpected read: ${url}`);
}
test("uses structured career data and connected inbox status for onboarding progress", async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === "/auth/me") return Promise.resolve({ data: { firstName: "Ada" } } as any);
if (url === "/career/profile") return Promise.resolve({ data: { cvText: "", profile: { jobs: [{ title: "Engineer" }] } } } as any);
return Promise.resolve({ data: { connected: url === "/gmail/status" } } as any);
});
function Destination() {
return <output aria-label="Destination">{useLocation().pathname}</output>;
}
renderChecklist();
function Checklist({ hasJobs = false }: { hasJobs?: boolean | null }) {
return <MemoryRouter><I18nProvider><OnboardingChecklist hasJobs={hasJobs} /><Destination /></I18nProvider></MemoryRouter>;
}
expect(await screen.findByText("5 / 6")).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "5 of 6 done" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Add CV" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Open profile" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Connect email" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add job" })).toBeInTheDocument();
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/career/profile"));
function expectNoWrites() {
for (const method of [mockedApi.post, mockedApi.put, mockedApi.patch, mockedApi.delete]) expect(method).not.toHaveBeenCalled();
}
beforeEach(() => {
jest.resetAllMocks();
window.localStorage.clear();
window.localStorage.setItem("authUserKey", "owner-a");
account = { firstName: "Ada" };
career = { cvText: "", profile: { jobs: [{ title: "Engineer" }] } };
connectedProvider = "/gmail/status";
mockedApi.get.mockImplementation((url: string) => Promise.resolve(sourceResponse(url) as any));
});
afterEach(() => {
jest.restoreAllMocks();
window.localStorage.clear();
});
test("uses structured career data and connected inbox status for four source-backed onboarding steps", async () => {
render(<Checklist />);
expect(await screen.findByText("3 / 4")).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "3 of 4 done" })).toHaveAttribute("aria-valuenow", "75");
const region = screen.getByRole("region", { name: translations.en.onboardingTitle });
expect(within(region).getByRole("heading", { name: translations.en.onboardingTitle, level: 2 })).toBeInTheDocument();
expect(within(region).getAllByRole("listitem")).toHaveLength(4);
expect(screen.queryByText(translations.en.onboardingStepSignup)).not.toBeInTheDocument();
expect(screen.queryByText(translations.en.onboardingStepVerify)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: translations.en.onboardingStepCvAction })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: translations.en.onboardingStepProfileAction })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: translations.en.onboardingStepEmailAction })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: translations.en.onboardingStepJobAction })).toBeInTheDocument();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(sources);
expectNoWrites();
});
test.each(["/microsoft-graph/status", "/imap/status"])("a stored %s connection counts without connecting or syncing", async (provider) => {
connectedProvider = provider;
render(<Checklist />);
expect(await screen.findByText("3 / 4")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: translations.en.onboardingStepEmailAction })).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(5);
expectNoWrites();
});
test.each([
["manual skills", { cvText: "", profile: { skills: ["SQL"] } }, true],
["raw career text", { cvText: " Existing resume text ", profile: {} }, true],
["contact only", { cvText: " ", profile: { contact: { fullName: "Career Name" } } }, false],
])("%s preserves the existing career-content criterion without claiming a CV import", async (_name, data, done) => {
career = data;
render(<Checklist />);
expect(await screen.findByText(done ? "3 / 4" : "2 / 4")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: translations.en.onboardingStepCvAction }) !== null).toBe(!done);
expect(mockedApi.get.mock.calls.some(([url]) => String(url).includes("cv-variant") || String(url).includes("profile-cv"))).toBe(false);
expectNoWrites();
});
test("career names and account email alone do not count as account identity details", async () => {
account = { email: "owner@example.test", firstName: " ", lastName: "", displayName: " " };
career = { cvText: "", profile: { contact: { fullName: "Career Name" }, skills: ["SQL"] } };
render(<Checklist />);
expect(await screen.findByText("2 / 4")).toBeInTheDocument();
expect(screen.getByRole("button", { name: translations.en.onboardingStepProfileAction })).toBeInTheDocument();
expectNoWrites();
});
test("unknown job summary has indeterminate progress, then responds to known jobs without refetching sources", async () => {
const view = render(<Checklist hasJobs={null} />);
const unknown = await screen.findByText(translations.en.onboardingJobStatusUnknown);
expect(unknown).toHaveAttribute("role", "status");
expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow");
expect(screen.queryByText(/\d \/ 4/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: translations.en.onboardingStepJobAction })).not.toBeInTheDocument();
view.rerender(<Checklist hasJobs={false} />);
expect(await screen.findByText("3 / 4")).toBeInTheDocument();
expect(screen.getByRole("button", { name: translations.en.onboardingStepJobAction })).toBeInTheDocument();
view.rerender(<Checklist hasJobs />);
expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(5);
expectNoWrites();
});
test("all four recorded steps hide the optional checklist after the sources settle", async () => {
render(<Checklist hasJobs />);
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(5);
expectNoWrites();
});
test.each(sources)("failed %s status stays unknown and retries only the same source reads", async (failedSource) => {
let rejectFirst = true;
mockedApi.get.mockImplementation((url: string) => url === failedSource && rejectFirst
? Promise.reject({ response: { status: 503, data: { detail: "Setup source unavailable." } } })
: Promise.resolve(sourceResponse(url) as any));
render(<Checklist />);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Setup source unavailable.");
expect(screen.queryByText(/\d \/ 4/)).not.toBeInTheDocument();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: translations.en.onboardingStepEmailAction })).not.toBeInTheDocument();
expectNoWrites();
rejectFirst = false;
const retry = within(alert).getByRole("button", { name: translations.en.retry });
retry.focus();
fireEvent.click(retry);
expect(await screen.findByText("3 / 4")).toBeInTheDocument();
expect(screen.getByRole("button", { name: translations.en.onboardingDismiss })).toHaveFocus();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual([...sources, ...sources]);
expectNoWrites();
});
test.each([
["onboardingStepProfileAction", "/profile"],
["onboardingStepCvAction", "/career"],
["onboardingStepEmailAction", "/settings/connected-accounts"],
["onboardingStepJobAction", "/jobs"],
] as const)("%s keeps its source-owned destination", async (key, destination) => {
account = {};
career = { cvText: "", profile: {} };
connectedProvider = null;
render(<Checklist />);
await screen.findByText("0 / 4");
fireEvent.click(screen.getByRole("button", { name: translations.en[key] }));
expect(screen.getByLabelText("Destination")).toHaveTextContent(destination);
expectNoWrites();
});
test("a legacy owner dismissal skips all reads on mount", async () => {
localStorage.setItem("onboardingDismissed:owner-a", "true");
render(<Checklist />);
await act(async () => { await Promise.resolve(); });
expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument();
expect(mockedApi.get).not.toHaveBeenCalled();
expectNoWrites();
});
test("dismissal survives reload for its owner and stays separate from another owner's checklist and Dashboard flags", async () => {
const flags = JSON.stringify({ cards: false, activity: true, funnel: true, companies: true, skills: true });
localStorage.setItem("dashboardPrefs:owner-a", flags);
const first = render(<Checklist />);
await screen.findByText("3 / 4");
fireEvent.click(screen.getByRole("button", { name: translations.en.onboardingDismiss }));
expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument();
expect(localStorage.getItem("onboardingDismissed:owner-a")).toBe("true");
expect(localStorage.getItem("dashboardPrefs:owner-a")).toBe(flags);
first.unmount();
mockedApi.get.mockClear();
const reloaded = render(<Checklist />);
await act(async () => { await Promise.resolve(); });
expect(mockedApi.get).not.toHaveBeenCalled();
reloaded.unmount();
localStorage.setItem("authUserKey", "owner-b");
const second = render(<Checklist />);
expect(await screen.findByText("3 / 4")).toBeInTheDocument();
expect(localStorage.getItem("onboardingDismissed:owner-b")).toBeNull();
second.unmount();
mockedApi.get.mockClear();
localStorage.setItem("authUserKey", "owner-a");
render(<Checklist />);
await act(async () => { await Promise.resolve(); });
expect(mockedApi.get).not.toHaveBeenCalled();
expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument();
expectNoWrites();
});
test("unavailable dismissal storage still permits setup and an in-memory dismissal", async () => {
const getItem = Storage.prototype.getItem;
const setItem = Storage.prototype.setItem;
jest.spyOn(Storage.prototype, "getItem").mockImplementation(function (this: Storage, key: string) {
if (key.startsWith("onboardingDismissed:")) throw new Error("Storage unavailable");
return getItem.call(this, key);
});
jest.spyOn(Storage.prototype, "setItem").mockImplementation(function (this: Storage, key: string, value: string) {
if (key.startsWith("onboardingDismissed:")) throw new Error("Storage unavailable");
return setItem.call(this, key, value);
});
render(<Checklist />);
expect(await screen.findByText("3 / 4")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: translations.en.onboardingDismiss }));
expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument();
expectNoWrites();
});
test("loading is announced without guessed progress and dismissal prevents late failure from reopening the checklist", async () => {
let rejectCareer!: (reason: unknown) => void;
mockedApi.get.mockImplementation((url: string) => url === "/career/profile"
? new Promise((_resolve, reject) => { rejectCareer = reject; })
: Promise.resolve(sourceResponse(url) as any));
render(<Checklist />);
const region = screen.getByRole("region", { name: translations.en.onboardingTitle });
expect(within(region).getByRole("status")).toHaveTextContent(translations.en.loading);
expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow");
expect(screen.queryByText(/\d \/ 4/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: translations.en.onboardingDismiss }));
await act(async () => rejectCareer(new Error("Late failure")));
expect(screen.queryByRole("region", { name: translations.en.onboardingTitle })).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(5);
expectNoWrites();
});
test("Bokmål guidance keeps semantic list and progress names without additional source reads", async () => {
localStorage.setItem("uiLanguage", "nb");
render(<Checklist />);
expect(await screen.findByText("3 / 4")).toBeInTheDocument();
const region = screen.getByRole("region", { name: translations.nb.onboardingTitle });
expect(within(region).getByRole("heading", { name: translations.nb.onboardingTitle, level: 2 })).toBeInTheDocument();
expect(within(region).getAllByRole("listitem")).toHaveLength(4);
expect(screen.getByRole("progressbar", { name: translations.nb.checklistProgress.replace("{completed}", "3").replace("{total}", "4") })).toHaveAttribute("aria-valuenow", "75");
expect(within(region).getByText(translations.nb.onboardingStepProfile)).toBeInTheDocument();
expect(within(region).getByText(translations.nb.onboardingStepCv)).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(5);
expectNoWrites();
});