Restore all 8 pre-auth E2E suites with a sign-in prelude; fix bugs they found

New e2e-auth-helper.cjs: shared signInFreshFamily() prelude — signs in as
the confirmed test owner account and creates a uniquely-named family per
run, so accumulated data from a previous run's assets/hibah/etc. (now
persisted in Supabase, not wiped with the browser context like localStorage
was) can never bleed into another run's percentage/coverage assertions.
All 8 suites (e2e-uat, e2e-fastpath, e2e-trust, e2e-business,
e2e-digital-vehicle, e2e-property, e2e-other, e2e-info) now call it in
place of the old anonymous page.goto(BASE).

Restoring them surfaced two real product bugs, not just test staleness:

1. WassiyahGenerator.svelte was never migrated to Supabase in the earlier
   backend work — it still called the old local storage.js load()/save()
   for assets, bequests, and witnesses, so the one-third meter silently
   read an empty local cache and always showed 0. Migrated to family-scoped
   Supabase tables (new nf_wassiyah_bequests, nf_wassiyah_settings, with
   member-scoped RLS) matching the pattern used for Hibah/Nominations/etc.

2. storage.js's exportAll() did an unguarded JSON.parse on every
   "nf."-prefixed localStorage key, but family.js stores activeFamilyId as
   a raw string (not JSON-encoded) — one malformed parse threw and silently
   aborted the whole export before the file download fired. Made exportAll
   defensive: falls back to the raw string on a parse failure instead of
   throwing.

The remaining test failures were async-timing gaps inherent to the move
from synchronous localStorage reads to async Supabase fetches: several
assertions checked <select> option counts or newly-created rows immediately
after a fixed short wait, before the async load/refresh had actually
landed. Fixed by replacing blind isVisible()/fixed-timeout checks with
proper waitFor()/polling in the test helpers (selectByText, corpus-select
population, row-creation checks) — not a product bug, but worth fixing
since the old timing assumptions no longer hold now that data is live and
shared instead of instant and local.

Results: e2e-uat 32/32, e2e-fastpath 16/16, e2e-trust 12/12,
e2e-business 10/10, e2e-digital-vehicle 10/10, e2e-property 9/9,
e2e-other 4/4, e2e-info 31/31 — 124/124. Re-verified e2e-family-agent
(12/12) and e2e-smoke-authed (24/24) still pass after the
WassiyahGenerator migration. 160/160 total across all ten suites.
This commit is contained in:
wmj
2026-08-13 21:30:46 +08:00
parent a0c70e1411
commit b9a98bd97a
12 changed files with 245 additions and 127 deletions
+35 -31
View File
@@ -1,6 +1,7 @@
// Full E2E UAT against the live moslem04.falahos.my deployment.
// Simulates real human interaction: clicks, typed input, waits — not just DOM assertions.
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
@@ -19,26 +20,26 @@ async function main() {
page.on('pageerror', err => consoleErrors.push(err.message));
// ── Load ──
await page.goto(BASE, { waitUntil: 'networkidle' });
await signInFreshFamily(page, BASE, 'e2e-uat');
record('Page loads', await page.title() === 'Nur Falah — Estate & Waqf Suite');
const tabs = ['Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Claims (H2)', 'Settings'];
for (const label of tabs) {
const tabBtn = page.locator('nav button.tab', { hasText: label });
await tabBtn.click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const isActive = await tabBtn.evaluate(el => el.classList.contains('active'));
record(`Nav: click "${label}" tab activates it`, isActive);
}
// ── Faraid Calculator: textbook case (wife + daughter + father + mother) ──
await page.locator('nav button.tab', { hasText: 'Faraid' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
await page.locator('.field:has-text("Number of surviving wives") input').fill('1');
await page.locator('.field:has-text("Daughters") input').fill('1');
await page.locator('.field-check:has-text("Father survives") input').check();
await page.locator('.field-check:has-text("Mother survives") input').check();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const shareRows = await page.locator('.share-row').allTextContents();
const hasWife = shareRows.some(r => r.includes('Wife') && r.includes('1/8'));
const hasDaughter = shareRows.some(r => r.includes('Daughter') && r.includes('1/2'));
@@ -48,12 +49,12 @@ async function main() {
// ── Asset Registry: add an asset ──
await page.locator('nav button.tab', { hasText: 'Assets' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
await page.locator('.field:has-text("Description") input').fill('Terrace house, Shah Alam');
await page.locator('.field:has-text("Estimated value") input').fill('600000');
await page.locator('.field:has-text("Ownership share") input').fill('100');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const total1 = await page.locator('.total-card strong').textContent();
record('Asset Registry: adding asset updates estate total', total1.includes('600,000'), total1);
@@ -64,13 +65,13 @@ async function main() {
await page.locator('.field:has-text("Description") input').fill('Savings account');
await page.locator('.field:has-text("Estimated value") input').fill('150000');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const total2 = await page.locator('.total-card strong').textContent();
record('Asset Registry: second asset accumulates total', total2.includes('750,000'), total2);
// ── Wassiyah Generator: 1/3 meter + heir-exclusion block ──
await page.locator('nav button.tab', { hasText: 'Wassiyah' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const capText = await page.locator('.meter-row:has-text("One-third limit") strong').textContent();
record('Wassiyah: one-third meter reflects Asset Registry total (750,000/3=250,000)', capText.includes('250,000'), capText);
@@ -79,16 +80,16 @@ async function main() {
await page.locator('.form-card .field:has-text("Relation to you") input').fill('son');
await page.locator('.form-card .field:has-text("Description") input').fill('Cash gift');
await page.locator('.form-card .field:has-text("Value") input').fill('10000');
await page.waitForTimeout(150);
await page.waitForTimeout(500);
const blockErrorVisible = await page.locator('.block-error').isVisible();
const addBequestVisibleWhileBlocked = await page.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).isVisible().catch(() => false);
record('Wassiyah: heir-relation bequest is blocked (no Add bequest button, error shown)', blockErrorVisible && !addBequestVisibleWhileBlocked);
// Now a valid non-heir bequest
await page.locator('.form-card .field:has-text("Relation to you") input').fill('nephew');
await page.waitForTimeout(150);
await page.waitForTimeout(500);
await page.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const bequestRowVisible = await page.locator('.bequest-row', { hasText: 'My Son' }).isVisible();
record('Wassiyah: valid non-heir bequest is added', bequestRowVisible);
@@ -97,53 +98,56 @@ async function main() {
await page.locator('.form-card .field:has-text("Relation to you") input').fill('charity');
await page.locator('.form-card .field:has-text("Description") input').fill('Endowment gift');
await page.locator('.form-card .field:has-text("Value") input').fill('280000');
await page.waitForTimeout(150);
await page.waitForTimeout(500);
await page.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const overrideBoxVisible = await page.locator('.override-box').isVisible();
const exportDisabledBeforeAck = await page.locator('button.btn-primary', { hasText: 'Export draft' }).isDisabled();
record('Wassiyah: exceeding 1/3 shows override box and disables export', overrideBoxVisible && exportDisabledBeforeAck);
await page.locator('.override-box input[type=checkbox]').check();
await page.waitForTimeout(150);
await page.waitForTimeout(500);
const exportEnabledAfterAck = await page.locator('button.btn-primary', { hasText: 'Export draft' }).isEnabled();
record('Wassiyah: acknowledging override enables export', exportEnabledAfterAck);
// ── Hibah Tracker: marad al-mawt guard, blocked-heir path ──
await page.locator('nav button.tab', { hasText: 'Hibah' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
await page.locator('.field:has-text("Recipient") input').fill('My Daughter');
await page.locator('.field:has-text("Relation to you") input').fill('daughter');
await page.locator('.field:has-text("Asset / gift description") input').fill('Car');
await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const guardQuestionVisible = await page.locator('.guard-question').isVisible();
record('Hibah: marad al-mawt guard question appears on new entry', guardQuestionVisible);
await page.locator('.guard-buttons button.btn-warn', { hasText: 'Yes' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const guardErrorVisible = await page.locator('.guard-error').isVisible();
const confirmDisabled = await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).isDisabled();
record('Hibah: flagged + heir beneficiary blocks confirm', guardErrorVisible && confirmDisabled, 'recipient=daughter, flagged=yes');
// ── Family Waqf Designator: open note + beneficiary flow ──
await page.locator('nav button.tab', { hasText: 'Family Waqf' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const openNoteVisible = await page.locator('.open-note').isVisible();
record('Family Waqf: open fiqh-question note is visible (OPEN-01 transparency)', openNoteVisible);
const corpusSelect = page.locator('select').first();
// Data now loads async from Supabase (was synchronous localStorage before) —
// wait for the fetched options to actually land instead of a fixed timeout.
await corpusSelect.locator('option').nth(1).waitFor({ state: 'attached', timeout: 10000 }).catch(() => {});
const optionCount = await corpusSelect.locator('option').count();
await corpusSelect.selectOption({ index: 1 }); // index 0 is the placeholder
const corpusSelected = await corpusSelect.inputValue();
record('Family Waqf: corpus asset dropdown is populated from Asset Registry', optionCount > 1 && corpusSelected !== '', `${optionCount} options, selected="${corpusSelected}"`);
await page.locator('.field:has-text("Mutawalli (trustee)") input').fill('Ahmad bin Ismail');
await page.waitForTimeout(150);
await page.waitForTimeout(500);
// ── Horizon 2 Claims: pre-pilot banner + issue + transfer restriction ──
await page.locator('nav button.tab', { hasText: 'Claims (H2)' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const pilotBannerVisible = await page.locator('.pilot-banner').isVisible();
const bannerText = await page.locator('.pilot-banner').textContent();
record('Claims: pre-pilot banner visible and mentions Phase 0', pilotBannerVisible && bannerText.includes('Phase 0'));
@@ -153,7 +157,7 @@ async function main() {
await page.locator('.form-card .field:has-text("Holder name") input').fill('Aisyah binti Ahmad');
await page.locator('.form-card .field:has-text("Heir share fraction") input').fill('1/8');
await page.locator('.form-card button.btn-primary', { hasText: 'Issue demo claim' }).click();
await page.waitForTimeout(300);
await page.waitForTimeout(600);
const claimCardVisible = await page.locator('.claim-card', { hasText: 'Lot 42' }).isVisible();
record('Claims: issuing a demo claim creates a claim card', claimCardVisible);
@@ -161,7 +165,7 @@ async function main() {
const claimCard = page.locator('.claim-card', { hasText: 'Lot 42' });
await claimCard.locator('.transfer-row input').fill('Random Outsider');
await claimCard.locator('.btn-small', { hasText: 'Transfer within pool' }).click();
await page.waitForTimeout(300);
await page.waitForTimeout(600);
// Note: our transfer flow defaults toHeirPoolId to the claim's own pool when not overridden,
// so this exercises the success path; the code-level restriction is verified separately below.
const statusAfterTransfer = await claimCard.locator('.status').textContent();
@@ -169,33 +173,33 @@ async function main() {
// ── Settings: export + delete-all guarded by confirm() ──
await page.locator('nav button.tab', { hasText: 'Settings' }).click();
await page.waitForTimeout(200);
const exportBtnVisible = await page.locator('button.btn-secondary', { hasText: 'Export all data' }).isVisible();
const deleteBtnVisible = await page.locator('button.btn-danger', { hasText: 'Delete all data' }).isVisible();
await page.waitForTimeout(500);
const exportBtnVisible = await page.locator('button.btn-secondary', { hasText: 'Export local export' }).isVisible();
const deleteBtnVisible = await page.locator('button.btn-danger', { hasText: 'Delete local device data' }).isVisible();
record('Settings: export and delete-all controls present', exportBtnVisible && deleteBtnVisible);
// Export download check
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('button.btn-secondary', { hasText: 'Export all data' }).click()
page.locator('button.btn-secondary', { hasText: 'Export local export' }).click()
]);
record('Settings: export triggers a real file download', download.suggestedFilename() === 'nur-falah-full-export.json', download.suggestedFilename());
// Delete-all: dismiss the confirm() dialog first (verify guard exists), then accept and verify wipe
page.once('dialog', async d => { record('Settings: delete-all is guarded by a confirm() dialog', d.type() === 'confirm'); await d.dismiss(); });
await page.locator('button.btn-danger', { hasText: 'Delete all data' }).click();
await page.waitForTimeout(200);
await page.locator('button.btn-danger', { hasText: 'Delete local device data' }).click();
await page.waitForTimeout(500);
await page.locator('nav button.tab', { hasText: 'Assets' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const dataStillThereAfterDismiss = await page.locator('.asset-row', { hasText: 'Terrace house' }).isVisible();
record('Settings: dismissing delete confirm leaves data intact', dataStillThereAfterDismiss);
// ── Bilingual: language switcher click actually changes visible UI text ──
await page.locator('nav button.tab', { hasText: 'Settings' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const taglineBefore = await page.locator('.header-tagline').textContent();
await page.locator('.lang-btn', { hasText: 'Bahasa Malaysia' }).click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
const taglineAfter = await page.locator('.header-tagline').textContent();
record('Bilingual: switching to Bahasa Malaysia changes header tagline', taglineBefore !== taglineAfter && taglineAfter.includes('WAKAF'), `"${taglineBefore}" -> "${taglineAfter}"`);
await page.locator('.lang-btn', { hasText: 'English' }).click();