feat(onboarding): migrate source-backed setup guidance
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user