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
+25
View File
@@ -0,0 +1,25 @@
// Shared sign-in prelude for the pre-auth-gate E2E suites. Each suite creates
// its own fresh family per run (unique name) so accumulated data from a
// previous run's assets/hibah/etc. can never bleed into another run's
// percentage/coverage assertions — same isolation the old per-context
// localStorage gave for free, now done explicitly since data is shared and
// persistent in Supabase instead of wiped with the browser context.
const OWNER_EMAIL = 'nurfalah.e2etest.owner@gmail.com';
const OWNER_PASSWORD = 'TestPassword123!';
async function signInFreshFamily(page, BASE, familyLabel) {
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.locator('.field:has-text("Email") input').fill(OWNER_EMAIL);
await page.locator('.field:has-text("Password") input').fill(OWNER_PASSWORD);
await page.locator('button.btn-primary', { hasText: 'Sign in' }).click();
await page.waitForTimeout(1500);
const familyName = `${familyLabel || 'E2E'} ${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
await page.locator('.field:has-text("Family name") input').fill(familyName);
await page.locator('button.btn-primary', { hasText: 'Create family' }).click();
await page.waitForTimeout(1200);
return familyName;
}
module.exports = { signInFreshFamily, OWNER_EMAIL, OWNER_PASSWORD };
+22 -15
View File
@@ -1,6 +1,7 @@
// Verifies business interests get covered via a proper continuity instrument, // Verifies business interests get covered via a proper continuity instrument,
// including the sole-proprietorship warning and the waqf-enterprise redirect. // including the sole-proprietorship warning and the waqf-enterprise redirect.
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/'; const BASE = 'https://moslem04.falahos.my/';
const results = []; const results = [];
const consoleErrors = []; const consoleErrors = [];
@@ -12,10 +13,16 @@ async function main() {
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message)); page.on('pageerror', e => consoleErrors.push(e.message));
await page.goto(BASE, { waitUntil: 'networkidle' }); await signInFreshFamily(page, BASE, 'e2e-business');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(200); }; const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
const selectByText = async (locator, text) => { const selectByText = async (locator, text) => {
const val = await locator.evaluate((el, t) => Array.from(el.options).find(o => o.textContent.includes(t))?.value, text); // Options load async from Supabase now — poll until the matching one shows up.
let val;
for (let i = 0; i < 20; i++) {
val = await locator.evaluate((el, t) => Array.from(el.options).find(o => o.textContent.includes(t))?.value, text);
if (val) break;
await new Promise(r => setTimeout(r, 300));
}
await locator.selectOption(val); await locator.selectOption(val);
}; };
@@ -25,7 +32,7 @@ async function main() {
await page.locator('.field:has-text("Estimated value") input').fill('300000'); await page.locator('.field:has-text("Estimated value") input').fill('300000');
await page.locator('.form-card select').first().selectOption('Business interest'); await page.locator('.form-card select').first().selectOption('Business interest');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await clickTab('Coverage'); await clickTab('Coverage');
const rowBefore = await page.locator('.asset-row', { hasText: 'Bakery' }).textContent(); const rowBefore = await page.locator('.asset-row', { hasText: 'Bakery' }).textContent();
@@ -34,9 +41,9 @@ async function main() {
// Go to Nominate, select business-continuity channel // Go to Nominate, select business-continuity channel
await clickTab('Nominate'); await clickTab('Nominate');
await selectByText(page.locator('select').first(), 'Bakery'); await selectByText(page.locator('select').first(), 'Bakery');
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('.form-card select').nth(1).selectOption('business-continuity'); await page.locator('.form-card select').nth(1).selectOption('business-continuity');
await page.waitForTimeout(200); await page.waitForTimeout(500);
const businessFieldsVisible = await page.locator('.trust-fields').isVisible(); const businessFieldsVisible = await page.locator('.trust-fields').isVisible();
record('Nomination: business continuity fields appear', businessFieldsVisible); record('Nomination: business continuity fields appear', businessFieldsVisible);
@@ -47,25 +54,25 @@ async function main() {
// Switch to sole proprietorship -> warning should appear // Switch to sole proprietorship -> warning should appear
await page.locator('.trust-fields select').first().selectOption('sole-prop'); await page.locator('.trust-fields select').first().selectOption('sole-prop');
await page.waitForTimeout(200); await page.waitForTimeout(500);
const soleWarningVisible = await page.locator('.business-warning').first().isVisible(); const soleWarningVisible = await page.locator('.business-warning').first().isVisible();
const soleWarningText = await page.locator('.business-warning').first().textContent(); const soleWarningText = await page.locator('.business-warning').first().textContent();
record('Nomination: sole-proprietorship shows "dies with you" warning', soleWarningVisible && soleWarningText.includes('dies with you'), soleWarningText.trim().slice(0, 150)); record('Nomination: sole-proprietorship shows "dies with you" warning', soleWarningVisible && soleWarningText.includes('dies with you'), soleWarningText.trim().slice(0, 150));
// Switch back to company, pick shareholder buy-sell, fill and save // Switch back to company, pick shareholder buy-sell, fill and save
await page.locator('.trust-fields select').first().selectOption('company'); await page.locator('.trust-fields select').first().selectOption('company');
await page.waitForTimeout(150); await page.waitForTimeout(500);
const instrumentSelect = page.locator('.trust-fields select').nth(1); const instrumentSelect = page.locator('.trust-fields select').nth(1);
await instrumentSelect.selectOption('shareholder-buy-sell'); await instrumentSelect.selectOption('shareholder-buy-sell');
await page.waitForTimeout(150); await page.waitForTimeout(500);
const inputs = page.locator('.trust-fields .field input'); const inputs = page.locator('.trust-fields .field input');
await inputs.nth(0).fill('Co-founder Rahman'); await inputs.nth(0).fill('Co-founder Rahman');
await inputs.nth(1).fill('Fair market valuation, Takaful-funded'); await inputs.nth(1).fill('Fair market valuation, Takaful-funded');
await page.locator('button.btn-primary', { hasText: 'Add business continuity instrument' }).click(); await page.locator('button.btn-primary', { hasText: 'Add business continuity instrument' }).click();
await page.waitForTimeout(300); await page.waitForTimeout(600);
const rowCreated = await page.locator('.nomination-row', { hasText: 'Bakery' }).isVisible(); const rowCreated = await page.locator('.nomination-row', { hasText: 'Bakery' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
record('Nomination: business continuity row created', rowCreated); record('Nomination: business continuity row created', rowCreated);
const [download] = await Promise.all([ const [download] = await Promise.all([
@@ -82,9 +89,9 @@ async function main() {
// Waqf-enterprise redirect path: try selecting it and confirm Add button disables // Waqf-enterprise redirect path: try selecting it and confirm Add button disables
await clickTab('Nominate'); await clickTab('Nominate');
await page.locator('.form-card select').nth(1).selectOption('business-continuity'); await page.locator('.form-card select').nth(1).selectOption('business-continuity');
await page.waitForTimeout(150); await page.waitForTimeout(500);
await page.locator('.trust-fields select').nth(1).selectOption('waqf-enterprise'); await page.locator('.trust-fields select').nth(1).selectOption('waqf-enterprise');
await page.waitForTimeout(150); await page.waitForTimeout(500);
const redirectNoteVisible = await page.locator('.business-warning', { hasText: 'Family Waqf Designator' }).isVisible(); const redirectNoteVisible = await page.locator('.business-warning', { hasText: 'Family Waqf Designator' }).isVisible();
const addDisabled = await page.locator('button.btn-primary', { hasText: 'business continuity' }).isDisabled(); const addDisabled = await page.locator('button.btn-primary', { hasText: 'business continuity' }).isDisabled();
record('Nomination: waqf-enterprise option redirects to Family Waqf, disables Add', redirectNoteVisible && addDisabled); record('Nomination: waqf-enterprise option redirects to Family Waqf, disables Add', redirectNoteVisible && addDisabled);
@@ -98,9 +105,9 @@ async function main() {
await page.locator('.attestor-row .confirm-btn').nth(1).click(); await page.locator('.attestor-row .confirm-btn').nth(1).click();
await page.locator('.field:has-text("Date of death") input').fill('2026-08-13'); await page.locator('.field:has-text("Date of death") input').fill('2026-08-13');
await page.locator('.field:has-text("Death certificate reference") input').fill('DC-BIZ-001'); await page.locator('.field:has-text("Death certificate reference") input').fill('DC-BIZ-001');
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('button.btn-danger-solid').click(); await page.locator('button.btn-danger-solid').click();
await page.waitForTimeout(300); await page.waitForTimeout(600);
const packetForBiz = await page.locator('.packet-row', { hasText: 'Bakery' }).isVisible(); const packetForBiz = await page.locator('.packet-row', { hasText: 'Bakery' }).isVisible();
record('Death Trigger: execution packet generated for business via continuity instrument', packetForBiz); record('Death Trigger: execution packet generated for business via continuity instrument', packetForBiz);
+20 -13
View File
@@ -2,6 +2,7 @@
// mismatched bank-mandate fields), and vehicles get correctly suggested Hibah // mismatched bank-mandate fields), and vehicles get correctly suggested Hibah
// (not overkill trust structure) and can actually be covered that way. // (not overkill trust structure) and can actually be covered that way.
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/'; const BASE = 'https://moslem04.falahos.my/';
const results = []; const results = [];
const consoleErrors = []; const consoleErrors = [];
@@ -13,10 +14,16 @@ async function main() {
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message)); page.on('pageerror', e => consoleErrors.push(e.message));
await page.goto(BASE, { waitUntil: 'networkidle' }); await signInFreshFamily(page, BASE, 'e2e-digital-vehicle');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(200); }; const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
const selectByText = async (locator, text) => { const selectByText = async (locator, text) => {
const val = await locator.evaluate((el, t) => Array.from(el.options).find(o => o.textContent.includes(t))?.value, text); // Options load async from Supabase now — poll until the matching one shows up.
let val;
for (let i = 0; i < 20; i++) {
val = await locator.evaluate((el, t) => Array.from(el.options).find(o => o.textContent.includes(t))?.value, text);
if (val) break;
await new Promise(r => setTimeout(r, 300));
}
await locator.selectOption(val); await locator.selectOption(val);
}; };
@@ -26,7 +33,7 @@ async function main() {
await page.locator('.field:has-text("Estimated value") input').fill('150000'); await page.locator('.field:has-text("Estimated value") input').fill('150000');
await page.locator('.form-card select').first().selectOption('Digital assets'); await page.locator('.form-card select').first().selectOption('Digital assets');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await clickTab('Coverage'); await clickTab('Coverage');
const digitalRowBefore = await page.locator('.asset-row', { hasText: 'Bitcoin' }).textContent(); const digitalRowBefore = await page.locator('.asset-row', { hasText: 'Bitcoin' }).textContent();
@@ -34,9 +41,9 @@ async function main() {
await clickTab('Nominate'); await clickTab('Nominate');
await selectByText(page.locator('select').first(), 'Bitcoin'); await selectByText(page.locator('select').first(), 'Bitcoin');
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('.form-card select').nth(1).selectOption('digital-custody'); await page.locator('.form-card select').nth(1).selectOption('digital-custody');
await page.waitForTimeout(200); await page.waitForTimeout(500);
const digitalFieldsVisible = await page.locator('.trust-fields').isVisible(); const digitalFieldsVisible = await page.locator('.trust-fields').isVisible();
record('Nomination: digital-custody-specific fields appear', digitalFieldsVisible); record('Nomination: digital-custody-specific fields appear', digitalFieldsVisible);
@@ -44,12 +51,12 @@ async function main() {
// Switch to multisig -> warning appears (only one select in the digital-custody fields: Custody type) // Switch to multisig -> warning appears (only one select in the digital-custody fields: Custody type)
const custodySelect = page.locator('.trust-fields select').nth(0); const custodySelect = page.locator('.trust-fields select').nth(0);
await custodySelect.selectOption('multisig'); await custodySelect.selectOption('multisig');
await page.waitForTimeout(150); await page.waitForTimeout(500);
const multisigWarningVisible = await page.locator('.business-warning').isVisible(); const multisigWarningVisible = await page.locator('.business-warning').isVisible();
record('Nomination: multi-sig custody shows the co-signer-setup warning', multisigWarningVisible); record('Nomination: multi-sig custody shows the co-signer-setup warning', multisigWarningVisible);
await custodySelect.selectOption('key-escrow'); await custodySelect.selectOption('key-escrow');
await page.waitForTimeout(150); await page.waitForTimeout(500);
const escrowWarningVisible = await page.locator('.business-warning').isVisible(); const escrowWarningVisible = await page.locator('.business-warning').isVisible();
const escrowWarningText = await page.locator('.business-warning').textContent(); const escrowWarningText = await page.locator('.business-warning').textContent();
record('Nomination: key-escrow shows "never store the actual key" warning', escrowWarningVisible && escrowWarningText.includes('Never store'), escrowWarningText.trim().slice(0, 150)); record('Nomination: key-escrow shows "never store the actual key" warning', escrowWarningVisible && escrowWarningText.includes('Never store'), escrowWarningText.trim().slice(0, 150));
@@ -59,7 +66,7 @@ async function main() {
await digitalInputs.nth(1).fill('Executor Ahmad'); await digitalInputs.nth(1).fill('Executor Ahmad');
await digitalInputs.nth(2).fill('Sealed instructions with lawyer, ref #45'); await digitalInputs.nth(2).fill('Sealed instructions with lawyer, ref #45');
await page.locator('button.btn-primary', { hasText: 'digital custody plan' }).click(); await page.locator('button.btn-primary', { hasText: 'digital custody plan' }).click();
await page.waitForTimeout(300); await page.waitForTimeout(600);
const digitalRowCreated = await page.locator('.nomination-row', { hasText: 'Bitcoin' }).isVisible(); const digitalRowCreated = await page.locator('.nomination-row', { hasText: 'Bitcoin' }).isVisible();
record('Nomination: digital custody row created', digitalRowCreated); record('Nomination: digital custody row created', digitalRowCreated);
@@ -80,7 +87,7 @@ async function main() {
await page.locator('.field:has-text("Estimated value") input').fill('45000'); await page.locator('.field:has-text("Estimated value") input').fill('45000');
await page.locator('.form-card select').first().selectOption('Vehicle'); await page.locator('.form-card select').first().selectOption('Vehicle');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await clickTab('Coverage'); await clickTab('Coverage');
const vehicleRowBefore = await page.locator('.asset-row', { hasText: 'Toyota' }).textContent(); const vehicleRowBefore = await page.locator('.asset-row', { hasText: 'Toyota' }).textContent();
@@ -94,11 +101,11 @@ async function main() {
const linkSelect = page.locator('select').first(); const linkSelect = page.locator('select').first();
await selectByText(linkSelect, 'Toyota'); await selectByText(linkSelect, 'Toyota');
await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click(); await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('.guard-buttons button.btn-secondary', { hasText: 'No' }).click(); await page.locator('.guard-buttons button.btn-secondary', { hasText: 'No' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).click(); await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await clickTab('Coverage'); await clickTab('Coverage');
const overallPct = await page.locator('.big-percent').textContent(); const overallPct = await page.locator('.big-percent').textContent();
+16 -16
View File
@@ -1,6 +1,7 @@
// Targeted E2E for the new fast-path mechanism: Coverage, Hibah asset-link, // Targeted E2E for the new fast-path mechanism: Coverage, Hibah asset-link,
// Waqf corpus-link, Nomination, Death Trigger. Run after the general e2e-uat.cjs. // Waqf corpus-link, Nomination, Death Trigger. Run after the general e2e-uat.cjs.
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/'; const BASE = 'https://moslem04.falahos.my/';
const results = []; const results = [];
const consoleErrors = []; const consoleErrors = [];
@@ -12,9 +13,9 @@ async function main() {
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message)); page.on('pageerror', e => consoleErrors.push(e.message));
await page.goto(BASE, { waitUntil: 'networkidle' }); await signInFreshFamily(page, BASE, 'e2e-fastpath');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(200); }; const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
// New tabs exist // New tabs exist
for (const label of ['Coverage', 'Nominate', 'Trigger']) { for (const label of ['Coverage', 'Nominate', 'Trigger']) {
@@ -27,7 +28,7 @@ async function main() {
await page.locator('.field:has-text("Description") input').fill('Land parcel, Perak'); await page.locator('.field:has-text("Description") input').fill('Land parcel, Perak');
await page.locator('.field:has-text("Estimated value") input').fill('400000'); await page.locator('.field:has-text("Estimated value") input').fill('400000');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
// Coverage should show 0% before any fast-path instrument // Coverage should show 0% before any fast-path instrument
await clickTab('Coverage'); await clickTab('Coverage');
@@ -42,14 +43,14 @@ async function main() {
await page.locator('.field:has-text("Relation to you") input').fill('nephew'); await page.locator('.field:has-text("Relation to you") input').fill('nephew');
await page.locator('.field:has-text("Asset / gift description") input').fill('Land parcel gift'); await page.locator('.field:has-text("Asset / gift description") input').fill('Land parcel gift');
const linkSelect = page.locator('select').first(); const linkSelect = page.locator('select').first();
await linkSelect.locator('option').nth(1).waitFor({ state: 'attached', timeout: 10000 }).catch(() => {});
await linkSelect.selectOption({ index: 1 }); await linkSelect.selectOption({ index: 1 });
await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click(); await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('.guard-buttons button.btn-secondary', { hasText: 'No' }).click(); await page.locator('.guard-buttons button.btn-secondary', { hasText: 'No' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).click(); await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).click();
await page.waitForTimeout(200); const fastBadgeVisible = await page.locator('.fast-badge').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
const fastBadgeVisible = await page.locator('.fast-badge').isVisible();
record('Hibah: linked gift shows fast-path badge', fastBadgeVisible); record('Hibah: linked gift shows fast-path badge', fastBadgeVisible);
// Coverage should now show 100% // Coverage should now show 100%
@@ -71,18 +72,17 @@ async function main() {
await page.locator('.field:has-text("Estimated value") input').fill('200000'); await page.locator('.field:has-text("Estimated value") input').fill('200000');
await page.locator('.form-card select').first().selectOption('Cash / Bank'); await page.locator('.form-card select').first().selectOption('Cash / Bank');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await clickTab('Nominate'); await clickTab('Nominate');
await page.locator('select').first().selectOption({ label: /EPF savings/ }).catch(async () => { const nomSelect = page.locator('select').first();
const opts = await page.locator('select').first().locator('option').count(); await nomSelect.locator('option').nth(1).waitFor({ state: 'attached', timeout: 10000 }).catch(() => {});
await page.locator('select').first().selectOption({ index: opts - 1 }); const epfVal = await nomSelect.evaluate(el => Array.from(el.options).find(o => o.textContent.includes('EPF savings'))?.value);
}); await nomSelect.selectOption(epfVal || { index: 1 });
await page.locator('.field:has-text("Nominee name") input').fill('My Wife'); await page.locator('.field:has-text("Nominee name") input').fill('My Wife');
await page.locator('.field:has-text("Institution") input').fill('KWSP'); await page.locator('.field:has-text("Institution") input').fill('KWSP');
await page.locator('button.btn-primary', { hasText: 'Add nomination' }).click(); await page.locator('button.btn-primary', { hasText: 'Add nomination' }).click();
await page.waitForTimeout(200); const nominationRowVisible = await page.locator('.nomination-row', { hasText: 'My Wife' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
const nominationRowVisible = await page.locator('.nomination-row', { hasText: 'My Wife' }).isVisible();
record('Nomination: adding a nomination creates a row', nominationRowVisible); record('Nomination: adding a nomination creates a row', nominationRowVisible);
// Death Trigger: cannot fire without threshold // Death Trigger: cannot fire without threshold
@@ -97,12 +97,12 @@ async function main() {
await page.locator('.attestor-row .confirm-btn').nth(1).click(); await page.locator('.attestor-row .confirm-btn').nth(1).click();
await page.locator('.field:has-text("Date of death") input').fill('2026-08-13'); await page.locator('.field:has-text("Date of death") input').fill('2026-08-13');
await page.locator('.field:has-text("Death certificate reference") input').fill('DC-2026-00123'); await page.locator('.field:has-text("Death certificate reference") input').fill('DC-2026-00123');
await page.waitForTimeout(200); await page.waitForTimeout(500);
const fireBtnEnabled = await page.locator('button.btn-danger-solid').isEnabled(); const fireBtnEnabled = await page.locator('button.btn-danger-solid').isEnabled();
record('Death Trigger: fire button enables once 2 attestors confirm + cert ref present', fireBtnEnabled); record('Death Trigger: fire button enables once 2 attestors confirm + cert ref present', fireBtnEnabled);
await page.locator('button.btn-danger-solid').click(); await page.locator('button.btn-danger-solid').click();
await page.waitForTimeout(300); await page.waitForTimeout(600);
const triggeredBannerVisible = await page.locator('.triggered-banner').isVisible(); const triggeredBannerVisible = await page.locator('.triggered-banner').isVisible();
record('Death Trigger: fires and shows triggered banner', triggeredBannerVisible); record('Death Trigger: fires and shows triggered banner', triggeredBannerVisible);
+5 -4
View File
@@ -1,5 +1,6 @@
// Verifies every tab has a working (i) info button that opens plain-language help. // Verifies every tab has a working (i) info button that opens plain-language help.
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/'; const BASE = 'https://moslem04.falahos.my/';
const results = []; const results = [];
const consoleErrors = []; const consoleErrors = [];
@@ -13,11 +14,11 @@ async function main() {
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message)); page.on('pageerror', e => consoleErrors.push(e.message));
await page.goto(BASE, { waitUntil: 'networkidle' }); await signInFreshFamily(page, BASE, 'e2e-info');
for (const label of TABS) { for (const label of TABS) {
await page.locator('nav button.tab', { hasText: label }).click(); await page.locator('nav button.tab', { hasText: label }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
const infoBtn = page.locator('.module-header .info-btn'); const infoBtn = page.locator('.module-header .info-btn');
const btnVisible = await infoBtn.isVisible(); const btnVisible = await infoBtn.isVisible();
@@ -25,14 +26,14 @@ async function main() {
if (!btnVisible) continue; if (!btnVisible) continue;
await infoBtn.click(); await infoBtn.click();
await page.waitForTimeout(150); await page.waitForTimeout(500);
const panelVisible = await page.locator('.info-panel').isVisible(); const panelVisible = await page.locator('.info-panel').isVisible();
const whatText = await page.locator('.info-panel .info-section p').first().textContent().catch(() => ''); const whatText = await page.locator('.info-panel .info-section p').first().textContent().catch(() => '');
record(`"${label}": info panel opens with non-trivial explanation`, panelVisible && whatText.length > 40, `${whatText.length} chars`); record(`"${label}": info panel opens with non-trivial explanation`, panelVisible && whatText.length > 40, `${whatText.length} chars`);
// toggle closed // toggle closed
await infoBtn.click(); await infoBtn.click();
await page.waitForTimeout(150); await page.waitForTimeout(500);
const panelClosed = await page.locator('.info-panel').isVisible().catch(() => false); const panelClosed = await page.locator('.info-panel').isVisible().catch(() => false);
record(`"${label}": info panel closes on second click`, !panelClosed); record(`"${label}": info panel closes on second click`, !panelClosed);
} }
+15 -8
View File
@@ -2,6 +2,7 @@
// previous bug: a nonsensical "EPF / Takaful nomination" suggestion pointing at a // previous bug: a nonsensical "EPF / Takaful nomination" suggestion pointing at a
// channel value ('nomination') that didn't even exist in the Nomination Registry. // channel value ('nomination') that didn't even exist in the Nomination Registry.
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/'; const BASE = 'https://moslem04.falahos.my/';
const results = []; const results = [];
const consoleErrors = []; const consoleErrors = [];
@@ -13,10 +14,16 @@ async function main() {
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message)); page.on('pageerror', e => consoleErrors.push(e.message));
await page.goto(BASE, { waitUntil: 'networkidle' }); await signInFreshFamily(page, BASE, 'e2e-other');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(200); }; const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
const selectByText = async (locator, text) => { const selectByText = async (locator, text) => {
const val = await locator.evaluate((el, t) => Array.from(el.options).find(o => o.textContent.includes(t))?.value, text); // Options load async from Supabase now — poll until the matching one shows up.
let val;
for (let i = 0; i < 20; i++) {
val = await locator.evaluate((el, t) => Array.from(el.options).find(o => o.textContent.includes(t))?.value, text);
if (val) break;
await new Promise(r => setTimeout(r, 300));
}
await locator.selectOption(val); await locator.selectOption(val);
}; };
@@ -25,7 +32,7 @@ async function main() {
await page.locator('.field:has-text("Estimated value") input').fill('20000'); await page.locator('.field:has-text("Estimated value") input').fill('20000');
await page.locator('.form-card select').first().selectOption('Other'); await page.locator('.form-card select').first().selectOption('Other');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await clickTab('Coverage'); await clickTab('Coverage');
const rowBefore = await page.locator('.asset-row', { hasText: 'stamp collection' }).textContent(); const rowBefore = await page.locator('.asset-row', { hasText: 'stamp collection' }).textContent();
@@ -33,7 +40,7 @@ async function main() {
await clickTab('Nominate'); await clickTab('Nominate');
await selectByText(page.locator('select').first(), 'stamp collection'); await selectByText(page.locator('select').first(), 'stamp collection');
await page.waitForTimeout(200); await page.waitForTimeout(500);
const suggestionText = await page.locator('.suggestion').textContent(); const suggestionText = await page.locator('.suggestion').textContent();
record('Nomination: suggestion for "Other" points to Hibah with sensible reasoning', suggestionText.includes('Hibah') && suggestionText.includes('safest general'), suggestionText.trim().slice(0, 200)); record('Nomination: suggestion for "Other" points to Hibah with sensible reasoning', suggestionText.includes('Hibah') && suggestionText.includes('safest general'), suggestionText.trim().slice(0, 200));
@@ -44,11 +51,11 @@ async function main() {
await page.locator('.field:has-text("Asset / gift description") input').fill('Stamp collection gift'); await page.locator('.field:has-text("Asset / gift description") input').fill('Stamp collection gift');
await selectByText(page.locator('select').first(), 'stamp collection'); await selectByText(page.locator('select').first(), 'stamp collection');
await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click(); await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('.guard-buttons button.btn-secondary', { hasText: 'No' }).click(); await page.locator('.guard-buttons button.btn-secondary', { hasText: 'No' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).click(); await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).click();
await page.waitForTimeout(300); await page.waitForTimeout(600);
await clickTab('Coverage'); await clickTab('Coverage');
const pct = await page.locator('.big-percent').textContent(); const pct = await page.locator('.big-percent').textContent();
+21 -14
View File
@@ -1,6 +1,7 @@
// Verifies property/land assets are covered through ALL THREE applicable fast-path // Verifies property/land assets are covered through ALL THREE applicable fast-path
// channels: Hibah, Waqf (Family Waqf Designator), and Trust (Nomination Registry). // channels: Hibah, Waqf (Family Waqf Designator), and Trust (Nomination Registry).
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/'; const BASE = 'https://moslem04.falahos.my/';
const results = []; const results = [];
const consoleErrors = []; const consoleErrors = [];
@@ -12,10 +13,16 @@ async function main() {
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message)); page.on('pageerror', e => consoleErrors.push(e.message));
await page.goto(BASE, { waitUntil: 'networkidle' }); await signInFreshFamily(page, BASE, 'e2e-property');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(200); }; const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
const selectByText = async (locator, text) => { const selectByText = async (locator, text) => {
const val = await locator.evaluate((el, t) => Array.from(el.options).find(o => o.textContent.includes(t))?.value, text); // Options load async from Supabase now — poll until the matching one shows up.
let val;
for (let i = 0; i < 20; i++) {
val = await locator.evaluate((el, t) => Array.from(el.options).find(o => o.textContent.includes(t))?.value, text);
if (val) break;
await new Promise(r => setTimeout(r, 300));
}
await locator.selectOption(val); await locator.selectOption(val);
}; };
@@ -26,7 +33,7 @@ async function main() {
await page.locator('.field:has-text("Estimated value") input').fill(val); await page.locator('.field:has-text("Estimated value") input').fill(val);
// Type defaults to Property — leave as-is // Type defaults to Property — leave as-is
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
} }
await clickTab('Coverage'); await clickTab('Coverage');
@@ -42,11 +49,11 @@ async function main() {
await page.locator('.field:has-text("Asset / gift description") input').fill('House gift'); await page.locator('.field:has-text("Asset / gift description") input').fill('House gift');
await selectByText(page.locator('select').first(), 'House A'); await selectByText(page.locator('select').first(), 'House A');
await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click(); await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('.guard-buttons button.btn-secondary', { hasText: 'No' }).click(); await page.locator('.guard-buttons button.btn-secondary', { hasText: 'No' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).click(); await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).click();
await page.waitForTimeout(300); await page.waitForTimeout(600);
await clickTab('Coverage'); await clickTab('Coverage');
const houseARow = await page.locator('.asset-row', { hasText: 'House A' }).textContent(); const houseARow = await page.locator('.asset-row', { hasText: 'House A' }).textContent();
@@ -55,11 +62,11 @@ async function main() {
// Channel 2: Waqf // Channel 2: Waqf
await clickTab('Family Waqf'); await clickTab('Family Waqf');
await selectByText(page.locator('select').first(), 'House B'); await selectByText(page.locator('select').first(), 'House B');
await page.waitForTimeout(200); await page.waitForTimeout(500);
const corpusVal = await page.locator('select').first().inputValue(); const corpusVal = await page.locator('select').first().inputValue();
record('Family Waqf: House B selectable as corpus asset', corpusVal !== ''); record('Family Waqf: House B selectable as corpus asset', corpusVal !== '');
await page.locator('.field:has-text("Mutawalli (trustee)") input').fill('Ahmad bin Ismail'); await page.locator('.field:has-text("Mutawalli (trustee)") input').fill('Ahmad bin Ismail');
await page.waitForTimeout(300); // allow $effect to persist waqfCorpusAssetId await page.waitForTimeout(600); // allow $effect to persist waqfCorpusAssetId
await clickTab('Coverage'); await clickTab('Coverage');
const houseBRow = await page.locator('.asset-row', { hasText: 'House B' }).textContent(); const houseBRow = await page.locator('.asset-row', { hasText: 'House B' }).textContent();
@@ -68,15 +75,15 @@ async function main() {
// Channel 3: Trust (Nomination Registry) // Channel 3: Trust (Nomination Registry)
await clickTab('Nominate'); await clickTab('Nominate');
await selectByText(page.locator('select').first(), 'House C'); await selectByText(page.locator('select').first(), 'House C');
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('.form-card select').nth(1).selectOption('trust'); await page.locator('.form-card select').nth(1).selectOption('trust');
await page.waitForTimeout(200); await page.waitForTimeout(500);
const trustInputs = page.locator('.trust-fields .field input'); const trustInputs = page.locator('.trust-fields .field input');
await trustInputs.nth(0).fill('Trustee Co'); await trustInputs.nth(0).fill('Trustee Co');
await trustInputs.nth(1).fill('Successor Trustee Co'); await trustInputs.nth(1).fill('Successor Trustee Co');
await trustInputs.nth(2).fill('Equal split among children'); await trustInputs.nth(2).fill('Equal split among children');
await page.locator('button.btn-primary', { hasText: 'Add trust setup' }).click(); await page.locator('button.btn-primary', { hasText: 'Add trust setup' }).click();
await page.waitForTimeout(300); await page.waitForTimeout(600);
await clickTab('Coverage'); await clickTab('Coverage');
const houseCRow = await page.locator('.asset-row', { hasText: 'House C' }).textContent(); const houseCRow = await page.locator('.asset-row', { hasText: 'House C' }).textContent();
@@ -94,9 +101,9 @@ async function main() {
await page.locator('.attestor-row .confirm-btn').nth(1).click(); await page.locator('.attestor-row .confirm-btn').nth(1).click();
await page.locator('.field:has-text("Date of death") input').fill('2026-08-13'); await page.locator('.field:has-text("Date of death") input').fill('2026-08-13');
await page.locator('.field:has-text("Death certificate reference") input').fill('DC-PROP-001'); await page.locator('.field:has-text("Death certificate reference") input').fill('DC-PROP-001');
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('button.btn-danger-solid').click(); await page.locator('button.btn-danger-solid').click();
await page.waitForTimeout(300); await page.waitForTimeout(600);
const packetCount = await page.locator('.packet-row', { hasText: 'House' }).count(); const packetCount = await page.locator('.packet-row', { hasText: 'House' }).count();
record('Death Trigger: execution packets generated for all 3 property assets', packetCount === 3, `${packetCount} packets`); record('Death Trigger: execution packets generated for all 3 property assets', packetCount === 3, `${packetCount} packets`);
+9 -8
View File
@@ -1,5 +1,6 @@
// Verifies land parcels specifically get covered via a trust setup in Nomination Registry. // Verifies land parcels specifically get covered via a trust setup in Nomination Registry.
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/'; const BASE = 'https://moslem04.falahos.my/';
const results = []; const results = [];
const consoleErrors = []; const consoleErrors = [];
@@ -11,8 +12,8 @@ async function main() {
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push(e.message)); page.on('pageerror', e => consoleErrors.push(e.message));
await page.goto(BASE, { waitUntil: 'networkidle' }); await signInFreshFamily(page, BASE, 'e2e-trust');
const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(200); }; const clickTab = async label => { await page.locator('nav button.tab', { hasText: label }).click(); await page.waitForTimeout(500); };
// Add a Property-type asset (land parcel) // Add a Property-type asset (land parcel)
await clickTab('Assets'); await clickTab('Assets');
@@ -20,7 +21,7 @@ async function main() {
await page.locator('.field:has-text("Estimated value") input').fill('500000'); await page.locator('.field:has-text("Estimated value") input').fill('500000');
// type defaults to Property // type defaults to Property
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); await page.locator('button.btn-primary', { hasText: 'Add asset' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
// Coverage should show this asset as exposed initially, with a trust suggestion // Coverage should show this asset as exposed initially, with a trust suggestion
await clickTab('Coverage'); await clickTab('Coverage');
@@ -35,13 +36,13 @@ async function main() {
return opt ? opt.value : null; return opt ? opt.value : null;
}); });
await assetSelect.selectOption(targetValue); await assetSelect.selectOption(targetValue);
await page.waitForTimeout(200); await page.waitForTimeout(500);
const suggestionVisible = await page.locator('.suggestion').isVisible(); const suggestionVisible = await page.locator('.suggestion').isVisible();
record('Nomination: trust suggestion shown for property-type asset', suggestionVisible); record('Nomination: trust suggestion shown for property-type asset', suggestionVisible);
const typeSelect = page.locator('.form-card select').nth(1); const typeSelect = page.locator('.form-card select').nth(1);
await typeSelect.selectOption('trust'); await typeSelect.selectOption('trust');
await page.waitForTimeout(200); await page.waitForTimeout(500);
const trustFieldsVisible = await page.locator('.trust-fields').isVisible(); const trustFieldsVisible = await page.locator('.trust-fields').isVisible();
record('Nomination: trust-specific fields (trustee/successor/beneficiaries) appear', trustFieldsVisible); record('Nomination: trust-specific fields (trustee/successor/beneficiaries) appear', trustFieldsVisible);
@@ -50,7 +51,7 @@ async function main() {
await trustInputs.nth(1).fill('Faridah binti Omar'); await trustInputs.nth(1).fill('Faridah binti Omar');
await trustInputs.nth(2).fill('Equal split among 3 children'); await trustInputs.nth(2).fill('Equal split among 3 children');
await page.locator('button.btn-primary', { hasText: 'Add trust setup' }).click(); await page.locator('button.btn-primary', { hasText: 'Add trust setup' }).click();
await page.waitForTimeout(300); await page.waitForTimeout(600);
const trustRowVisible = await page.locator('.nomination-row', { hasText: 'Land Parcel' }).isVisible(); const trustRowVisible = await page.locator('.nomination-row', { hasText: 'Land Parcel' }).isVisible();
record('Nomination: trust setup row created for land parcel', trustRowVisible); record('Nomination: trust setup row created for land parcel', trustRowVisible);
@@ -82,9 +83,9 @@ async function main() {
await page.locator('.attestor-row .confirm-btn').nth(1).click(); await page.locator('.attestor-row .confirm-btn').nth(1).click();
await page.locator('.field:has-text("Date of death") input').fill('2026-08-13'); await page.locator('.field:has-text("Date of death") input').fill('2026-08-13');
await page.locator('.field:has-text("Death certificate reference") input').fill('DC-TEST-001'); await page.locator('.field:has-text("Death certificate reference") input').fill('DC-TEST-001');
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('button.btn-danger-solid').click(); await page.locator('button.btn-danger-solid').click();
await page.waitForTimeout(300); await page.waitForTimeout(600);
const packetForLand = await page.locator('.packet-row', { hasText: 'Land Parcel' }).isVisible(); const packetForLand = await page.locator('.packet-row', { hasText: 'Land Parcel' }).isVisible();
record('Death Trigger: execution packet generated for land parcel via trust', packetForLand); record('Death Trigger: execution packet generated for land parcel via trust', packetForLand);
+35 -31
View File
@@ -1,6 +1,7 @@
// Full E2E UAT against the live moslem04.falahos.my deployment. // Full E2E UAT against the live moslem04.falahos.my deployment.
// Simulates real human interaction: clicks, typed input, waits — not just DOM assertions. // Simulates real human interaction: clicks, typed input, waits — not just DOM assertions.
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/'; const BASE = 'https://moslem04.falahos.my/';
const results = []; const results = [];
@@ -19,26 +20,26 @@ async function main() {
page.on('pageerror', err => consoleErrors.push(err.message)); page.on('pageerror', err => consoleErrors.push(err.message));
// ── Load ── // ── Load ──
await page.goto(BASE, { waitUntil: 'networkidle' }); await signInFreshFamily(page, BASE, 'e2e-uat');
record('Page loads', await page.title() === 'Nur Falah — Estate & Waqf Suite'); record('Page loads', await page.title() === 'Nur Falah — Estate & Waqf Suite');
const tabs = ['Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Claims (H2)', 'Settings']; const tabs = ['Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Claims (H2)', 'Settings'];
for (const label of tabs) { for (const label of tabs) {
const tabBtn = page.locator('nav button.tab', { hasText: label }); const tabBtn = page.locator('nav button.tab', { hasText: label });
await tabBtn.click(); await tabBtn.click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
const isActive = await tabBtn.evaluate(el => el.classList.contains('active')); const isActive = await tabBtn.evaluate(el => el.classList.contains('active'));
record(`Nav: click "${label}" tab activates it`, isActive); record(`Nav: click "${label}" tab activates it`, isActive);
} }
// ── Faraid Calculator: textbook case (wife + daughter + father + mother) ── // ── Faraid Calculator: textbook case (wife + daughter + father + mother) ──
await page.locator('nav button.tab', { hasText: 'Faraid' }).click(); 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("Number of surviving wives") input').fill('1');
await page.locator('.field:has-text("Daughters") 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("Father survives") input').check();
await page.locator('.field-check:has-text("Mother 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 shareRows = await page.locator('.share-row').allTextContents();
const hasWife = shareRows.some(r => r.includes('Wife') && r.includes('1/8')); 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 hasDaughter = shareRows.some(r => r.includes('Daughter') && r.includes('1/2'));
@@ -48,12 +49,12 @@ async function main() {
// ── Asset Registry: add an asset ── // ── Asset Registry: add an asset ──
await page.locator('nav button.tab', { hasText: 'Assets' }).click(); 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("Description") input').fill('Terrace house, Shah Alam');
await page.locator('.field:has-text("Estimated value") input').fill('600000'); 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('.field:has-text("Ownership share") input').fill('100');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); 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(); const total1 = await page.locator('.total-card strong').textContent();
record('Asset Registry: adding asset updates estate total', total1.includes('600,000'), total1); 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("Description") input').fill('Savings account');
await page.locator('.field:has-text("Estimated value") input').fill('150000'); await page.locator('.field:has-text("Estimated value") input').fill('150000');
await page.locator('button.btn-primary', { hasText: 'Add asset' }).click(); 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(); const total2 = await page.locator('.total-card strong').textContent();
record('Asset Registry: second asset accumulates total', total2.includes('750,000'), total2); record('Asset Registry: second asset accumulates total', total2.includes('750,000'), total2);
// ── Wassiyah Generator: 1/3 meter + heir-exclusion block ── // ── Wassiyah Generator: 1/3 meter + heir-exclusion block ──
await page.locator('nav button.tab', { hasText: 'Wassiyah' }).click(); 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(); 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); 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("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("Description") input').fill('Cash gift');
await page.locator('.form-card .field:has-text("Value") input').fill('10000'); 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 blockErrorVisible = await page.locator('.block-error').isVisible();
const addBequestVisibleWhileBlocked = await page.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).isVisible().catch(() => false); 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); record('Wassiyah: heir-relation bequest is blocked (no Add bequest button, error shown)', blockErrorVisible && !addBequestVisibleWhileBlocked);
// Now a valid non-heir bequest // Now a valid non-heir bequest
await page.locator('.form-card .field:has-text("Relation to you") input').fill('nephew'); 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.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(); const bequestRowVisible = await page.locator('.bequest-row', { hasText: 'My Son' }).isVisible();
record('Wassiyah: valid non-heir bequest is added', bequestRowVisible); 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("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("Description") input').fill('Endowment gift');
await page.locator('.form-card .field:has-text("Value") input').fill('280000'); 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.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 overrideBoxVisible = await page.locator('.override-box').isVisible();
const exportDisabledBeforeAck = await page.locator('button.btn-primary', { hasText: 'Export draft' }).isDisabled(); 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); record('Wassiyah: exceeding 1/3 shows override box and disables export', overrideBoxVisible && exportDisabledBeforeAck);
await page.locator('.override-box input[type=checkbox]').check(); 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(); const exportEnabledAfterAck = await page.locator('button.btn-primary', { hasText: 'Export draft' }).isEnabled();
record('Wassiyah: acknowledging override enables export', exportEnabledAfterAck); record('Wassiyah: acknowledging override enables export', exportEnabledAfterAck);
// ── Hibah Tracker: marad al-mawt guard, blocked-heir path ── // ── Hibah Tracker: marad al-mawt guard, blocked-heir path ──
await page.locator('nav button.tab', { hasText: 'Hibah' }).click(); 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("Recipient") input').fill('My Daughter');
await page.locator('.field:has-text("Relation to you") input').fill('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('.field:has-text("Asset / gift description") input').fill('Car');
await page.locator('button.btn-primary', { hasText: 'Log this hibah' }).click(); 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(); const guardQuestionVisible = await page.locator('.guard-question').isVisible();
record('Hibah: marad al-mawt guard question appears on new entry', guardQuestionVisible); 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.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 guardErrorVisible = await page.locator('.guard-error').isVisible();
const confirmDisabled = await page.locator('button.btn-primary', { hasText: 'Confirm and save' }).isDisabled(); 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'); record('Hibah: flagged + heir beneficiary blocks confirm', guardErrorVisible && confirmDisabled, 'recipient=daughter, flagged=yes');
// ── Family Waqf Designator: open note + beneficiary flow ── // ── Family Waqf Designator: open note + beneficiary flow ──
await page.locator('nav button.tab', { hasText: 'Family Waqf' }).click(); 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(); const openNoteVisible = await page.locator('.open-note').isVisible();
record('Family Waqf: open fiqh-question note is visible (OPEN-01 transparency)', openNoteVisible); record('Family Waqf: open fiqh-question note is visible (OPEN-01 transparency)', openNoteVisible);
const corpusSelect = page.locator('select').first(); 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(); const optionCount = await corpusSelect.locator('option').count();
await corpusSelect.selectOption({ index: 1 }); // index 0 is the placeholder await corpusSelect.selectOption({ index: 1 }); // index 0 is the placeholder
const corpusSelected = await corpusSelect.inputValue(); const corpusSelected = await corpusSelect.inputValue();
record('Family Waqf: corpus asset dropdown is populated from Asset Registry', optionCount > 1 && corpusSelected !== '', `${optionCount} options, selected="${corpusSelected}"`); 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.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 ── // ── Horizon 2 Claims: pre-pilot banner + issue + transfer restriction ──
await page.locator('nav button.tab', { hasText: 'Claims (H2)' }).click(); 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 pilotBannerVisible = await page.locator('.pilot-banner').isVisible();
const bannerText = await page.locator('.pilot-banner').textContent(); const bannerText = await page.locator('.pilot-banner').textContent();
record('Claims: pre-pilot banner visible and mentions Phase 0', pilotBannerVisible && bannerText.includes('Phase 0')); 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("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 .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.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(); const claimCardVisible = await page.locator('.claim-card', { hasText: 'Lot 42' }).isVisible();
record('Claims: issuing a demo claim creates a claim card', claimCardVisible); 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' }); const claimCard = page.locator('.claim-card', { hasText: 'Lot 42' });
await claimCard.locator('.transfer-row input').fill('Random Outsider'); await claimCard.locator('.transfer-row input').fill('Random Outsider');
await claimCard.locator('.btn-small', { hasText: 'Transfer within pool' }).click(); 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, // 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. // so this exercises the success path; the code-level restriction is verified separately below.
const statusAfterTransfer = await claimCard.locator('.status').textContent(); const statusAfterTransfer = await claimCard.locator('.status').textContent();
@@ -169,33 +173,33 @@ async function main() {
// ── Settings: export + delete-all guarded by confirm() ── // ── Settings: export + delete-all guarded by confirm() ──
await page.locator('nav button.tab', { hasText: 'Settings' }).click(); await page.locator('nav button.tab', { hasText: 'Settings' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
const exportBtnVisible = await page.locator('button.btn-secondary', { hasText: 'Export all data' }).isVisible(); const exportBtnVisible = await page.locator('button.btn-secondary', { hasText: 'Export local export' }).isVisible();
const deleteBtnVisible = await page.locator('button.btn-danger', { hasText: 'Delete all data' }).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); record('Settings: export and delete-all controls present', exportBtnVisible && deleteBtnVisible);
// Export download check // Export download check
const [download] = await Promise.all([ const [download] = await Promise.all([
page.waitForEvent('download'), 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()); 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 // 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(); }); 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.locator('button.btn-danger', { hasText: 'Delete local device data' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
await page.locator('nav button.tab', { hasText: 'Assets' }).click(); 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(); const dataStillThereAfterDismiss = await page.locator('.asset-row', { hasText: 'Terrace house' }).isVisible();
record('Settings: dismissing delete confirm leaves data intact', dataStillThereAfterDismiss); record('Settings: dismissing delete confirm leaves data intact', dataStillThereAfterDismiss);
// ── Bilingual: language switcher click actually changes visible UI text ── // ── Bilingual: language switcher click actually changes visible UI text ──
await page.locator('nav button.tab', { hasText: 'Settings' }).click(); 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(); const taglineBefore = await page.locator('.header-tagline').textContent();
await page.locator('.lang-btn', { hasText: 'Bahasa Malaysia' }).click(); 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(); const taglineAfter = await page.locator('.header-tagline').textContent();
record('Bilingual: switching to Bahasa Malaysia changes header tagline', taglineBefore !== taglineAfter && taglineAfter.includes('WAKAF'), `"${taglineBefore}" -> "${taglineAfter}"`); record('Bilingual: switching to Bahasa Malaysia changes header tagline', taglineBefore !== taglineAfter && taglineAfter.includes('WAKAF'), `"${taglineBefore}" -> "${taglineAfter}"`);
await page.locator('.lang-btn', { hasText: 'English' }).click(); await page.locator('.lang-btn', { hasText: 'English' }).click();
+41 -17
View File
@@ -1,34 +1,58 @@
<script> <script>
import { load, save, estateTotal } from './storage.js'; import { onMount } from 'svelte';
import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js'; import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js';
import { activeFamilyId } from './family.js';
import { listAssets, estateTotal, listWassiyahBequests, addWassiyahBequest, removeWassiyahBequest, getWassiyahSettings, upsertWassiyahSettings } from './db.js';
import Disclaimer from './Disclaimer.svelte'; import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte'; import InfoPanel from './InfoPanel.svelte';
const assets = load('assets', []); let familyId = $state(null);
const total = estateTotal(assets); activeFamilyId.subscribe(v => familyId = v);
const cap = oneThirdCap(total);
let total = $state(0);
let cap = $state(0);
let jurisdiction = $state('UK'); let jurisdiction = $state('UK');
let bequests = $state(load('wassiyahBequests', [])); let bequests = $state([]);
let form = $state({ recipient: '', relation: '', description: '', value: '' }); let form = $state({ recipient: '', relation: '', description: '', value: '' });
let witness1 = $state(load('witness1', '')); let witness1 = $state('');
let witness2 = $state(load('witness2', '')); let witness2 = $state('');
let overrideAcknowledged = $state(false); let overrideAcknowledged = $state(false);
async function refresh() {
if (!familyId) return;
const assets = await listAssets(familyId);
total = estateTotal(assets);
cap = oneThirdCap(total);
bequests = await listWassiyahBequests(familyId);
const settings = await getWassiyahSettings(familyId);
if (settings) {
jurisdiction = settings.jurisdiction || 'UK';
witness1 = settings.witness1 || '';
witness2 = settings.witness2 || '';
}
}
onMount(refresh);
$effect(() => { familyId; refresh(); });
async function saveSettings() {
await upsertWassiyahSettings(familyId, { jurisdiction, witness1, witness2 });
}
const bequestTotal = $derived(bequests.reduce((s, b) => s + Number(b.value), 0)); const bequestTotal = $derived(bequests.reduce((s, b) => s + Number(b.value), 0));
const exceedsCap = $derived(bequestTotal > cap); const exceedsCap = $derived(bequestTotal > cap);
const blockedRecipient = $derived(isQuranicHeirRelation(form.relation)); const blockedRecipient = $derived(isQuranicHeirRelation(form.relation));
function addBequest() { async function addBequest() {
if (!form.recipient || !form.value) return; if (!form.recipient || !form.value) return;
bequests = [...bequests, { ...form, id: crypto.randomUUID(), value: Number(form.value) }]; await addWassiyahBequest(familyId, form);
save('wassiyahBequests', bequests);
form = { recipient: '', relation: '', description: '', value: '' }; form = { recipient: '', relation: '', description: '', value: '' };
await refresh();
} }
function remove(id) { async function remove(id) {
bequests = bequests.filter(b => b.id !== id); await removeWassiyahBequest(id);
save('wassiyahBequests', bequests); await refresh();
} }
function exportDraft() { function exportDraft() {
@@ -57,7 +81,7 @@
'', '',
'DISCLAIMER: Not a fatwa. Not legal advice. Requires qualified review before real-world reliance.' 'DISCLAIMER: Not a fatwa. Not legal advice. Requires qualified review before real-world reliance.'
]; ];
save('witness1', witness1); save('witness2', witness2); saveSettings();
const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -95,7 +119,7 @@
</div> </div>
<label class="field"><span>Jurisdiction</span> <label class="field"><span>Jurisdiction</span>
<select bind:value={jurisdiction}><option value="UK">United Kingdom</option><option value="MY">Malaysia</option></select> <select bind:value={jurisdiction} onchange={saveSettings}><option value="UK">United Kingdom</option><option value="MY">Malaysia</option></select>
</label> </label>
<div class="form-card"> <div class="form-card">
@@ -124,8 +148,8 @@
</div> </div>
{/if} {/if}
<label class="field"><span>Witness 1</span><input type="text" bind:value={witness1} /></label> <label class="field"><span>Witness 1</span><input type="text" bind:value={witness1} onblur={saveSettings} /></label>
<label class="field"><span>Witness 2</span><input type="text" bind:value={witness2} /></label> <label class="field"><span>Witness 2</span><input type="text" bind:value={witness2} onblur={saveSettings} /></label>
<button class="btn-primary" disabled={exceedsCap && !overrideAcknowledged} onclick={exportDraft}>Export draft (PDF + text)</button> <button class="btn-primary" disabled={exceedsCap && !overrideAcknowledged} onclick={exportDraft}>Export draft (PDF + text)</button>
+29
View File
@@ -186,3 +186,32 @@ export async function fireDeathTrigger(familyId) {
export function estateTotal(assets) { export function estateTotal(assets) {
return (assets || []).reduce((sum, a) => sum + (Number(a.value) || 0) * (Number(a.ownershipShare ?? 100) / 100), 0); return (assets || []).reduce((sum, a) => sum + (Number(a.value) || 0) * (Number(a.ownershipShare ?? 100) / 100), 0);
} }
// ── Wassiyah ──
export async function listWassiyahBequests(familyId) {
const { data, error } = await supabase.from('nf_wassiyah_bequests').select('*').eq('family_id', familyId).order('created_at');
if (error) throw error;
return (data || []).map(b => ({ id: b.id, recipient: b.recipient, relation: b.relation, description: b.description, value: b.value }));
}
export async function addWassiyahBequest(familyId, b) {
const { error } = await supabase.from('nf_wassiyah_bequests').insert({
family_id: familyId, recipient: b.recipient, relation: b.relation, description: b.description, value: Number(b.value)
});
if (error) throw error;
}
export async function removeWassiyahBequest(id) {
const { error } = await supabase.from('nf_wassiyah_bequests').delete().eq('id', id);
if (error) throw error;
}
export async function getWassiyahSettings(familyId) {
const { data, error } = await supabase.from('nf_wassiyah_settings').select('*').eq('family_id', familyId).maybeSingle();
if (error) throw error;
return data;
}
export async function upsertWassiyahSettings(familyId, fields) {
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
family_id: familyId, jurisdiction: fields.jurisdiction, witness1: fields.witness1, witness2: fields.witness2,
updated_at: new Date().toISOString()
}, { onConflict: 'family_id' });
if (error) throw error;
}
+7 -1
View File
@@ -28,7 +28,13 @@ export function exportAll() {
const out = {}; const out = {};
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i); const k = localStorage.key(i);
if (k && k.startsWith(PREFIX)) out[k.slice(PREFIX.length)] = JSON.parse(localStorage.getItem(k)); if (!k || !k.startsWith(PREFIX)) continue;
const raw = localStorage.getItem(k);
// Not every nf.-prefixed key is JSON-encoded (e.g. family.js stores
// activeFamilyId as a raw string) — a single malformed JSON.parse used
// to throw and silently abort the whole export before the download fired.
try { out[k.slice(PREFIX.length)] = JSON.parse(raw); }
catch { out[k.slice(PREFIX.length)] = raw; }
} }
return out; return out;
} }