Files
nur-falah-prevention/e2e-uat.cjs
T
wmj d5e25e9f8e Seed 5 fully-populated demo families with dummy assets, coverage, and trees
Per explicit request: "full dummy data on at least 5 families with dummy
assets and family members." Seeded directly against the live Supabase
backend (SQL, not driving the UI 5x) for speed and reliability, then
verified through the actual live app rather than trusting the inserts.

5 families, each with a distinct role in demonstrating the app:
- The Ismail Family (100% coverage) — near-fully-planned estate: Hibah,
  bank-mandate nomination, digital-custody (key-escrow) nomination,
  business-continuity (company shares). Has an extra member (spouse) and
  an agent/mutawalli.
- The Rahman Family (UK, 20% coverage) — diaspora market, partial
  coverage, exercises Wassiyah (charitable bequest) alongside Hibah/
  Nomination. Has an extra adult-child member.
- The Osman Family (31% coverage) — deliberately mostly UNCOVERED to
  demonstrate the Coverage Dashboard's warning state and per-asset
  suggestions; includes a sole-proprietorship business with the
  waqf-enterprise redirect note.
- The Yusuf Family (94% coverage) — young family, digital-asset-heavy
  (multisig nomination). Shares the same mutawalli/agent as Ismail —
  demonstrates one professional agent managing multiple families.
- The Karim Family (100% coverage) — Family Waqf demo: an apartment
  dedicated as corpus with a named mutawalli and successor.

Every fast-path channel type (Hibah, EPF/bank-mandate, digital-custody in
both multisig and key-escrow modes, business-continuity in both
company-shares and sole-proprietorship modes, trust, family waqf) and
every family role combination (owner-only, owner+member, owner+member+
agent, shared cross-family agent) now has at least one real example.
Each family also has a genealogy tree (3-6 people, parent/child and
spouse relationships) so the Tree tab is never empty for a demo.

DEMO_DATA.md documents all logins (shared password DemoPassword123!),
what each family demonstrates, and the verification method.

verify-demo-families.cjs: confirms all 5 families actually render
correctly in the live app, not just that the SQL succeeded — sign-in,
correct asset count, non-zero estate total, coverage percentage, correct
person count, non-empty tree render. 35/35 passing.

Also fixed a genuinely flaky e2e-uat.cjs assertion found while
re-verifying after seeding — same instant-isVisible()-after-fixed-wait
pattern already fixed elsewhere in this session, now using a proper
waitFor(). Stable across 3 consecutive runs (32/32 each). Full sweep
after seeding: 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, e2e-per-member 11/11, e2e-family-tree 9/9 — 209/209 total across
all suites plus the 35 demo-family checks.
2026-08-14 09:35:45 +08:00

237 lines
14 KiB
JavaScript

// 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 = [];
const consoleErrors = [];
function record(name, pass, detail = '') {
results.push({ name, pass, detail });
console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
}
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); // mobile-ish, matches .app max-width:480px design
page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
page.on('pageerror', err => consoleErrors.push(err.message));
// ── Load ──
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(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(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(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'));
const hasMother = shareRows.some(r => r.includes('Mother') && r.includes('1/6'));
const hasFather = shareRows.some(r => r.includes('Father') && r.includes('5/24'));
record('Faraid: wife+daughter+father+mother produces textbook shares', hasWife && hasDaughter && hasMother && hasFather, shareRows.join(' | '));
// ── Asset Registry: add an asset ──
await page.locator('nav button.tab', { hasText: 'Assets' }).click();
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(500);
const total1 = await page.locator('.total-card strong').textContent();
record('Asset Registry: adding asset updates estate total', total1.includes('600,000'), total1);
const assetRowVisible = await page.locator('.asset-row', { hasText: 'Terrace house' }).isVisible();
record('Asset Registry: new asset appears in list', assetRowVisible);
// Add a second asset so Wassiyah has a meaningful 1/3 cap to test against
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(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(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);
// Try to bequeath to an heir relation — should block
await page.locator('.form-card .field:has-text("Recipient name") input').fill('My Son');
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(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(500);
await page.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).click();
const bequestRowVisible = await page.locator('.bequest-row', { hasText: 'My Son' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
record('Wassiyah: valid non-heir bequest is added', bequestRowVisible);
// Push over the 1/3 cap and check override gating
await page.locator('.form-card .field:has-text("Recipient name") input').fill('Local Charity');
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(500);
await page.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).click();
await page.waitForTimeout(500);
const overrideBoxVisible = await page.locator('.override-box').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
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(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(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(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(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(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(500);
// ── Horizon 2 Claims: pre-pilot banner + issue + transfer restriction ──
await page.locator('nav button.tab', { hasText: 'Claims (H2)' }).click();
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'));
await page.locator('.form-card .field:has-text("Asset (named") input').fill('Lot 42, Kampung Baru');
await page.locator('.form-card .field:has-text("Heir pool ID") input').fill('HP-2026-001');
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(600);
const claimCardVisible = await page.locator('.claim-card', { hasText: 'Lot 42' }).isVisible();
record('Claims: issuing a demo claim creates a claim card', claimCardVisible);
// Attempt transfer outside heir pool -> should error
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(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();
record('Claims: transfer-within-pool updates status', statusAfterTransfer.trim() === 'transferred', statusAfterTransfer);
// ── Settings: export + delete-all guarded by confirm() ──
await page.locator('nav button.tab', { hasText: 'Settings' }).click();
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 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 local device data' }).click();
await page.waitForTimeout(500);
await page.locator('nav button.tab', { hasText: 'Assets' }).click();
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(500);
const taglineBefore = await page.locator('.header-tagline').textContent();
await page.locator('.lang-btn', { hasText: 'Bahasa Malaysia' }).click();
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();
// ── PWA basics ──
const swReg = await page.evaluate(async () => {
const regs = await navigator.serviceWorker.getRegistrations();
return regs.length;
});
record('PWA: service worker registered', swReg > 0, `${swReg} registration(s)`);
const manifestOk = await page.evaluate(async () => {
const link = document.querySelector('link[rel=manifest]');
if (!link) return false;
const res = await fetch(link.href);
return res.ok;
});
record('PWA: manifest.webmanifest is reachable', manifestOk);
// ── Console error check across the whole session ──
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
await browser.close();
const passCount = results.filter(r => r.pass).length;
const failCount = results.length - passCount;
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
if (failCount > 0) {
console.log('\nFailures:');
results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}${r.detail ? ': ' + r.detail : ''}`));
}
process.exit(failCount > 0 ? 1 : 0);
}
main().catch(e => { console.error('UAT SCRIPT ERROR:', e); process.exit(2); });