diff --git a/apps/e2e/README.md b/apps/e2e/README.md new file mode 100644 index 000000000..cfec73a58 --- /dev/null +++ b/apps/e2e/README.md @@ -0,0 +1,88 @@ +# End-to-end browser tests + +Playwright, driving the portal and the backoffice in a real browser against a +real API and a real database. + +```bash +# from emaui/ +npx nx e2e @ema-platform/e2e # or: npx playwright test --config apps/e2e/playwright.config.ts +npx playwright test --config apps/e2e/playwright.config.ts --headed --debug +npx playwright show-report ../../dist/e2e-report +``` + +## What it runs against + +Nothing shared with your own dev stack. The suite starts its own servers, on its +own ports, against its own database: + +| | port | notes | +|---|---|---| +| API | 3011 | `node dist/main.js` with explicit env | +| Portal | 4302 | `vite build --mode e2e` then `vite preview` | +| Backoffice | 4303 | same | +| Database | — | `ema_e2e` | + +This is deliberate. A developer's stack is usually already up on 3000/4200/4201, +and `dev/start.sh` **rewrites** `emaapi/apps/server/emaapi/.env` and the apps' +`.env.local` on every run — a suite that read those files would point at +whichever stack was started last. The API is launched with `DATABASE_NAME`, +`PORT` and the payment flags passed directly, and the frontends are built with +`--mode e2e`, which picks up `apps/*/.env.e2e.local` (higher precedence than +`.env.local`, so your own config is left alone). + +`vite preview` on a production build rather than `nx serve`, because Nx +serialises `serve` targets per project: with your dev portal already running, +a second `nx serve @ema-platform/portal` waits forever on the first. + +### First-time setup + +The E2E database has to exist and be bootstrapped once: + +```bash +DB_NAME=ema_e2e ./dev/start.sh # creates it, migrates, seeds, then starts a stack you can Ctrl-C +``` + +Afterwards the suite manages its own servers; you do not need `start.sh` again. +Note that this run leaves the shared `.env` files pointing at the E2E database +and its ports — rerun `./dev/start.sh` plain to put them back. + +## How it gets its data + +- **Accounts are created through the UI.** Every spec signs up its own applicant + with a timestamped email, and deletes it in `afterEach` (`deleteApplicant`). + Nothing is shared between specs, so specs can run in any order and the second + run of the suite behaves exactly like the first. +- **The one-time code is scraped from the API log.** There is no local SMS + gateway, `iam.user_verifications.verification_code` is argon2-**hashed**, and + the notification rows it is delivered through carry an empty body — the log + line is the only plaintext. The API is therefore started with its output teed + to `/tmp/ema-e2e-api.log`, and `support/api-log.ts` reads codes written after + a recorded offset. The test still types the real code into the real screen. + Because the log line names no recipient, matching is by position, which is + sound only while the suite runs single-worker — hence `workers: 1`. +- **Database reads use `psql`**, shelled out from `support/db.ts`, rather than + adding a `pg` dependency to the frontend workspace for the sake of a few + assertions. + +## Conventions + +- One worker, serial. The officer queue is global state and the log-offset trick + needs ordering; determinism is worth the wall-clock. +- Assertions are on what the user sees — headings, badges, buttons — with + database checks only where the point is that something was *persisted*. +- No `waitForTimeout`. Wait for a condition. +- Traces, screenshots and video are retained for failures only. + +## Debugging a failure + +Playwright writes `test-results//error-context.md` with a full +accessibility snapshot of the page at the moment it failed — usually enough on +its own. Otherwise `npx playwright show-trace test-results//trace.zip`. + +## Status + +Implemented: signup → OTP → operations gate → dashboard, and the empty-state +dashboard. See the parent prompt (`dev/prompts/e2e-browser-tests-prompt.md`) for +the flows still to be written: catalogue filtering, the full application +lifecycle per licence type, officer review, payment bypass, certificate +download, expiry, renewal and reminders. diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts new file mode 100644 index 000000000..550433099 --- /dev/null +++ b/apps/e2e/playwright.config.ts @@ -0,0 +1,115 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * End-to-end suite, driving the portal and the backoffice in a real browser. + * + * Both frontends are in one project on purpose: the flows that matter cross + * them — an applicant files, an officer decides, the applicant collects the + * certificate — and per-app projects cannot express that. + * + * Nothing here reads the shared `.env` files. A developer's own stack is + * usually already running against `ema_db` on the default ports, and + * `dev/start.sh` rewrites those files on every run; a suite that read them + * would point at whichever stack was started last. Instead the servers below + * are launched with explicit environment, on ports of their own, against a + * database of their own. + */ + +const API_PORT = Number(process.env.E2E_API_PORT ?? 3011); +const PORTAL_PORT = Number(process.env.E2E_PORTAL_PORT ?? 4302); +const BACKOFFICE_PORT = Number(process.env.E2E_BACKOFFICE_PORT ?? 4303); + +export const E2E = { + apiUrl: `http://localhost:${API_PORT}/api`, + portalUrl: `http://localhost:${PORTAL_PORT}`, + backofficeUrl: `http://localhost:${BACKOFFICE_PORT}`, + database: process.env.E2E_DB_NAME ?? 'ema_e2e', + /** + * Where the API's own output is teed. + * + * The one-time code an applicant types at signup exists in exactly one + * readable place: this log. `iam.user_verifications` stores it argon2-hashed, + * and the notification rows it is delivered through carry an empty body — so + * the log line is not a convenience here, it is the only source. + */ + apiLog: process.env.E2E_API_LOG ?? '/tmp/ema-e2e-api.log', +}; + +const emaapi = + '../../../emaapi/apps/server/emaapi'; + +/** Env the API is started with. Explicit, so no `.env` can redirect it. */ +const apiEnv = { + ...process.env, + PORT: String(API_PORT), + DATABASE_NAME: E2E.database, + // The API applies migrations on boot, which is what bootstraps a fresh E2E + // database — but it must never be the thing that decides what the schema is. + MIGRATIONS_RUN: 'false', + NODE_ENV: 'development', + FRONTEND_URLS: `${E2E.portalUrl},${E2E.backofficeUrl}`, + PUBLIC_VERIFY_URL: `${E2E.portalUrl}/verify`, + PORTAL_BASE_URL: E2E.portalUrl, + PAYMENT_AUTO_CONFIRM: 'true', + ALLOW_PAYMENT_BYPASS: 'true', +}; + +export default defineConfig({ + testDir: './src', + // Flows share one database, and an officer queue is global state. Serial + // execution costs wall-clock and buys determinism. + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + timeout: 90_000, + expect: { timeout: 15_000 }, + reporter: [['list'], ['html', { outputFolder: '../../dist/e2e-report', open: 'never' }]], + + use: { + baseURL: E2E.portalUrl, + // Kept only for failures — a trace per passing test is noise nobody reads. + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 15_000, + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + + webServer: [ + { + name: 'api', + // Teed rather than piped to Playwright's own stdout, because the OTP + // helper has to read it back. + command: `sh -c 'node dist/main.js 2>&1 | tee ${E2E.apiLog}'`, + cwd: emaapi, + env: apiEnv, + url: `${E2E.apiUrl}/license-types`, + // 401 is the healthy answer: the route exists and demands a session. + ignoreHTTPSErrors: true, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, + { + name: 'portal', + command: `npx vite build --config apps/portal/vite.config.mts --mode e2e && npx vite preview --config apps/portal/vite.config.mts --mode e2e --port ${PORTAL_PORT} --strictPort`, + cwd: '../..', + url: E2E.portalUrl, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, + { + name: 'backoffice', + command: `npx vite build --config apps/backoffice/vite.config.mts --mode e2e && npx vite preview --config apps/backoffice/vite.config.mts --mode e2e --port ${BACKOFFICE_PORT} --strictPort`, + cwd: '../..', + url: E2E.backofficeUrl, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, + ], +}); diff --git a/apps/e2e/project.json b/apps/e2e/project.json new file mode 100644 index 000000000..9c9edae98 --- /dev/null +++ b/apps/e2e/project.json @@ -0,0 +1,15 @@ +{ + "name": "@ema-platform/e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/e2e/src", + "targets": { + "e2e": { + "executor": "nx:run-commands", + "options": { + "command": "npx playwright test --config apps/e2e/playwright.config.ts", + "cwd": "." + } + } + } +} diff --git a/apps/e2e/src/onboarding.spec.ts b/apps/e2e/src/onboarding.spec.ts new file mode 100644 index 000000000..8fe87905b --- /dev/null +++ b/apps/e2e/src/onboarding.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from '@playwright/test'; +import { + Applicant, + newApplicant, + signUp, + verifyOtpIfPrompted, +} from './support/applicant'; +import { deleteApplicant, sql } from './support/db'; + +/** + * Signing up, and being asked what you operate as before anything else. + * + * The gate is the whole point: the catalogue is filtered by the answer and the + * API refuses an application for a mode the profile does not hold, so an + * applicant who skipped this would reach a dashboard that offers them nothing + * and explains nothing. + */ +test.describe('signup and onboarding', () => { + let applicant: Applicant; + + test.beforeEach(() => { + applicant = newApplicant('onboard'); + }); + + test.afterEach(() => { + deleteApplicant(applicant.email); + }); + + test('a new applicant is asked for their operations before reaching the dashboard', async ({ + page, + }) => { + const offset = await signUp(page, applicant); + await verifyOtpIfPrompted(page, offset); + + // The account exists in IAM ... + const rows = sql( + `SELECT email FROM iam.users WHERE email = '${applicant.email}'`, + ); + expect(rows).toHaveLength(1); + + // ... and the first thing behind the session is the question, not the app. + await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); + await expect( + page.getByRole('heading', { name: /what do you operate as/i }), + ).toBeVisible(); + + // The dashboard stays out of reach until it is answered. + await page.goto('/dashboard'); + await expect(page).toHaveURL(/\/onboarding\/operations/); + + // Answer it. + await page + .getByRole('checkbox', { name: /freight forwarder license/i }) + .first() + .check(); + await page.getByRole('button', { name: /save operations/i }).click(); + + await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 }); + + // And it stuck: stored against the profile, keyed by the IAM user id. + const declared = sql(` + SELECT lt.key + FROM profile_operator_types pot + JOIN profiles p ON p.id = pot.profile_id + JOIN iam.users u ON u.id = p.user_id + JOIN license_types lt ON lt.id = pot.license_type_id + WHERE u.email = '${applicant.email}' AND pot.deleted_at IS NULL + `); + expect(declared.map((r) => r[0])).toEqual(['FREIGHT_FORWARDER']); + }); + + test('the dashboard leads with a get-started panel when there is nothing to show', async ({ + page, + }) => { + const offset = await signUp(page, applicant); + await verifyOtpIfPrompted(page, offset); + await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); + + await page + .getByRole('checkbox', { name: /freight forwarder license/i }) + .first() + .check(); + await page.getByRole('button', { name: /save operations/i }).click(); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 }); + + // Nothing filed and nothing held: one panel, not two empty sections. + await expect(page.getByRole('heading', { name: 'Get started' })).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'My applications' }), + ).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'My licences' })).toHaveCount( + 0, + ); + + // The catalogue is directly beneath it, filtered to what they declared. + await expect( + page.getByRole('heading', { name: /apply for a licence/i }), + ).toBeVisible(); + await expect(page.getByText('Freight Forwarder License').first()).toBeVisible(); + await expect(page.getByText('Shipping Agent License')).toHaveCount(0); + }); +}); diff --git a/apps/e2e/src/support/api-log.ts b/apps/e2e/src/support/api-log.ts new file mode 100644 index 000000000..5cddf304f --- /dev/null +++ b/apps/e2e/src/support/api-log.ts @@ -0,0 +1,58 @@ +import { readFileSync, statSync } from 'node:fs'; +import { E2E } from '../../playwright.config'; + +/** + * Reading one-time codes back out of the API's own output. + * + * There is no local SMS gateway, and the code is not recoverable anywhere else: + * `iam.user_verifications.verification_code` holds an argon2 hash, and the + * notification rows it is delivered through store an empty body. The log line + * the message composer emits is the only plaintext. + * + * Codes are matched by *position in the log*, not by recipient — the line names + * no user. Callers take an offset before the action that triggers the send and + * only read what was written after it, which is sound because the suite runs + * single-worker and serially. + */ + +const OTP_PATTERN = /is (\d{4,8})\./g; + +/** Byte offset to read from later. Zero when the log does not exist yet. */ +export function logOffset(): number { + try { + return statSync(E2E.apiLog).size; + } catch { + return 0; + } +} + +/** Waits for a code to appear after `offset`, and returns the last one seen. */ +export async function waitForOtp( + offset: number, + timeoutMs = 20_000, +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const found = otpSince(offset); + if (found) return found; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error( + `No one-time code appeared in ${E2E.apiLog} within ${timeoutMs}ms. ` + + `Is the API teeing its output there?`, + ); +} + +function otpSince(offset: number): string | null { + let text: string; + try { + text = readFileSync(E2E.apiLog, 'utf8').slice(offset); + } catch { + return null; + } + + const codes = [...text.matchAll(OTP_PATTERN)].map((m) => m[1]); + return codes.length > 0 ? codes[codes.length - 1] : null; +} diff --git a/apps/e2e/src/support/applicant.ts b/apps/e2e/src/support/applicant.ts new file mode 100644 index 000000000..ba4452de2 --- /dev/null +++ b/apps/e2e/src/support/applicant.ts @@ -0,0 +1,99 @@ +import { Page, expect } from '@playwright/test'; +import { logOffset, waitForOtp } from './api-log'; + +/** + * A new applicant, unique to this run. + * + * Every spec makes its own. Sharing one account between specs means the second + * run of the suite starts from a state the first run left behind — an + * applicant who already declared their operations, already holds a licence — + * and the assertions quietly stop meaning what they say. + */ +export interface Applicant { + email: string; + username: string; + phoneNumber: string; + password: string; + name: string; +} + +export function newApplicant(label: string): Applicant { + const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`; + return { + email: `e2e.${label}.${stamp}@example.test`, + username: `e2e${label}${stamp}`.slice(0, 28), + // Ethiopian mobile format; the last digits vary so two runs never collide. + phoneNumber: `+2519${stamp.slice(-8)}`, + password: 'E2ePassw0rd!', + name: `E2E ${label} ${stamp.slice(-4)}`, + }; +} + +/** + * Fills and submits the signup form. + * + * Returns the log offset taken immediately before submitting, so the caller can + * pick up the one-time code that submission triggers without matching a stale + * one from an earlier test. + */ +export async function signUp(page: Page, applicant: Applicant): Promise { + await page.goto('/signup'); + + await page.getByLabel('Name (English)').fill(applicant.name); + await page.getByLabel('Email address').fill(applicant.email); + await page.getByLabel('Username').fill(applicant.username); + await page.getByLabel('Phone number').fill(applicant.phoneNumber); + await page.getByLabel('Password', { exact: true }).fill(applicant.password); + await page.getByLabel('Confirm password').fill(applicant.password); + + // The terms checkbox gates the submit button. + await page.getByRole('checkbox').check(); + + const offset = logOffset(); + await page.getByRole('button', { name: /create account|sign up/i }).click(); + return offset; +} + +/** + * Completes phone verification by typing the real code. + * + * No-op when signup did not land on the OTP screen — whether it does depends + * on how the account was created, and a test that asserts the flow rather than + * the screen should not care. + */ +export async function verifyOtpIfPrompted( + page: Page, + sinceOffset: number, +): Promise { + await page.waitForURL(/\/(otp-verify|onboarding|dashboard)/, { timeout: 30_000 }); + if (!page.url().includes('/otp-verify')) return false; + + const code = await waitForOtp(sinceOffset); + + // Mantine's PinInput is one input per character and moves focus itself, so + // the code is typed rather than filled — filling each box individually + // fights the component's own focus handling. + const boxes = page.locator('input[inputmode], input[type="text"]'); + await boxes.first().click(); + await page.keyboard.type(code, { delay: 30 }); + + // `onComplete` submits on the last character; the button is the fallback for + // when it does not fire. + const submit = page.getByRole('button', { name: /^verify$/i }); + await Promise.race([ + page.waitForURL(/\/(onboarding|dashboard)/, { timeout: 10_000 }).catch(() => null), + submit.click({ timeout: 5_000 }).catch(() => null), + ]); + return true; +} + +/** Signs in an existing applicant through the login screen. */ +export async function logIn(page: Page, applicant: Applicant): Promise { + await page.goto('/login'); + // The API takes an email here, not a username — sending a username answers + // "email should not be empty". + await page.getByLabel(/email/i).fill(applicant.email); + await page.getByLabel(/password/i).fill(applicant.password); + await page.getByRole('button', { name: /sign in|log in|login/i }).click(); + await expect(page).toHaveURL(/\/(onboarding|dashboard|profile)/, { timeout: 30_000 }); +} diff --git a/apps/e2e/src/support/db.ts b/apps/e2e/src/support/db.ts new file mode 100644 index 000000000..4143f80dc --- /dev/null +++ b/apps/e2e/src/support/db.ts @@ -0,0 +1,60 @@ +import { execFileSync } from 'node:child_process'; +import { E2E } from '../../playwright.config'; + +/** + * Direct access to the E2E database. + * + * Shelling out to `psql` rather than adding a `pg` dependency to the frontend + * workspace: the suite needs a handful of reads and a cleanup delete, and a + * driver in `emaui/package.json` would follow the frontend build around + * forever for the sake of them. + */ +const PSQL_ENV = { + ...process.env, + PGPASSWORD: process.env.E2E_DB_PASSWORD ?? 'TradingTria@2090', +}; + +export function sql(query: string): string[][] { + const out = execFileSync( + 'psql', + [ + '-h', process.env.E2E_DB_HOST ?? 'localhost', + '-p', process.env.E2E_DB_PORT ?? '5432', + '-U', process.env.E2E_DB_USER ?? 'postgres', + '-d', E2E.database, + '-tAF', '\t', + '-c', query, + ], + { env: PSQL_ENV, encoding: 'utf8' }, + ); + return out + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => line.split('\t')); +} + +/** First column of the first row, or null when the query found nothing. */ +export function sqlValue(query: string): string | null { + const rows = sql(query); + return rows.length > 0 ? rows[0][0] : null; +} + +/** Removes everything a run created, so the next run starts from the same place. */ +export function deleteApplicant(email: string): void { + const safe = email.replace(/'/g, "''"); + const userId = sqlValue(`SELECT id FROM iam.users WHERE email = '${safe}'`); + if (!userId) return; + + sql(` + DELETE FROM licenses WHERE holder_user_id = '${userId}'; + DELETE FROM license_applications WHERE applicant_user_id = '${userId}'; + DELETE FROM profile_operator_types + WHERE profile_id IN (SELECT id FROM profiles WHERE user_id = '${userId}'); + DELETE FROM profiles WHERE user_id = '${userId}'; + DELETE FROM iam.notifications WHERE recipient_id = '${userId}'; + DELETE FROM iam.user_verifications WHERE user_id = '${userId}'; + DELETE FROM iam.user_credentials WHERE user_id = '${userId}'; + DELETE FROM iam.user_roles WHERE user_id = '${userId}'; + DELETE FROM iam.users WHERE id = '${userId}'; + `); +} diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json new file mode 100644 index 000000000..51ba4be84 --- /dev/null +++ b/apps/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "allowJs": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "playwright.config.ts"] +} diff --git a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx index 5767846a3..beb7edea4 100644 --- a/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx +++ b/apps/portal/src/app/features/dashboard/pages/DashboardPage.tsx @@ -32,6 +32,7 @@ import { IconCreditCard, IconDownload, IconFileText, + IconRefresh, IconShieldCheck, } from '@tabler/icons-react'; import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge'; @@ -41,11 +42,14 @@ import { STATUS_LABELS, STATUS_PROGRESS, TERMINAL_STATUSES, + extractErrorMessage, localized, + useCreateApplicationMutation, useGetCertificateUrlMutation, useGetMyApplicationsQuery, useGetMyLicensesQuery, } from '@ema-platform/api'; +import { notify } from '@ema-platform/ui'; import type { IssuedLicense, LicenseApplication } from '@ema-platform/api'; import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue'; @@ -53,8 +57,13 @@ import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue'; * The applicant's home screen. * * Ordered by what the applicant needs from it: first anything blocked on them, - * then a read of where their applications stand, then the licence catalogue, - * then the licences they already hold. Every figure is the signed-in user's + * then the records they already have — licences, then applications — and only + * then the catalogue to file something new. A returning applicant comes here to + * check on their own things, not to shop; the catalogue used to sit above both + * and pushed them below the fold. + * + * The exception is an applicant with nothing at all, for whom both sections are + * empty and the catalogue *is* the page. Every figure is the signed-in user's * own data — there are no illustrative numbers on this page. */ @@ -92,6 +101,8 @@ export function DashboardPage() { const { data: licenses } = useGetMyLicensesQuery(); const [getCertificateUrl, { isLoading: isDownloading }] = useGetCertificateUrlMutation(); + const [createApplication, { isLoading: isRenewing }] = + useCreateApplicationMutation(); const items = useMemo(() => applications?.items ?? [], [applications]); const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]); @@ -103,6 +114,9 @@ export function DashboardPage() { (a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT', ); const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE'); + // Nothing filed and nothing held: the two "my …" sections would both be + // empty, so they collapse into one panel and the catalogue carries the page. + const hasNoRecords = items.length === 0 && heldLicenses.length === 0; const expiringSoon = activeLicenses.filter((l) => { const days = daysUntil(l.expiryDate); return days >= 0 && days <= EXPIRY_WARNING_DAYS; @@ -113,6 +127,27 @@ export function DashboardPage() { window.open(result.url, '_blank', 'noopener'); } + /** + * Renewal reuses the ordinary application wizard — a renewal is an + * application of kind RENEWAL, asking for that licence type's renewal + * document set. `previousLicenseId` is what ties it to the certificate being + * replaced, and what the API requires. + */ + async function renewLicense(license: IssuedLicense) { + const typeKey = license.licenseType?.key; + if (!typeKey) return; + try { + const application = await createApplication({ + licenseType: typeKey, + kind: 'RENEWAL', + previousLicenseId: license.id, + }).unwrap(); + navigate(`/licensing/${typeKey}/applications/${application.id}`); + } catch (err) { + notify.error(extractErrorMessage(err), 'Could not start the renewal'); + } + } + if (isLoading) { return (
@@ -167,50 +202,60 @@ export function DashboardPage() { expiringSoon={expiringSoon.length} /> + {hasNoRecords ? ( + + ) : ( + <> +
+ {heldLicenses.length === 0 ? ( + + ) : ( + + {heldLicenses.map((license) => ( + downloadCertificate(license)} + onRenew={() => renewLicense(license)} + /> + ))} + + )} +
+ +
0 ? ( + navigate('/licensing/applications')} + > + View all + + ) : undefined + } + > + {items.length === 0 ? ( + + ) : ( + + )} +
+ + )} +
- -
0 ? ( - navigate('/licensing/applications')} - > - View all - - ) : undefined - } - > - {items.length === 0 ? ( - - ) : ( - - )} -
- - {heldLicenses.length > 0 && ( -
- - {heldLicenses.map((license) => ( - downloadCertificate(license)} - /> - ))} - -
- )} ); @@ -507,14 +552,21 @@ function ApplicationTable({ function LicenseCard({ license, isDownloading, + isRenewing, onDownload, + onRenew, }: { license: IssuedLicense; isDownloading: boolean; + isRenewing: boolean; onDownload: () => void; + onRenew: () => void; }) { - const days = daysUntil(license.expiryDate); + // The API computes both in the authority's timezone; the local fallbacks are + // only for a cached response from before those fields existed. + const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate); const expired = license.status === 'EXPIRED' || days < 0; + const renewable = license.renewable ?? false; return ( @@ -559,6 +611,50 @@ function LicenseCard({ + + {/* Renewal opens inside the licence type's window and stays open after + expiry, so a lapsed licence is renewed rather than applied for afresh. */} + {renewable && ( + + )} + + ); +} + +/** + * The first-visit panel, in place of two empty sections saying the same thing. + * It carries no call to action of its own — the catalogue is directly beneath + * it, and a button that only scrolls the page is noise. + */ +function GetStartedPanel() { + return ( + + + + + + + Get started + + You have not filed an application yet. Choose the licence that + matches what your company does — your applications and the licences + issued to you will appear here as you go. + + + ); } diff --git a/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx b/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx index b97b03a1b..2f3dfd49c 100644 --- a/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx +++ b/apps/portal/src/app/features/licensing/components/LicenseCatalogue.tsx @@ -1,6 +1,7 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { + Anchor, Badge, Box, Button, @@ -25,6 +26,7 @@ import { localized, useGetLicenseCategoriesQuery, useGetLicenseTypesQuery, + useGetMyOperatorTypesQuery, } from '@ema-platform/api'; import type { LicenseCategory, LicenseType } from '@ema-platform/api'; @@ -52,9 +54,25 @@ export function LicenseCatalogue() { const navigate = useNavigate(); const { data: types } = useGetLicenseTypesQuery(); const { data: categories } = useGetLicenseCategoriesQuery(); + const { data: operatorTypes, isLoading: loadingOperatorTypes } = + useGetMyOperatorTypesQuery(); + // An escape hatch, not a preference: someone who wants to see what else + // exists can, without first editing their profile to find out. + const [showAll, setShowAll] = useState(false); + + const declared = useMemo( + () => new Set((operatorTypes?.items ?? []).map((o) => o.licenseTypeId)), + [operatorTypes], + ); + const hasDeclared = declared.size > 0; const { groups, orphans } = useMemo(() => { - const active = (types?.items ?? []).filter((t) => t.isActive); + const active = (types?.items ?? []) + .filter((t) => t.isActive) + // Only what the applicant operates as. The server enforces the same rule + // on create; this is what stops them starting an application they will + // be refused at the end of. + .filter((t) => showAll || !hasDeclared || declared.has(t.id)); const catalogue = (categories?.items ?? []) .slice() .sort((a, b) => a.sortOrder - b.sortOrder); @@ -71,7 +89,38 @@ export function LicenseCatalogue() { // vanish from the page entirely. orphans: active.filter((t) => !known.has(t.category)), }; - }, [types, categories]); + }, [types, categories, declared, hasDeclared, showAll]); + + // A profile created before modes existed, or one whose modes were all + // removed. Showing an empty catalogue would read as "there is nothing for + // you here" when the truth is "tell us what you do". + if (!loadingOperatorTypes && !hasDeclared && !showAll) { + return ( + + + + + + + Tell us what you operate as + + + Licences are offered against your mode of operation — freight + forwarder, shipping agent, multimodal transport operator and so on. + Choose yours and the licences you can apply for appear here. + + + + + + + + ); + } if (groups.length === 0 && orphans.length === 0) { return ( @@ -91,8 +140,26 @@ export function LicenseCatalogue() { ); } + // In browse-all, a card the applicant cannot yet apply for sends them to the + // Operations tab rather than into a form the server would refuse to accept. + const canApply = (type: LicenseType) => !hasDeclared || declared.has(type.id); + const select = (type: LicenseType) => + canApply(type) + ? navigate(`/licensing/${type.key}/apply`) + : navigate('/profile#operations'); + return ( + {showAll && hasDeclared && ( + + + Showing every licence, including ones outside your operations. + + setShowAll(false)}> + Show only mine + + + )} {groups.map(({ category, licenseTypes }) => ( navigate(`/licensing/${type.key}/apply`)} + canApply={canApply} + onSelect={select} /> ))} {orphans.length > 0 && ( @@ -109,9 +177,20 @@ export function LicenseCatalogue() { title="Other licences" description="Licence types that have not been assigned a category." licenseTypes={orphans} - onSelect={(type) => navigate(`/licensing/${type.key}/apply`)} + canApply={canApply} + onSelect={select} /> )} + {hasDeclared && !showAll && ( + + + Only licences matching your declared operations are shown. + + setShowAll(true)}> + Browse all licences + + + )} ); } @@ -121,12 +200,14 @@ function CategoryGroup({ title, description, licenseTypes, + canApply, onSelect, }: { icon: typeof IconShip; title: string; description: string; licenseTypes: LicenseType[]; + canApply: (type: LicenseType) => boolean; onSelect: (type: LicenseType) => void; }) { return ( @@ -144,7 +225,12 @@ function CategoryGroup({ {licenseTypes.map((type) => ( - + ))} @@ -153,9 +239,11 @@ function CategoryGroup({ function LicenseTypeCard({ type, + canApply, onSelect, }: { type: LicenseType; + canApply: boolean; onSelect: (type: LicenseType) => void; }) { const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null; @@ -216,9 +304,10 @@ function LicenseTypeCard({ mt="sm" size="xs" variant="light" + color={canApply ? undefined : 'gray'} rightSection={} > - Start application + {canApply ? 'Start application' : 'Add to my operations'} diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx index cb72a4dd6..c90f8bc9c 100644 --- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx @@ -1,7 +1,6 @@ import { useNavigate } from 'react-router-dom'; import { Badge, - Box, Button, Card, Center, @@ -54,7 +53,7 @@ export function MyApplicationsPage() { color: 'teal', title: 'Payment bypassed', message: result.certificateIssued - ? 'The licence has been issued — see My licences below.' + ? 'The licence has been issued — see My licences above.' : `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`, }); } catch (err) { @@ -118,14 +117,9 @@ export function MyApplicationsPage() { Licence applications - Choose a licence to apply for. Each one asks for its own forms and - supporting documents. + Your licences and applications, and the catalogue to file a new one. - - - - {(licences?.items ?? []).length > 0 && ( <> @@ -205,7 +199,7 @@ export function MyApplicationsPage() { <Stack align="center" gap="xs"> <Text c="dimmed">You have not filed any applications yet.</Text> <Text size="sm" c="dimmed"> - Pick a licence above to get started. + Pick a licence below to get started. </Text> </Stack> </Card> @@ -314,6 +308,13 @@ export function MyApplicationsPage() { </Table> </Card> )} + + {/* Last, for the same reason as on the dashboard: someone opening this + page came to check on what they already filed, not to browse. */} + <Title order={4} mt="xl" mb="sm"> + Apply for a licence + + ); } diff --git a/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx b/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx new file mode 100644 index 000000000..5863a574e --- /dev/null +++ b/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx @@ -0,0 +1,54 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { Center, Loader } from '@mantine/core'; +import { useGetMyOperatorTypesQuery } from '@ema-platform/api'; + +/** + * Sends an applicant who has not said what they operate as to the step that + * asks. Everything else in the portal is keyed off that answer — the catalogue + * offers nothing without it, and the server refuses an application for a mode + * the profile does not hold — so it is asked once, up front. + * + * Deliberately not a hard gate on every route: the profile and support pages + * stay reachable, because someone who cannot answer the question yet must + * still be able to reach their account and ask for help. + */ +const ALWAYS_ALLOWED = ['/onboarding/operations', '/profile', '/support']; + +export function RequireOperations({ children }: { children: React.ReactNode }) { + const { pathname } = useLocation(); + const { data, isLoading, isFetching, isError } = useGetMyOperatorTypesQuery(); + + if (ALWAYS_ALLOWED.some((path) => pathname.startsWith(path))) { + return <>{children}; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + // A failed lookup must not lock anyone out of the portal — the server still + // enforces the rule on create, so the worst case is a catalogue that offers + // more than it should for one session. + if (isError) return <>{children}; + + if ((data?.items ?? []).length === 0) { + // An empty set while a request is in flight is not an answer. Saving the + // onboarding form invalidates this query and navigates to the dashboard in + // the same tick; deciding on the pre-save cache bounced the applicant + // straight back to the screen they had just completed. + if (isFetching) { + return ( +
+ +
+ ); + } + return ; + } + + return <>{children}; +} diff --git a/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx b/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx new file mode 100644 index 000000000..bdfe4f524 --- /dev/null +++ b/apps/portal/src/app/features/onboarding/pages/OperationsOnboardingPage.tsx @@ -0,0 +1,38 @@ +import { useNavigate } from 'react-router-dom'; +import { Container, Paper, Stack, Text, Title } from '@mantine/core'; +import { OperationsFormContent } from '../../profile/components/OperationsFormContent'; + +/** + * The one thing a new applicant is asked for beyond their credentials. + * + * It is a step of its own rather than a field on the signup form because the + * licence catalogue it offers is not readable without a session — signup + * issues the token, and this is the first screen behind it. An applicant who + * arrived before modes existed lands here once, for the same reason. + * + * Saving is what completes it: `RequireOperations` stops redirecting here as + * soon as the profile has at least one mode. + */ +export function OperationsOnboardingPage() { + const navigate = useNavigate(); + + return ( + + +
+ What do you operate as? + + The Authority licenses by mode of operation. Tell us what your + company does and we will show you the licences you can apply for — + you can change this later from your profile. + +
+ + navigate('/dashboard')} /> + +
+
+ ); +} + +export default OperationsOnboardingPage; diff --git a/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx b/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx new file mode 100644 index 000000000..cc25ed0b8 --- /dev/null +++ b/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Loader, + Modal, + Stack, + Text, + Title, +} from '@mantine/core'; +import { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react'; +import { + extractErrorMessage, + localized, + useGetLicenseTypesQuery, + useGetMyOperatorTypesQuery, + useUpdateMyOperatorTypesMutation, +} from '@ema-platform/api'; +import { notify } from '@ema-platform/ui'; + +/** + * The applicant's modes of operation — what they do, and therefore which + * licences the portal offers them. + * + * The options come from the licence-type catalogue rather than a list in the + * code, so a sixth licence type configured by EMA appears here without a + * release. Removing a mode is confirmed separately from adding one: adding + * only widens what is on offer, while removing changes what the applicant can + * still file. + */ +export function OperationsFormContent({ + onSaved, +}: { + /** Where to go once the set is stored — used by the onboarding step. */ + onSaved?: () => void; +} = {}) { + const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery(); + const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery(); + const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation(); + + const declaredIds = useMemo( + () => (mine?.items ?? []).map((o) => o.licenseTypeId), + [mine], + ); + const [selected, setSelected] = useState([]); + const [confirmingRemoval, setConfirmingRemoval] = useState(false); + + // Re-sync whenever the server's answer changes — including after a save, so + // the form reflects what was actually stored rather than what was typed. + useEffect(() => setSelected(declaredIds), [declaredIds]); + + const options = useMemo( + () => (catalogue?.items ?? []).filter((t) => t.isActive), + [catalogue], + ); + + const removed = declaredIds.filter((id) => !selected.includes(id)); + const dirty = + removed.length > 0 || selected.some((id) => !declaredIds.includes(id)); + + const lastChanged = useMemo(() => { + const dates = (mine?.items ?? []) + .map((o) => o.declaredAt) + .filter((d): d is string => Boolean(d)) + .sort(); + return dates.length > 0 ? dates[dates.length - 1] : null; + }, [mine]); + + async function persist() { + try { + await save({ licenseTypeIds: selected }).unwrap(); + setConfirmingRemoval(false); + notify.success( + 'The licences you can apply for have been updated to match.', + 'Operations updated', + ); + onSaved?.(); + } catch (err) { + notify.error(extractErrorMessage(err), 'Could not save'); + } + } + + if (loadingTypes || loadingMine) { + return ; + } + + const removedNames = options + .filter((t) => removed.includes(t.id)) + .map((t) => localized(t.name)); + + return ( + +
+ Mode of operation + + What your company operates as. This decides which licences you are + offered — you can change it whenever your business changes. + + + + + {options.map((type) => ( + + {localized(type.name)} + {declaredIds.includes(type.id) && ( + + Current + + )} + + } + description={ + type.description ? localized(type.description) : undefined + } + /> + ))} + + + + {options.length === 0 && ( + + No licence types are configured yet. Contact EMA if you were + expecting one. + + )} +
+ + {selected.length === 0 && ( + } + title="No operations selected" + > + With none selected you will not be offered any licence to apply for. + Existing applications and issued licences are unaffected. + + )} + + + + {lastChanged + ? `Last changed ${new Date(lastChanged).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + })}` + : 'Not set yet'} + + + {dirty && ( + + )} + + + + + {/* Removal is the one direction that takes something away, so it is + spelled out rather than saved on a single click. */} + setConfirmingRemoval(false)} + title="Remove from your operations?" + centered + > + + + You are removing{' '} + + {removedNames.join(', ')} + + . + + + You will no longer be offered a new application of that type. + Applications already filed carry on as they are, and licences + already issued to you stay valid and can still be renewed. + + + + + + + +
+ ); +} diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 912b2b09f..5a791ad86 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -32,6 +32,7 @@ import { IconDeviceFloppy, IconLock, IconMail, + IconBuildingWarehouse, IconMapPin, IconMoon, IconPhone, @@ -63,10 +64,18 @@ import { addressSchema, type AddressValues, } from '../components/AddressFormContent'; +import { OperationsFormContent } from '../components/OperationsFormContent'; import classes from './ProfilePage.module.css'; /** Tab keys addressable via the URL hash. */ -const VALID_TABS = ['personal', 'profile', 'address', 'security', 'preferences']; +const VALID_TABS = [ + 'personal', + 'profile', + 'address', + 'operations', + 'security', + 'preferences', +]; function getInitials(name: string, fallback: string) { const source = name?.trim() || fallback?.trim() || ''; @@ -525,6 +534,12 @@ export function ProfilePage() { }> Address + } + > + Operations + }> {t('profile.tabs.security')} @@ -695,6 +710,13 @@ export function ProfilePage() { + {/* ---- Operations (what the applicant may apply for) ---- */} + + + + + + {/* ---- Security ---- */} diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index 82e0d9ec4..5ab101561 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -9,6 +9,8 @@ import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from ' // Portal feature pages import { DashboardPage } from './features/dashboard/pages/DashboardPage'; +import { RequireOperations } from './features/onboarding/components/RequireOperations'; +import { OperationsOnboardingPage } from './features/onboarding/pages/OperationsOnboardingPage'; import { ProfilePage } from './features/profile/pages/ProfilePage'; import { SupportPage } from './features/support/pages/SupportPage'; import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage'; @@ -67,13 +69,18 @@ export const router = createBrowserRouter([ element: ( - + {/* Asks what the applicant operates as before the rest of the + portal, which is filtered by that answer. */} + + + ), children: [ { path: '/', element: }, { path: '/dashboard', element: }, + { path: '/onboarding/operations', element: }, // Config-driven licensing: one set of pages serves every licence type. { path: '/licensing/applications', element: }, diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index c99f4c907..106b6c0f2 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -14,6 +14,7 @@ import type { LicenseStatus, LicenseType, LicenseTypeRequirements, + OperatorType, AssignableOfficer, DocumentDecision, DocumentReview, @@ -54,6 +55,7 @@ function serialiseQueueFilter( const TAGS = [ 'LicenseType', + 'OperatorType', 'LicenseApplication', 'ApplicationQueue', 'Attachment', @@ -84,6 +86,33 @@ export const licensingApi = baseApi providesTags: () => [listTag('LicenseType')], }), + /** + * The signed-in applicant's declared modes of operation. + * + * Separate from the licence-type catalogue on purpose: the catalogue is + * the same for everyone and heavily cached, while this is per-user and + * changes the moment they edit their profile. + */ + getMyOperatorTypes: builder.query<{ items: OperatorType[] }, void>({ + query: () => ({ url: '/profiles/me/operations' }), + providesTags: () => [listTag('OperatorType')], + }), + + /** Replaces the set — see the Operations tab in the portal profile. */ + updateMyOperatorTypes: builder.mutation< + { items: OperatorType[] }, + { licenseTypeIds: string[] } + >({ + query: (body) => ({ + url: '/profiles/me/operations', + method: 'PUT', + body, + }), + // The catalogue is filtered by this, so it has to refetch too. + invalidatesTags: (_r, error) => + error ? [] : [listTag('OperatorType'), listTag('LicenseType')], + }), + /** * Category catalogue used to group the licence cards. Static on the * server, so it is cached under the licence-type list tag. @@ -758,4 +787,6 @@ export const { useGetNotificationsQuery, useGetUnseenNotificationsQuery, useMarkNotificationReadMutation, + useGetMyOperatorTypesQuery, + useUpdateMyOperatorTypesMutation, } = licensingApi; diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index 5c95f8221..c325fee17 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -86,6 +86,19 @@ export interface LicenseCategoryDefinition { sortOrder: number; } +/** + * A mode of operation the applicant has declared — the licence types they may + * file new applications for. `key` and `name` are denormalised from the + * licence type so the profile screen can render the set without also loading + * the whole catalogue. + */ +export interface OperatorType { + licenseTypeId: string; + key: string | null; + name: Bilingual | null; + declaredAt: string | null; +} + export interface LicenseType { id: string; key: string; @@ -442,6 +455,18 @@ export interface IssuedLicense { issueDate: string; expiryDate: string; status: 'ACTIVE' | 'EXPIRED' | 'SUSPENDED' | 'CANCELLED' | 'SUPERSEDED'; + /** + * Days until the expiry date; negative once it has passed. Computed by the + * API in the authority's timezone — the client must not re-derive it, since + * a browser in another zone would land on a different day. + */ + daysUntilExpiry?: number; + /** + * Whether a renewal can be filed now: inside the licence type's renewal + * window, or after expiry. The API decides, because the window is per-type + * configuration. + */ + renewable?: boolean; verificationCode: string; certificateFileKey: string | null; } diff --git a/package-lock.json b/package-lock.json index ca8a4a1cb..04ca777d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@nx/react": "^22.5.4", "@nx/vite": "^22.5.4", "@nx/vitest": "^22.5.4", + "@playwright/test": "^1.62.1", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -5153,6 +5154,22 @@ "typescript": ">3.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -16180,6 +16197,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/png-js": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", diff --git a/package.json b/package.json index 5c02ced2c..9b8fd4ab5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@nx/react": "^22.5.4", "@nx/vite": "^22.5.4", "@nx/vitest": "^22.5.4", + "@playwright/test": "^1.62.1", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 000000000..cbcc1fbac --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file