const assert = require('node:assert/strict'); const path = require('node:path'); const { randomUUID } = require('node:crypto'); const { mkdtempSync, mkdirSync } = require('node:fs'); const { createRequire } = require('node:module'); const root = path.resolve(__dirname, '../..'); const clientRequire = createRequire(path.join(root, 'client/package.json')); const { chromium, expect } = clientRequire('@playwright/test'); // D1 diagnostic: the company-representative authorization dialog pages its // lender list (30 per page). With the target lender on page 3, compare the old // test loop (response-only wait) with a loop that waits for the option list to // change, under CPU throttling that models a loaded host. Synthetic data only. async function authorizationDialog(page) { await page.getByRole('tab', { name: '公司代表授权', exact: true }).click({ timeout: 30000 }); await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click(); const dialog = page.getByRole('dialog'); await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached({ timeout: 30000 }); return dialog; } const optionValues = (dialog) => dialog.getByLabel('授权出借人').locator('option').evaluateAll((os) => os.map((o) => o.value).join(',')); // The original loop from company-loan-employee-browser.cjs (before D1). async function oldLoop(page, dialog, targetId, timeout) { for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${targetId}"]`).count()); i++) { assert.ok(i < 20); await Promise.all([page.waitForResponse((r) => r.url().includes('/loan-lenders?page='), { timeout }), dialog.getByRole('button', { name: '下一页出借人', exact: true }).click({ timeout })]); } } // Wait for the rendered options to change after each page step (works for // network responses and cache hits alike). async function stepTo(dialog, direction) { const before = await optionValues(dialog); const button = dialog.getByRole('button', { name: direction === 'next' ? '下一页出借人' : '上一页出借人', exact: true }); await expect(button).toBeEnabled({ timeout: 10000 }); await button.click(); // The list empties while a page loads; only a loaded, different page counts. await expect.poll(async () => { const now = await optionValues(dialog); return now.split(',').length > 1 && now !== before; }, { timeout: 15000 }).toBe(true); } async function newLoop(dialog, targetId) { const target = dialog.getByLabel('授权出借人').locator(`option[value="${targetId}"]`); while (await dialog.getByRole('button', { name: '上一页出借人', exact: true }).isEnabled()) await stepTo(dialog, 'previous'); for (let i = 0; !(await target.count()); i++) { assert.ok(i < 20, 'target lender not found on any page'); await stepTo(dialog, 'next'); } } module.exports = async function paginationDiagnostic(h) { const { app, prisma, call, finance, manager, password, config, verifiedLender, confirmedLoan } = h; const checks = []; // Fillers are eligible for the dialog (verified, linked to their own employee), // so the target is reached only by real paging, with or without a server filter. for (let n = 0; n < 64; n++) { const staff = await h.actor(['employee']); await verifiedLender(`合成翻页-${String(n).padStart(2, '0')}`, staff.id, '5'); } const target = await verifiedLender('合成翻页-目标', manager.id, '3'); const loan = await confirmedLoan(target.id, '100.00', '合成翻页目标借款'); // Explicit exclusions prove that filtering is applied before pagination and // count, including the current finance actor and an unverified linked lender. const own = await verifiedLender('合成翻页-本人排除', finance.id, '4'); const unverified = await call(finance, 'POST', '/loan-lenders', { legalEntityId: h.company.id, name: '合成翻页-未核实排除', bankInstitutionId: h.bank.id, bankName: '合成', bankAccount: '9'.repeat(16), accountName: '合成翻页-未核实排除', requestKey: randomUUID() }); // The dialog pages the server-filtered list of lenders it can offer. const pages = []; for (let p = 1; ; p++) { const r = await call(finance, 'GET', `/loan-lenders?page=${p}&pageSize=30&eligible=related_authority`); assert.ok(r.items.every((i) => i.identityStatus === 'verified' && i.employeeId && i.employeeId !== finance.id), 'filter returns only offerable lenders'); pages.push(r.items.map((i) => i.id)); if (p * 30 >= r.total) break; } const all = await call(finance, 'GET', '/loan-lenders?page=1&pageSize=30'); await call(finance, 'GET', '/loan-lenders?page=1&pageSize=30&eligible=other', undefined, 400); for (const who of [manager, h.ordinary]) await call(who, 'GET', '/loan-lenders?eligible=related_authority', undefined, 403); assert.ok(!pages.flat().includes(own.id) && !pages.flat().includes(unverified.id)); assert.equal(pages.flat().length, 65, 'eligible total excludes unlinked, unverified and self lenders'); checks.push(`eligible_filter_total_${pages.flat().length}_of_${all.total}`); const targetPage = pages.findIndex((ids) => ids.includes(target.id)) + 1; assert.ok(targetPage >= 3, `target lender must be on page 3 or later, observed ${targetPage}`); checks.push(`target_on_page_${targetPage}_of_${pages.length}`); const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] }); const origin = await app.listen({ host: '127.0.0.1', port: 0 }); const base = path.join(root, 'artifacts/qa-flow/loan-browser-diagnostics-20261010/authorization-pagination'); mkdirSync(base, { recursive: true }); const output = mkdtempSync(path.join(base, 'run-')); const oldOrigin = config.appOrigin; config.appOrigin = origin; const results = []; const record = (entry) => { results.push(entry); console.log(JSON.stringify({ stage: 'loan_authorization_pagination_step', ...entry })); }; try { for (const mode of ['desktop', 'mobile']) { const context = await browser.newContext({ viewport: mode === 'mobile' ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); await context.route('**/*', (route) => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); await context.tracing.start({ screenshots: true, snapshots: true }); const page = await context.newPage(); const requests = []; page.on('request', (r) => { if (r.url().includes('/loan-lenders?page=')) requests.push(new URL(r.url()).searchParams.get('page')); }); try { await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mode === 'mobile' ? '/mobile' : ''}/company-loans`)}`); 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(); const cdp = await context.newCDPSession(page); // 0. Counterexample before any paging (only page 1 cached): page 2 is answered // with page 1's data, so the list never changes and the new wait must fail. let dialog = await authorizationDialog(page); await page.route('**/api/loan-lenders?page=*', (route) => { const url = new URL(route.request().url()); url.searchParams.set('page', '1'); return route.continue({ url: url.toString() }); }); await stepTo(dialog, 'next').then(() => { throw new Error('a page that never changes must not satisfy the wait'); }, (error) => { assert.ok(/toBe|Timeout|expect/i.test(String(error.message)), String(error.message).slice(0, 200)); }); await page.unroute('**/api/loan-lenders?page=*'); record({ mode, step: 'unchanged_page_counterexample', outcome: 'new wait fails as required' }); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); await page.reload(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible({ timeout: 30000 }); requests.length = 0; // 1. Old loop under a loaded host: does it reproduce the timeout? await cdp.send('Emulation.setCPUThrottlingRate', { rate: 20 }); dialog = await authorizationDialog(page); let oldOutcome = 'pass'; try { await oldLoop(page, dialog, target.id, 8000); } catch (error) { oldOutcome = error.name === 'TimeoutError' ? 'timeout' : `error:${error.name}`; } const landed = await dialog.getByLabel('授权出借人').locator(`option[value="${target.id}"]`).count(); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); record({ mode, step: 'old_loop_throttled', outcome: oldOutcome, targetVisibleAfter: landed > 0, requests: [...requests] }); // 2. Reopen: which lender page does the dialog start on? requests.length = 0; dialog = await authorizationDialog(page); const firstPage = (await optionValues(dialog)).split(',').filter(Boolean); const startsOnPage = pages.findIndex((ids) => ids.some((id) => firstPage.includes(id))) + 1; record({ mode, step: 'reopen_start_page', startsOnPage }); assert.equal(startsOnPage, 1, 'reopening the dialog starts from the first lender page'); assert.deepEqual(firstPage, pages[0], 'reopening renders precisely the first page'); // 3. New loop, throttled, including returning to page 1 first. await newLoop(dialog, target.id); assert.equal(await dialog.getByLabel('授权出借人').locator(`option[value="${target.id}"]`).count(), 1); assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 1]); // 4. Cache: step back and forward again; the forward step may be served from cache. const beforeCache = requests.length; await stepTo(dialog, 'previous'); assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 2]); await stepTo(dialog, 'next'); assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 1]); record({ mode, step: 'new_loop_throttled_with_cache', outcome: 'pass', requestsDuringBackForward: requests.length - beforeCache }); await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }); // The third-page target must be usable, not merely visible. Finance // proposes this scope and its designated representative confirms it. await dialog.getByLabel('授权出借人').selectOption(target.id); await dialog.getByRole('checkbox', { name: new RegExp(loan.loanNo) }).check(); await dialog.getByLabel('代表权及授权范围依据').fill('合成第三页目标公司代表权及借款范围'); const proposed = page.waitForResponse(r => new URL(r.url()).pathname === '/api/loan-related-authorizations' && r.request().method() === 'POST'); await dialog.getByRole('button', { name: '确认', exact: true }).click(); const proposalResponse = await proposed; assert.equal(proposalResponse.status(), 200); const authorizationId = (await proposalResponse.json()).data.id; await expect(dialog).not.toBeVisible(); const representative = await browser.newContext({ viewport: mode === 'mobile' ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); try { await representative.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const rp = await representative.newPage(); await rp.goto(`${origin}/login?redirect=${encodeURIComponent(`${mode === 'mobile' ? '/mobile' : ''}/company-loans`)}`); await rp.getByTestId('login-identifier').fill(manager.email); await rp.getByTestId('login-password').fill(password); await rp.getByTestId('login-submit').click(); await expect(rp.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); await rp.getByRole('button', { name: '代表公司确认授权范围', exact: true }).click(); const rd = rp.getByRole('dialog'); await expect(rd).toContainText(loan.loanNo); await rd.getByLabel('确认或停止的理由').fill('合成本人核对第三页借款及公司代表范围'); const confirmed = rp.waitForResponse(r => new URL(r.url()).pathname === `/api/loan-related-authorizations/${authorizationId}/actions` && r.request().method() === 'POST'); await rd.getByRole('button', { name: '确认', exact: true }).click(); assert.equal((await confirmed).status(), 200); await expect(rd).not.toBeVisible(); } finally { await representative.close(); } const saved = await prisma.loanRelatedAuthorization.findUniqueOrThrow({ where: { id: authorizationId }, include: { events: true, loans: true } }); assert.equal(saved.approver_id, manager.id); assert.equal(saved.proposed_by, finance.id); assert.deepEqual(saved.loans.map(r => r.loan_id), [loan.id]); assert.ok(saved.events.some(r => r.action === 'confirm' && r.actor_id === manager.id)); assert.ok(await prisma.auditLog.count({ where: { action: 'LOAN_RELATED_AUTHORIZATION_DECIDED', target_id: authorizationId } })); assert.equal(await prisma.loanRepayment.count({ where: { lender_id: target.id } }), 0); record({ mode, step: 'third_page_scope_proposed_and_confirmed', outcome: 'pass', zeroRepayments: true }); checks.push(`${mode}_pagination_diagnosed`); } finally { await context.tracing.stop({ path: path.join(output, `trace-${mode}.zip`) }); await page.screenshot({ path: path.join(output, `final-${mode}.png`), fullPage: true }).catch(() => {}); await context.close(); } } } finally { config.appOrigin = oldOrigin; await browser.close(); } assert.ok(loan.id); console.log(JSON.stringify({ stage: 'loan_authorization_pagination_diagnostic', status: 'PASS', checks, results, output: path.relative(root, output) })); return results; };