mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 23:32:00 +00:00
827 lines
67 KiB
JavaScript
827 lines
67 KiB
JavaScript
#!/usr/bin/env node
|
||
// 数据源的变化、表详情的「结构与取值变化」、定时维护、目录页后台任务条的界面检查(docs/design/next-round 03 B2–B4、05 (g))。
|
||
//
|
||
// 守的是截图看不出来、点了才知道对不对的那些:
|
||
// - 数据目录页的「变化」视图按要处理 / 提醒 / 说明分组、组内照服务端的先后;能按开着的 / 处理过的、结构 / 取值筛,筛选和打开
|
||
// 的那一条记在地址里;视图标签上写几条要处理;
|
||
// - 一条变化写依据(谁测到的、取值变化的观察口径、结构变化比的两版)、前后、影响面(目录项现在的状态、写死的 SQL 会报列
|
||
// 不存在、有效记录条件);动作照服务端给的 actions 画,做不了的写原因;各种动作的请求体(清理先确认、加入码值带含义、
|
||
// 误报必须写理由——写之前按钮不能点);处理完照实写目录版本、建了的任务(点得进进度);
|
||
// - 表详情的「结构与取值变化」一节:这张表的变化、撤销过的「已列全」、最近几次观察和各自的口径、口径换过的提醒;
|
||
// - 数据源卡片的角标(读屏写全,点了到「变化」视图);定时维护:缺省关、开启时提示低峰时段,建(POST /schedules,
|
||
// target_kind=source_maintenance)和改(PATCH 带版本)、步骤(PUT /maintenance),最近一次建了哪些任务、哪几步没执行;
|
||
// - 后台任务条:按服务端给的先后列出探查、起草、剖析(两种排法的文字都对),页头只做入口(读屏写状态、不写数字),进度和
|
||
// 提醒只在这一条上,「查看」打开对应的对话框;窄屏不撑宽页面。
|
||
//
|
||
// 写法同 check-catalog-profile:数据源列表、目录、变化、观察、后台任务、定时都用 page.route 伪造(虚构的网店库,表名 orders、
|
||
// customers 都是通用的假名字),没配的 GET 放行到服务端(外壳要它),没配的写请求一律拦成 503 并记账;写请求在浏览器层拦下,
|
||
// 断言请求体。最后核对真库里的数据源数量没有变化。
|
||
//
|
||
// 跑之前前后端都得起着,默认连 5273 / 8000;对别的实例:AGENTLAB_WEB=… AGENTLAB_API=…/api node scripts/check-catalog-changes.mjs
|
||
// 只跑其中几节:CHECK_ONLY=分组,定时 node scripts/check-catalog-changes.mjs(按节名包含匹配)。CHECK_SHOTS=<目录> 存截图。
|
||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||
|
||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
|
||
const SHOTS = process.env.CHECK_SHOTS ?? ''
|
||
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||
const ACTOR = '检查脚本'
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 200)
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
|
||
try {
|
||
const r = await fetch(`${API}/datasources/kinds`)
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||
} catch (e) {
|
||
console.error(`✗ 连不上服务端(${API})——先起沙箱后端\n ${e.message}`)
|
||
process.exit(1)
|
||
}
|
||
const realSources = async () => (await (await fetch(`${API}/datasources`)).json()).length
|
||
const before = await realSources()
|
||
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
const opened = new Set()
|
||
const ONLY = process.env.CHECK_ONLY?.split(',').map((x) => x.trim()).filter(Boolean)
|
||
async function section(name, fn) {
|
||
if (ONLY?.length && !ONLY.some((k) => name.includes(k))) return
|
||
console.log(`\n=== ${name} ===`)
|
||
try {
|
||
await fn()
|
||
} catch (e) {
|
||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||
} finally {
|
||
for (const c of opened) await c.close().catch(() => {})
|
||
opened.clear()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 开一页:handlers 按「METHOD 路径正则」匹配(路径去掉 /api,不含查询串),没配的 GET 放行到服务端,没配的写请求一律拦成 503。
|
||
* sent 记下每个配上的请求(含查询串和请求体)。原生对话框都算失败
|
||
*/
|
||
async function open(path, { handlers = [], viewport = { width: 1440, height: 900 } } = {}) {
|
||
const ctx = await browser.newContext({ viewport, timezoneId: 'Asia/Shanghai' })
|
||
opened.add(ctx)
|
||
ctx.setDefaultTimeout(6000)
|
||
ctx.setDefaultNavigationTimeout(30000)
|
||
await ctx.addInitScript((actor) => {
|
||
try { localStorage.setItem('agentlab_actor', actor); localStorage.removeItem('agentlab.health') } catch { /* noop */ }
|
||
}, ACTOR)
|
||
const page = await ctx.newPage()
|
||
const sent = []
|
||
const errors = []
|
||
const natives = []
|
||
page.on('pageerror', (e) => errors.push(e.message))
|
||
page.on('dialog', (d) => { natives.push(`${d.type()}: ${d.message().slice(0, 40)}`); void d.dismiss() })
|
||
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
|
||
const r = route.request()
|
||
const url = new URL(r.url())
|
||
const key = `${r.method()} ${decodeURIComponent(url.pathname.replace(/^\/api/, ''))}`
|
||
for (const [pattern, handle] of handlers) {
|
||
if (pattern.test(key)) {
|
||
let body = null
|
||
try { body = r.postDataJSON() } catch { body = r.postData() }
|
||
sent.push({ key, body, query: Object.fromEntries(url.searchParams) })
|
||
return handle(route, { body, url, key })
|
||
}
|
||
}
|
||
if (r.method() === 'GET') return route.continue()
|
||
sent.push({ key, body: null, blocked: true })
|
||
return route.fulfill({ status: 503, body: '' })
|
||
})
|
||
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(300)
|
||
return { page, sent, errors, natives }
|
||
}
|
||
const goto = async (page, path) => { await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' }); await page.waitForTimeout(250) }
|
||
const json = (data, status = 200) => (route) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(data) })
|
||
const until = async (fn, ms = 6000, step = 120) => {
|
||
const end = Date.now() + ms
|
||
let v = await fn().catch(() => false)
|
||
while (!v && Date.now() < end) { await new Promise((r) => setTimeout(r, step)); v = await fn().catch(() => false) }
|
||
return v
|
||
}
|
||
const textOf = async (loc) => (await loc.first().innerText({ timeout: 800 }).catch(() => '')).replace(/\s+/g, ' ').trim()
|
||
const attrs = (loc, name) => loc.evaluateAll((els, n) => els.map((e) => e.getAttribute(n)), name)
|
||
const shot = async (page, name) => { if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png` }) }
|
||
const writes = (sent, re) => sent.filter((s) => re.test(s.key) && !s.key.startsWith('GET ') && !s.blocked)
|
||
const blocked = (sent) => sent.filter((s) => s.blocked)
|
||
const clone = (x) => JSON.parse(JSON.stringify(x))
|
||
const isoNow = () => new Date().toISOString()
|
||
const ago = (days) => new Date(Date.now() - days * 86_400_000).toISOString()
|
||
const overflow = (page) => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 夹具:虚构的网店库(剖析没开)和一个导入表格的源。名字、数字全是假的
|
||
// ---------------------------------------------------------------------------
|
||
const S1 = 'check-chg-shop'
|
||
const S2 = 'check-chg-upload'
|
||
const NAME1 = 'zzchgshop'
|
||
const NAME2 = 'zzchgupload'
|
||
const NOT_PROFILED = `数据源「${NAME1}」未开启数据剖析。剖析会对业务库发查询,请先在数据源设置中开启数据剖析`
|
||
const blank = {
|
||
kind: 'sqlite', host: null, port: null, username: null, readonly: true, description: '', enabled: true,
|
||
password_masked: '', has_password: false, cached_schema: '', schema_error: '', available_schemas: [],
|
||
last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null, current_snapshot: null,
|
||
}
|
||
const sources = () => [
|
||
{ ...blank, id: S1, name: NAME1, origin: 'manual', database: `/tmp/check/${NAME1}.db`, table_count: 2, tools: [`db_query__${NAME1}`],
|
||
schema_synced_at: ago(1), options: { catalog_profile: { enabled: false } } },
|
||
{ ...blank, id: S2, name: NAME2, origin: 'upload', database: `/tmp/check/uploads/${S2}.db`, table_count: 1, tools: [`db_query__${NAME2}`],
|
||
schema_synced_at: ago(1), import_mode: 'simple', current_recipe: null, open_staging: null, options: {},
|
||
current_snapshot: { id: 'snap-check-chg', created_at: ago(2), file_name: '客流日报.xlsx', raw_state: 'kept' } },
|
||
]
|
||
const item = (value, source = 'human', status = 'confirmed') => ({ value, source, status, updated_at: ago(3) })
|
||
const col = (name, type = 'INTEGER') => ({ name, type, pk: name === 'id', not_null: name === 'id', comment: null })
|
||
const TABLES = {
|
||
orders: { usage: 30, columns: [col('id'), col('channel', 'VARCHAR(8)'), col('amount', 'NUMERIC(12, 2)'), col('created_at', 'TEXT')],
|
||
notes: { label: item('订单'), columns: { channel: { codes: item({ web: '线上', shop: '门店' }) } } }, version: 5 },
|
||
customers: { usage: 12, columns: [col('id'), col('level')], notes: null, version: 0 },
|
||
}
|
||
const counts0 = { proposed: 0, verified: 0, confirmed: 0, rejected: 0 }
|
||
const rowOf = (name) => {
|
||
const t = TABLES[name]
|
||
return { table_name: name, qualified: name, is_view: false, in_schema: true, label: t.notes?.label?.value ?? null, label_status: null, kind: null,
|
||
counts: t.notes ? { ...counts0, confirmed: 2 } : counts0, relations: 0, usage: t.usage, version: t.version, updated_at: null, updated_by: null,
|
||
last_draft: null, last_profile: null, profile_due: false, profile_due_reason: null, profile_checks: 0 }
|
||
}
|
||
const detailOf = (name) => {
|
||
const t = TABLES[name]
|
||
return { table_name: name, in_schema: true, absent_reason: null, notes: t.notes ?? {}, version: t.version, updated_at: null, updated_by: null,
|
||
structure: { qualified: name, is_view: false, comment: null, columns: t.columns, primary_key: ['id'], foreign_keys: [], unique: [] },
|
||
system_notes: false, usage: t.usage, last_profile: null }
|
||
}
|
||
|
||
/** 一条变化(agentlab-change/1 加上给界面的几项),没写的取缺省 */
|
||
const change = (o) => ({
|
||
schema: 'agentlab-change/1', source_id: S1, source_name: NAME1, column: null, detected_at: ago(1), last_seen_at: ago(1), seen_count: 1,
|
||
detected_by: { job_kind: 'introspect', job_id: 'check-ijob-1', task_seq: 1, trigger: 'schedule', actor: '定时任务「zzchgshop 定时维护」' },
|
||
basis: null, basis_text: null, before: null, after: null, affects: { catalog: [], templates: [], rules_touched: false },
|
||
struct_version_before: null, struct_version_after: null, status: 'open', resolution: null, job: null, ...o,
|
||
})
|
||
const act = (action, reason = null) => ({ action, ok: !reason, reason })
|
||
const COMMON = [act('acknowledge'), act('dismiss')]
|
||
function freshChanges() {
|
||
return [
|
||
change({ seq: 11, id: 'chg-removed', table: 'orders', column: 'status', kind: 'column_removed', category: 'struct', severity: 'action',
|
||
summary: 'orders.status:删除了列', before: { type: 'VARCHAR(10)', nullable: true }, struct_version_before: 2, struct_version_after: 3,
|
||
affects: {
|
||
catalog: [
|
||
{ table: 'orders', path: 'columns.status.codes', label: '列 status 的码值', status: 'confirmed', source: 'human', needs_action: true, now: { status: 'confirmed', source: 'human' } },
|
||
{ table: 'orders', path: 'valid_filter', label: '表的有效记录条件', status: 'confirmed', source: 'human', needs_action: true, now: { status: 'confirmed', source: 'human' } },
|
||
],
|
||
templates: [{ workflow_id: 'wf-check-weekly', name: '门店周报', version: 3, level: 'published', impact: 'direct',
|
||
nodes: [{ node_id: 'q_orders', label: '取订单', type: 'tool', impact: 'direct', breaks: true, unknown_columns: ['status'] }] }],
|
||
rules_touched: true,
|
||
// 表结构变了之后没能试算的口径(服务端 ledger/recall._probe_skipped):缺哪几个参数
|
||
probes: [{ workflow_id: 'wf-check-weekly', version: 3, name: '门店周报', outcome: 'skipped', missing: ['start', 'end'],
|
||
reason: '「start」必填;「end」必填', inputs_from: 'defaults' }],
|
||
},
|
||
actions: [act('clean_catalog'), act('redraft'), act('reprofile', NOT_PROFILED), ...COMMON] }),
|
||
change({ seq: 14, id: 'chg-codes', table: 'orders', column: 'channel', kind: 'codes_new', category: 'value', severity: 'action',
|
||
summary: 'orders.channel:出现了新码值(app)', basis: { method: 'first_rows', key: 'first_rows:100000', query_refs: [3] },
|
||
basis_text: '按存储顺序取前 100000 行', before: { known: ['shop', 'web'], complete: true },
|
||
after: { codes: ['app'], shares: { app: 0.4 }, complete_revoked: true, catalog_version: 5 },
|
||
detected_by: { job_kind: 'profile', job_id: 'check-pjob-9', task_seq: 1, trigger: 'schedule', actor: null },
|
||
actions: [act('add_codes'), act('reprofile', NOT_PROFILED), ...COMMON] }),
|
||
change({ seq: 12, id: 'chg-type', table: 'orders', column: 'amount', kind: 'column_type_changed', category: 'struct', severity: 'warning',
|
||
summary: 'orders.amount:列的类型变了(NUMERIC(10, 2) → NUMERIC(12, 2))', before: 'NUMERIC(10, 2)', after: 'NUMERIC(12, 2)',
|
||
struct_version_before: 2, struct_version_after: 3, actions: [act('redraft'), act('reprofile', NOT_PROFILED), ...COMMON] }),
|
||
change({ seq: 15, id: 'chg-rows', table: 'customers', kind: 'rows_jump', category: 'value', severity: 'info', summary: 'customers:行数大幅上涨',
|
||
basis: { method: 'count', key: 'count', query_refs: [] }, basis_text: '逐行数出', before: { rows: 100 }, after: { rows: 400, estimated: false },
|
||
detected_by: { job_kind: 'profile', job_id: 'check-pjob-9', task_seq: 2, trigger: 'manual', actor: '审核甲' },
|
||
actions: [act('reprofile', NOT_PROFILED), ...COMMON] }),
|
||
change({ seq: 8, id: 'chg-stalled', table: 'orders', column: 'created_at', kind: 'date_stalled', category: 'value', severity: 'warning',
|
||
summary: 'orders.created_at:最新业务日期没有前进', basis_text: '整表', status: 'dismissed',
|
||
after: { max: '2026-09-30', age_days: 9, threshold_days: 3 }, before: { max: '2026-09-30', median_days: 1 },
|
||
resolution: { by: '审核甲', at: ago(2), action: 'dismiss', note: '国庆假期停业' }, actions: [] }),
|
||
]
|
||
}
|
||
const PENDING = new Set(['open', 'acknowledged'])
|
||
const countsOf = (changes) => {
|
||
const by = { action: 0, warning: 0, info: 0 }
|
||
let open = 0
|
||
for (const c of changes) if (PENDING.has(c.status)) { by[c.severity]++; if (c.status === 'open') open++ }
|
||
return { pending: by.action + by.warning + by.info, open, by_severity: by }
|
||
}
|
||
const RANK = { action: 3, warning: 2, info: 1 }
|
||
|
||
/** 观察:行数(两次同口径)、码值(口径换过:块采样 → 按存储顺序前 N 行)、按日行数(中间断了两天) */
|
||
const OBSERVATIONS = { table: 'orders', series: [
|
||
{ kind: 'rows', column: '', basis_changed: false, points: [
|
||
{ at: ago(0), basis_key: 'count', basis_text: '逐行数出', data: { rows: 1200, at_least: false, method: 'count' } },
|
||
{ at: ago(1), basis_key: 'count', basis_text: '逐行数出', data: { rows: 1180, at_least: false, method: 'count' } },
|
||
] },
|
||
{ kind: 'codes_observe', column: 'channel', basis_changed: true, points: [
|
||
{ at: ago(0), basis_key: 'first_rows:100000', basis_text: '按存储顺序取前 100000 行',
|
||
data: { values: [['web', 600, 0.5], ['app', 480, 0.4], ['shop', 120, 0.1]], rows: 1200, truncated: false } },
|
||
{ at: ago(3), basis_key: 'sample:1', basis_text: '块采样约 1%', data: { values: [['web', 30, 0.75], ['shop', 10, 0.25]], rows: 40, truncated: false } },
|
||
] },
|
||
{ kind: 'freshness', column: 'created_at', basis_changed: false, points: [
|
||
{ at: ago(0), basis_key: 'full', basis_text: '整表', data: { max: '2026-10-15' } },
|
||
] },
|
||
{ kind: 'daily_volume', column: 'created_at', basis_changed: false, points: [
|
||
{ at: ago(0), basis_key: 'window:created_at:35', basis_text: '业务日期 created_at 最近 35 天',
|
||
data: { since: '2026-10-11', until: '2026-10-15', days: [['2026-10-11', 40], ['2026-10-12', 0], ['2026-10-13', 0], ['2026-10-14', 42], ['2026-10-15', 41]],
|
||
total: 123, days_with_data: 3 } },
|
||
] },
|
||
] }
|
||
|
||
/** 后台任务:探查在做、起草等探查、剖析等起草(服务端的先后),或者反过来 */
|
||
const JOBS_A = [
|
||
{ kind: 'introspect', id: 'check-ijob-2', status: 'running', total: 20, done: 3, waiting_for: null, position: 0 },
|
||
{ kind: 'draft', id: 'check-djob-1', status: 'queued', total: 5, done: 0, waiting_for: 'introspect', position: 1 },
|
||
{ kind: 'profile', id: 'check-pjob-1', status: 'queued', total: 8, done: 0, waiting_for: 'draft', position: 2 },
|
||
]
|
||
const JOBS_B = [
|
||
{ kind: 'profile', id: 'check-pjob-1', status: 'running', total: 8, done: 2, waiting_for: null, position: 0 },
|
||
{ kind: 'draft', id: 'check-djob-1', status: 'queued', total: 5, done: 0, waiting_for: 'profile', position: 1 },
|
||
]
|
||
const PROFILE_NOTICE = '最近 6 条查询里有 5 条超时:业务库可能正忙,或者表太大。可以先停止,到剖析设置里调大单条查询时限'
|
||
const draftJob = (status, done = 0) => ({ id: 'check-djob-1', source_id: S1, status, use_model: false, model: null, guidance: null, note: null,
|
||
created_at: ago(0), started_at: status === 'running' ? ago(0) : null, finished_at: null, created_by: ACTOR,
|
||
usage: { calls: 0, input_tokens: 0, output_tokens: 0 }, error: null, notice: null, waiting_for: status === 'queued' ? 'profile' : null,
|
||
total: 5, done, counts: { pending: 5 - done, running: 0, ok: done, partial: 0, failed: 0, skipped: 0, cancelled: 0 }, added: 0, updated: 0, removed: 0,
|
||
tasks: ['orders', 'customers', 'a1', 'a2', 'a3'].map((n, i) => ({ table_name: n, status: i < done ? 'ok' : 'pending', columns: 3, parts_total: null,
|
||
parts_done: 0, added: 0, updated: 0, removed: 0, version: 0, model_items: null, error: null, model_error: null, started_at: null, finished_at: null })) })
|
||
const profileJob = (status, done = 0, notice = null) => ({ id: 'check-pjob-1', source_id: S1, status, scope: 'needed',
|
||
settings: { enabled: true, table_max_queries: 40, table_max_s: 60, query_timeout_s: 10, sample_size: 2000, max_scan_rows: 100000 },
|
||
created_at: ago(0), started_at: status === 'running' ? ago(0) : null, finished_at: null, created_by: ACTOR, retry_of: null,
|
||
waiting_for: status === 'queued' ? 'draft' : null, stop_reason: null, notice, error: null, total: 8, done,
|
||
counts: { pending: 8 - done, running: 0, ok: done, partial: 0, failed: 0, skipped: 0, cancelled: 0 }, queries: 0, timeouts: 0, errors: 0,
|
||
added: 0, updated: 0, removed: 0, summary: { relations_checked: 0, relations_verified: 0, codes: 0, codes_pending: 0, business_dates: 0 },
|
||
tasks: Array.from({ length: 8 }, (_, i) => ({ seq: i + 1, table_name: `t${i}`, status: i < done ? 'ok' : 'pending', checks_total: 1, checks_done: i < done ? 1 : 0,
|
||
checks_by_kind: { relation: [0, 0], codes: [0, 1], date: [0, 0] }, current: null, queries: 0, elapsed_ms: 0, row_estimate: null, summary: { relations_checked: 0,
|
||
relations_verified: 0, codes: 0, codes_pending: 0, business_dates: 0 }, skipped_count: 0, unfinished: 0, added: 0, updated: 0, removed: 0, version: 0,
|
||
error: null, started_at: null, finished_at: null })) })
|
||
|
||
function freshState() {
|
||
return { sources: sources(), changes: freshChanges(), jobs: [], activeDraft: null, activeProfile: null, draftJobs: {}, profileJobs: {},
|
||
maintenance: { steps: ['introspect', 'profile_needed', 'observe'], profile_enabled: false, maintained: true, scheduler_on: true, schedules: [] },
|
||
schedSeq: 0 }
|
||
}
|
||
const P = 'check-chg-[a-z]+'
|
||
const parts = (key) => key.split(' ')[1].split('/')
|
||
const view = (c) => clone(c)
|
||
|
||
function handlers(state) {
|
||
const findChange = (key) => state.changes.find((c) => c.id === parts(key)[4])
|
||
return [
|
||
[/^GET \/datasources$/, (route) => json(state.sources)(route)],
|
||
[/^GET \/datasources\/upkeep$/, (route) => json({ items: state.sources.map((s) => ({ source_id: s.id,
|
||
changes: s.id === S1 ? countsOf(state.changes) : countsOf([]),
|
||
maintenance: s.id === S1 ? state.maintenance.schedules.map((x) => ({ id: x.id, enabled: x.enabled, cron: x.cron, timezone: x.timezone,
|
||
description_text: x.description_text, next_run_at: x.next_run_at })) : [] })) })(route)],
|
||
[new RegExp(`^GET /datasources/${P}/catalog$`), (route) => json({
|
||
tables: Object.keys(TABLES).map(rowOf), system_notes: false, schema_note: null, schema_truncated: false, schema_total: 2, guidance: null, scope: null,
|
||
active_job: state.activeDraft ? (({ id, status, total, done, waiting_for }) => ({ id, status, total, done, waiting_for }))(state.activeDraft) : null,
|
||
active_profile_job: state.activeProfile ? (({ id, status, total, done, waiting_for }) => ({ id, status, total, done, waiting_for }))(state.activeProfile) : null,
|
||
})(route)],
|
||
[new RegExp(`^GET /datasources/${P}/catalog/draft-jobs/[^/]+$`), (route, { key }) => {
|
||
const job = state.draftJobs[parts(key).at(-1)]
|
||
return job ? json({ job })(route) : json({ detail: '起草任务不存在' }, 404)(route)
|
||
}],
|
||
[new RegExp(`^GET /datasources/${P}/catalog/profile-jobs/[^/]+$`), (route, { key }) => {
|
||
const job = state.profileJobs[parts(key).at(-1)]
|
||
return job ? json({ job })(route) : json({ detail: '剖析任务不存在' }, 404)(route)
|
||
}],
|
||
[new RegExp(`^GET /datasources/${P}/catalog/[^/]+/impact$`), (route, { key }) => json({ table: parts(key)[4], templates: [] })(route)],
|
||
[new RegExp(`^GET /datasources/${P}/catalog/[^/]+$`), (route, { key }) => {
|
||
const name = parts(key)[4]
|
||
return TABLES[name] ? json(detailOf(name))(route) : json({ detail: `数据源中没有表 ${name}` }, 404)(route)
|
||
}],
|
||
[new RegExp(`^GET /datasources/${P}/background-jobs$`), (route) => json({ items: state.jobs })(route)],
|
||
[new RegExp(`^GET /datasources/${P}/observations$`), (route, { url }) =>
|
||
json(url.searchParams.get('table') === 'orders' ? OBSERVATIONS : { table: url.searchParams.get('table'), series: [] })(route)],
|
||
[new RegExp(`^GET /datasources/${P}/changes$`), (route, { url }) => {
|
||
const status = url.searchParams.get('status') ?? 'pending'
|
||
const cat = url.searchParams.get('category')
|
||
const table = url.searchParams.get('table')
|
||
const limit = Number(url.searchParams.get('limit') ?? 100)
|
||
let rows = state.changes.filter((c) => (status === 'handled' ? !PENDING.has(c.status) : PENDING.has(c.status)))
|
||
if (cat) rows = rows.filter((c) => c.category === cat)
|
||
if (table) rows = rows.filter((c) => c.table === table)
|
||
rows = status === 'handled' ? rows.sort((a, b) => b.seq - a.seq) : rows.sort((a, b) => RANK[b.severity] - RANK[a.severity] || b.seq - a.seq)
|
||
return json({ items: rows.slice(0, limit).map((c) => view(c)), total: rows.length, counts: countsOf(state.changes) })(route)
|
||
}],
|
||
[new RegExp(`^GET /datasources/${P}/changes/[^/]+$`), (route, { key }) => {
|
||
const c = findChange(key)
|
||
return c ? json({ change: view(c) })(route) : json({ detail: '这条变化不存在,可能已随数据源删除' }, 404)(route)
|
||
}],
|
||
[new RegExp(`^POST /datasources/${P}/changes/[^/]+/resolve$`), (route, { key, body }) => {
|
||
const c = findChange(key)
|
||
if (!c) return json({ detail: '这条变化不存在' }, 404)(route)
|
||
if (!PENDING.has(c.status)) return json({ detail: '这条变化已经处理过了' }, 409)(route)
|
||
if (body?.action === 'dismiss' && !String(body.note ?? '').trim()) return json({ detail: '标为误报要写明理由' }, 422)(route)
|
||
const res = { by: ACTOR, at: isoNow(), action: body.action, note: body.note ?? null }
|
||
const out = {}
|
||
if (body.action === 'clean_catalog' || body.action === 'add_codes') {
|
||
TABLES[c.table].version += 1
|
||
res.catalog_version = TABLES[c.table].version
|
||
out.catalog_version = res.catalog_version
|
||
c.affects = { ...c.affects, catalog: c.affects.catalog.map((r) => ({ ...r, now: null })) }
|
||
}
|
||
if (body.action === 'redraft' || body.action === 'reprofile') {
|
||
const kind = body.action === 'redraft' ? 'draft' : 'profile'
|
||
res.job_id = kind === 'draft' ? 'check-djob-1' : 'check-pjob-1'
|
||
c.job = { kind, id: res.job_id }
|
||
out.job = c.job
|
||
if (kind === 'draft') { state.draftJobs['check-djob-1'] = draftJob('running', 1); state.activeDraft = state.draftJobs['check-djob-1'] }
|
||
}
|
||
c.status = body.action === 'acknowledge' ? 'acknowledged' : body.action === 'dismiss' ? 'dismissed' : 'resolved'
|
||
c.resolution = res
|
||
c.actions = c.status === 'acknowledged' ? c.actions.filter((a) => a.action !== 'acknowledge') : []
|
||
return json({ ...out, change: view(c) })(route)
|
||
}],
|
||
// ---- 定时维护
|
||
[new RegExp(`^GET /datasources/${P}/maintenance$`), (route, { key }) => json(parts(key)[2] === S2
|
||
? { ...state.maintenance, maintained: false, schedules: [] } : state.maintenance)(route)],
|
||
[new RegExp(`^PUT /datasources/${P}/maintenance$`), (route, { body }) => {
|
||
state.maintenance.steps = ['introspect', 'profile_needed', 'observe'].filter((s) => body.steps.includes(s))
|
||
return json(state.maintenance)(route)
|
||
}],
|
||
[/^POST \/schedules\/preview$/, (route, { body }) => {
|
||
if (!/^\d+ \d+ \* \* \*$/.test(body.cron) && body.cron !== '0 3 * * *') return json({ detail: '检查脚本只认每天的频率' }, 422)(route)
|
||
const [m, h] = body.cron.split(' ')
|
||
return json({ description_text: `每天 ${h.padStart(2, '0')}:${m.padStart(2, '0')}(${body.timezone})`, next: ['2026-10-17T19:00:00Z', '2026-10-18T19:00:00Z'], inputs: {} })(route)
|
||
}],
|
||
[/^POST \/schedules$/, (route, { body }) => {
|
||
const [m, h] = body.cron.split(' ')
|
||
const row = { id: `check-sched-${++state.schedSeq}`, name: body.name, target_kind: body.target_kind, target_id: body.target_id, target_label: '数据源维护',
|
||
target_name: NAME1, target_exists: true, workflow_id: '', workflow_name: NAME1, cron: body.cron, timezone: body.timezone, run_class: 'exploratory',
|
||
input_template: {}, enabled: body.enabled, overlap: 'skip', misfire_grace_s: body.misfire_grace_s, webhook: null,
|
||
next_run_at: '2026-10-17T19:00:00Z', last_fire: null, description_text: `每天 ${h.padStart(2, '0')}:${m.padStart(2, '0')}(${body.timezone})`,
|
||
created_by: ACTOR, created_at: isoNow(), updated_at: isoNow(), version: 1 }
|
||
state.maintenance.schedules.push(row)
|
||
return json(row, 201)(route)
|
||
}],
|
||
[/^PATCH \/schedules\/[^/]+$/, (route, { key, body }) => {
|
||
const row = state.maintenance.schedules.find((s) => s.id === parts(key)[2])
|
||
if (!row) return json({ detail: '定时任务不存在' }, 404)(route)
|
||
if (body.version !== row.version) return json({ detail: '这个定时任务刚被别人改过,请刷新后再改' }, 409)(route)
|
||
Object.assign(row, Object.fromEntries(Object.entries(body).filter(([k]) => k !== 'version')), { version: row.version + 1 })
|
||
if (body.cron) { const [m, h] = body.cron.split(' '); row.description_text = `每天 ${h.padStart(2, '0')}:${m.padStart(2, '0')}(${row.timezone})` }
|
||
if (body.enabled === false) row.next_run_at = null
|
||
return json(row)(route)
|
||
}],
|
||
]
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 页面上的定位
|
||
// ---------------------------------------------------------------------------
|
||
const queue = (page) => page.locator('[data-changes-queue]')
|
||
const detail = (page) => page.locator('[data-change-detail]')
|
||
const groupsOf = (page) => attrs(page.locator('[data-change-group]'), 'data-change-group')
|
||
const itemsOf = (page) => attrs(page.locator('[data-change-item]'), 'data-change-item')
|
||
const lastQuery = (sent, re) => sent.filter((s) => re.test(s.key)).at(-1)?.query ?? null
|
||
const openChange = async (page, id) => {
|
||
await page.locator(`[data-change-item="${id}"] [data-change-open]`).click()
|
||
await until(async () => (await detail(page).getAttribute('data-change-detail')) === id)
|
||
}
|
||
|
||
try {
|
||
const probe = await open('/data')
|
||
const overlay = await probe.page.locator('vite-error-overlay').count()
|
||
for (const c of opened) await c.close().catch(() => {})
|
||
opened.clear()
|
||
if (overlay) {
|
||
console.error('✗ 页面上是 vite 的报错层——有文件正在改,等一会儿再跑')
|
||
process.exit(1)
|
||
}
|
||
} catch (e) {
|
||
console.error(`✗ 打不开前端(${WEB})——先起沙箱前端\n ${e.message}`)
|
||
process.exit(1)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
await section('变化视图:分组、筛选、地址、标签上的计数', async () => {
|
||
const state = freshState()
|
||
const { page, sent, errors, natives } = await open(`/data/catalog/${S1}?view=changes`, { handlers: handlers(state) })
|
||
await queue(page).waitFor()
|
||
await until(async () => (await page.locator('[data-change-item]').count()) === 4)
|
||
check('三个视图:表清单、审阅队列、变化', JSON.stringify(await attrs(page.locator('[data-catalog-view-tab]'), 'data-catalog-view-tab'))
|
||
=== '["list","review","changes"]')
|
||
check('「变化」标签选中,带着 2 条要处理(读屏写全)', (await page.locator('[data-catalog-view-tab="changes"]').getAttribute('aria-selected')) === 'true'
|
||
&& (await page.locator('[data-catalog-changes-count]').getAttribute('data-catalog-changes-count')) === '2'
|
||
&& (await textOf(page.locator('[data-catalog-view-tab="changes"]'))).includes('2 条要处理'),
|
||
await textOf(page.locator('[data-catalog-view-tab="changes"]')))
|
||
check('按严重程度分组:要处理、提醒、说明', JSON.stringify(await groupsOf(page)) === '["action","warning","info"]', JSON.stringify(await groupsOf(page)))
|
||
check('组内照服务端给的先后', JSON.stringify(await itemsOf(page)) === '["chg-codes","chg-removed","chg-type","chg-rows"]', JSON.stringify(await itemsOf(page)))
|
||
check('组名写几条', (await textOf(page.locator('[data-change-group="action"] h3'))) === '要处理 2 条', await textOf(page.locator('[data-change-group="action"] h3')))
|
||
const row = page.locator('[data-change-item="chg-removed"]')
|
||
check('一条:服务端的一句说法、类别、测到的时间', (await textOf(row)).includes('orders.status:删除了列') && (await textOf(row)).includes('结构变化')
|
||
&& (await textOf(row)).includes('测到于'), await textOf(row))
|
||
check('……读屏先念严重程度', (await row.locator('.sr-only').first().innerText()).startsWith('要处理'))
|
||
check('开着的不带 status=handled,不按类别筛', lastQuery(sent, /\/changes$/)?.status === 'pending' && !lastQuery(sent, /\/changes$/)?.category)
|
||
|
||
await page.locator('[data-changes-cat="struct"]').click()
|
||
check('筛「结构变化」:请求带 category=struct,只剩两条结构变化', await until(async () => JSON.stringify(await itemsOf(page)) === '["chg-removed","chg-type"]')
|
||
&& lastQuery(sent, /\/changes$/)?.category === 'struct', JSON.stringify(await itemsOf(page)))
|
||
check('……筛选记进地址', new URL(page.url()).searchParams.get('ccat') === 'struct')
|
||
await page.locator('[data-changes-cat="value"]').click()
|
||
check('筛「取值变化」:只剩两条取值变化', await until(async () => JSON.stringify(await itemsOf(page)) === '["chg-codes","chg-rows"]'))
|
||
await page.locator('[data-changes-cat="all"]').click()
|
||
await page.locator('[data-changes-status="handled"]').click()
|
||
check('「处理过的」:请求带 status=handled,列出标为误报的那一条', await until(async () => JSON.stringify(await itemsOf(page)) === '["chg-stalled"]')
|
||
&& lastQuery(sent, /\/changes$/)?.status === 'handled' && new URL(page.url()).searchParams.get('cstatus') === 'handled')
|
||
check('……写着它的状态', (await textOf(page.locator('[data-change-item="chg-stalled"] [data-change-state]'))) === '标为误报')
|
||
await page.locator('[data-change-item="chg-stalled"] [data-change-open]').click()
|
||
await until(async () => (await detail(page).getAttribute('data-change-detail')) === 'chg-stalled')
|
||
check('点开处理过的:写处理结果(谁、动作、理由),不给动作', (await textOf(page.locator('[data-change-resolution]'))).includes('已标为误报 · 审核甲')
|
||
&& (await textOf(page.locator('[data-change-resolved-note]'))) === '理由:国庆假期停业' && await page.locator('[data-change-action]').count() === 0)
|
||
await page.reload({ waitUntil: 'networkidle' })
|
||
check('刷新:筛选和打开的那一条都还在', await until(async () => (await detail(page).getAttribute('data-change-detail')) === 'chg-stalled')
|
||
&& (await page.locator('[data-changes-status="handled"]').getAttribute('aria-checked')) === 'true')
|
||
await page.locator('[data-catalog-view-tab="list"]').click()
|
||
check('切回表清单:变化的参数都去掉', await until(async () => !new URL(page.url()).searchParams.has('cstatus') && !new URL(page.url()).searchParams.has('change')
|
||
&& !new URL(page.url()).searchParams.has('view')))
|
||
check('没有拦下的写请求', blocked(sent).length === 0, blocked(sent).map((s) => s.key).join('、'))
|
||
check('没有原生对话框、没有运行时报错', natives.length === 0 && errors.length === 0, [...natives, ...errors].join(' | '))
|
||
})
|
||
|
||
await section('变化:依据、前后、影响面、动作照服务端给的画', async () => {
|
||
const state = freshState()
|
||
const { page, errors } = await open(`/data/catalog/${S1}?view=changes`, { handlers: handlers(state) })
|
||
await until(async () => (await page.locator('[data-change-item]').count()) === 4)
|
||
await openChange(page, 'chg-removed')
|
||
const d = detail(page)
|
||
check('依据:定时维护的探查结构测到,比对的是结构第 2 版和第 3 版', (await textOf(d.locator('[data-change-detected]'))).startsWith('定时维护的探查结构')
|
||
&& (await textOf(d.locator('[data-change-basis="struct"]'))) === '比对的是这张表的结构第 2 版和第 3 版', await textOf(d.locator('[data-change-section="basis"]')))
|
||
check('前后:删掉的列之前是什么类型', (await textOf(d.locator('[data-change-facts]'))).includes('VARCHAR(10)'))
|
||
check('影响面:说到这一列的两项目录(测到时和现在的状态),人工确认过的标出来', await d.locator('[data-change-ref]').count() === 2
|
||
&& (await textOf(d.locator('[data-change-ref="orders::columns.status.codes"]'))).includes('现在:已确认')
|
||
&& await d.locator('[data-change-ref-needs]').count() === 2)
|
||
check('……写死这张表的已发布模板:「这条 SQL 现在会报列不存在(status)」', (await textOf(d.locator('[data-change-node="q_orders"]'))).includes('这条 SQL 现在会报列不存在(status)')
|
||
&& (await d.locator('[data-change-node="q_orders"]').getAttribute('data-change-breaks')) === '1')
|
||
check('……模板链到编排页那个节点', (await d.locator('[data-change-template] a').first().getAttribute('href')) === '/studio/wf-check-weekly?focus=q_orders')
|
||
check('……没能试算的口径照实写缺哪几个参数', (await textOf(d.locator('[data-change-probe="wf-check-weekly"]')))
|
||
.includes('「门店周报」v3:缺少参数 start、end,没有试算'))
|
||
check('……有效记录条件用到了这一列', (await textOf(d.locator('[data-change-rules]'))).includes('有效记录条件用到了这一列'))
|
||
check('动作照服务端给的:清理目录项、重新起草、重新剖析、知道了、误报', JSON.stringify(await attrs(d.locator('[data-change-action]'), 'data-change-action'))
|
||
=== '["clean_catalog","redraft","reprofile","acknowledge","dismiss"]')
|
||
check('……做不了的不能点,写原因(剖析没开)', await d.locator('[data-change-action="reprofile"]').isDisabled()
|
||
&& (await textOf(d.locator('[data-change-why="reprofile"]'))).includes('未开启数据剖析'), await textOf(d.locator('[data-change-why="reprofile"]')))
|
||
await openChange(page, 'chg-codes')
|
||
check('取值变化的依据写观察口径,只和同口径比', (await textOf(d.locator('[data-change-basis="value"]'))).startsWith('观察口径:按存储顺序取前 100000 行')
|
||
&& (await textOf(d.locator('[data-change-basis="value"]'))).includes('只和口径相同的历史观察比'))
|
||
check('前后:新码值和占比、撤销了「已列全」(目录第 5 版)', (await textOf(d.locator('[data-change-facts]'))).includes('app(占 40%)')
|
||
&& (await textOf(d.locator('[data-change-revoked]'))).includes('撤销了码值项的「已列全」(目录第 5 版)'), await textOf(d.locator('[data-change-facts]')))
|
||
check('……影响面空的照实说', (await textOf(d.locator('[data-change-impact-none]'))).length > 0)
|
||
await openChange(page, 'chg-type')
|
||
check('类型变了:前后两种写法', (await textOf(d.locator('[data-change-facts]'))).includes('NUMERIC(10, 2) → NUMERIC(12, 2)'))
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
})
|
||
|
||
await section('变化:各种处理动作的请求体、误报必须写理由、处理完照实写结果', async () => {
|
||
const state = freshState()
|
||
const { page, sent, errors, natives } = await open(`/data/catalog/${S1}?view=changes`, { handlers: handlers(state) })
|
||
await until(async () => (await page.locator('[data-change-item]').count()) === 4)
|
||
const d = detail(page)
|
||
// 误报:理由没写之前按钮不能点
|
||
await openChange(page, 'chg-type')
|
||
await d.locator('[data-change-action="dismiss"]').click()
|
||
check('误报:先写理由,没写之前「标为误报」不能点', await d.locator('[data-change-note]').isVisible() && await d.locator('[data-change-note-submit]').isDisabled())
|
||
await d.locator('[data-change-note]').fill(' ')
|
||
check('……只填空白也不能点', await d.locator('[data-change-note-submit]').isDisabled())
|
||
await d.locator('[data-change-note]').fill('测试库里改的类型')
|
||
await d.locator('[data-change-note-submit]').click()
|
||
await until(async () => (await d.getAttribute('data-change-status')) === 'dismissed')
|
||
check('……请求体是 {action: dismiss, note}', JSON.stringify(writes(sent, /chg-type\/resolve$/).at(-1)?.body) === '{"action":"dismiss","note":"测试库里改的类型"}',
|
||
JSON.stringify(writes(sent, /chg-type\/resolve$/).at(-1)?.body))
|
||
check('……处理完写结果和理由,左栏这一条移走', (await textOf(d.locator('[data-change-resolved-note]'))) === '理由:测试库里改的类型'
|
||
&& await until(async () => !(await itemsOf(page)).includes('chg-type')))
|
||
|
||
// 清理目录项:先确认,确认后请求体只有动作,结果写目录第几版、影响面里的项现在不在目录里了
|
||
await openChange(page, 'chg-removed')
|
||
await d.locator('[data-change-action="clean_catalog"]').click()
|
||
const dlg = page.locator('[role="alertdialog"], [role="dialog"]').last()
|
||
await until(async () => (await textOf(dlg)).includes('清理 2 项目录'))
|
||
check('清理目录项:先确认,写清后果', (await textOf(dlg)).includes('目录升一版') && writes(sent, /chg-removed\/resolve$/).length === 0, await textOf(dlg))
|
||
await dlg.getByRole('button', { name: '清理', exact: true }).click()
|
||
await until(async () => (await d.getAttribute('data-change-status')) === 'resolved')
|
||
check('……请求体是 {action: clean_catalog}', JSON.stringify(writes(sent, /chg-removed\/resolve$/).at(-1)?.body) === '{"action":"clean_catalog"}')
|
||
check('……结果写目录第 6 版、谁处理的', (await d.locator('[data-change-resolved-version]').getAttribute('data-change-resolved-version')) === '6'
|
||
&& (await textOf(d.locator('[data-change-resolution]'))).includes(`已清理目录项 · ${ACTOR}`))
|
||
check('……影响面里的项现在不在目录里了', JSON.stringify(await attrs(d.locator('[data-change-ref]'), 'data-change-ref-now')) === '["gone","gone"]')
|
||
check('……动了目录:重取了清单', sent.filter((s) => /^GET .*\/catalog$/.test(s.key)).length >= 2)
|
||
check('……标签上的计数跟着少了一条', await until(async () => (await page.locator('[data-catalog-changes-count]').getAttribute('data-catalog-changes-count')) === '1'))
|
||
|
||
// 加入码值:表单里列出新码值,含义可以先填,请求体带 codes
|
||
await openChange(page, 'chg-codes')
|
||
await d.locator('[data-change-action="add_codes"]').click()
|
||
check('加入码值:列出新出现的码值,含义可以先空着', await d.locator('[data-change-code-input="app"]').isVisible())
|
||
await d.locator('[data-change-code-input="app"]').fill('应用内')
|
||
await d.locator('[data-change-codes-submit]').click()
|
||
await until(async () => (await d.getAttribute('data-change-status')) === 'resolved')
|
||
check('……请求体带码值和含义', JSON.stringify(writes(sent, /chg-codes\/resolve$/).at(-1)?.body) === '{"action":"add_codes","codes":{"app":"应用内"}}',
|
||
JSON.stringify(writes(sent, /chg-codes\/resolve$/).at(-1)?.body))
|
||
|
||
// 知道了:只改状态,还留在开着的里、不能再点知道了
|
||
await openChange(page, 'chg-rows')
|
||
await d.locator('[data-change-action="acknowledge"]').click()
|
||
await until(async () => (await d.getAttribute('data-change-status')) === 'acknowledged')
|
||
check('知道了:请求体只有动作,留在开着的里,不再给「知道了」', JSON.stringify(writes(sent, /chg-rows\/resolve$/).at(-1)?.body) === '{"action":"acknowledge"}'
|
||
&& await d.locator('[data-change-action="acknowledge"]').count() === 0 && (await itemsOf(page)).includes('chg-rows')
|
||
&& (await textOf(page.locator('[data-change-item="chg-rows"] [data-change-state]'))) === '知道了,还没处理')
|
||
|
||
// 服务端拒了(误报没写理由的 422 这种):原话写在处理一节里
|
||
state.changes.find((c) => c.id === 'chg-rows').status = 'open'
|
||
check('没有拦下的写请求、没有原生对话框、没有运行时报错', blocked(sent).length === 0 && natives.length === 0 && errors.length === 0,
|
||
[...blocked(sent).map((s) => s.key), ...natives, ...errors].join(' | '))
|
||
})
|
||
|
||
await section('变化:重新起草建了任务,点得进进度', async () => {
|
||
const state = freshState()
|
||
state.changes = state.changes.filter((c) => c.id !== 'chg-codes')
|
||
const { page, sent, errors } = await open(`/data/catalog/${S1}?view=changes&change=chg-type`, { handlers: handlers(state) })
|
||
const d = detail(page)
|
||
await until(async () => (await d.getAttribute('data-change-detail')) === 'chg-type')
|
||
await d.locator('[data-change-action="redraft"]').click()
|
||
await until(async () => (await d.getAttribute('data-change-status')) === 'resolved')
|
||
check('重新起草:请求体 {action: redraft}', JSON.stringify(writes(sent, /chg-type\/resolve$/).at(-1)?.body) === '{"action":"redraft"}')
|
||
check('……结果写起草任务的编号', (await textOf(d.locator('[data-change-job]'))).includes('起草任务 #check-'))
|
||
await d.locator('[data-change-job-open]').click()
|
||
check('……「查看进度」打开起草的进度', await until(async () => (await page.locator('[data-draft-dialog]').getAttribute('data-draft-dialog')) === 'running'),
|
||
await page.locator('[data-draft-dialog]').getAttribute('data-draft-dialog').catch(() => ''))
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
})
|
||
|
||
await section('表详情:结构与取值变化一节', async () => {
|
||
const state = freshState()
|
||
const { page, errors } = await open(`/data/catalog/${S1}/orders`, { handlers: handlers(state) })
|
||
const sec = page.locator('[data-catalog-section="changes"]')
|
||
await sec.waitFor()
|
||
await until(async () => (await sec.locator('[data-table-change]').count()) > 0)
|
||
check('这张表的变化:开着的在前,处理过的也列上', JSON.stringify(await attrs(sec.locator('[data-table-change]'), 'data-table-change'))
|
||
=== '["chg-codes","chg-removed","chg-type","chg-stalled"]', JSON.stringify(await attrs(sec.locator('[data-table-change]'), 'data-table-change')))
|
||
check('……撤销过「已列全」照实说(哪一列、目录第几版)', (await textOf(sec.locator('[data-table-revoked]'))).includes('channel')
|
||
&& (await textOf(sec.locator('[data-table-revoked]'))).includes('撤销了「已列全」(目录第 5 版)'), await textOf(sec.locator('[data-table-revoked]')))
|
||
check('最近几次观察:行数、码值分布、最新业务日期、按日行数', JSON.stringify(await attrs(sec.locator('[data-table-observe]'), 'data-table-observe'))
|
||
=== '["rows:","codes_observe:channel","freshness:created_at","daily_volume:created_at"]')
|
||
const codes = sec.locator('[data-table-observe="codes_observe:channel"]')
|
||
check('……各写口径(照实写块采样、前 N 行、窗口)', (await textOf(codes.locator('[data-observe-basis]'))) === '口径:按存储顺序取前 100000 行'
|
||
&& (await textOf(sec.locator('[data-table-observe="daily_volume:created_at"] [data-observe-basis]'))) === '口径:业务日期 created_at 最近 35 天')
|
||
check('……码值分布照服务端的占比写', (await textOf(codes.locator('[data-observe-value]'))).includes('web 50% · app 40% · shop 10%'), await textOf(codes.locator('[data-observe-value]')))
|
||
check('……口径换过的说一句不互相比较', (await codes.getAttribute('data-observe-basis-changed')) === '1'
|
||
&& (await textOf(codes.locator('[data-observe-basis-note]'))).includes('口径不同的不互相比较'))
|
||
check('……行数写数、往前几次收着', (await textOf(sec.locator('[data-table-observe="rows:"] [data-observe-value]'))) === '1,200 行'
|
||
&& await sec.locator('[data-table-observe="rows:"] details').count() === 1)
|
||
const bars = sec.locator('[data-observe-bars]')
|
||
check('……按日行数的小柱图:断了的两天也在(5 天),读屏名称写数', (await bars.getAttribute('data-observe-bars')) === '5'
|
||
&& (await bars.getAttribute('aria-label')).includes('有数据的 3 天,共 123 行'), await bars.getAttribute('aria-label').catch(() => ''))
|
||
await sec.locator('[data-table-change="chg-removed"] [data-table-change-open]').click()
|
||
check('「去处理」:到变化视图打开那一条', await until(async () => (await detail(page).getAttribute('data-change-detail')) === 'chg-removed')
|
||
&& new URL(page.url()).searchParams.get('view') === 'changes' && new URL(page.url()).searchParams.get('change') === 'chg-removed')
|
||
await detail(page).locator('[data-change-open-table="orders"]').click()
|
||
check('……从变化回到这张表:表清单视图,地址里不再带变化', await until(async () => new URL(page.url()).pathname.endsWith('/orders')
|
||
&& !new URL(page.url()).searchParams.has('view') && await page.locator('[data-catalog-section="changes"]').count() === 1),
|
||
`${page.url()} · ${await page.locator('[data-catalog-section="changes"]').count()}`)
|
||
await goto(page, `/data/catalog/${S1}/customers`)
|
||
await until(async () => (await page.locator('[data-catalog-section="changes"] [data-table-observe-none]').count()) === 1)
|
||
check('没有观察的表照实说', (await textOf(page.locator('[data-table-observe-none]'))).includes('还没有取值观察'))
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
})
|
||
|
||
await section('数据源卡片:要处理的角标', async () => {
|
||
const state = freshState()
|
||
const { page, errors } = await open('/data/databases', { handlers: handlers(state) })
|
||
const card = page.locator(`[data-source="${NAME1}"]`)
|
||
await card.waitFor()
|
||
const badge = card.locator('[data-source-changes]')
|
||
check('角标写几个变化要处理', await until(async () => (await badge.getAttribute('data-source-changes')) === '2') && (await textOf(badge)) === '2 个变化要处理')
|
||
check('……读屏写全:哪个源、几条、点了去哪', (await badge.getAttribute('aria-label')) === `数据源「${NAME1}」有 2 个变化要处理,打开数据目录的「变化」`)
|
||
await badge.click()
|
||
check('……点开到数据目录的「变化」视图', await until(async () => new URL(page.url()).pathname === `/data/catalog/${S1}`
|
||
&& new URL(page.url()).searchParams.get('view') === 'changes') && await until(async () => (await page.locator('[data-change-item]').count()) === 4))
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
})
|
||
|
||
await section('定时维护:缺省关、建一条维护定时、改频率和开关、步骤', async () => {
|
||
const state = freshState()
|
||
const { page, sent, errors, natives } = await open('/data/databases', { handlers: handlers(state) })
|
||
const card = page.locator(`[data-source="${NAME1}"]`)
|
||
await card.waitFor()
|
||
check('卡片上没有维护定时时不画「定时维护」标签', await card.locator('[data-maintenance-chip]').count() === 0)
|
||
check('导入表格的源不给定时维护', await page.goto(`${WEB}/data/tables`, { waitUntil: 'networkidle' }).then(async () =>
|
||
(await page.locator(`[data-source="${NAME2}"]`).waitFor().then(() => true)) && await page.locator(`[data-source="${NAME2}"] [data-maintenance-open]`).count() === 0))
|
||
await goto(page, '/data/databases')
|
||
await card.locator('[data-maintenance-open]').click()
|
||
const dlg = page.locator('[data-maintenance-dialog]')
|
||
await dlg.waitFor()
|
||
check('缺省关闭,不提示低峰时段', (await dlg.getAttribute('data-maintenance-dialog')) === 'off' && await dlg.locator('[data-maintenance-offpeak]').count() === 0)
|
||
check('三步都选着:探查结构、剖析需要的表、取值观察', JSON.stringify(await dlg.locator('[data-maintenance-step] input').evaluateAll((els) => els.map((e) => e.checked)))
|
||
=== '[true,true,true]' && JSON.stringify(await attrs(dlg.locator('[data-maintenance-step]'), 'data-maintenance-step')) === '["introspect","profile_needed","observe"]')
|
||
check('剖析没开:照实说到点时剖析、观察两步不执行', (await textOf(dlg.locator('[data-maintenance-profile-off]'))).includes('两步不执行'))
|
||
check('缺省时段是凌晨 03:00', (await page.locator('#maint-time').inputValue()) === '03:00')
|
||
await dlg.locator('[data-maintenance-enabled] [role="switch"]').click()
|
||
check('开启:提示选低峰时段', await until(async () => (await dlg.locator('[data-maintenance-offpeak]').count()) === 1)
|
||
&& (await textOf(dlg.locator('[data-maintenance-offpeak]'))).includes('低峰时段'))
|
||
check('……频率的说法和接下来几次由服务端算', await until(async () => (await textOf(page.locator('[data-maintenance-preview]'))).includes('每天 03:00')))
|
||
await page.locator('[data-maintenance-save]').click()
|
||
await until(async () => writes(sent, /^POST \/schedules$/).length === 1)
|
||
const created = writes(sent, /^POST \/schedules$/)[0].body
|
||
check('保存:建一条 target_kind=source_maintenance、target_id=数据源的定时任务', created.target_kind === 'source_maintenance' && created.target_id === S1
|
||
&& created.cron === '0 3 * * *' && created.timezone === 'Asia/Shanghai' && created.enabled === true && created.overlap === 'skip'
|
||
&& created.misfire_grace_s === 3600 && !('workflow_id' in created) && !('webhook' in created) && created.name.includes(NAME1), JSON.stringify(created))
|
||
check('……步骤没改不发 PUT /maintenance', writes(sent, /\/maintenance$/).length === 0)
|
||
check('……保存后照服务端写下一次什么时候、还没有触发过', await until(async () => (await dlg.locator('[data-maintenance-next]').count()) === 1)
|
||
&& (await textOf(dlg.locator('[data-maintenance-last]'))).includes('还没有触发过'))
|
||
// 最近一次触发:建了探查任务、剖析两步没执行和原因
|
||
state.maintenance.schedules[0].last_fire = { id: 'fire-1', fire_at: ago(0.1), kind: 'scheduled', status: 'started', run_id: null, run_status: null,
|
||
reason: '剖析需要的表没有执行:未开启;取值观察没有执行:未开启', notified: [], compare: null, created_at: ago(0.1),
|
||
inputs: { steps: ['introspect', 'profile_needed', 'observe'], jobs: [{ step: 'introspect', kind: 'introspect', id: 'check-ijob-7', tables: 12 }],
|
||
skipped: [{ step: 'profile_needed', reason: NOT_PROFILED }, { step: 'observe', reason: NOT_PROFILED }] } }
|
||
await page.getByRole('button', { name: '取消', exact: true }).click()
|
||
check('卡片上带「定时维护 · 每天 03:00」标签', await until(async () => (await textOf(card.locator('[data-maintenance-chip]'))).includes('定时维护 · 每天 03:00')))
|
||
await card.locator('[data-maintenance-open]').click()
|
||
await dlg.waitFor()
|
||
check('再打开:开着,最近一次建了哪个任务(几张表)', (await dlg.getAttribute('data-maintenance-dialog')) === 'on'
|
||
&& (await textOf(dlg.locator('[data-maintenance-job="check-ijob-7"]'))).includes('探查结构:探查任务 #check-'), await textOf(dlg.locator('[data-maintenance-last]')))
|
||
check('……哪几步没执行、原因照服务端', (await textOf(dlg.locator('[data-maintenance-skipped="profile_needed"]'))).startsWith('剖析需要的表没有执行:')
|
||
&& (await textOf(dlg.locator('[data-maintenance-skipped="observe"]'))).includes('未开启数据剖析'))
|
||
check('……链到定时任务页的触发记录', (await dlg.locator('[data-maintenance-history]').getAttribute('href')) === '/schedules/check-sched-1')
|
||
check('……探查任务没有另外的进度页,不给点了没用的「查看进度」', await dlg.locator('[data-maintenance-job="check-ijob-7"] [data-maintenance-job-open]').count() === 0)
|
||
// 改频率:PATCH 只带改了的和版本
|
||
await page.locator('#maint-time').fill('02:30')
|
||
await until(async () => (await textOf(page.locator('[data-maintenance-preview]'))).includes('每天 02:30'))
|
||
await page.locator('[data-maintenance-save]').click()
|
||
await until(async () => writes(sent, /^PATCH \/schedules\//).length === 1)
|
||
check('改频率:PATCH 只带 cron 和读到的版本', JSON.stringify(writes(sent, /^PATCH \/schedules\//)[0].body) === '{"version":1,"cron":"30 2 * * *"}',
|
||
JSON.stringify(writes(sent, /^PATCH \/schedules\//)[0].body))
|
||
// 改步骤、关掉:PUT 步骤,PATCH 开关(带新版本)
|
||
await until(async () => (await dlg.getAttribute('data-maintenance-dialog')) === 'on')
|
||
await dlg.locator('[data-maintenance-step="observe"] input').uncheck()
|
||
await dlg.locator('[data-maintenance-enabled] [role="switch"]').click()
|
||
await page.locator('[data-maintenance-save]').click()
|
||
await until(async () => writes(sent, /^PATCH \/schedules\//).length === 2)
|
||
check('改步骤:PUT /maintenance 只带选着的两步', JSON.stringify(writes(sent, /\/maintenance$/).at(-1)?.body) === '{"steps":["introspect","profile_needed"]}',
|
||
JSON.stringify(writes(sent, /\/maintenance$/).at(-1)?.body))
|
||
check('关掉:PATCH 带 enabled=false 和新的版本', JSON.stringify(writes(sent, /^PATCH \/schedules\//)[1].body) === '{"version":2,"enabled":false}',
|
||
JSON.stringify(writes(sent, /^PATCH \/schedules\//)[1].body))
|
||
await until(async () => (await dlg.getAttribute('data-maintenance-dialog')) === 'off')
|
||
// 一步都不选:不能保存
|
||
for (const s of ['introspect', 'profile_needed']) await dlg.locator(`[data-maintenance-step="${s}"] input`).uncheck()
|
||
check('一步都不选:写明至少选一步,不能保存', await page.locator('[data-maintenance-save]').isDisabled()
|
||
&& (await textOf(dlg.locator('#maint-steps-hint'))) === '至少选一步')
|
||
// 有没存的改动:关的时候先问,放弃修改才关
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('button', { name: '放弃修改', exact: true }).click()
|
||
await until(async () => (await page.locator('[data-maintenance-dialog]').count()) === 0)
|
||
check('卡片上的标签照实写已暂停', await until(async () => (await textOf(card.locator('[data-maintenance-chip]'))) === '定时维护 · 已暂停'))
|
||
// 最近一次建了剖析任务:「查看进度」到数据目录页打开那个剖析任务(地址里的 pjob 用过就去掉)
|
||
state.profileJobs['check-pjob-1'] = profileJob('running', 1)
|
||
state.maintenance.schedules[0].last_fire = { ...state.maintenance.schedules[0].last_fire, inputs: { steps: ['introspect', 'profile_needed'],
|
||
jobs: [{ step: 'introspect', kind: 'introspect', id: 'check-ijob-8', tables: 12 }, { step: 'profile_needed', kind: 'profile', id: 'check-pjob-1', tables: 8 }],
|
||
skipped: [] } }
|
||
await card.locator('[data-maintenance-open]').click()
|
||
await dlg.waitFor()
|
||
await dlg.locator('[data-maintenance-job="check-pjob-1"] [data-maintenance-job-open]').click()
|
||
check('剖析任务的「查看进度」:到数据目录页打开那个剖析任务', await until(async () => new URL(page.url()).pathname === `/data/catalog/${S1}`
|
||
&& (await page.locator('[data-profile-dialog]').count()) === 1) && await until(async () => !new URL(page.url()).searchParams.has('pjob')),
|
||
page.url())
|
||
check('没有拦下的写请求、没有原生对话框、没有运行时报错', blocked(sent).length === 0 && natives.length === 0 && errors.length === 0,
|
||
[...blocked(sent).map((s) => s.key), ...natives, ...errors].join(' | '))
|
||
})
|
||
|
||
// 指名一张卡片的地址全站只有一种写法:/data/databases#source-<id>,带 &maintenance 时打开它的「定时维护」(复核 W4R-04:定时
|
||
// 任务页上维护定时的「去数据源页设置」以前链的是 ?source=<id>,数据源页不认,落在列表顶上)
|
||
await section('链到一张卡片:#source-<id> 滚过去,带 &maintenance 打开定时维护', async () => {
|
||
const state = freshState()
|
||
// 前面垫几张别的库,指名的那张落在第一屏下面:不滚过去就看不见
|
||
const fillers = 'abcdefghij'.split('').map((x) => ({ ...state.sources[0], id: `check-chg-fill${x}`, name: `zzchgfill${x}`,
|
||
database: `/tmp/check/zzchgfill${x}.db`, tools: [`db_query__zzchgfill${x}`] }))
|
||
state.sources = [...fillers, ...state.sources]
|
||
const inView = (page, id) => page.locator(`[id="source-${id}"]`).evaluate((el) => {
|
||
const r = el.getBoundingClientRect()
|
||
return r.top >= 0 && r.bottom <= window.innerHeight
|
||
}).catch(() => false)
|
||
// 从别的页链过来:先离开本页再进,免得只换 # 的同页跳转
|
||
const land = async (page, path) => { await page.goto('about:blank'); await goto(page, path) }
|
||
const { page, sent, errors, natives } = await open('/data/databases', { handlers: handlers(state), viewport: { width: 1280, height: 720 } })
|
||
await page.locator(`[data-source="${NAME1}"]`).waitFor()
|
||
check('(对照)不带地址落在列表顶上:那张卡片在第一屏下面', !(await inView(page, S1)))
|
||
const dlg = page.locator('[data-maintenance-dialog]')
|
||
await land(page, `/data/databases#source-${S1}&maintenance`)
|
||
check('带 &maintenance:打开那张卡片的「定时维护」', await until(async () => (await dlg.count()) === 1)
|
||
&& (await textOf(page.getByRole('dialog'))).includes(`「${NAME1}」的定时维护`), await textOf(page.getByRole('dialog')))
|
||
check('……卡片滚到眼前', await until(() => inView(page, S1)))
|
||
check('……用过的 &maintenance 从地址里去掉,只留 #source-<id>', await until(async () => new URL(page.url()).hash === `#source-${S1}`), page.url())
|
||
await page.keyboard.press('Escape')
|
||
await until(async () => (await dlg.count()) === 0)
|
||
await page.reload({ waitUntil: 'networkidle' })
|
||
await page.locator(`[data-source="${NAME1}"]`).waitFor()
|
||
await page.waitForTimeout(500)
|
||
check('刷新:只滚过去,不再打开定时维护', await until(() => inView(page, S1)) && await dlg.count() === 0)
|
||
await land(page, '/data/databases#source-check-chg-gone&maintenance')
|
||
check('指名的数据源不在了:照实说,不打开对话框', await until(async () => (await page.locator('[data-toast]')
|
||
.filter({ hasText: '没找到这个数据源,可能已经删除' }).count()) === 1) && await dlg.count() === 0)
|
||
await land(page, `/data/tables#source-${S2}&maintenance`)
|
||
await page.locator(`[data-source="${NAME2}"]`).waitFor()
|
||
await page.waitForTimeout(500)
|
||
check('导入表格的源不做定时维护:只滚过去,不打开对话框', await dlg.count() === 0
|
||
&& await until(async () => new URL(page.url()).hash === `#source-${S2}`), page.url())
|
||
// 对话框打开时会算一次频率的说法(POST /schedules/preview,纯计算),不算写
|
||
const saved = writes(sent, /./).filter((s) => s.key !== 'POST /schedules/preview')
|
||
check('只是打开、没有保存:没有写请求、没有原生对话框、没有运行时报错', saved.length === 0 && blocked(sent).length === 0 && natives.length === 0
|
||
&& errors.length === 0, [...saved.map((s) => s.key), ...blocked(sent).map((s) => s.key), ...natives, ...errors].join(' | '))
|
||
})
|
||
|
||
await section('后台任务条:按服务端的先后,页头只做入口', async () => {
|
||
const state = freshState()
|
||
state.jobs = clone(JOBS_A)
|
||
state.activeDraft = draftJob('queued')
|
||
state.activeProfile = profileJob('queued')
|
||
state.draftJobs['check-djob-1'] = state.activeDraft
|
||
state.profileJobs['check-pjob-1'] = state.activeProfile
|
||
const { page, errors } = await open(`/data/catalog/${S1}`, { handlers: handlers(state) })
|
||
const strip = page.locator('[data-catalog-job-strip]')
|
||
await until(async () => (await strip.locator('[data-catalog-job]').count()) === 3)
|
||
check('一条任务条,按服务端的先后:探查、起草、剖析', JSON.stringify(await attrs(strip.locator('[data-catalog-job]'), 'data-catalog-job'))
|
||
=== '["introspect","draft","profile"]', JSON.stringify(await attrs(strip.locator('[data-catalog-job]'), 'data-catalog-job')))
|
||
const texts = await strip.locator('[data-catalog-job-text]').allInnerTexts()
|
||
check('……每项一行:「探查 3/20」「起草 排队中,探查结束后开始」「剖析 排队中,起草结束后开始」',
|
||
JSON.stringify(texts) === JSON.stringify(['探查 3/20', '起草 排队中,探查结束后开始', '剖析 排队中,起草结束后开始']), JSON.stringify(texts))
|
||
check('……进度条读屏可读', (await strip.locator('[data-catalog-job="introspect"] [role="progressbar"]').getAttribute('aria-valuenow')) === '3')
|
||
const draftHead = page.locator('[data-catalog-draft-head]')
|
||
const profileHead = page.locator('[data-catalog-profile]')
|
||
check('页头只做入口:读屏写状态、不写数字', (await draftHead.getAttribute('aria-label')) === '起草(排队中)'
|
||
&& (await profileHead.getAttribute('aria-label')) === '数据剖析(排队中)'
|
||
&& !/\d/.test(`${await draftHead.innerText()}${await profileHead.innerText()}`), `${await draftHead.getAttribute('aria-label')} · ${await profileHead.getAttribute('aria-label')}`)
|
||
check('……进度只在任务条上一处(页头没有进度数)', await page.locator('[data-catalog-page] header [data-progress]').count() === 0)
|
||
|
||
// 反过来排:剖析在做(最近查询多数超时),起草等剖析
|
||
state.jobs = clone(JOBS_B)
|
||
state.activeProfile = profileJob('running', 2, PROFILE_NOTICE)
|
||
state.profileJobs['check-pjob-1'] = state.activeProfile
|
||
state.activeDraft = draftJob('queued')
|
||
state.draftJobs['check-djob-1'] = state.activeDraft
|
||
await goto(page, `/data/catalog/${S1}`)
|
||
await until(async () => (await strip.locator('[data-catalog-job]').count()) === 2)
|
||
const texts2 = await strip.locator('[data-catalog-job-text]').allInnerTexts()
|
||
check('反过来排:「剖析 2/8」「起草 排队中,剖析结束后开始」', JSON.stringify(texts2) === JSON.stringify(['剖析 2/8', '起草 排队中,剖析结束后开始']), JSON.stringify(texts2))
|
||
check('……剖析的「最近查询多数超时」在任务条上写全文(窄屏、触屏也看得见)', await until(async () =>
|
||
(await textOf(strip.locator('[data-catalog-job="profile"] [data-catalog-job-notice]'))) === PROFILE_NOTICE))
|
||
check('……页头的剖析入口进行中:读屏写「进行中」,不带提醒标记', (await profileHead.getAttribute('aria-label')) === '数据剖析(进行中)'
|
||
&& await page.locator('[data-catalog-profile-notice]').count() === 0)
|
||
await strip.locator('[data-catalog-job="profile"] [data-catalog-job-open]').click()
|
||
check('「查看」打开剖析的进度', await until(async () => (await page.locator('[data-profile-dialog]').count()) === 1))
|
||
await page.keyboard.press('Escape')
|
||
await until(async () => (await page.locator('[data-profile-dialog]').count()) === 0)
|
||
await strip.locator('[data-catalog-job="draft"] [data-catalog-job-open]').click()
|
||
check('「查看」打开起草的进度', await until(async () => (await page.locator('[data-draft-dialog]').count()) === 1))
|
||
await page.keyboard.press('Escape')
|
||
// 探查的「查看」:到数据源卡片上
|
||
state.jobs = clone(JOBS_A)
|
||
await goto(page, `/data/catalog/${S1}`)
|
||
await until(async () => (await strip.locator('[data-catalog-job="introspect"]').count()) === 1)
|
||
await strip.locator('[data-catalog-job="introspect"] [data-catalog-job-open]').click()
|
||
check('探查的「查看」:到数据源页那张卡片', await until(async () => new URL(page.url()).pathname === '/data/databases'
|
||
&& new URL(page.url()).hash === `#source-${S1}`) && await until(async () => (await page.locator(`#source-${S1}`).count()) === 1))
|
||
// 都结束了:任务条收起
|
||
state.jobs = []
|
||
state.activeDraft = null
|
||
state.activeProfile = null
|
||
await goto(page, `/data/catalog/${S1}`)
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
check('没有排队、进行中的任务:没有任务条,页头是普通入口', await page.locator('[data-catalog-job-strip]').count() === 0
|
||
&& (await draftHead.getAttribute('aria-label')) === '起草' && (await profileHead.getAttribute('aria-label')) === '数据剖析')
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
})
|
||
|
||
await section('窄屏:变化视图、任务条、表详情一节不撑宽页面', async () => {
|
||
const state = freshState()
|
||
state.jobs = clone(JOBS_B)
|
||
state.activeProfile = profileJob('running', 2, PROFILE_NOTICE)
|
||
state.profileJobs['check-pjob-1'] = state.activeProfile
|
||
state.activeDraft = draftJob('queued')
|
||
state.draftJobs['check-djob-1'] = state.activeDraft
|
||
const { page, errors } = await open(`/data/catalog/${S1}?view=changes`, { handlers: handlers(state), viewport: { width: 390, height: 844 } })
|
||
await until(async () => (await page.locator('[data-change-item]').count()) === 4)
|
||
await until(async () => (await page.locator('[data-catalog-job-notice]').count()) === 1)
|
||
check('变化列表、任务条(带着提醒)不横向滚动', await overflow(page) <= 1, String(await overflow(page)))
|
||
await shot(page, 'changes-narrow-list')
|
||
await openChange(page, 'chg-removed')
|
||
check('一次只显示一栏:打开一条时列表收起,详情不横向滚动', !(await queue(page).isVisible()) && await overflow(page) <= 1)
|
||
await detail(page).locator('[data-change-back]').click()
|
||
check('「返回变化列表」回到列表', await until(async () => queue(page).isVisible()))
|
||
await goto(page, `/data/catalog/${S1}/orders`)
|
||
await page.locator('[data-catalog-section="changes"] [data-table-observe]').first().waitFor()
|
||
await page.locator('[data-catalog-section="changes"]').scrollIntoViewIfNeeded()
|
||
check('表详情的「结构与取值变化」不横向滚动', await overflow(page) <= 1, String(await overflow(page)))
|
||
await shot(page, 'changes-narrow-table')
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
})
|
||
|
||
await browser.close()
|
||
const after = await realSources()
|
||
check('真库里的数据源数量没有变化', after === before, `${before} → ${after}`)
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 数据源的变化与定时维护全部通过')
|
||
process.exit(failed ? 1 : 0)
|