文件
hyb-oa/scripts/qa/company-loan-related-browser.cjs
T
wangsli和Claude Opus 5.5 4a5cd8f2b3
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效
- 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离
- 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页
- 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表)
- 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 15:22:12 +08:00

275 行
25 KiB
JavaScript

const assert = require('node:assert/strict');
const path = require('node:path');
const { mkdtempSync, mkdirSync, writeFileSync } = require('node:fs');
const { createRequire } = require('node:module');
const req = createRequire(path.resolve(__dirname, '../../client/package.json'));
const { chromium, expect } = req('@playwright/test');
const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs');
module.exports = async function relatedBrowser(h) {
const { finance, manager, ordinary, password, prisma, relatedParty, config } = h;
const own = !h.browser; const browser = h.browser ?? await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
const origin = h.origin ?? await h.app.listen({ host: '127.0.0.1', port: 0 });
const root = path.resolve(__dirname, '../..'); mkdirSync(path.join(root, 'artifacts/qa-flow/loan-related-20261008'), { recursive: true });
const output = h.output ?? mkdtempSync(path.join(root, 'artifacts/qa-flow/loan-related-20261008/browser-'));
const f = relatedParty.helpers; const checks = [];
const oldWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 0;
const oldOrigin = config.appOrigin; config.appOrigin = origin;
try {
for (const s of relatedParty.ui) {
const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : ''; const contexts = [];
const technicalFinanceRequests = [];
console.log(JSON.stringify({ stage: 'loan_related_browser_start', mode: s.mode }));
async function login(who, route = `${prefix}/company-loans`) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
contexts.push(context); await context.route('**/*', r => r.request().url().startsWith(`${origin}/`) ? r.continue() : r.abort());
const p = await context.newPage();
if (who.id === ordinary.id) p.on('request', request => {
const pathname = new URL(request.url()).pathname;
if (/^\/api\/(company-loans|loan-lenders|loan-repayments|loan-related-authorizations)(\/|$)/.test(pathname)) technicalFinanceRequests.push(pathname);
});
await p.goto(`${origin}/login?redirect=${encodeURIComponent(route)}`);
await p.getByTestId('login-identifier').fill(who.email); await p.getByTestId('login-password').fill(password); await p.getByTestId('login-submit').click();
await p.waitForURL(url => !url.pathname.includes('login'));
return p;
}
const go = async (p, b) => { await p.goto(`${origin}${prefix}/payment-batches?id=${b.id}`); await expect(p.getByLabel('关联方还款模式')).toBeVisible({ timeout: 20000 }); };
const paymentButton = (p, kind) => p.getByTestId(mobile ? 'payment-batch-mobile-actions' : 'payment-batch-action-panel').getByTestId(`payment-batch-action-${kind}`);
async function submit(p, fill, confirm = '确认') {
const d = p.getByRole('dialog'); if (fill) await fill(d);
const response = p.waitForResponse(r => r.url().startsWith(`${origin}/api/`) &&
!r.url().includes('/api/auth/') && ['POST', 'PATCH'].includes(r.request().method()));
await d.getByRole('button', { name: confirm, exact: true }).click();
const result = await response;
if (!result.ok()) {
const failure = (await result.json().catch(() => ({}))).error?.details?.reasonCode;
throw new Error(`related ${s.mode} ${confirm}: HTTP ${result.status()} ${failure ?? ''}`);
}
try { await expect(d).not.toBeVisible({ timeout: 15000 }); }
catch { throw new Error(`related ${s.mode} dialog: ${await d.getByRole('alert').allTextContents()}`); }
return result;
}
async function recon(p, label, fill) {
await p.getByRole('region', { name: '银行结果对账', exact: true }).getByRole('button', { name: label, exact: true }).click();
return submit(p, async d => { if (fill) await fill(d); if (await d.getByLabel('依据与说明').count()) await d.getByLabel('依据与说明').fill('合成材料唯一定位具体银行指令及全部候选批次'); });
}
const review = (p, label) => recon(p, label, d => d.getByLabel('复核结论').selectOption('approve'));
async function upload(p, kind) {
const response = await recon(p, '上传银行证据', async d => { await d.getByLabel('证据类别').selectOption(kind); await d.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await f.pdf() }); });
return (await response.json()).data.id;
}
async function receiptUpload(p, b, cents, ref, overrides = {}, document = {}) {
const response = p.waitForResponse(r => r.url().endsWith(`/payment-batches/${b.id}/receipt-attachments`) && r.request().method() === 'POST');
const status = overrides.transactionStatus === null ? 'booked' : overrides.transactionStatus ?? 'success';
await p.getByTestId('payment-batch-receipt-file-input').setInputFiles({ name: 'synthetic-receipt.pdf', mimeType: 'application/pdf',
buffer: await syntheticBankReceiptPdf({ status, tag: ref, reference: ref,
amount: cents === null ? String(b.items[0].amount) : `${BigInt(cents) / 100n}.${String(BigInt(cents) % 100n).padStart(2, '0')}`,
payerAccount: '8'.repeat(16), payeeAccount: s.lender.bankAccount, ...document }) });
assert.equal((await response).status(), 200);
const result = await f.processUploadedReceipt(cents, ref, { payeeAccount: s.lender.bankAccount, ...overrides });
await p.reload(); return result;
}
try {
const fp = await login(finance); const mp = await login(manager);
await fp.getByRole('tab', { name: '公司代表授权', exact: true }).click();
await fp.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
const dialog = fp.getByRole('dialog');
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached();
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${s.lender.id}"]`).count()); i++) {
assert.ok(i < 12); await expect(dialog.getByRole('button', { name: '下一页出借人' })).toBeEnabled();
const response = fp.waitForResponse(r => r.url().includes('/loan-lenders?page=')); await dialog.getByRole('button', { name: '下一页出借人' }).click(); await response;
}
await dialog.getByLabel('授权出借人').selectOption(s.lender.id);
await dialog.getByRole('checkbox', { name: new RegExp(s.loan.loanNo) }).check();
await submit(fp, d => d.getByLabel('代表权及授权范围依据').fill('合成已确认公司代表权,限定本公司与这笔借款'));
const a = await prisma.loanRelatedAuthorization.findFirstOrThrow({ where: { lender_id: s.lender.id }, orderBy: { version: 'desc' } });
await expect(fp.getByRole('button', { name: '代表公司确认授权范围', exact: true })).toHaveCount(0);
await mp.reload();
await expect(mp.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0);
await expect(mp.getByRole('button', { name: '登记公司代表授权范围', exact: true })).toHaveCount(0);
const card = mp.locator('section.card-std').filter({ hasText: s.lender.name });
await expect(card).toBeVisible();
await card.getByRole('button', { name: '代表公司确认授权范围' }).click();
await expect(mp.getByRole('dialog')).toContainText(s.loan.loanNo);
await submit(mp, d => d.getByLabel('确认或停止的理由').fill('合成代表公司确认已登记范围与本人关联身份'));
await fp.getByRole('tab', { name: '出借人', exact: true }).click();
const lenderCard = fp.locator('section.card-std').filter({ hasText: s.lender.name });
for (let page = 1; !(await lenderCard.count()); page++) {
assert.ok(page < 20);
const next = fp.getByRole('button', { name: '下一页', exact: true }); await expect(next).toBeEnabled();
const loaded = fp.waitForResponse(r => r.url().includes(`/loan-lenders?page=${page + 1}&`));
await next.click(); await loaded;
await expect(fp.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
}
await fp.getByRole('button', { name: '申请还本', exact: true }).click();
const creation = fp.getByRole('dialog'); await creation.getByLabel('出借人', { exact: true }).selectOption(s.lender.id);
await creation.getByLabel(`分配 ${s.loan.originReference}`, { exact: true }).fill('10.00');
await submit(fp, d => d.getByLabel('说明', { exact: true }).fill('合成关联方正常还本'), '提交还款申请');
await fp.waitForURL(/payment-batches\?id=/);
const b = await prisma.paymentBatch.findFirstOrThrow({ where: { loan_repayment: { lender_id: s.lender.id } }, orderBy: { created_at: 'desc' }, include: { items: true } });
await go(mp, b);
await expect(mp.getByTestId('payment-batch-action-downloadOrder')).toHaveCount(0);
await mp.getByRole('button', { name: '查看完整账号', exact: true }).click();
await mp.getByRole('dialog').getByLabel('查看收款账号原因').fill('合成公司代表核对本笔收款账号');
await mp.getByRole('dialog').getByRole('button', { name: '确认查看', exact: true }).click();
await expect(mp.getByRole('dialog')).toContainText(s.lender.bankAccount);
await mp.getByRole('dialog').getByRole('button', { name: '返回', exact: true }).click();
await paymentButton(mp, 'review').click(); await submit(mp, undefined, '代表公司确认');
await go(fp, b); await paymentButton(fp, 'downloadOrder').click();
const downloaded = fp.waitForEvent('download'); await submit(fp, undefined, '确认下载'); await downloaded;
assert.equal((await receiptUpload(fp, b, '1000', `RP-UI-${s.mode}`)).status, 'matched');
await expect(paymentButton(fp, 'execute')).toBeEnabled({ timeout: 15000 });
await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行');
assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: b.loan_repayment_id } })).reservation_state, 'settled');
await expect(fp.getByLabel('关联方还款模式')).toBeVisible();
checks.push(`${s.mode}_related_scope_company_confirmation_payee_read_export_ocr_settlement`);
const returned = await f.draft(s, '9.00'); await go(mp, returned);
await mp.getByRole('button', { name: '更多', exact: true }).click();
await mp.getByRole('menuitem', { name: '退回付款申请', exact: true }).click();
await submit(mp, d => d.getByLabel('付款退回理由').fill('合成公司代表退回,请更正付款金额'), '确认退回');
await expect(mp.getByTestId('payment-batch-action-review')).toHaveCount(0);
assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: returned.loanRepaymentId } })).reservation_state, 'reserved');
await go(fp, returned); await expect(fp.getByText('退回未释放占用,财务须取消原申请后重新提交。', { exact: false })).toBeVisible();
await fp.getByRole('button', { name: '更多', exact: true }).click();
await fp.getByRole('menuitem', { name: '取消批次', exact: true }).click();
await submit(fp, d => d.getByLabel('批次取消原因').fill('合成按公司代表退回要求取消后重新提交'), '确认取消');
assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: returned.loanRepaymentId } })).reservation_state, 'released');
checks.push(`${s.mode}_related_return_reason_finance_cancel_and_retained_decision`);
const manual = await f.batch(s, '20.00'); await go(fp, manual);
assert.equal((await receiptUpload(fp, manual, null, `RP-UI-MANUAL-${s.mode}`)).status, 'needs_review');
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
let d = fp.getByRole('dialog'); await d.getByLabel('第1页对应付款明细').selectOption(manual.items[0].id);
await d.getByRole('button', { name: '更正识别', exact: true }).click();
await d.getByLabel('第1页更正小写金额').fill('20.00');
await submit(fp, d => d.getByLabel('更正说明(仅关联可留空)').fill('合成回单原件金额清晰,人工补录'), '保存核验');
await go(mp, manual); await mp.getByRole('button', { name: '核验详情', exact: true }).click();
await expect(mp.getByRole('dialog').getByRole('button', { name: '保存核验' })).toHaveCount(0);
await submit(mp, d => d.getByLabel('复核说明').fill('合成公司代表逐项核对回单原件'), '公司代表确认');
await go(fp, manual); await expect(paymentButton(fp, 'execute')).toBeEnabled();
await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行');
checks.push(`${s.mode}_related_manual_receipt_confirmation_and_rebuilt_settlement`);
const booked = await f.batch(s, '18.00'); await go(fp, booked);
assert.equal((await receiptUpload(fp, booked, '1800', `RP-UI-BOOKED-${s.mode}`, { transactionStatus: null })).status, 'needs_review');
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
d = fp.getByRole('dialog');
await expect(d).toContainText('状态:未确认');
await d.getByLabel('第1页对应付款明细').selectOption(booked.items[0].id);
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled();
await d.getByLabel('第1页付款凭证依据').selectOption('booked_debit');
await d.getByLabel('第1页原件记账日期').fill('2026-10-01');
await d.getByRole('button', { name: '更正识别', exact: true }).click();
await expect(d.getByLabel('第1页更正单据状态')).toHaveCount(0);
await d.getByLabel('更正说明(仅关联可留空)').fill('合成正式回单为本公司借方记账,未记载成功状态字样');
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled();
await d.getByRole('checkbox', { name: /已查看第1页完整原件/ }).check();
await submit(fp, undefined, '保存核验');
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
await expect(fp.getByRole('dialog').getByRole('button', { name: '公司代表确认', exact: true })).toHaveCount(0);
await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
await expect(fp.getByTestId('payment-batch-action-execute')).toHaveCount(0);
await go(mp, booked); await mp.getByRole('button', { name: '核验详情', exact: true }).click();
d = mp.getByRole('dialog');
await expect(d).toContainText('已记账支出(人工核对)'); await expect(d).toContainText('记账日期 2026-10-01');
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toHaveCount(0);
assert.equal(await mp.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
await mp.screenshot({ path: path.join(output, `related-${s.mode}-booked-proof.png`), fullPage: true });
await submit(mp, current => current.getByLabel('复核说明').fill('合成代表公司核对原件借方记账日期与付款明细'), '公司代表确认');
await go(fp, booked); await expect(paymentButton(fp, 'execute')).toBeEnabled();
await paymentButton(fp, 'execute').click(); await submit(fp, undefined, '确认执行');
assert.equal((await prisma.loanRepaymentAllocation.findFirstOrThrow({ where: { repayment_id: booked.loanRepaymentId } })).reservation_state, 'settled');
checks.push(`${s.mode}_booked_receipt_proof_dialog_company_confirmation_and_settlement`);
const nonfinal = await f.batch(s, '12.00'); await go(fp, nonfinal);
assert.equal((await receiptUpload(fp, nonfinal, '1200', `RP-UI-NONFINAL-${s.mode}`, {}, { final: false })).status, 'needs_review');
await fp.getByRole('button', { name: '核验详情', exact: true }).click();
d = fp.getByRole('dialog'); await expect(d).toContainText('非最终回执');
await expect(d.getByRole('button', { name: '更正识别', exact: true })).toHaveCount(0);
await expect(d.getByRole('button', { name: '保存核验', exact: true })).toBeDisabled();
await d.getByRole('button', { name: '关闭', exact: true }).click();
await expect(fp.getByTestId('payment-batch-action-execute')).toHaveCount(0);
checks.push(`${s.mode}_nonfinal_receipt_has_no_manual_override_or_settlement`);
const failed = await f.batch(s, '7.00'); await f.batch(s, '7.00'); const ref = `RP-UI-FAIL-${s.mode}`.toUpperCase();
await f.receipt(failed.id, '700', ref, { payeeAccount: s.lender.bankAccount, transactionStatus: 'failed' });
await go(fp, failed);
await expect(fp.getByRole('button', { name: '登记人工未提交', exact: true })).toHaveCount(0);
await recon(fp, '登记银行提交', async d => { await d.getByLabel('使用的导出文件(手工录入可不选)').selectOption({ index: 1 }); await d.getByLabel('银行指令号').fill(ref); await d.getByLabel('实际提交时间').fill(new Date().toISOString().slice(0, 16)); });
await recon(fp, '确认提交清单完整'); await go(mp, failed); await review(mp, '公司代表确认提交清单');
await go(fp, failed); await recon(fp, '登记失败回单归属', async d => { await d.getByLabel('对应银行提交').selectOption({ index: 1 }); await d.getByLabel('失败回单(终态书面证据可不选)').selectOption({ index: 1 }); await d.getByLabel('排除其他候选批次的依据').fill('合成其他同金额批次对应不同的银行指令'); });
await go(mp, failed); await review(mp, '公司代表确认失败归属');
await go(fp, failed); await recon(fp, '登记银行终态事实', async d => { await d.getByLabel('对应银行提交').selectOption({ index: 1 }); await d.getByLabel('失败回单(终态书面证据可不选)').selectOption({ index: 1 }); });
await go(mp, failed); await review(mp, '公司代表确认终态事实'); await recon(mp, '确认银行未付款');
await go(fp, failed); await recon(fp, '发起补证恢复'); await recon(fp, '送审补证恢复');
await go(mp, failed); await review(mp, '公司代表确认补证恢复');
await go(fp, failed); await recon(fp, '重验依据并取消释放');
assert.equal(await prisma.loanRepaymentAllocation.count({ where: { repayment_id: failed.loanRepaymentId, reservation_state: 'reserved' } }), 0);
checks.push(`${s.mode}_related_submission_failure_attribution_terminal_recovery_release`);
const late = await f.setup('100.00'); const old = await f.batch(late, '80.00'); await f.terminate(late, old, true);
const paid = await f.batch(late, '60.00'); await f.receipt(paid.id, '6000', `RP-UI-ALREADY-${s.mode}`, { payeeAccount: late.lender.bankAccount });
// Seed the distinct already-paid transaction; every new reconciliation
// action under test below is made through the rendered dialogs.
const execute = await h.relatedParty.helpers.execute(paid); assert.equal(execute.item.status, 'executed');
const count = await prisma.paymentBatch.count();
async function bankFields(d, amount, reference, evidenceId, incoming = false) {
await d.getByLabel('对应的成功银行回单').selectOption(evidenceId);
await d.getByLabel('回单付款账号').fill(incoming ? late.lender.bankAccount : '8'.repeat(16)); await d.getByLabel('回单收款账号').fill(incoming ? '8'.repeat(16) : late.lender.bankAccount);
await d.getByLabel('回单人民币金额').fill(amount); await d.getByLabel('回单流水号').fill(reference);
await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成原件指明原批次,逐笔排除其他同金额付款');
}
await go(fp, old); const lateEvidence = await upload(fp, 'late_success'); await recon(fp, '登记晚到成功', d => bankFields(d, '80.00', `RP-UI-LATE-${s.mode}`, lateEvidence));
await go(mp, old); await review(mp, '公司代表确认银行事实'); await go(fp, old); await recon(fp, '登记补记或追回');
await expect(fp.getByRole('region', { name: '银行结果对账', exact: true })).toContainText('待追回 ¥40.00');
for (const [amount, left] of [['15.00', '25.00'], ['25.00', '0.00']]) {
const returnEvidence = await upload(fp, 'incoming_return'); await recon(fp, '登记追回到账', d => bankFields(d, amount, `RP-UI-RETURN-${s.mode}-${amount}`, returnEvidence, true));
await go(mp, old); await review(mp, '公司代表确认银行事实'); await go(fp, old); await recon(fp, '登记补记或追回');
await expect(fp.getByRole('region', { name: '银行结果对账', exact: true })).toContainText(`待追回 ¥${left}`);
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lender.id, status: 'active' } }), left === '0.00' ? 0 : 1);
}
assert.equal(await prisma.paymentBatch.count(), count);
checks.push(`${s.mode}_related_late_success_partial_and_full_return_no_payment_batch`);
const duplicateEvidence = await upload(fp, 'late_success'); await recon(fp, '登记晚到成功', d => bankFields(d, '80.00', `RP-UI-LATE-${s.mode}`, duplicateEvidence)); const correctionEvidence = await upload(fp, 'bank_terminal');
await go(mp, old); await recon(mp, '公司代表确认银行事实', async d => {
await d.getByLabel('复核结论').selectOption('reject');
const field = d.getByLabel('误关联或重复登记的更正证据(晚到成功必填)'); await field.selectOption(correctionEvidence);
await d.getByLabel('关闭原因').selectOption('duplicate');
});
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lender.id, status: 'active' } }), 0);
await expect(mp.getByRole('region', { name: '银行结果对账', exact: true })).toContainText('已确认误报');
assert.equal(await mp.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
await mp.screenshot({ path: path.join(output, `related-${s.mode}-closed.png`), fullPage: true });
checks.push(`${s.mode}_related_evidenced_duplicate_dismissal_and_no_overflow`);
const tech = await login(ordinary);
await expect(tech.getByRole('heading', { name: '出借人确认借款', exact: true })).toBeVisible();
await expect(tech.getByText('暂无可查看的出借人确认。', { exact: true })).toBeVisible();
await expect(tech.getByRole('tablist')).toHaveCount(0);
await expect(tech.getByRole('button', { name: /登记借入|登记出借人|申请还本|发起出借人确认|登记公司代表授权范围|代表公司确认授权范围|停止新付款授权/ })).toHaveCount(0);
assert.deepEqual(technicalFinanceRequests, [], 'technical employee own declarations must not load finance or company-authority data');
assert.equal(await prisma.notification.count({ where: { recipient_employee_id: ordinary.id, target_id: { in: [b.id, failed.id, old.id] } } }), 0);
// Stopping from the actual scope card uses the current grant only.
await mp.goto(`${origin}${prefix}/company-loans`);
const originalCard = mp.locator('section.card-std').filter({ hasText: s.lender.name });
await originalCard.getByRole('button', { name: '停止新付款授权', exact: true }).click();
await submit(mp, d => d.getByLabel('确认或停止的理由').fill('合成暂停后续新付款,保留对账'));
assert.equal((await prisma.loanRelatedAuthorizationEvent.findFirstOrThrow({ where: { authorization_id: a.id }, orderBy: { sequence: 'desc' } })).payment_enabled, false);
checks.push(`${s.mode}_technical_employee_excluded_and_scope_stop_via_ui`);
} catch (error) {
for (const [index, context] of contexts.entries()) {
const p = context.pages()[0]; if (!p) continue;
await p.screenshot({ path: path.join(output, `failed-${s.mode}-${index}.png`), fullPage: true }).catch(() => {});
writeFileSync(path.join(output, `failed-${s.mode}-${index}.json`), JSON.stringify({
path: new URL(p.url()).pathname, alerts: await p.getByRole('alert').allTextContents(),
headings: await p.getByRole('heading').allTextContents(), buttons: await p.getByRole('button').allTextContents(),
}, null, 2));
}
throw error;
} finally { for (const context of contexts) await context.close(); }
}
} finally { config.appOrigin = oldOrigin; if (oldWait === undefined) delete config.loanReleaseWaitDays; else config.loanReleaseWaitDays = oldWait; if (own) await browser.close(); }
console.log(JSON.stringify({ stage: 'loan_related_browser', status: 'PASS', checks, output: path.relative(root, output) }));
return checks;
};