import { chromium } from 'playwright' import assert from 'node:assert/strict' import { mkdir } from 'node:fs/promises' import { installCertFixture } from './mp-pl-plan-cert-fixture.mjs' const origin = process.env.PLAN_CERT_TEST_ORIGIN || 'http://localhost:3006' const artifacts = '/tmp/shanxia-plan-cert-e2e' await mkdir(artifacts, { recursive: true }) const browser = await chromium.launch({ headless: true, channel: 'chrome' }) const context = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true }) await context.addInitScript(() => { const start = () => { const root = document.documentElement if (!root) return const hide = () => { const el = document.getElementById('__vue-devtools-container__') if (el) el.style.setProperty('display', 'none', 'important') } hide() new MutationObserver(hide).observe(root, { childList: true, subtree: true }) } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start) else start() }) const fixture = await installCertFixture(context) const page = await context.newPage() const pageErrors = [] let releaseLinkedList page.on('pageerror', (error) => pageErrors.push(error.message)) page.setDefaultTimeout(12000) const button = (name) => page.getByRole('button', { name, exact: true }).filter({ visible: true }) const card = (id) => page.locator(`.cert-card[data-plan-id="plan-${id}"]`) const sheet = (title) => page.locator('.van-popup, .mp-sheet').filter({ has: page.getByText(title, { exact: true }), visible: true }) const noOverflow = async (scope, label) => assert.ok(await scope.evaluate((el) => el.scrollWidth <= el.clientWidth + 1), `${label} 横向溢出`) const assertCenteredSheetActions = async (scope, label) => { const actions = scope.locator('.mp-customer-filter-vant__foot') const styles = await actions.evaluate((el) => { const buttons = [...el.querySelectorAll('button')].map((button) => ({ height: button.getBoundingClientRect().height })) return { buttons } }) assert.equal(styles.buttons.length, 2, `${label}底栏必须保留重置和确定`) for (const style of styles.buttons) { assert.ok(style.height >= 30, `${label}按钮须与报价单筛选底栏同高`) } } const waitPatch = (suffix, action) => Promise.all([ page.waitForResponse((r) => r.request().method() === 'PATCH' && r.url().endsWith(suffix)), action() ]) const cleanScreenshot = async (name) => { await page.locator('.el-message').last().waitFor({ state: 'hidden' }) await page.screenshot({ path: `${artifacts}/${name}.png`, animations: 'disabled' }) } const latestList = () => fixture.calls.filter((c) => c.path === '/transport/plan/').at(-1) const quickDate = () => page.locator('.mp-customer-dd .van-dropdown-menu__item').nth(0) const quickReady = () => page.locator('.mp-customer-dd .van-dropdown-menu__item').nth(1) const quickMore = () => page.locator('.mp-customer-dd .van-dropdown-menu__item').nth(2) const quickOption = (label) => page.locator('.van-dropdown-item__option').filter({ hasText: label }).first() const waitList = (action) => Promise.all([ page.waitForResponse((r) => new URL(r.url()).pathname === '/api/transport/plan/'), action() ]) try { await page.goto(`${origin}/#/m/pl/plan-cert/plan-1?mpTicket=cert-fixture`) const directDetail = page.locator('.cert-detail-page') await directDetail.getByText('YS001', { exact: true }).waitFor() await page.reload() await directDetail.getByText('YS001', { exact: true }).waitFor() assert.equal( await directDetail.getByRole('heading', { name: '危化品证详情', exact: true }).count(), 1, '独立详情必须支持携带 mpTicket 直达和刷新' ) await page.goto(`${origin}/#/m/pl/plan-cert?mpTicket=cert-fixture`) const tabs = ['未上传证书', '全部', '入库单', '出库单', '直销单'] await page.getByRole('tab', { name: tabs[0], exact: true }).waitFor() const header = page.locator('.mp-wv-header') assert.equal(await header.count(), 1, '列表必须复用标准 mp-wv-header') assert.equal(await page.locator('.cert-header').count(), 0, '不得保留自定义 cert-header') assert.equal(await header.locator('.mp-wv-header__sub').count(), 0, '标准列表顶栏不得显示副标题') assert.equal( await header.locator('.mp-wv-search').count(), 1, '检索框必须放在绿顶栏内、状态 Tab 下方' ) assert.equal( await header.locator('.mp-customer-view-tabs').count(), 1, '状态 Tab 必须放在绿顶栏内' ) assert.equal(await page.locator('.cert-search-bar').count(), 0, '不得另做检索条') assert.equal( await header.locator('h1').evaluate((el) => getComputedStyle(el).fontSize), '17px', '页面标题字号必须与标准一致' ) assert.deepEqual(await page.getByRole('tab').allTextContents(), tabs) assert.deepEqual( (await page.locator('.mp-customer-dd .van-dropdown-menu__title').allTextContents()).map((text) => text.replace(/\s+/g, '') ), ['全部日期', '全部齐备状态', '更多'], '检索框下方必须紧跟日期、齐备状态、更多三栏筛选' ) const headerBox = await header.boundingBox() const tabsBox = await header.locator('.mp-customer-view-tabs').boundingBox() const searchBox = await header.locator('.mp-wv-search').boundingBox() const filterBox = await header.locator('.mp-customer-dd').boundingBox() assert.ok(tabsBox.y >= headerBox.y, '状态 Tab 必须落在绿顶栏区域内') assert.ok(tabsBox.y + tabsBox.height <= headerBox.y + headerBox.height + 1, '状态 Tab 不得溢出绿顶栏') assert.ok(tabsBox.y + tabsBox.height <= searchBox.y + 1, '状态 Tab 必须在检索框上方') assert.ok(searchBox.y < filterBox.y, '检索框必须在三栏筛选上方') assert.ok(searchBox.y + searchBox.height <= filterBox.y + 1, '检索框与三栏筛选必须紧贴') assert.ok(filterBox.y + filterBox.height <= headerBox.y + headerBox.height + 1, '三栏筛选必须落在绿顶栏内') assert.equal(Math.round(headerBox.x), 0, '绿顶栏必须横向铺满视口') assert.equal(Math.round(headerBox.width), 375, '绿顶栏不得叠加 Shell 左右留白') for (const [label, box] of [ ['检索框', searchBox], ['三栏筛选', filterBox] ]) { assert.ok(box.x >= headerBox.x, `${label}必须落在绿顶栏内`) assert.ok(box.x + box.width <= headerBox.x + headerBox.width + 1, `${label}不得溢出绿顶栏`) } assert.equal(await page.locator('.cert-shortcuts').count(), 0, '不得保留横向日期 Chip') assert.equal(await page.locator('.cert-tools').count(), 0, '不得保留筛选/刷新/重置工具条') await page.getByText('YS001', { exact: true }).first().waitFor() assert.ok(await card(1).evaluate((el) => el.classList.contains('expense-card'))) const listCardBox = await card(1).boundingBox() assert.ok(listCardBox.x >= 10, '列表卡片左右必须留白') assert.ok(listCardBox.width <= 355, '列表卡片不得铺满视口') const summary = page.locator('.cert-total').filter({ hasText: /共 \d+ 条计划/ }) assert.equal(await summary.count(), 1, '列表底部必须展示条数汇总') const summaryBox = await summary.boundingBox() assert.ok(summaryBox.y > listCardBox.y, '条数汇总必须在列表卡片之后') assert.equal(await card(1).locator('.ec-top').count(), 1) assert.equal(await card(1).locator('.list-card-kv').count(), 1) assert.equal(await card(1).locator('.ec-bottom').count(), 1) assert.equal(latestList().params.hasHazardProduct, '1') assert.equal(latestList().params.certUploadStatus, 'incomplete') assert.equal(latestList().params.hazardCertReady, '0') assert.ok(!latestList().params.followStatus.split(',').includes('cancelled')) const firstStatusTabBox = await page .getByRole('tab', { name: '未上传证书', exact: true }) .boundingBox() assert.ok(firstStatusTabBox.height >= 44, '状态 Tab 命中高度必须至少为 44px') assert.ok( await page.getByRole('tab', { name: '未上传证书', exact: true }).evaluate((el) => { const text = el.querySelector('.van-tab__text') || el const line = el.closest('.van-tabs')?.querySelector('.van-tabs__line') const textBox = text.getBoundingClientRect() const lineBox = line?.getBoundingClientRect() return ( getComputedStyle(text).whiteSpace === 'nowrap' && textBox.height < 28 && !!lineBox && lineBox.top >= textBox.bottom ) }), '未上传证书必须单行显示,下划线在文字下方' ) await quickReady().click() await waitList(() => quickOption('已齐备').click()) assert.equal( await page.getByRole('tab', { name: '全部', exact: true }).getAttribute('aria-selected'), 'true', '未上传 Tab 选择已齐备后必须切换到全部,避免展示条件与请求冲突' ) assert.equal(latestList().params.hazardCertReady, '1') await waitList(() => page.getByRole('tab', { name: '未上传证书', exact: true }).click()) assert.equal(await button('导出').count(), 0) for (const [label, type] of [ ['全部', undefined], ['入库单', 'inbound'], ['出库单', 'outbound'], ['直销单', 'direct'] ]) { await waitList(() => page.getByRole('tab', { name: label, exact: true }).click()) assert.equal(latestList().params.planType, type) assert.equal(latestList().params.hasHazardProduct, '1') assert.equal(latestList().params.certUploadStatus, undefined) } await waitList(() => page.getByRole('tab', { name: '全部', exact: true }).click()) await quickReady().click() await waitList(() => quickOption('未齐备').click()) assert.equal(latestList().params.hazardCertReady, '0') await quickReady().click() await waitList(() => quickOption('全部齐备状态').click()) assert.equal(latestList().params.hazardCertReady, undefined) for (const width of [375, 414]) { await page.setViewportSize({ width, height: 812 }) assert.ok( await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `列表 ${width}px 横向溢出` ) await page.screenshot({ path: `${artifacts}/list-${width}.png`, fullPage: true }) } await page.setViewportSize({ width: 375, height: 812 }) await page.getByRole('checkbox', { name: '选择 YS001', exact: true }).check() await quickDate().click() await waitList(() => quickOption('今天').click()) assert.equal(latestList().params.planDateFrom, new Date().toLocaleDateString('sv-SE')) assert.equal( await page.getByRole('checkbox', { name: '选择 YS001', exact: true }).isChecked(), false ) await page.getByRole('searchbox', { name: '搜索计划' }).fill('YS002') await waitList(() => page.getByRole('searchbox', { name: '搜索计划' }).press('Enter')) assert.equal(latestList().params.q, 'YS002') await card(2).waitFor() assert.equal(await page.locator('.cert-card').count(), 1) await page.getByRole('searchbox', { name: '搜索计划' }).fill('') await waitList(() => page.getByRole('searchbox', { name: '搜索计划' }).press('Enter')) // 同一筛选面板覆盖动态枚举、日期、数值、文本、合同侧别与排序。 await quickMore().click() const filter = sheet('更多筛选') assert.ok(await filter.locator('.filter-chip').count(), '更多筛选中的静态枚举必须使用 Chip') const field = (label) => filter.locator('.el-form-item').filter({ has: page.getByText(label, { exact: true }) }) await field('车牌号').locator('.el-select').click() await page.getByRole('option', { name: '鄂E12345', exact: true }).waitFor() const facetCall = fixture.calls.filter((c) => c.path.endsWith('column-facet-values')).at(-1) assert.equal(facetCall.body.planDateFrom, new Date().toLocaleDateString('sv-SE')) await field('车牌号').locator('.el-select').click() await field('计划日期').locator('input').nth(0).fill('2026-10-20') await field('计划日期').locator('input').nth(1).fill('2026-10-01') await button('确定').click() await page.getByText('计划日期开始不能晚于结束', { exact: true }).waitFor() assert.equal(await filter.isVisible(), true) await field('计划日期').locator('input').nth(0).fill('2026-10-01') await field('计划日期').locator('input').nth(1).fill('2026-10-31') await field('人员').locator('input').fill('测试司机') await filter.getByRole('spinbutton', { name: '计划量最小值' }).fill('5') await filter.getByRole('spinbutton', { name: '计划量最大值' }).fill('20') await field('装货客户').locator('.el-select').click() await page.getByRole('option', { name: '测试供应商', exact: true }).click() await field('装货客户').locator('.el-select').click() await field('合同').locator('.el-select').click() await page.getByRole('option', { name: '供', exact: true }).click() await page.getByRole('option', { name: 'HT-001', exact: true }).click() await field('合同').locator('.el-select').click() await field('排序字段').locator('.el-select').click() await page.getByRole('option', { name: '计划日期', exact: true }).click() assert.equal( await field('排序字段') .locator('input') .evaluate((el) => getComputedStyle(el).backgroundColor), 'rgba(0, 0, 0, 0)', 'select内部输入不得用白底遮住选中标签' ) for (const width of [375, 414]) { await page.setViewportSize({ width, height: 812 }) await noOverflow(filter.locator('.mp-customer-filter-vant__body'), `筛选${width}`) await cleanScreenshot(`filters-${width}`) } await assertCenteredSheetActions(filter, '筛选Sheet') await waitList(() => button('确定').click()) assert.equal(latestList().params.planDateFrom, '2026-10-01') assert.equal(latestList().params.personnelSummary, '测试司机') assert.equal(latestList().params.productPlanQtyMin, '5') assert.equal(latestList().params.productPlanQtyMax, '20') assert.equal(latestList().params.stopKindIn, 'load') assert.equal(latestList().params.contractNoIn, 'HT-001') assert.match(latestList().params.stopCustomers, /测试供应商/) assert.equal(latestList().params.sortField, 'planDate') await quickMore().click() await waitList(() => button('重置').click()) await waitList(() => page.getByRole('tab', { name: '未上传证书', exact: true }).click()) await page.setViewportSize({ width: 375, height: 812 }) await card(24).waitFor() assert.ok( fixture.calls.some( (call) => call.path === '/transport/plan/' && call.params.page === '2' && call.params.certUploadStatus === 'incomplete' && call.params.q == null ), '滚到底必须沿用当前已提交的查询请求下一页' ) assert.equal(await page.locator('.cert-card').count(), 22) assert.equal(await button('加载更多').count(), 0, '不得再使用加载更多按钮') // 请求乱序:慢入库响应不能覆盖后发的出库页。 let releaseList const listGate = new Promise((resolve) => { releaseList = resolve }) fixture.listDelay = (params) => (params.planType === 'inbound' ? listGate : undefined) await page.getByRole('tab', { name: '入库单', exact: true }).click() await waitList(() => page.getByRole('tab', { name: '出库单', exact: true }).click()) await card(2).waitFor() await Promise.all([ page.waitForResponse((r) => new URL(r.url()).searchParams.get('planType') === 'inbound'), Promise.resolve().then(() => releaseList()) ]) fixture.listDelay = null assert.equal(await card(1).count(), 0) await waitList(() => page.getByRole('tab', { name: '未上传证书', exact: true }).click()) // 逐项读取最新状态,批量报告成功/失败/跳过。 assert.equal( await card(4).getByRole('button', { name: '上传证书', exact: true }).isDisabled(), true ) for (const id of [1, 2, 4]) await page.getByRole('checkbox', { name: `选择 YS00${id}`, exact: true }).check() fixture.failReadyIds = ['plan-2'] await page.locator('.cert-batch').getByRole('button', { name: '证书齐备', exact: true }).click() await button('确认齐备').waitFor() await waitList(() => button('确认齐备').click()) await page.getByText('成功 1 条,失败 1 条,跳过 1 条', { exact: true }).waitFor() assert.equal(fixture.plans[0].hazardCertReady, 1) assert.equal(fixture.plans[1].hazardCertReady, 0) assert.equal(fixture.calls.filter((c) => c.path.endsWith('hazard-cert-ready')).length, 2) fixture.failReadyIds = [] await waitList(() => page.getByRole('tab', { name: '全部', exact: true }).click()) await card(1).getByRole('button', { name: '取消齐备', exact: true }).click() await waitList(() => button('确认取消').click()) assert.equal(fixture.plans[0].hazardCertReady, 0) // 上传只提交证书白名单;阻止上传中保存/关闭,失败保留数据可重试。 fixture.plans[0].products[0].purchaseCertUrl = '/fixtures/existing.pdf' await card(1).getByRole('button', { name: '上传证书', exact: true }).click() const upload = sheet('上传证书') await upload.locator('input[type=file]').first().waitFor({ state: 'attached' }) assert.equal(await upload.locator('input[type=file]').count(), 3) await upload.locator('.attachment-item').first().waitFor() const firstAttachmentBox = await upload.locator('.attachment-item').first().boundingBox() const uploadCardBox = await upload.locator('.upload-card').first().boundingBox() assert.equal(Math.round(firstAttachmentBox.width), 72, '附件项宽度必须为 72px') assert.equal(Math.round(firstAttachmentBox.height), 72, '附件项高度必须为 72px') assert.equal(Math.round(uploadCardBox.width), 72, '上传入口宽度必须为 72px') assert.equal(Math.round(uploadCardBox.height), 72, '上传入口高度必须为 72px') const attachmentLayout = await upload .locator('.attachment-list') .first() .evaluate((list) => ({ list: list.getBoundingClientRect().toJSON(), display: getComputedStyle(list).display, flexDirection: getComputedStyle(list).flexDirection, gap: getComputedStyle(list).gap, children: [...list.children].map((child) => child.getBoundingClientRect().toJSON()) })) assert.equal( Math.round(attachmentLayout.children[0].y), Math.round(attachmentLayout.children[1].y), `上传入口必须作为宫格首项与已上传附件同行:${JSON.stringify(attachmentLayout)}` ) assert.equal(await upload.locator('.attachment-preview__ext').first().textContent(), 'PDF') assert.equal(await upload.locator('.attachment-preview__ext').first().isVisible(), true) assert.equal(await upload.locator('.attachment-name').first().isVisible(), true) assert.equal(await upload.locator('.attachment-name').first().textContent(), 'existing.pdf') let releaseUpload fixture.uploadGate = new Promise((resolve) => { releaseUpload = resolve }) await upload .locator('input[type=file]') .first() .setInputFiles({ name: '测试证书.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF') }) await upload.locator('.is-uploading').waitFor() const certWrites = () => fixture.calls.filter((c) => c.path.endsWith('product-certs')) await upload.getByRole('button', { name: '保存证书', exact: true }).click() await page.getByText('附件正在上传,请稍候再保存', { exact: true }).waitFor() assert.equal(certWrites().length, 0) await upload.locator('.van-nav-bar__left').click() assert.equal(await upload.isVisible(), true) releaseUpload() fixture.uploadGate = null await upload.locator('.is-uploading').waitFor({ state: 'detached' }) await noOverflow(upload.locator('.mp-customer-filter-vant__body'), '上传sheet') const saveBox = await upload.getByRole('button', { name: '保存证书', exact: true }).boundingBox() assert.ok(saveBox.height >= 28 && saveBox.height <= 32, '上传确认按钮上下内间距减半后高约 30px') assert.ok(saveBox.y + saveBox.height >= 760, '上传确认按钮必须贴在视口底部') const editAttachment = upload.locator('.attachment-item').first() const editDownload = editAttachment.locator('[title="下载"]') const editDelete = editAttachment.locator('[title="删除"]') const [editDownloadBox, editDeleteBox] = await Promise.all([ editDownload.boundingBox(), editDelete.boundingBox() ]) assert.ok(editDownloadBox.width >= 44 && editDownloadBox.height >= 44) assert.ok(editDeleteBox.width >= 44 && editDeleteBox.height >= 44) assert.ok( editDownloadBox.x + editDownloadBox.width <= editDeleteBox.x || editDeleteBox.x + editDeleteBox.width <= editDownloadBox.x || editDownloadBox.y + editDownloadBox.height <= editDeleteBox.y || editDeleteBox.y + editDeleteBox.height <= editDownloadBox.y, `编辑态附件的下载与删除命中区不得重叠:${JSON.stringify({ editDownloadBox, editDeleteBox })}` ) await cleanScreenshot('upload-375') // 预览、下载、删除卡片(删除后取消不写业务记录)。 await upload .locator('.attachment-preview') .first() .click({ position: { x: 10, y: 10 } }) const preview = page.getByRole('dialog', { name: 'existing.pdf', exact: true }) await preview.waitFor() assert.ok((await preview.boundingBox()).width <= 375) await preview.screenshot({ path: `${artifacts}/attachment-preview-375.png`, animations: 'disabled' }) await preview.locator('.el-dialog__headerbtn').click() const download = page.waitForEvent('download') await upload.locator('[title="下载"]').first().click() assert.equal((await download).suggestedFilename(), 'existing.pdf') fixture.failSave = true await waitPatch('product-certs', () => upload.getByRole('button', { name: '保存证书', exact: true }).click() ) await upload.getByText('保存失败,已保留填写内容,请重试', { exact: true }).waitFor() assert.equal(await upload.locator('.attachment-item').count(), 2) await waitPatch('product-certs', () => upload.getByRole('button', { name: '保存证书', exact: true }).click() ) for (const p of certWrites().at(-1).body.products) assert.deepEqual( Object.keys(p).sort(), ['id', 'legalUseCertUrl', 'purchaseCertUrl', 'transportCertUrl'].sort() ) assert.match(fixture.plans[0].products[0].purchaseCertUrl, /existing\.pdf/) assert.match(fixture.plans[0].products[0].purchaseCertUrl, /cert\.pdf/) await page.waitForURL(/#\/m\/pl\/plan-cert\/plan-1(?:\?|$)/) const detail = page.locator('.cert-detail-page') await detail.getByText('证书迁移测试备注', { exact: true }).waitFor() assert.equal(await sheet('计划详情').count(), 0, '详情不得继续使用 Bottom Sheet') assert.equal( await detail.locator('[class*="ticket-"]').count(), 0, '详情不得显示票根或顶部特殊区' ) assert.equal(await detail.locator('.mp-wv-header__sub').count(), 0, '详情顶栏不得显示记录摘要') assert.equal( await detail.getByRole('heading', { name: '危化品证详情', exact: true }).count(), 1, '详情必须使用固定标准标题' ) assert.deepEqual(await detail.getByRole('tab').allTextContents(), [ '基础信息', '运输与段次', '证书与附件' ]) await noOverflow(detail, '计划详情') await cleanScreenshot('detail-375') await page.setViewportSize({ width: 414, height: 812 }) await noOverflow(detail, '计划详情414') await cleanScreenshot('detail-414') // 详情页独立编排上传、齐备与取消齐备,成功后原位刷新而不返回列表。 const detailUrl = page.url() await detail.getByRole('button', { name: '上传证书', exact: true }).click() await upload.locator('input[type=file]').first().waitFor({ state: 'attached' }) await waitPatch('product-certs', () => upload.getByRole('button', { name: '保存证书', exact: true }).click() ) assert.equal(page.url(), detailUrl, '详情上传成功后必须停留在原详情路由') await detail.getByRole('button', { name: '证书齐备', exact: true }).click() await button('确认齐备').waitFor() await waitPatch('hazard-cert-ready', () => button('确认齐备').click()) assert.equal(page.url(), detailUrl, '详情标记齐备后必须停留在原详情路由') await detail.getByRole('button', { name: '取消齐备', exact: true }).click() await button('确认取消').waitFor() await waitPatch('hazard-cert-ready', () => button('确认取消').click()) assert.equal(page.url(), detailUrl, '详情取消齐备后必须停留在原详情路由') await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click() await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/) await card(1).getByRole('button', { name: '上传证书', exact: true }).click() await upload.locator('.attachment-item').first().waitFor() await upload.locator('[title="删除"]').first().click() assert.equal(await upload.locator('.attachment-item').count(), 1) await cleanScreenshot('upload-414') const writesBeforeCancel = certWrites().length await upload.locator('.van-nav-bar__left').click() assert.equal(certWrites().length, writesBeforeCancel) assert.match(fixture.plans[0].products[0].purchaseCertUrl, /existing\.pdf/) // 合同仅查看;列表合同入口通过独立详情路由打开,关联计划仍留在详情页。 await card(1).locator('summary').click() await card(1).getByRole('button', { name: 'HT-001', exact: true }).first().click() const contract = sheet('合同详情(只读)') await contract.getByText('测试账号001', { exact: true }).waitFor() await contract.getByText('测试关联商机', { exact: true }).waitFor() await contract.getByText('BJ-001', { exact: true }).waitFor() await contract.getByText(/别名 测试别名/).waitFor() assert.equal(await contract.getByText('金额统计', { exact: true }).count(), 0) assert.equal(await contract.locator('.attachment-item').count(), 2) await noOverflow(contract.locator('.mp-sheet-body'), '合同详情') await contract.getByRole('button', { name: 'YS002', exact: true }).click() await page.waitForURL(/#\/m\/pl\/plan-cert\/plan-2(?:\?|$)/) await detail.getByText('YS002', { exact: true }).waitFor() await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click() await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/) // 价格权限仅开启合同金额展示,不引入编辑或审批动作。 fixture.codes.push('pl:contract:view-product-price') await page.reload() await card(1).waitFor() await card(1).locator('summary').click() await card(1).getByRole('button', { name: 'HT-001', exact: true }).first().click() await contract.getByRole('heading', { name: '金额统计', exact: true }).waitFor() assert.equal(await contract.getByRole('button', { name: /编辑|审批|作废/ }).count(), 0) await contract.getByRole('button', { name: '关闭', exact: true }).click() await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click() await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/) // 已安排计划的回传和复制动作;未安排计划不显示复制运输摘要。 await card(7).getByRole('button', { name: '查看详情', exact: true }).click() await detail.getByRole('tab', { name: '运输与段次', exact: true }).click() await detail.getByText('司机回传测试备注', { exact: true }).first().waitFor() const feedbackCard = detail.locator('.pl-stop-feedback-ticket__attach .attachment-item').first() const feedbackPreview = feedbackCard.locator('.attachment-preview') const feedbackDownload = feedbackCard.locator('.attachment-compact-download') await feedbackPreview.scrollIntoViewIfNeeded() const previewBox = await feedbackPreview.boundingBox(), downloadBox = await feedbackDownload.boundingBox() assert.ok(previewBox.width >= 44 && previewBox.height >= 44) assert.ok(downloadBox.width >= 44 && downloadBox.height >= 44) assert.ok(downloadBox.y >= previewBox.y + previewBox.height, '反馈下载与预览触达区不得重叠') await feedbackPreview.click() const feedbackDialog = page.getByRole('dialog', { name: 'driver.png', exact: true }) await feedbackDialog.locator('img').waitFor() await feedbackDialog.screenshot({ path: `${artifacts}/feedback-preview-414.png`, animations: 'disabled' }) await feedbackDialog.locator('.el-dialog__headerbtn').click() const feedbackFile = page.waitForEvent('download') await feedbackDownload.click() assert.equal((await feedbackFile).suggestedFilename(), 'driver.png') await detail.getByRole('button', { name: '复制司机链接', exact: true }).click() assert.equal(await page.evaluate(() => window.__copiedText), 'https://example.test/driver') await detail.getByRole('button', { name: '复制给司机', exact: true }).click() assert.match(await page.evaluate(() => window.__copiedText), /测试易制毒产品/) await detail.getByRole('button', { name: '复制给现场', exact: true }).click() assert.match(await page.evaluate(() => window.__copiedText), /https:\/\/example.test\/site/) await detail.getByRole('button', { name: '复制磅单二维码', exact: true }).click() await page.locator('.pound-qr-panel img').waitFor() await noOverflow(page.locator('.pound-qr-panel'), '磅单二维码') await page.locator('.pound-qr-panel').getByRole('button', { name: '关闭' }).click() await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click() await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/) // 切换合同时,旧合同未结束的关联查询不能阻止新合同查询。 const secondPlan = fixture.plans[1] for (const stop of [secondPlan.load, ...secondPlan.deliveries]) { for (const c of stop.contracts) { c.contractId = 'contract-2' c.contractNo = 'HT-002' } for (const p of stop.products) { p.contractId = 'contract-2' p.contractNo = 'HT-002' } } await page.reload() await card(1).waitFor() fixture.linkedListGate = new Promise((resolve) => { releaseLinkedList = resolve }) await card(1).locator('summary').click() await Promise.all([ page.waitForRequest((r) => new URL(r.url()).searchParams.get('contractId') === 'contract-1'), card(1).getByRole('button', { name: 'HT-001', exact: true }).first().click() ]) await contract.getByRole('button', { name: '关闭', exact: true }).click() await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click() await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/) await card(2).locator('summary').click() await card(2).getByRole('button', { name: 'HT-002', exact: true }).first().click() await contract.getByRole('heading', { name: 'HT-002', exact: true }).waitFor() assert.ok( fixture.calls.some( (c) => c.path === '/transport/plan/' && c.params.contractId === 'contract-2' ), '新合同必须发起自己的关联列表请求' ) await contract.getByRole('button', { name: 'YS002', exact: true }).waitFor() releaseLinkedList() fixture.linkedListGate = null await contract.getByRole('button', { name: '关闭', exact: true }).click() await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click() await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/) fixture.failList = true await card(2).locator('summary').click() await card(2).getByRole('button', { name: 'HT-002', exact: true }).first().click() await contract.getByRole('button', { name: '重试关联计划', exact: true }).waitFor() await waitList(() => contract.getByRole('button', { name: '重试关联计划', exact: true }).click()) assert.equal(latestList().params.contractId, 'contract-2') assert.equal(latestList().params.page, '1') await contract.getByRole('button', { name: 'YS002', exact: true }).waitFor() await contract.getByRole('button', { name: '关闭', exact: true }).click() await detail.getByRole('button', { name: '返回危化品证列表', exact: true }).click() await page.waitForURL(/#\/m\/pl\/plan-cert(?:\?|$)/) // 独立详情在只读/权限失败时仍先展示 Tab,且不暴露写操作。 fixture.codes = ['mini:pl:plan-cert:list'] await page.goto(`${origin}/#/m/pl/plan-cert/plan-1`) const readonlyDetail = page.locator('.cert-detail-page') await readonlyDetail.getByText('当前仅有查看权限', { exact: true }).waitFor() assert.equal(await readonlyDetail.locator('.cert-detail-actions').count(), 0) assert.equal( await readonlyDetail.locator('.detail-tabs').evaluate( (tabs, hint) => { return Boolean(tabs.compareDocumentPosition(hint) & Node.DOCUMENT_POSITION_FOLLOWING) }, await readonlyDetail.getByText('当前仅有查看权限', { exact: true }).elementHandle() ), true, '权限提示必须下沉到详情 Tab 之后' ) fixture.failPermissions = true await page.reload() await readonlyDetail.getByRole('button', { name: '重试权限', exact: true }).waitFor() assert.equal(await readonlyDetail.locator('.cert-detail-actions').count(), 0) fixture.failPermissions = false await readonlyDetail.getByRole('button', { name: '重试权限', exact: true }).click() await readonlyDetail.getByText('当前仅有查看权限', { exact: true }).waitFor() // 列表同样在只读/权限失败时关闭上传与齐备入口。 await page.goto(`${origin}/#/m/pl/plan-cert`) await page.getByText('当前仅有查看权限', { exact: true }).waitFor() assert.equal(await button('上传证书').count(), 0) assert.equal(await button('证书齐备').count(), 0) fixture.failPermissions = true await page.reload() await button('重试权限').waitFor() assert.equal(await button('上传证书').count(), 0) fixture.failPermissions = false await button('重试权限').click() await page.getByText('当前仅有查看权限', { exact: true }).waitFor() assert.deepEqual(fixture.unknown, []) assert.deepEqual(pageErrors, []) console.log(JSON.stringify({ result: 'passed', calls: fixture.calls.length, artifacts })) } catch (error) { console.error(error) await page.screenshot({ path: `${artifacts}/failure.png`, fullPage: true }) console.error( JSON.stringify({ pageErrors, unknown: fixture.unknown, recent: fixture.calls.slice(-5) }) ) throw error } finally { releaseLinkedList?.() await context.close() await browser.close() }