Commit 2d26844c authored by liguangyu06's avatar liguangyu06
Browse files

同步基础管理/报表用例与登录态稳定性修复

修复 auth 写入锁 stale PID 导致 setup 空等超时;主辅账号 setup 串行并延长超时,login 依赖 setup。同步用户/厂商/准入等 CSV·MD·spec·PO,移除 stat 调试 spec,补充离线全量脚本与 gate-stamp 目录哈希修复。
parent 45178f79
import { test } from '../../fixtures'; import { test } from '../../fixtures';
import { standardProductNameDateSyncFields } from '../../data/dept-report-field-defs';
import { import {
deptReportSheetComboAssertKeys, deptReportSheetComboAssertKeys,
deptReportSheetComboDrawerFields, deptReportSheetComboDrawerFields,
...@@ -55,6 +56,21 @@ test.describe('科室出入库明细查询 · 高级筛选', () => { ...@@ -55,6 +56,21 @@ test.describe('科室出入库明细查询 · 高级筛选', () => {
); );
await deptReportSheetPage.expectRowContainsSampleFields(sample, deptReportSheetComboAssertKeys); await deptReportSheetPage.expectRowContainsSampleFields(sample, deptReportSheetComboAssertKeys);
}); });
test('STAT-DOD-005 快捷区条件自动带入高级筛选', async ({ deptReportSheetPage }) => {
const sample = await deptReportSheetPage.searchAndExtractSample();
const quickFields = deptReportSheetComboQuickFields.filter(
(field) => field.key === 'productName' || field.key === 'accountDate',
);
await deptReportSheetPage.applyQuickFields(sample, quickFields, { onlyIfPresent: true });
await deptReportSheetPage.search();
await deptReportSheetPage.openAdvancedFilter();
await deptReportSheetPage.expectAdvancedDrawerSyncedFromQuick(
sample,
quickFields,
standardProductNameDateSyncFields,
);
});
}); });
test.describe('科室出入库明细查询 · 列表与汇总', () => { test.describe('科室出入库明细查询 · 列表与汇总', () => {
...@@ -72,6 +88,11 @@ test.describe('科室出入库明细查询 · 列表与汇总', () => { ...@@ -72,6 +88,11 @@ test.describe('科室出入库明细查询 · 列表与汇总', () => {
await deptReportSheetPage.searchWithMockFromConfig(); await deptReportSheetPage.searchWithMockFromConfig();
await deptReportSheetPage.expectEmptyList(); await deptReportSheetPage.expectEmptyList();
}); });
test('STAT-DOD-008 列表横向拖拉滚动与列对齐正常', async ({ deptReportSheetPage }) => {
await deptReportSheetPage.searchWithDefaultAccountDateRange();
await deptReportSheetPage.expectTableHorizontalScrollAndLayout();
});
}); });
test.describe('科室出入库明细查询 · 列设置', () => { test.describe('科室出入库明细查询 · 列设置', () => {
......
...@@ -63,9 +63,10 @@ test.describe('基础设置 · 流程设置', () => { ...@@ -63,9 +63,10 @@ test.describe('基础设置 · 流程设置', () => {
const before = await flowDefPage.defaultFlowText(); const before = await flowDefPage.defaultFlowText();
const switched = await flowDefPage.switchOtherType(); const switched = await flowDefPage.switchOtherType();
expect(switched, '应能切换类型').toBeTruthy(); expect(switched, '应能切换类型').toBeTruthy();
expect(before.length, '切换前应读到默认审批流').toBeGreaterThan(0);
expect(await flowDefPage.selectedTypeName(), '当前类型应为切换目标').toBe(switched);
const after = await flowDefPage.defaultFlowText(); const after = await flowDefPage.defaultFlowText();
expect(after.length, '右侧已刷新').toBeGreaterThan(0); expect(after.length, '右侧已刷新').toBeGreaterThan(0);
expect(before.length >= 0, '记录过上一类型').toBeTruthy();
}); });
test('FLOW-DEF-007 无匹配类型(mock反例)', async ({ flowDefPage }) => { test('FLOW-DEF-007 无匹配类型(mock反例)', async ({ flowDefPage }) => {
......
import { test } from '../../fixtures';
import {
reagEntryComboAssertKeys,
reagEntryComboFullQuickFields,
} from '../../data/reag-entry.data';
/**
* STAT-RBE-002 全量快捷区组合查询(调试/演示专用)
*
* 运行示例:
* SLOW_MO=500 npx playwright test tests/spd/reag-entry.stat-rbe-002.spec.ts \
* --headed --project=chromium --workers=1
*/
test.describe('试剂批号统计明细 · STAT-RBE-002', () => {
test('STAT-RBE-002 全量快捷区组合查询', async ({ reagEntryPage, page }) => {
await reagEntryPage.open();
await reagEntryPage.prepareFixedStatDateQuery();
await reagEntryPage.searchWithQuickComboVerified(
reagEntryComboFullQuickFields,
reagEntryComboAssertKeys,
);
await page.pause();
});
});
import { test } from "../../fixtures";
import {
traceBarcodeComboAssertKeys,
traceBarcodeComboDrawerFields,
traceBarcodeComboQuickFields,
} from "../../data/trace-barcode.data";
/**
* STAT-TBC-004 全量查询条件组合查询(调试/演示专用)
*
* 运行示例:
* SLOW_MO=500 npx playwright test tests/spd/trace-barcode.stat-tbc-004.spec.ts \
* --headed --project=chromium --workers=1
*
* 或:npm run test:stat-tbc-004
*/
test.describe("条码追溯 · STAT-TBC-004", () => {
test("STAT-TBC-004 全量查询条件组合查询", async ({
traceBarcodePage,
page,
}) => {
await traceBarcodePage.open();
const sample = await traceBarcodePage.searchWithQuickAndDrawerCombo(
traceBarcodeComboQuickFields,
traceBarcodeComboDrawerFields,
);
await traceBarcodePage.expectQuickFieldsApplied(
sample,
traceBarcodeComboQuickFields,
);
await traceBarcodePage.expectRowContainsSampleFields(
sample,
traceBarcodeComboAssertKeys,
);
await traceBarcodePage.expectSummaryVisible();
await page.pause();
});
});
import { test } from "../../fixtures";
import {
traceDeptBuyComboAssertKeys,
traceDeptBuyComboDrawerFields,
traceDeptBuyComboQuickFields,
} from "../../data/trace-dept-buy.data";
/**
* STAT-TDB-004 全量查询条件组合查询(调试/演示专用)
*
* 运行示例:
* SLOW_MO=500 npx playwright test tests/spd/trace-dept-buy.stat-tdb-004.spec.ts \
* --headed --project=chromium --workers=1
*
* 或:npm run test:stat-tdb-004
*/
test.describe("科室请领单追溯 · STAT-TDB-004", () => {
test.describe.configure({ timeout: 180_000 });
test("STAT-TDB-004 全量查询条件组合查询", async ({
traceDeptBuyPage,
page,
}) => {
await traceDeptBuyPage.open();
const sample = await traceDeptBuyPage.searchWithQuickAndDrawerCombo(
traceDeptBuyComboQuickFields,
traceDeptBuyComboDrawerFields,
);
await traceDeptBuyPage.expectQuickFieldsApplied(
sample,
traceDeptBuyComboQuickFields,
);
await traceDeptBuyPage.expectRowContainsSampleFields(
sample,
traceDeptBuyComboAssertKeys,
);
await traceDeptBuyPage.expectSummaryVisible();
await page.pause();
});
});
import { test } from "../../fixtures";
import {
traceDirectBuyComboAssertKeys,
traceDirectBuyComboDrawerFields,
traceDirectBuyComboQuickFields,
} from "../../data/trace-direct-buy.data";
/**
* STAT-TBY-004 全量查询条件组合查询(调试/演示专用)
*
* 运行示例:
* SLOW_MO=500 npx playwright test tests/spd/trace-direct-buy.stat-tby-004.spec.ts \
* --headed --project=chromium --workers=1
*
* 或:npm run test:stat-tby-004
*/
test.describe("直配请领单追溯 · STAT-TBY-004", () => {
test.describe.configure({ timeout: 180_000 });
test("STAT-TBY-004 全量查询条件组合查询", async ({
traceDirectBuyPage,
page,
}) => {
await traceDirectBuyPage.open();
const sample = await traceDirectBuyPage.searchWithQuickAndDrawerCombo(
traceDirectBuyComboQuickFields,
traceDirectBuyComboDrawerFields,
);
await traceDirectBuyPage.expectQuickFieldsApplied(
sample,
traceDirectBuyComboQuickFields,
);
await traceDirectBuyPage.expectRowContainsSampleFields(
sample,
traceDirectBuyComboAssertKeys,
);
await traceDirectBuyPage.expectSummaryVisible();
await page.pause();
});
});
import { test } from "../../fixtures";
import {
traceDirectDetailComboAssertKeys,
traceDirectDetailComboDrawerFields,
traceDirectDetailComboQuickFields,
} from "../../data/trace-direct-detail.data";
/**
* STAT-TDD-004 全量查询条件组合查询(调试/演示专用)
*
* 运行示例:
* SLOW_MO=500 npx playwright test tests/spd/trace-direct-detail.stat-tdd-004.spec.ts \
* --headed --project=chromium --workers=1
*/
test.describe("直配请领明细查询 · STAT-TDD-004", () => {
test("STAT-TDD-004 全量查询条件组合查询", async ({
traceDirectDetailPage,
page,
}) => {
await traceDirectDetailPage.open();
const sample = await traceDirectDetailPage.searchWithQuickAndDrawerCombo(
traceDirectDetailComboQuickFields,
traceDirectDetailComboDrawerFields,
);
await traceDirectDetailPage.expectQuickFieldsApplied(
sample,
traceDirectDetailComboQuickFields,
);
await traceDirectDetailPage.expectRowContainsSampleFields(
sample,
traceDirectDetailComboAssertKeys,
);
await traceDirectDetailPage.expectSummaryVisible();
await page.pause();
});
});
...@@ -79,7 +79,7 @@ test.describe('基础设置 · 厂商管理', () => { ...@@ -79,7 +79,7 @@ test.describe('基础设置 · 厂商管理', () => {
await vendorQueryPage.switchRegistrant(); await vendorQueryPage.switchRegistrant();
await vendorQueryPage.goToPage2(); await vendorQueryPage.goToPage2();
const state = await vendorQueryPage.snapshotList(); const state = await vendorQueryPage.snapshotList();
expect(state.rows, '第 2 页应可加载').toBeGreaterThanOrEqual(0); expect(state.rows, '第 2 页应有数据').toBeGreaterThan(0);
}); });
test('VENDOR-QRY-009 仅空格关键字搜索', async ({ vendorQueryPage }) => { test('VENDOR-QRY-009 仅空格关键字搜索', async ({ vendorQueryPage }) => {
......
...@@ -159,10 +159,10 @@ test.describe('基础设置 · 库房管理', () => { ...@@ -159,10 +159,10 @@ test.describe('基础设置 · 库房管理', () => {
test('WH-PRB-014 弹窗分页独立于主列表', async ({ warehouseProbePage }) => { test('WH-PRB-014 弹窗分页独立于主列表', async ({ warehouseProbePage }) => {
await warehouseProbePage.open(); await warehouseProbePage.open();
await warehouseProbePage.query(); await warehouseProbePage.query();
const mainTotal = await warehouseProbePage.getTotalCount();
const modal = await warehouseProbePage.openBindModal(); const modal = await warehouseProbePage.openBindModal();
const modalTotal = await warehouseProbePage.modalTotal(modal); const modalTotalText = modal.locator('.ant-pagination-total-text').first();
expect(modalTotal === mainTotal || modalTotal >= 0, '弹窗总条数独立于主列表').toBeTruthy(); await expect(modalTotalText, '弹窗应有独立于主列表的分页总数').toBeVisible();
await expect(modalTotalText).toHaveText(/共\s*\d+\s*条/);
await warehouseProbePage.cancelModal(modal); await warehouseProbePage.cancelModal(modal);
}); });
}); });
...@@ -177,7 +177,7 @@ test.describe('基础设置 · 库房管理', () => { ...@@ -177,7 +177,7 @@ test.describe('基础设置 · 库房管理', () => {
const rows = await warehouseProbePage.modalRows(modal); const rows = await warehouseProbePage.modalRows(modal);
if ((await rows.count()) > 0 && wh) { if ((await rows.count()) > 0 && wh) {
const text = ((await rows.first().innerText()) ?? '').replace(/\s/g, ''); const text = ((await rows.first().innerText()) ?? '').replace(/\s/g, '');
expect(text.includes(wh.replace(/\s/g, '')) || text.length >= 0, '可见行对应当前库房').toBeTruthy(); expect(text, '可见行对应当前库房').toContain(wh.replace(/\s/g, ''));
} }
await warehouseProbePage.cancelModal(modal); await warehouseProbePage.cancelModal(modal);
}); });
......
...@@ -68,10 +68,11 @@ test.describe('基础设置 · 库房管理', () => { ...@@ -68,10 +68,11 @@ test.describe('基础设置 · 库房管理', () => {
const firstNo = (await rows.count()) > 0 ? await warehouseZonePage.tryCell(rows.first(), '库区编号') : ''; const firstNo = (await rows.count()) > 0 ? await warehouseZonePage.tryCell(rows.first(), '库区编号') : '';
const switched = await warehouseZonePage.switchOtherWarehouse(); const switched = await warehouseZonePage.switchOtherWarehouse();
expect(switched, '应能切换到另一库房').toBeTruthy(); expect(switched, '应能切换到另一库房').toBeTruthy();
expect(await warehouseZonePage.selectedWarehouse(), '当前库房应为切换目标').toBe(switched);
await assertListOrEmpty(await warehouseZonePage.snapshotList(), '切换后库区'); await assertListOrEmpty(await warehouseZonePage.snapshotList(), '切换后库区');
if (firstNo && (await warehouseZonePage.getDataRowCount()) > 0) { if (firstNo && (await warehouseZonePage.getDataRowCount()) > 0) {
const nextNo = await warehouseZonePage.tryCell(await warehouseZonePage.firstRow(), '库区编号'); const nextNo = await warehouseZonePage.tryCell(await warehouseZonePage.firstRow(), '库区编号');
expect(nextNo === firstNo || nextNo.length >= 0, '右侧列表已刷新').toBeTruthy(); expect(nextNo.length, '切换后首行应有库区编号').toBeGreaterThan(0);
} }
}); });
...@@ -181,7 +182,13 @@ test.describe('基础设置 · 库房管理', () => { ...@@ -181,7 +182,13 @@ test.describe('基础设置 · 库房管理', () => {
expect(await warehouseZonePage.dialogTitle(), '标题').toContain('编辑库区信息'); expect(await warehouseZonePage.dialogTitle(), '标题').toContain('编辑库区信息');
const filled = await warehouseZonePage.readEditValues(); const filled = await warehouseZonePage.readEditValues();
expect(filled.zoneNo.replace(/\s/g, ''), '编号回填').toBe(sample.zoneNo.replace(/\s/g, '')); expect(filled.zoneNo.replace(/\s/g, ''), '编号回填').toBe(sample.zoneNo.replace(/\s/g, ''));
expect(filled.zoneName.replace(/\s/g, '').includes(sample.zoneName.replace(/\s/g, '').replace(/默认库区/g, '')) || filled.zoneName.length > 0, '名称回填').toBeTruthy(); const expectedName = sample.zoneName.replace(/\s/g, '').replace(/默认库区/g, '');
const actualName = filled.zoneName.replace(/\s/g, '');
if (expectedName) {
expect(actualName, '名称回填').toContain(expectedName);
} else {
expect(actualName, '名称回填').toBe(sample.zoneName.replace(/\s/g, ''));
}
await warehouseZonePage.cancelDialog(); await warehouseZonePage.cancelDialog();
}); });
...@@ -216,7 +223,7 @@ test.describe('基础设置 · 库房管理', () => { ...@@ -216,7 +223,7 @@ test.describe('基础设置 · 库房管理', () => {
const selected = await warehouseZonePage.selectedWarehouse(); const selected = await warehouseZonePage.selectedWarehouse();
const panel = await warehouseZonePage.openRowPanel('人员管理'); const panel = await warehouseZonePage.openRowPanel('人员管理');
expect(panel.opened, '人员管理应打开').toBeTruthy(); expect(panel.opened, '人员管理应打开').toBeTruthy();
expect(panel.title.includes('人员') || panel.title.length >= 0, '标题含人员管理或当前库区').toBeTruthy(); expect(panel.title, '标题含人员或当前库区').toMatch(/人员|库区/);
await warehouseZonePage.closeOverlay(); await warehouseZonePage.closeOverlay();
expect(await warehouseZonePage.stillOnZonePage(), '应仍在库区设置').toBeTruthy(); expect(await warehouseZonePage.stillOnZonePage(), '应仍在库区设置').toBeTruthy();
expect(await warehouseZonePage.selectedWarehouse(), '库房选中保持').toBe(selected); expect(await warehouseZonePage.selectedWarehouse(), '库房选中保持').toBe(selected);
...@@ -229,7 +236,7 @@ test.describe('基础设置 · 库房管理', () => { ...@@ -229,7 +236,7 @@ test.describe('基础设置 · 库房管理', () => {
const selected = await warehouseZonePage.selectedWarehouse(); const selected = await warehouseZonePage.selectedWarehouse();
const panel = await warehouseZonePage.openRowPanel('货位管理'); const panel = await warehouseZonePage.openRowPanel('货位管理');
expect(panel.opened, '货位管理应打开').toBeTruthy(); expect(panel.opened, '货位管理应打开').toBeTruthy();
expect(panel.title.includes('货位') || panel.title.length >= 0, '标题含货位').toBeTruthy(); expect(panel.title, '标题含货位或当前库区').toMatch(/货位|库区/);
await warehouseZonePage.closeOverlay(); await warehouseZonePage.closeOverlay();
expect(await warehouseZonePage.stillOnZonePage(), '应仍在库区设置').toBeTruthy(); expect(await warehouseZonePage.stillOnZonePage(), '应仍在库区设置').toBeTruthy();
expect(await warehouseZonePage.selectedWarehouse(), '库房选中保持').toBe(selected); expect(await warehouseZonePage.selectedWarehouse(), '库房选中保持').toBe(selected);
......
import {
credentials,
credentialsSecondary,
hasSecondaryCredentials,
type Credentials,
} from '../config/credentials.config';
import { AUTH_STATE_PATH, AUTH_STATE_PATH_SECONDARY } from './path.util';
export type AuthAccountSlot = 'primary' | 'secondary';
export function authPoolSize(): number {
return hasSecondaryCredentials() ? 2 : 1;
}
export function storageStatePathForSlot(slot: AuthAccountSlot): string {
return slot === 'primary' ? AUTH_STATE_PATH : AUTH_STATE_PATH_SECONDARY;
}
export function credentialsForSlot(slot: AuthAccountSlot): Credentials {
return slot === 'primary' ? credentials : credentialsSecondary;
}
/** 按 parallel worker 序号轮换账号,降低同账号互踢会话概率。 */
export function slotForParallelIndex(parallelIndex: number): AuthAccountSlot {
if (!hasSecondaryCredentials()) {
return 'primary';
}
return parallelIndex % 2 === 0 ? 'primary' : 'secondary';
}
export function resolveAuthStorageStatePath(parallelIndex = 0): string {
return storageStatePathForSlot(slotForParallelIndex(parallelIndex));
}
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import type { Browser, Page } from '@playwright/test'; import type { Browser, Page } from '@playwright/test';
import { credentials, hasCredentials } from '../config/credentials.config'; import { hasCredentials, hasSecondaryCredentials } from '../config/credentials.config';
import { env } from '../config/env.config'; import { env } from '../config/env.config';
import { logger } from '../core/logger'; import { logger } from '../core/logger';
import { LoginPage } from '../pages/auth/login.page'; import { LoginPage } from '../pages/auth/login.page';
...@@ -14,6 +14,11 @@ import { ...@@ -14,6 +14,11 @@ import {
probeAuthenticatedSession, probeAuthenticatedSession,
shouldForceAuthLogin, shouldForceAuthLogin,
} from './auth-state.util'; } from './auth-state.util';
import {
type AuthAccountSlot,
credentialsForSlot,
storageStatePathForSlot,
} from './auth-pool.util';
import { AUTH_STATE_PATH } from './path.util'; import { AUTH_STATE_PATH } from './path.util';
export type AuthEnsureResult = 'reused-fresh' | 'reused-probed' | 'logged-in'; export type AuthEnsureResult = 'reused-fresh' | 'reused-probed' | 'logged-in';
...@@ -31,38 +36,76 @@ export async function isPageOnLogin(page: Page): Promise<boolean> { ...@@ -31,38 +36,76 @@ export async function isPageOnLogin(page: Page): Promise<boolean> {
} }
} }
const AUTH_LOCK_PATH = `${AUTH_STATE_PATH}.lock`; function isStaleAuthLock(lockPath: string): boolean {
if (!existsSync(lockPath)) return false;
try {
const raw = readFileSync(lockPath, 'utf8').trim();
const owner = Number(raw);
if (!Number.isFinite(owner) || owner <= 0) return true;
try {
process.kill(owner, 0);
return false;
} catch {
return true;
}
} catch {
return true;
}
}
async function withAuthWriteLock<T>(fn: () => Promise<T>): Promise<T> { async function withAuthWriteLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
const started = Date.now(); const started = Date.now();
while (existsSync(AUTH_LOCK_PATH)) { while (existsSync(lockPath)) {
if (Date.now() - started > 60_000) break; if (isStaleAuthLock(lockPath)) {
try {
unlinkSync(lockPath);
} catch {
/* ignore */
}
break;
}
if (Date.now() - started > 60_000) {
logger.step(`登录态锁等待超时,尝试清理 stale lock: ${lockPath}`);
try {
unlinkSync(lockPath);
} catch {
/* ignore */
}
break;
}
await new Promise((resolve) => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
} }
mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true }); mkdirSync(path.dirname(lockPath), { recursive: true });
writeFileSync(AUTH_LOCK_PATH, String(process.pid)); writeFileSync(lockPath, String(process.pid));
try { try {
return await fn(); return await fn();
} finally { } finally {
try { try {
unlinkSync(AUTH_LOCK_PATH); unlinkSync(lockPath);
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
} }
async function loginAndSaveStorage(browser: Browser): Promise<void> { async function loginAndSaveStorage(browser: Browser, slot: AuthAccountSlot): Promise<void> {
if (!hasCredentials()) { const storageStatePath = storageStatePathForSlot(slot);
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'); const creds = credentialsForSlot(slot);
if (!creds.username || !creds.password) {
throw new Error(
slot === 'primary'
? '未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'
: '未配置 TEST_USER_2/TEST_PASS_2',
);
} }
await withAuthWriteLock(async () => { const lockPath = `${storageStatePath}.lock`;
const cached = loadStorageState(AUTH_STATE_PATH); await withAuthWriteLock(lockPath, async () => {
const cached = loadStorageState(storageStatePath);
if (cached && isStorageStateTokenFresh(cached) && !shouldForceAuthLogin()) { if (cached && isStorageStateTokenFresh(cached) && !shouldForceAuthLogin()) {
logger.step('登录态写入锁等待期间已被其他进程刷新,跳过重复登录'); logger.step(`登录态写入锁等待期间已被其他进程刷新(${slot}),跳过重复登录`);
return; return;
} }
mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true }); mkdirSync(path.dirname(storageStatePath), { recursive: true });
const context = await browser.newContext({ const context = await browser.newContext({
baseURL: env.baseURL, baseURL: env.baseURL,
storageState: { cookies: [], origins: [] }, storageState: { cookies: [], origins: [] },
...@@ -70,53 +113,66 @@ async function loginAndSaveStorage(browser: Browser): Promise<void> { ...@@ -70,53 +113,66 @@ async function loginAndSaveStorage(browser: Browser): Promise<void> {
const page = await context.newPage(); const page = await context.newPage();
try { try {
const loginPage = new LoginPage(page); const loginPage = new LoginPage(page);
await loginPage.login(credentials.username, credentials.password); await loginPage.login(creds.username, creds.password);
await context.storageState({ path: AUTH_STATE_PATH }); await context.storageState({ path: storageStatePath });
logger.step(`已写入登录态: ${AUTH_STATE_PATH}`); logger.step(`已写入登录态 (${slot}): ${storageStatePath}`);
} finally { } finally {
await context.close(); await context.close();
} }
}); });
} }
export type EnsureAuthOptions = {
honorForce?: boolean;
slot?: AuthAccountSlot;
};
/** /**
* 会话级登录态:文件有效则复用;过期/被踢则重新登录。 * 会话级登录态:文件有效则复用;过期/被踢则重新登录。
* FORCE_AUTH_LOGIN=1 只应由 auth.setup 传入 honorForce,避免每个 describe 再登一次。 * FORCE_AUTH_LOGIN=1 只应由 auth.setup 传入 honorForce,避免每个 describe 再登一次。
*/ */
export async function ensureAuthStorageFresh( export async function ensureAuthStorageFresh(
browser: Browser, browser: Browser,
options?: { honorForce?: boolean }, options?: EnsureAuthOptions,
): Promise<AuthEnsureResult> { ): Promise<AuthEnsureResult> {
mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true }); const slot = options?.slot ?? 'primary';
if (slot === 'secondary' && !hasSecondaryCredentials()) {
if (!hasCredentials()) { throw new Error('未配置 TEST_USER_2/TEST_PASS_2');
}
if (slot === 'primary' && !hasCredentials()) {
throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号'); throw new Error('未配置 TEST_USER/TEST_PASS,请复制 .env.example 为 .env 后填写测试账号');
} }
const storageStatePath = storageStatePathForSlot(slot);
mkdirSync(path.dirname(storageStatePath), { recursive: true });
const forceLogin = options?.honorForce !== false && shouldForceAuthLogin(); const forceLogin = options?.honorForce !== false && shouldForceAuthLogin();
const cachedState = loadStorageState(AUTH_STATE_PATH); const cachedState = loadStorageState(storageStatePath);
if (!forceLogin && cachedState && isStorageStateTokenValid(cachedState)) { if (!forceLogin && cachedState && isStorageStateTokenValid(cachedState)) {
if (isStorageStateTokenFresh(cachedState)) { if (isStorageStateTokenFresh(cachedState)) {
logger.step(`复用新鲜登录态(跳过探活): ${AUTH_STATE_PATH}`); logger.step(`复用新鲜登录态(跳过探活)(${slot}): ${storageStatePath}`);
return 'reused-fresh'; return 'reused-fresh';
} }
const sessionAlive = await probeAuthenticatedSession(browser, { const sessionAlive = await probeAuthenticatedSession(browser, {
baseURL: env.baseURL, baseURL: env.baseURL,
homePath: env.homePath, homePath: env.homePath,
storageStatePath: AUTH_STATE_PATH, storageStatePath,
}); });
if (sessionAlive) { if (sessionAlive) {
logger.step(`复用有效登录态(token 未过期): ${AUTH_STATE_PATH}`); logger.step(`复用有效登录态(token 未过期)(${slot}): ${storageStatePath}`);
return 'reused-probed'; return 'reused-probed';
} }
logger.step('本地 token 存在但服务端会话已失效,重新登录'); logger.step(`本地 token 存在但服务端会话已失效(${slot}),重新登录`);
} else if (forceLogin) { } else if (forceLogin) {
logger.step('FORCE_AUTH_LOGIN=1,强制重新登录'); logger.step(`FORCE_AUTH_LOGIN=1,强制重新登录(${slot})`);
} else { } else {
logger.step('无有效登录态缓存,执行登录'); logger.step(`无有效登录态缓存(${slot}),执行登录`);
} }
await loginAndSaveStorage(browser); await loginAndSaveStorage(browser, slot);
return 'logged-in'; return 'logged-in';
} }
/** 兼容旧引用:主账号 storage 路径 */
export { AUTH_STATE_PATH };
import path from 'node:path'; import path from 'node:path';
/** 主账号(TEST_USER)登录态 */
export const AUTH_STATE_PATH = path.join(process.cwd(), 'auth', 'user.json'); export const AUTH_STATE_PATH = path.join(process.cwd(), 'auth', 'user.json');
/** 辅账号(TEST_USER_2,默认 playwright)登录态 */
export const AUTH_STATE_PATH_SECONDARY = path.join(process.cwd(), 'auth', 'user-playwright.json');
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment