import { expect, test, type Page, type Route } from '@playwright/test' import fs from 'node:fs' import path from 'node:path' /** * 作业指导书全流程回归:编制 → 送审 → 审批时间线 → 预览 → 预检 → 批准 → 发布 → 交付物下载。 * * 内容制作接口按 wiki/11-内容制作模块.md 的契约打桩(与 teaching-module 系列同一做法), * 验证前端流程、生命周期流转请求、预检口径与浏览器内交付物生成,不依赖数据库与在线服务。 * 打桩的状态流转表与服务端 REVIEW_TRANSITIONS 一致,契约分叉会在这里暴露。 */ type Json = Record const SHOTS = path.resolve('artifacts/content-guide-lifecycle') fs.mkdirSync(SHOTS, { recursive: true }) const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'ute2e-ofd' }) const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) }) const fail = (route: Route, status: number, message: string) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-ofd' }), }) const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 20 }) const GUIDE_IMAGE = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAADZSiLoAAAAGUlEQVR4nGP8z8DAwMDAxMDAwMDAAAALAAH+Q0KzAAAAAElFTkSuQmCC', 'base64') const step = (id: string, title: string): Json => ({ id, title, order: 1, summary: '确认整机停机并系统卸压后,按对角顺序拆除缸筒固定螺栓。', safety: '全程佩戴护目镜与防割手套,作业区设置警戒。', tools: '液压扳手、专用吊具', acceptance: '缸筒外观无划痕,接口无渗漏。', part: '支腿液压缸', anchor: '右前支腿默认视角', animation: '支腿拆卸', media: 'GUIDE_IMAGE_01', stepType: '拆卸', duration: '12:30', status: '已完成', hotspot: true, start: 0, end: 12, parameters: { 拧紧力矩: '220 N·m', 系统压力: '不高于 0.5 MPa', 环境温度: '5 至 35 摄氏度' }, }) const completeContent = (): Json => ({ equipment: 'GX-200 全地形起重机', owner: '整机维修保障中心', specification: 'GB/T 33190-2016', chapters: [ { id: 'c1', number: '01', title: '作业准备', order: 1, steps: [step('s1', '停机挂牌与卸压')] }, { id: 'c2', number: '02', title: '缸体拆卸', order: 2, steps: [step('s2', '拆卸支腿液压缸')] }, ], resources: [{ id: 'r1', assetCode: 'GUIDE_IMAGE_01', type: '图片', name: '支腿总成配图', status: 'READY' }], partsCatalog: [{ id: 'p1', name: '支腿液压缸' }], three: { parts: ['支腿液压缸'], markers: [], selectedPart: '支腿液压缸' }, publish: { version: 'V1.2.0', note: '首次正式发布', channels: ['OFD静态版式', '单机离线包'], publishedAt: '' }, }) const TRANSITIONS: Record = { SUBMIT: { from: ['DRAFT', 'REJECTED'], to: 'REVIEW', commentRequired: false }, WITHDRAW: { from: ['REVIEW'], to: 'DRAFT', commentRequired: false }, APPROVE: { from: ['REVIEW'], to: 'APPROVED', commentRequired: false }, REJECT: { from: ['REVIEW'], to: 'REJECTED', commentRequired: true }, REVOKE: { from: ['PUBLISHED'], to: 'WITHDRAWN', commentRequired: true }, REVISE: { from: ['REJECTED', 'WITHDRAWN', 'SUPERSEDED'], to: 'DRAFT', commentRequired: false }, } class GuideMock { readonly requests: Array<{ method: string; path: string; body: Json }> = [] content: Json = completeContent() status = 'DRAFT' version = 3 publishedVersionId: string | null = null readCount = 0 downloadCount = 0 reviews: Json[] = [] private assets() { return [{ id: 'asset-image-01', versionId: '11', code: 'GUIDE_IMAGE_01', name: '支腿总成配图', type: 'IMAGE', storageUri: 'content://sha256/guide-image-01', mimeType: 'image/png', sizeBytes: GUIDE_IMAGE.length, sha256: 'a'.repeat(64), metadata: { format: 'PNG' }, status: 'READY', sortOrder: 0, }] } private project() { return { id: '901', type: 'GUIDE', code: 'OFD-2026-0031', name: '支腿液压缸中修作业指导书', description: '整机支腿液压缸中修标准作业', categoryCode: 'MAINTENANCE', coverUri: '', status: this.status, version: this.version, currentVersionId: '11', currentVersionNo: 3, currentVersionCode: 'V1.2.0', currentVersionStatus: 'DRAFT', publishedVersionId: this.publishedVersionId, ownerUserId: '7', ownerName: '内容作者', departmentId: '20', departmentName: '维修保障中心', addTime: 1786387200, updateTime: 1786387200, publishedTime: this.publishedVersionId ? 1786387200 : null, usageCount: 0, readCount: this.readCount, downloadCount: this.downloadCount, dependencyCount: 0, assetCount: this.assets().length, } } private version11(status = 'DRAFT') { return { id: '11', projectId: '901', versionNo: 3, versionCode: 'V1.2.0', status, content: this.content, dependencies: [], assets: this.assets(), createdByName: '内容作者', publishedByName: status === 'PUBLISHED' ? '内容作者' : '', publishedTime: status === 'PUBLISHED' ? 1786387200 : null, addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '', } } private detail() { return { project: this.project(), currentVersion: this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT') } } async install(page: Page) { await page.addInitScript(() => { sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-ofd-token') }) await page.route('**/api/auth/v1/auth/me', (route) => ok(route, { user: { id: '1', username: 'admin', displayName: '管理员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 }, activeRoleId: 'role-admin', roles: [{ id: 'role-admin', code: 'admin', name: '管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL' }], permissions: ['content.ofd', 'content.model', 'content.scene', 'content.training', 'content.create', 'content.update', 'content.publish', 'content.delete'], authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200, })) await page.route('**/api/auth/v1/menus/navigation', (route) => ok(route, [])) await page.route('**/api/tran/v1/content/**', (route) => this.handle(route)) } private async handle(route: Route) { const request = route.request() const url = new URL(request.url()) const target = url.pathname.replace(/^.*\/v1\/content/, '') const method = request.method() let body: Json = {} try { const text = request.postData() if (text) body = JSON.parse(text) as Json } catch { body = {} } this.requests.push({ method, path: target, body }) if (method === 'GET' && target === '/projects') return ok(route, pageOf([this.project()])) if (method === 'GET' && target === '/projects/summary') { return ok(route, { total: 1, draft: 0, review: 0, published: 0, byType: [], byTrainingMode: [] }) } if (method === 'GET' && target === '/catalog') return ok(route, pageOf([])) if (method === 'GET' && target === '/projects/901') return ok(route, this.detail()) if (method === 'PUT' && target === '/projects/901') { if (body.content && typeof body.content === 'object') this.content = body.content as Json this.version += 1 return ok(route, this.detail()) } if (method === 'GET' && target === '/projects/901/versions') { return ok(route, pageOf([this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT')])) } if (method === 'GET' && target === '/projects/901/versions/11') { return ok(route, this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT')) } if (method === 'GET' && target === '/projects/901/assets/asset-image-01/content') { return route.fulfill({ status: 200, contentType: 'image/png', body: GUIDE_IMAGE }) } if (method === 'GET' && target === '/projects/901/reviews') return ok(route, [...this.reviews].reverse()) if (method === 'POST' && target === '/projects/901/review') { const action = String(body.action ?? '') const transition = TRANSITIONS[action] if (!transition) return fail(route, 400, '动作无效') if (!transition.from.includes(this.status)) return fail(route, 409, '起始状态不匹配') const comment = String(body.comment ?? '').trim() if (transition.commentRequired && !comment) return fail(route, 400, '该动作必须填写处理意见') this.reviews.push({ id: String(this.reviews.length + 1), projectId: '901', versionId: '11', action, fromStatus: this.status, toStatus: transition.to, comment, actorUserId: '1', actorName: '管理员', operateTime: 1786387200 + this.reviews.length * 60, }) this.status = transition.to this.version += 1 return ok(route, this.detail()) } if (method === 'POST' && target === '/projects/901/publish') { if (!['REVIEW', 'APPROVED'].includes(this.status)) return fail(route, 409, '指导书必须先提交复核,复核中或已批准才能发布') this.status = 'PUBLISHED' this.publishedVersionId = '11' this.version += 1 return ok(route, this.detail()) } if (method === 'POST' && target === '/projects/901/metrics') { if (String(body.metric) === 'READ') this.readCount += 1 if (String(body.metric) === 'DOWNLOAD') this.downloadCount += 1 return ok(route, {}) } return fail(route, 404, `未打桩的内容接口 ${method} ${target}`) } } const statValue = async (page: Page, label: string) => ( page.locator('.preflight-summary article', { hasText: label }).locator('b').first().innerText() ) test.describe('作业指导书生命周期与交付物', () => { test('编制 → 预览 → 预检 → 送审 → 批准 → 发布 → 交付物下载', async ({ page }) => { const mock = new GuideMock() await mock.install(page) const consoleErrors: string[] = [] page.on('pageerror', (error) => consoleErrors.push(error.message)) // 0. 资源导入:只能展示真实版本资产,并提供受控图片上传入口 await page.goto('/content/ofd/901/studio?view=resources') await expect(page.locator('button', { hasText: '上传配图' })).toBeVisible() const resourceTable = page.locator('.single-panel .el-table').first() await expect(resourceTable).toContainText('GUIDE_IMAGE_01') await expect(resourceTable).toContainText('已就绪') // 1. 编制:工作台读到草稿,能改内容并保存 await page.goto('/content/ofd/901/studio?view=editor') await expect(page.locator('.guide-flow-panel')).toBeVisible() await expect(page.locator('.chapter-panel')).toContainText('作业准备') await page.locator('.step-row', { hasText: '停机挂牌与卸压' }).first().click() const titleInput = page.locator('.step-editor .form-grid input').first() await titleInput.fill('停机挂牌与系统卸压') // el-input 的 change 在失焦时触发,脏标记要等失焦后才置位。 await titleInput.blur() await expect(page.locator('.studio-dirty')).toBeVisible() await page.getByRole('button', { name: /^保存$/ }).click() await expect(page.locator('.studio-dirty')).toHaveCount(0) const saved = mock.requests.filter((item) => item.method === 'PUT') expect(saved.length).toBeGreaterThan(0) await page.screenshot({ path: path.join(SHOTS, '01-编制.png'), fullPage: true }) // 2. 送审:再次修改但不手工保存,生命周期动作必须先保存最新草稿再发起 SUBMIT await titleInput.fill('停机挂牌、系统卸压与复核确认') await titleInput.blur() await expect(page.locator('.studio-dirty')).toBeVisible() const putsBeforeSubmit = mock.requests.filter((item) => item.method === 'PUT').length await page.getByRole('button', { name: '提交复核' }).click() await page.getByRole('button', { name: '确认提交复核' }).click() await expect.poll(() => mock.status).toBe('REVIEW') expect(mock.requests.filter((item) => item.method === 'PUT').length).toBe(putsBeforeSubmit + 1) expect(((mock.content.chapters as Json[])[0]!.steps as Json[])[0]!.title).toBe('停机挂牌、系统卸压与复核确认') await page.screenshot({ path: path.join(SHOTS, '02-送审.png'), fullPage: true }) // 3. 审批时间线读到刚才的流转 await page.goto('/content/ofd/901/studio?view=feedback') await expect(page.locator('.single-panel')).toContainText('提交复核') await expect(page.locator('.single-panel')).toContainText('草稿 → 审核中') await page.screenshot({ path: path.join(SHOTS, '03-审批时间线.png'), fullPage: true }) // 4. 预览:章节目录与正文来自当前草稿 await page.goto('/content/ofd/901/preview') await expect(page.locator('.guide-toc')).toContainText('缸体拆卸') await expect(page.locator('.guide-document')).toContainText('停机挂牌、系统卸压与复核确认') await page.screenshot({ path: path.join(SHOTS, '04-预览.png'), fullPage: true }) // 5. 预检:24 项内容检查 + 3 项平台检查全部通过 await page.goto('/content/ofd/901/preflight') await expect(page.locator('.preflight-summary')).toBeVisible() expect(await statValue(page, '通过')).toBe('26') expect(await statValue(page, '待确认')).toBe('1') expect(await statValue(page, '阻断')).toBe('0') expect(await statValue(page, '完成度')).toContain('100') const enterPublish = page.getByRole('button', { name: '进入发布确认' }) await expect(enterPublish).toBeEnabled() await page.screenshot({ path: path.join(SHOTS, '05-预检.png'), fullPage: true }) // 6. 批准 + 发布 await enterPublish.click() await expect(page).toHaveURL(/\/content\/ofd\/901\/publish$/) await page.getByRole('button', { name: '复核通过' }).click() await page.getByRole('button', { name: '确认复核通过' }).click() await expect.poll(() => mock.status).toBe('APPROVED') await page.getByRole('button', { name: '确认发布' }).click() await page.getByRole('button', { name: '确认发布' }).last().click() await expect.poll(() => mock.status).toBe('PUBLISHED') await expect(page.locator('.publish-confirmation h2')).toContainText('当前版本已发布') await page.screenshot({ path: path.join(SHOTS, '06-发布.png'), fullPage: true }) // 7. 交付物:OFD 静态版式与单机离线包都能生成、自校验通过并触发下载 const ofdDownload = page.waitForEvent('download') await page.getByRole('button', { name: 'OFD 静态版式' }).click() const ofdFile = await ofdDownload expect(ofdFile.suggestedFilename()).toMatch(/\.ofd$/) const ofdPath = path.join(SHOTS, 'delivery.ofd') await ofdFile.saveAs(ofdPath) const ofdBytes = fs.readFileSync(ofdPath) expect(ofdBytes.length).toBeGreaterThan(4096) expect(ofdBytes.subarray(0, 4).toString('hex')).toBe('504b0304') expect(ofdBytes.toString('latin1')).toContain('META-INF/EXPORT-METADATA.json') await expect(page.locator('.delivery-report')).toContainText('OFD 静态版式') await expect(page.locator('.delivery-report')).toContainText('发布版本 V1.2.0') const hash = await page.locator('.delivery-hash dd').innerText() expect(hash).toMatch(/^[a-f0-9]{64}$/) // 交付信息必须单列堆叠,否则文件名与 64 位哈希会被挤成折行。 const rows = page.locator('.delivery-report > div') const firstRow = await rows.nth(0).boundingBox() const secondRow = await rows.nth(1).boundingBox() expect(secondRow!.y).toBeGreaterThanOrEqual(firstRow!.y + firstRow!.height - 1) const offlineDownload = page.waitForEvent('download') await page.getByRole('button', { name: '单机离线包' }).click() const offlineFile = await offlineDownload expect(offlineFile.suggestedFilename()).toMatch(/离线阅读版\.html$/) const offlinePath = path.join(SHOTS, 'delivery-offline.html') await offlineFile.saveAs(offlinePath) const offlineHtml = fs.readFileSync(offlinePath, 'utf8') expect(offlineHtml.startsWith('')).toBe(true) expect(offlineHtml).toContain('Content-Security-Policy') expect(offlineHtml).toContain('data-role="toc"') expect(offlineHtml.match(/data-step-page="\d+"/g)?.length).toBe(2) expect(offlineHtml.match(/data:image\/png;base64/g)?.length).toBe(2) expect(/<(?:script[^>]*src|link[^>]*href|img[^>]*src)=["']https?:/i.test(offlineHtml)).toBe(false) await page.screenshot({ path: path.join(SHOTS, '07-交付物.png'), fullPage: true }) // 8. 两次下载各回报一次 DOWNLOAD 统计 expect(mock.downloadCount).toBe(2) const metrics = mock.requests.filter((item) => item.path === '/projects/901/metrics') expect(metrics.map((item) => item.body.metric)).toEqual(['DOWNLOAD', 'DOWNLOAD']) expect(consoleErrors, `页面运行时错误:${consoleErrors.join(' | ')}`).toEqual([]) }) test('撤回发布必须填写处理意见', async ({ page }) => { const mock = new GuideMock() mock.status = 'PUBLISHED' mock.publishedVersionId = '11' await mock.install(page) await page.goto('/content/ofd/901/studio?view=editor') await page.getByRole('button', { name: '撤回发布' }).click() const dialog = page.locator('.el-dialog') await expect(dialog).toBeVisible() await dialog.getByRole('button', { name: '确认撤回发布' }).click() await expect(page.locator('.el-message')).toContainText('必须填写处理意见') expect(mock.status).toBe('PUBLISHED') await dialog.locator('textarea').fill('发现第 2 步拧紧力矩参数有误') await dialog.getByRole('button', { name: '确认撤回发布' }).click() await expect.poll(() => mock.status).toBe('WITHDRAWN') const record = mock.reviews.at(-1) expect(record?.action).toBe('REVOKE') expect(record?.comment).toBe('发现第 2 步拧紧力矩参数有误') await page.screenshot({ path: path.join(SHOTS, '08-撤回发布.png'), fullPage: true }) }) test('要素缺失时预检阻断并禁用发布确认', async ({ page }) => { const mock = new GuideMock() mock.status = 'REVIEW' const content = completeContent() const chapters = content.chapters as Json[] const steps = (chapters[0] as Json).steps as Json[] const target = steps[0] as Json delete target.safety delete target.acceptance target.hotspot = false target.parameters = { 拧紧力矩: '220 N·m' } mock.content = content await mock.install(page) await page.goto('/content/ofd/901/preflight') await expect(page.locator('.preflight-summary')).toBeVisible() expect(Number(await statValue(page, '阻断'))).toBeGreaterThan(0) await expect(page.getByRole('button', { name: '进入发布确认' })).toBeDisabled() await expect(page.locator('.check-list')).toContainText('安全要求完整') await expect(page.locator('.check-list')).toContainText('热点交互完整') await page.screenshot({ path: path.join(SHOTS, '09-预检阻断.png'), fullPage: true }) }) test('未发布草稿不能生成正式交付物', async ({ page }) => { const mock = new GuideMock() await mock.install(page) await page.goto('/content/ofd/901/publish') await expect(page.locator('.delivery-panel')).toContainText('完成复核与发布后方可生成正式交付物') await expect(page.getByRole('button', { name: 'OFD 静态版式' })).toBeDisabled() await expect(page.getByRole('button', { name: '单机离线包' })).toBeDisabled() await page.screenshot({ path: path.join(SHOTS, '10-草稿禁止交付.png'), fullPage: true }) }) })