SonarQube reported 0% coverage for 78 unit, 134 integration and 83 end-to-end tests, so the coverage-on-new-code gate — the most useful thing SonarQube offers a project this size — has been failing permanently while looking configured. It now reports 69.6%, verified by a real scan. Backend coverage comes from both suites, written to separate directories because jest writes coverage/lcov.info by default and the second run would silently overwrite the first. Both are needed rather than just the fast one: the unit suite alone reports 11%, because everything in src/routes is exercised by the integration suite. That suite is manual-only after hanging for 3h12m post-run, so it runs here with --forceExit and the job carries a hard timeout; jest confirmed during testing that it would otherwise have hung. The frontend had no unit tests at all, so its coverage comes from Playwright driving an istanbul-instrumented dev server, collected per test by an auto-fixture and merged with nyc. The 17 specs now import from a local fixtures module that re-exports @playwright/test, which is what lets the fixture attach without touching each test body. Instrumentation is gated behind COVERAGE=true and loaded by dynamic import, since vite-plugin-istanbul is ESM-only while vite.config.ts evaluates as CommonJS. Both directions were checked rather than assumed: a normal build contains no instrumentation, and the dev server instruments nested modules as well as top-level ones — the first attempt used an include glob of src/* which would have silently missed everything under src/admin and src/cart. coverage:report fails when nothing was collected instead of writing an empty report, and that guard was fired deliberately to confirm it works. This project has been bitten twice by tools succeeding while measuring nothing — SonarQube skipping the whole frontend and still exiting EXECUTION SUCCESS in #67, and an ESLint matcher silently matching no files during #60 — and coverage has exactly that shape: an uninstrumented dev server lets every test pass while gathering nothing, and the 0% that follows reads as lost coverage rather than broken collection. Worth knowing when reading the numbers: end-to-end coverage flatters. Istanbul marks a line covered when the browser ran it, so a component rendered during a test counts as covered with nothing asserting anything about it. Recorded in the design doc and the project context rather than left to be discovered. Also declares sonar.tests so test files are analysed under the test rule set rather than as production code. Closes #61
196 lines
8.3 KiB
TypeScript
Executable File
196 lines
8.3 KiB
TypeScript
Executable File
import { test, expect, Page } from './fixtures';
|
|
|
|
const PASSWORD = 'supersecret123';
|
|
|
|
function uniqueEmail(): string {
|
|
return `playwright-${Date.now()}-${Math.floor(Math.random() * 10000)}@example.com`;
|
|
}
|
|
|
|
// Registering closes the auth modal and returns the customer to the page behind
|
|
// it — the storefront, for a direct visit to /register — rather than navigating
|
|
// to /account. So the header, not the URL, is what proves the session exists.
|
|
//
|
|
// The wait is generous because this is a bcrypt round-trip rather than a render,
|
|
// and the suite's workers all register at once.
|
|
async function register(page: Page, email: string) {
|
|
await page.goto('/register');
|
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
await page.getByLabel('Password').fill(PASSWORD);
|
|
await page.getByRole('button', { name: 'Create account' }).click();
|
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
}
|
|
|
|
// Log out lives inside the account view, which is a modal over the storefront.
|
|
async function openAccount(page: Page) {
|
|
await page.goto('/account');
|
|
await expect(page.getByRole('dialog', { name: 'My Account' })).toBeVisible();
|
|
}
|
|
|
|
test.describe('Customer accounts', () => {
|
|
test('marketing consent checkbox is unchecked by default', async ({ page }) => {
|
|
await page.goto('/register');
|
|
await expect(page.getByRole('checkbox')).not.toBeChecked();
|
|
});
|
|
|
|
test('the consent label is the exact wording the server records', async ({ page }) => {
|
|
await page.goto('/register');
|
|
|
|
// The stored consent text is kept verbatim so the record says what the
|
|
// customer actually saw. Three different wordings were in circulation
|
|
// before the sign-in form was shared between the routes and the cart
|
|
// prompt, and none of them matched what was stored.
|
|
const consent =
|
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
|
await expect(page.getByRole('dialog', { name: 'Create an account' })).toContainText(consent);
|
|
});
|
|
|
|
test('registering signs the customer in and returns them where they were', async ({ page }) => {
|
|
const email = uniqueEmail();
|
|
await register(page, email);
|
|
|
|
// Back on the storefront, signed in — not moved to the account page.
|
|
await expect(page).toHaveURL(/\/$/);
|
|
await openAccount(page);
|
|
await expect(page.getByText(email)).toBeVisible();
|
|
});
|
|
|
|
test('rejects login with the wrong password', async ({ page }) => {
|
|
const email = uniqueEmail();
|
|
await register(page, email);
|
|
await openAccount(page);
|
|
await page.getByRole('button', { name: 'Log out' }).click();
|
|
|
|
await page.goto('/login');
|
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
await page.getByLabel('Password').fill('wrong-password');
|
|
// Scoped to the modal: the storefront rendered behind it has a "Log in"
|
|
// button of its own, which is what opened this one.
|
|
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
|
|
|
|
await expect(page.getByText('invalid email or password')).toBeVisible();
|
|
});
|
|
|
|
test('logging out returns to the home page and resets the header', async ({ page }) => {
|
|
await register(page, uniqueEmail());
|
|
await openAccount(page);
|
|
|
|
await page.getByRole('button', { name: 'Log out' }).click();
|
|
|
|
// A server round-trip followed by re-rendering the storefront behind the
|
|
// modal, so the 5s default is too tight when workers run concurrently.
|
|
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
|
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
|
|
});
|
|
|
|
test('the logged-out header survives a reload', async ({ page }) => {
|
|
await register(page, uniqueEmail());
|
|
await openAccount(page);
|
|
|
|
await page.getByRole('button', { name: 'Log out' }).click();
|
|
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
|
|
|
// Proves the server session was actually destroyed, rather than the header
|
|
// merely being repainted from stale client state.
|
|
await page.reload();
|
|
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
|
|
});
|
|
|
|
test('logging out does not leave the account page on the back stack', async ({ page }) => {
|
|
await register(page, uniqueEmail());
|
|
await openAccount(page);
|
|
|
|
await page.getByRole('button', { name: 'Log out' }).click();
|
|
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
|
|
|
await page.goBack();
|
|
await expect(page).not.toHaveURL(/\/account/);
|
|
});
|
|
|
|
test('a failed logout says so instead of appearing to succeed', async ({ page }) => {
|
|
await register(page, uniqueEmail());
|
|
await openAccount(page);
|
|
|
|
await page.route('**/api/customers/logout', (route) =>
|
|
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
|
|
);
|
|
|
|
await page.getByRole('button', { name: 'Log out' }).click();
|
|
|
|
// The session cookie is still valid, so pretending to be logged out would
|
|
// silently log the customer back in on their next reload.
|
|
await expect(page.getByText(/couldn't log out/i)).toBeVisible();
|
|
await expect(page).toHaveURL(/\/account/);
|
|
});
|
|
});
|
|
|
|
test.describe('Auth routes are not dead ends', () => {
|
|
test('opening Log in from the header closes back to where browsing left off', async ({ page }) => {
|
|
await page.goto('/?max_price=50000');
|
|
await page.getByRole('button', { name: 'Log in' }).click();
|
|
|
|
const modal = page.getByRole('dialog', { name: 'Log in' });
|
|
await expect(modal).toBeVisible();
|
|
await expect(page).toHaveURL(/\/login/);
|
|
|
|
await modal.getByRole('button', { name: 'Close' }).click();
|
|
|
|
await expect(modal).toBeHidden();
|
|
await expect(page).toHaveURL(/max_price=50000/);
|
|
});
|
|
|
|
test('a direct visit opens over the storefront rather than a blank page', async ({ page }) => {
|
|
await page.goto('/login');
|
|
|
|
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
|
|
});
|
|
|
|
test('switching between sign in and sign up keeps one history entry', async ({ page }) => {
|
|
await page.goto('/?max_price=50000');
|
|
await page.getByRole('button', { name: 'Log in' }).click();
|
|
|
|
await page.getByRole('tab', { name: 'Create Account' }).click();
|
|
await expect(page).toHaveURL(/\/register/);
|
|
await page.getByRole('tab', { name: 'Log In' }).click();
|
|
await expect(page).toHaveURL(/\/login/);
|
|
|
|
// Back returns to browsing rather than walking through each tab visited.
|
|
await page.goBack();
|
|
await expect(page).toHaveURL(/max_price=50000/);
|
|
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeHidden();
|
|
});
|
|
|
|
test('signing in from the header returns to the page behind, signed in', async ({ page }) => {
|
|
const email = uniqueEmail();
|
|
await register(page, email);
|
|
await page.goto('/account');
|
|
await page.getByRole('button', { name: 'Log out' }).click();
|
|
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
|
|
|
await page.goto('/?max_price=50000');
|
|
await page.getByRole('button', { name: 'Log in' }).click();
|
|
const modal = page.getByRole('dialog', { name: 'Log in' });
|
|
await modal.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
await modal.getByLabel('Password').fill(PASSWORD);
|
|
await modal.getByRole('button', { name: 'Log in' }).click();
|
|
|
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
await expect(page).toHaveURL(/max_price=50000/);
|
|
});
|
|
|
|
test('reaching password recovery from the login form keeps a way back', async ({ page }) => {
|
|
await page.goto('/login');
|
|
await page.getByRole('button', { name: 'Forgot password?' }).click();
|
|
|
|
const modal = page.getByRole('dialog', { name: 'Reset your password' });
|
|
await expect(modal).toBeVisible();
|
|
await expect(page).toHaveURL(/\/forgot-password/);
|
|
|
|
await modal.getByRole('button', { name: 'Sign in' }).click();
|
|
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible();
|
|
});
|
|
});
|