d5e25e9f8e
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.
237 lines
14 KiB
JavaScript
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); });
|