193 行
14 KiB
JavaScript
193 行
14 KiB
JavaScript
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;
|
|
};
|