const assert = require('node:assert/strict'); const { mkdtempSync } = require('node:fs'); const path = require('node:path'); const { createRequire } = require('node:module'); const root = path.resolve(__dirname, '../..'); const req = createRequire(path.join(root, 'client/package.json')); const { chromium, expect } = req('@playwright/test'); module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi, reconciliationUi, config, relatedParty, employeeSources }) { const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-')); const origin = await app.listen({ host: '127.0.0.1', port: 0 }); const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] }); const checks = []; async function findOnPages(page, locator, endpoint) { for (let attempt = 0; attempt < 12; attempt++) { await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0); if (await locator.count()) return; const next = page.getByRole('button', { name: '下一页', exact: true }); await expect(next).toBeEnabled(); const currentLabel = await page.getByText(/^第 \d+ 页$/).first().innerText(); await next.click(); await expect(page.getByText(currentLabel, { exact: true })).toHaveCount(0); await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0); } throw new Error('synthetic record not found after pagination'); } try { for (const mobile of [false, true]) { const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); const mode = mobile ? 'mobile' : 'desktop'; const target = mobile ? '/mobile/company-loans' : '/company-loans'; await page.goto(`${origin}/login?redirect=${encodeURIComponent(target)}`); await page.getByTestId('login-identifier').fill(finance.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); await page.getByRole('button', { name: '登记出借人', exact: true }).click(); let dialog = page.getByRole('dialog'); await dialog.getByRole('button', { name: '登记非员工出借人', exact: true }).click(); await dialog.getByLabel('公司主体').selectOption(company.id); const lenderName = `合成浏览器出借人-${mode}`; await dialog.getByLabel('出借人姓名').fill(lenderName); await dialog.getByLabel('收款银行').selectOption(bank.id); await dialog.getByLabel('开户行名称').fill('合成建行'); await dialog.getByLabel('账户名', { exact: true }).fill(lenderName); await dialog.getByLabel('收款账号', { exact: true }).fill('6'.repeat(16)); await dialog.getByRole('button', { name: '确认', exact: true }).click(); await expect(dialog).not.toBeVisible(); await page.getByRole('tab', { name: '出借人', exact: true }).click(); const lenderCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) }); await findOnPages(page, lenderCard, 'loan-lenders'); await lenderCard.getByRole('button', { name: '核实身份关联' }).click(); dialog = page.getByRole('dialog'); await dialog.getByLabel('出借人对应身份').selectOption('external'); await dialog.getByLabel('说明', { exact: true }).fill('合成浏览器非员工身份核实'); await dialog.getByRole('button', { name: '确认', exact: true }).click(); await expect(dialog).not.toBeVisible(); await expect(lenderCard).toContainText('身份已核实'); await page.getByRole('button', { name: '登记借入', exact: true }).click(); dialog = page.getByRole('dialog'); const lender = await prisma.loanLender.findFirstOrThrow({ where: { name: lenderName } }); await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id); const reference = `合成浏览器原借入-${mode}`; await dialog.getByLabel('原借入凭据唯一引用').fill(reference); await dialog.getByLabel('原始本金').fill('300.05'); await dialog.getByLabel('核对日未还本金').fill('300.05'); await dialog.getByLabel('实际到账日期').fill('2025-01-01'); await dialog.getByLabel('余额核对截止日').fill('2026-01-01'); await dialog.getByRole('button', { name: '确认', exact: true }).click(); await expect(dialog).not.toBeVisible(); await page.getByRole('tab', { name: '借款台账', exact: true }).click(); const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference } }); const card = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${loan.loan_no}`, exact: true }) }); await findOnPages(page, card, 'company-loans'); await card.locator('summary').click(); for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) { const received = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST'); await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf', buffer: Buffer.from(`%PDF-1.4\n% synthetic-${kind}-${loan.id}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`) }); assert.equal((await received).status(), 200); } await card.getByRole('button', { name: '确认期初余额' }).click(); dialog = page.getByRole('dialog'); await dialog.getByLabel('代账余额确认引用').fill('合成代账核对'); await dialog.getByRole('button', { name: '确认', exact: true }).click(); await expect(dialog).not.toBeVisible(); await expect(card).toContainText('余额已确认'); await page.getByRole('button', { name: '申请还本', exact: true }).click(); dialog = page.getByRole('dialog'); await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id); await dialog.getByLabel(`分配 ${reference}`, { exact: true }).fill('100.05'); await dialog.getByLabel('说明', { exact: true }).fill('合成浏览器部分还本'); await dialog.getByRole('button', { name: '提交还款申请', exact: true }).click(); await expect(page.getByText('原借款分配与会计交接', { exact: true })).toBeVisible(); await expect(page.getByText('可复核:', { exact: false }).first()).toBeVisible(); await expect(page.getByText('暂无企微个人消息发送成功记录', { exact: false }).first()).toBeVisible(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1); assert.equal(overflow, false, `${mode} horizontal overflow`); await page.screenshot({ path: path.join(output, `${mode}-repayment.png`), fullPage: true }); checks.push(`${mode}_real_login_register_materials_confirm_submit_and_named_progress`); await page.goBack(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); await page.screenshot({ path: path.join(output, `${mode}-lenders-after-submit.png`), fullPage: true }); await page.getByRole('tab', { name: '出借人', exact: true }).click(); const frozenCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) }); await findOnPages(page, frozenCard, 'loan-lenders'); await expect(frozenCard).toContainText('有还款正在办理'); await expect(frozenCard.getByRole('button', { name: '核实身份关联' })).toBeDisabled(); await expect(frozenCard.getByRole('button', { name: '更新收款资料' })).toBeDisabled(); checks.push(`${mode}_in_flight_repayment_disables_lender_maintenance`); await context.close(); } if (attributionUi) { // Two separate sessions: finance registers, an independent manager reviews. async function session(who) { const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block' }); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); await page.goto(`${origin}/login?redirect=${encodeURIComponent(`/payment-batches?id=${attributionUi.batchId}`)}`); await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); const panel = page.getByLabel('回单归属确认'); await expect(panel).toContainText('回单归属待确认'); return { context, page, panel }; } async function register(page, panel) { await panel.getByRole('button', { name: '登记回单归属依据' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByLabel('归属依据类型').selectOption('customer_serial'); await dialog.getByLabel('依据说明').fill('合成浏览器:回单客户方流水号与本批次制单流水号一致'); await dialog.getByLabel('排除其他候选批次的理由').fill('合成浏览器:其他候选批次的制单流水号均不一致'); await dialog.getByRole('button', { name: '确认', exact: true }).click(); await expect(dialog).not.toBeVisible(); await expect(panel).toContainText('待独立复核'); } async function approve(page, panel) { await expect(panel).toContainText('待独立复核'); await panel.getByRole('button', { name: '独立复核回单归属' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByLabel('复核结论').selectOption('approve'); await dialog.getByLabel('复核说明').fill('合成浏览器独立复核'); await dialog.getByRole('button', { name: '确认', exact: true }).click(); await expect(dialog).not.toBeVisible(); await expect(panel).toContainText('已通过独立复核'); } // Each step uses a fresh login: a full reload drops the session in this harness. let view = await session(finance); await expect(view.panel.getByRole('button', { name: '独立复核回单归属' })).toHaveCount(0); await register(view.page, view.panel); await expect(view.panel.getByRole('button', { name: '独立复核回单归属' })).toHaveCount(0); await view.context.close(); view = await session(manager); await expect(view.panel.getByRole('button', { name: '登记回单归属依据' })).toHaveCount(0); await approve(view.page, view.panel); await view.context.close(); checks.push('desktop_attribution_register_and_independent_review'); // A new same-amount competitor reaches the bank: the approval is stale and // finance can re-register, then the manager reviews again. await attributionUi.addCompetitor(); view = await session(finance); await expect(view.panel).toContainText('须重新登记并复核'); await register(view.page, view.panel); await view.context.close(); view = await session(manager); await approve(view.page, view.panel); await view.page.screenshot({ path: path.join(output, 'desktop-attribution.png'), fullPage: true }); await view.context.close(); const settled = await attributionUi.execute(); assert.equal(settled.statusCode, 200, 'settles after UI re-registration and review'); checks.push('desktop_stale_attribution_reregistered_reviewed_and_settled'); } if (reconciliationUi) { async function confirmDialog(dialog, step) { await dialog.getByRole('button', { name: '确认', exact: true }).click(); try { await expect(dialog).not.toBeVisible(); } catch { throw new Error(`synthetic Q6 UI ${step}: ${await dialog.getByRole('alert').allTextContents()} (dialog remains open)`); } } for (const [index, batchId] of reconciliationUi.batches.entries()) { const mobile = index === 1; async function session(who) { // Reconciliation forms fill UTC wall-clock times; pin the browser to UTC // instead of inheriting the host timezone (fails 00:00–08:00 in CST). const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block', timezoneId: 'UTC' }); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) }))); await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batchId}`)}`); await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); await page.waitForURL(/\/payment-batches\?id=/); const panel = page.getByRole('region', { name: '银行结果对账', exact: true }); await expect(panel).toBeVisible({ timeout: 20000 }); return { context, page, panel }; } let view = await session(finance); for (const kind of ['query_no_result', 'account_statement', 'exporter_statement']) { await view.panel.getByRole('button', { name: '上传银行证据', exact: true }).click(); const dialog = view.page.getByRole('dialog'); await dialog.getByLabel('证据类别').selectOption(kind); await dialog.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await reconciliationUi.bankLetterPdf() }); await confirmDialog(dialog, await dialog.getByRole('heading').innerText()); } await view.panel.getByRole('button', { name: '登记人工未提交', exact: true }).click(); let dialog = view.page.getByRole('dialog'); await dialog.getByLabel('查询无结果官方 PDF', { exact: true }).selectOption({ index: 1 }); await dialog.getByLabel('同期对账单', { exact: true }).selectOption({ index: 1 }); await dialog.getByLabel('导出人 1 的文件去向说明', { exact: true }).selectOption({ index: 1 }); const now = new Date(); const earlier = new Date(now.getTime() - 86400000); // Browser local timezone is UTC in this synthetic context. await dialog.getByLabel('查询与对账单覆盖开始').fill(earlier.toISOString().slice(0, 16)); await dialog.getByLabel('查询与对账单覆盖截至(须含登记日)').fill(now.toISOString().slice(0, 16)); await dialog.getByLabel('依据与说明').fill('合成浏览器逐份核对官方查询、对账单和文件去向'); await confirmDialog(dialog, await dialog.getByRole('heading').innerText()); await expect(view.panel).toContainText('已有 0 人同意'); await expect(view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true })).toHaveCount(0); await view.context.close(); for (const [n, who] of [manager, reconciliationUi.independent].entries()) { view = await session(who); await view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true }).click(); dialog = view.page.getByRole('dialog'); await dialog.getByLabel('复核结论').selectOption('approve'); await dialog.getByLabel('依据与说明').fill('合成浏览器独立复核全部证据与未提交结论'); await confirmDialog(dialog, await dialog.getByRole('heading').innerText()); await expect(view.panel).toContainText(`已有 ${n + 1} 人同意`); await expect(view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true })).toHaveCount(0); await view.context.close(); } view = await session(finance); await view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true }).click(); dialog = view.page.getByRole('dialog'); await dialog.getByLabel('依据与说明').fill('合成浏览器特别复核完成后重验并取消'); await confirmDialog(dialog, await dialog.getByRole('heading').innerText()); await expect(view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true })).toHaveCount(0); assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false); await view.page.screenshot({ path: path.join(output, `${mobile ? 'mobile' : 'desktop'}-manual-not-submitted.png`), fullPage: true }); await view.context.close(); checks.push(`${mobile ? 'mobile' : 'desktop'}_manual_not_submitted_evidence_upload_two_independent_reviews_and_release`); } } if (reconciliationUi) { for (const recovery of [false, true]) for (const [index, batch] of (recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).entries()) { const mobile = index === 1; async function session(who) { // Reconciliation forms fill UTC wall-clock times; pin the browser to UTC // instead of inheriting the host timezone (fails 00:00–08:00 in CST). const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block', timezoneId: 'UTC' }); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) }))); await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batch.id}`)}`); await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); await page.waitForURL(/\/payment-batches\?id=/); const panel = page.getByRole('region', { name: '银行结果对账', exact: true }); await expect(panel).toBeVisible({ timeout: 20000 }); return { context, page, panel }; } let view = await session(finance); async function action(name, fill) { await view.panel.getByRole('button', { name, exact: true }).click(); const dialog = view.page.getByRole('dialog'); if (fill) await fill(dialog); if (await dialog.getByLabel('依据与说明').count()) await dialog.getByLabel('依据与说明').fill('合成浏览器独立核对指定银行指令与全部依据'); await dialog.getByRole('button', { name: '确认', exact: true }).click(); try { await expect(dialog).not.toBeVisible(); } catch { throw new Error(`synthetic reconciliation ${name}: ${await dialog.getByRole('alert').allTextContents()}`); } } if (recovery) { await action('发起补证恢复'); await action('上传银行证据', async (dialog) => { await dialog.getByLabel('证据类别').selectOption('bank_terminal'); await dialog.getByLabel('证据文件').setInputFiles({ name: 'replacement-terminal.pdf', mimeType: 'application/pdf', buffer: await reconciliationUi.bankLetterPdf() }); }); } await action('登记银行终态事实', async (dialog) => { await dialog.getByLabel('对应银行提交').selectOption({ index: 1 }); await dialog.getByLabel('银行终态书面证据').selectOption({ index: 1 }); await dialog.getByLabel('银行终态', { exact: true }).selectOption('failed'); if (!recovery) { const boxes = dialog.getByRole('checkbox'); await expect(boxes).toHaveCount(2); for (const box of await boxes.all()) await box.check(); } }); await expect(view.panel).toContainText(batch.instruction); await view.context.close(); view = await session(manager); await expect(view.panel).toContainText(batch.instruction); if (!recovery) { await expect(view.panel).toContainText('了结处理中回单'); const receiptButtons = view.panel.getByRole('button', { name: /^付款回单 [12]/ }); await expect(receiptButtons).toHaveCount(2); for (const receiptButton of await receiptButtons.all()) { await receiptButton.click(); const dialog = view.page.getByRole('dialog'); await dialog.getByLabel('依据与说明').fill('合成复核指定银行指令的历史回单事实'); const downloaded = view.page.waitForEvent('download'); await dialog.getByRole('button', { name: '确认', exact: true }).click(); await downloaded; await expect(dialog).not.toBeVisible(); } } await action('独立复核终态事实', async (dialog) => dialog.getByLabel('复核结论').selectOption('approve')); if (!recovery) await action('确认银行未付款'); await view.context.close(); view = await session(finance); if (recovery) { await action('送审补证恢复'); await view.context.close(); view = await session(manager); await action('复核补证恢复', async (dialog) => dialog.getByLabel('复核结论').selectOption('approve')); await view.context.close(); view = await session(finance); } await action('重验依据并取消释放'); await expect(view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true })).toHaveCount(0); assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false); await view.page.screenshot({ path: path.join(output, `${mobile ? 'mobile' : 'desktop'}-${recovery ? 'recovery' : 'terminal-multiple-receipts'}.png`), fullPage: true }); await view.context.close(); checks.push(`${mobile ? 'mobile' : 'desktop'}_${recovery ? 'recovery_start_evidence_review_round_release' : 'terminal_multiple_pending_receipts_independent_review_release'}`); } } if (reconciliationUi) checks.push(...await require('./company-loan-reconciliation-browser.cjs')({ browser, origin, output, finance, manager, password, prisma, fixtures: reconciliationUi, })); if (relatedParty) checks.push(...await require('./company-loan-related-browser.cjs')({ browser, origin, output, finance, manager, ordinary, password, prisma, config, relatedParty })); if (employeeSources) checks.push(...await require('./company-loan-employee-browser.cjs')({ browser, origin, output, finance, password, prisma, config, employeeSources })); const context = await browser.newContext({ serviceWorkers: 'block' }); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); const financeReads = []; page.on('request', request => { if (['/api/company-loans', '/api/loan-lenders', '/api/loan-repayments'].includes(new URL(request.url()).pathname)) financeReads.push(request.url()); }); await page.goto(`${origin}/login?redirect=%2Fcompany-loans`); await page.getByTestId('login-identifier').fill(ordinary.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: '出借人确认借款', exact: true })).toBeVisible(); await expect(page.getByText('暂无可查看的出借人确认。', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0); await expect(page.getByRole('tablist', { name: '借款台账视图' })).toHaveCount(0); assert.deepEqual(financeReads, [], 'own declaration entry never queries the finance ledger'); checks.push('ordinary_employee_only_own_acknowledgements_without_finance_ledger'); await context.close(); console.log(JSON.stringify({ stage: 'loan_browser', status: 'PASS', checks, output: path.relative(root, output) })); } finally { await browser.close(); } };