From 9252e6bb0e9c191d8991b70c0b390cf2c46f2731 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 20:05:43 +0800 Subject: [PATCH] =?UTF-8?q?W4-d=20=E6=A3=80=E6=9F=A5=EF=BC=9Acheck-catalog?= =?UTF-8?q?-changes=20=E6=8D=A2=E6=88=90=E7=9C=9F=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=EF=BC=9Bcheck-catalog=E3=80=81check-catalog-profile=E3=80=81ch?= =?UTF-8?q?eck-catalog-loop=E3=80=81check-capabilities=20=E8=B7=9F?= =?UTF-8?q?=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check-catalog-changes(骨架里的占位换掉):假接口加夹具(虚构的网店库),写请求在浏览器层拦下、断言请求体。守的是「变化」 视图的分组(要处理 / 提醒 / 说明,组内照服务端的先后)、两种筛选进请求和地址、刷新后还在;一条变化的依据、前后、影响面 (目录项现在的状态、「这条 SQL 现在会报列不存在」、模板链到编排页那个节点、有效记录条件);动作照服务端给的 actions 画、做不了 的写原因;清理先确认、加入码值带含义、知道了、误报的请求体,误报没写理由(含只填空白)按钮不能点;处理完写目录版本、建了的 起草任务点得进进度;表详情一节(变化、撤销过的「已列全」、四种观察和口径、口径换过的提醒、断档的日子也在小柱图里);数据源 角标(读屏写全、点了到「变化」);定时维护缺省关、开启时提示低峰时段、建(target_kind=source_maintenance)和改(PATCH 只带 改了的和版本)、步骤走 PUT、最近一次建了哪个任务和哪几步没执行;后台任务条按服务端的两种先后、页头读屏不写数字、提醒在条上 写全文、「查看」打开对应的对话框、探查的到数据源卡片;窄屏不撑宽页面。去掉排序、去掉「没写理由不能点」回跑,这几项都会报出来。 - check-catalog、check-catalog-profile:DraftStrip 的断言换成后台任务条(data-catalog-job-strip、data-catalog-job),页头的 「起草」「数据剖析」只断言读屏写状态、不写数字;剖析的「最近查询多数超时」改在任务条上断言全文;check-catalog-profile 的假接口 按建的先后给出队列,两种排法(起草在前 / 剖析在前)的文字都断言;窄屏下任务条看得见。三个目录检查都给变化、观察、后台任务、 upkeep 配上空的假接口,不把假的源放行到服务端去查。 - check-catalog-loop.mjs 的 PROFILE_ON 换成 table_max_queries: 40、table_max_s: 60(和 check-catalog-profile 的 DEFAULTS 一致, 05 (g))。 - check-capabilities:全局渠道可订阅的事件里有了「数据源有变化要处理」(第 4 波打开了订阅),登记了还没推送的只剩召回。 本包相关的检查(catalog-changes、catalog、catalog-profile、catalog-loop、capabilities、manage)在另起的一套后端 + vite 上都过了。 --- scripts/check-capabilities.mjs | 8 +- scripts/check-catalog-changes.mjs | 769 +++++++++++++++++++++++++++++- scripts/check-catalog-loop.mjs | 8 +- scripts/check-catalog-profile.mjs | 64 ++- scripts/check-catalog.mjs | 52 +- 5 files changed, 854 insertions(+), 47 deletions(-) diff --git a/scripts/check-capabilities.mjs b/scripts/check-capabilities.mjs index 2e85afe6..bfae8538 100644 --- a/scripts/check-capabilities.mjs +++ b/scripts/check-capabilities.mjs @@ -343,15 +343,17 @@ await section('设置 → 通知:可订阅的事件照服务端登记列(总 } const every = ['task.opened', 'run.finished', 'run.failed', 'action.unknown'] // 第 3 波起:定时评测的两个事件只推全局渠道(评测是搭建者们共用的一把尺,不归哪个人;服务端 notify/service.notify_eval); - // 访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道 + // 访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道。 + // 第 4 波:数据源有变化要处理只推全局渠道(按源汇总,notify_source_changed) check('全局渠道:推给全局渠道的几项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1) - && JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'eval.regressed', 'eval.failed', 'key.expiring']), + && JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'source.changed', 'eval.regressed', 'eval.failed', 'key.expiring']), JSON.stringify(await listed())) const text = await dlg.innerText() // 只推定时发起的评测:叫法照实带「定时」,勾上的人不会以为手动发起的那次也推 check('评测的两个事件照实说是定时的', ['定时评测有退步', '定时评测没有评完'].every((t) => text.includes(t)), text) check('从来推不到的不列', !['待办升级', '待办超时', '待办处理完', '动作失败', '动作已生效'].some((t) => text.includes(t)), text) - check('登记了还没推送的不列', !['数据源有变化要处理', '口径升版,答案的数变了'] + check('……「数据源有变化要处理」列着(第 4 波打开了订阅)', text.includes('数据源有变化要处理'), text) + check('登记了还没推送的不列', !['口径升版,答案的数变了'] .some((t) => text.includes(t)), text) check('只推个人渠道的「每天的待办汇总」全局渠道不列', !text.includes('每天的待办汇总'), text) check('应用的渠道:不列只发给提交人的「我提交审核的应用有结果」', await scope('app') diff --git a/scripts/check-catalog-changes.mjs b/scripts/check-catalog-changes.mjs index 226ec799..c7daabe5 100644 --- a/scripts/check-catalog-changes.mjs +++ b/scripts/check-catalog-changes.mjs @@ -1,7 +1,766 @@ #!/usr/bin/env node -// 数据源的变化与定时维护的界面检查(docs/design/next-round 03 B3、B4,05 (g))。 +// 数据源的变化、表详情的「结构与取值变化」、定时维护、目录页后台任务条的界面检查(docs/design/next-round 03 B2–B4、05 (g))。 // -// 第 4 波骨架里的占位:check-all 先登记好名字,W4-d 换成真检查(假接口加夹具,照 check-share 的写法)。 -console.log('=== 占位 ===') -console.log(' ✓ 占位:W4-d 换成真检查') -console.log('\n✓ 占位通过') +// 守的是截图看不出来、点了才知道对不对的那些: +// - 数据目录页的「变化」视图按要处理 / 提醒 / 说明分组、组内照服务端的先后;能按开着的 / 处理过的、结构 / 取值筛,筛选和打开 +// 的那一条记在地址里;视图标签上写几条要处理; +// - 一条变化写依据(谁测到的、取值变化的观察口径、结构变化比的两版)、前后、影响面(目录项现在的状态、写死的 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, + }, + 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-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') + // 改频率: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]'))) === '定时维护 · 已暂停')) + 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.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) diff --git a/scripts/check-catalog-loop.mjs b/scripts/check-catalog-loop.mjs index 0968e622..0898a01a 100644 --- a/scripts/check-catalog-loop.mjs +++ b/scripts/check-catalog-loop.mjs @@ -216,7 +216,8 @@ const manual = (id, name, count, options = {}) => ({ ...blank, id, name, origin: 'manual', database: `/tmp/check/${name}.db`, table_count: count, tools: [`db_query__${name}`], schema_synced_at: T0, options, }) -const PROFILE_ON = { catalog_profile: { enabled: true, max_queries: 60, query_timeout_s: 10, sample_size: 2000, max_scan_rows: 100000, max_total_s: 120 } } +// 剖析设置照现在的键(每张表的两项预算,和 check-catalog-profile 的 DEFAULTS 一致);整次的旧键不再用(05 (g)) +const PROFILE_ON = { catalog_profile: { enabled: true, table_max_queries: 40, table_max_s: 60, query_timeout_s: 10, sample_size: 2000, max_scan_rows: 100000 } } const SOURCES = [manual(S1, NAME1, 5), manual(S2, NAME2, 2, PROFILE_ON)] const NAMES = SOURCES.map((s) => s.name) @@ -356,6 +357,11 @@ function loopHandlers(state, replies = {}) { } return [ [/^GET \/datasources$/, (route) => json(state.sources)(route)], + // 数据源的变化、取值观察、后台任务条(next-round 第 4 波):这里没有,给空的(不放行到服务端去查一个假的源) + [/^GET \/datasources\/check-loop-[a-z]+\/changes$/, json({ items: [], total: 0, counts: { pending: 0, open: 0, by_severity: { action: 0, warning: 0, info: 0 } } })], + [/^GET \/datasources\/check-loop-[a-z]+\/observations$/, json({ table: '', series: [] })], + [/^GET \/datasources\/check-loop-[a-z]+\/background-jobs$/, json({ items: [] })], + [/^GET \/datasources\/upkeep$/, json({ items: [] })], [/^GET \/datasources\/check-loop-[a-z]+\/catalog$/, (route, { key }) => json(listOf(state, parts(key)[2]))(route)], [/^GET \/datasources\/check-loop-[a-z]+\/catalog\/draft-jobs\/(active|latest)$/, json({ job: null })], // 某一次起草任务:表助手开场从中取起草时记下的留空列名。只认 state.jobs 里有的,别的 404(同服务端:任务不在了) diff --git a/scripts/check-catalog-profile.mjs b/scripts/check-catalog-profile.mjs index 5c7f6cde..6fac3c71 100644 --- a/scripts/check-catalog-profile.mjs +++ b/scripts/check-catalog-profile.mjs @@ -532,6 +532,19 @@ function handlers(state, replies = {}) { const P = 'check-prof-[a-z]+' return [ [/^GET \/datasources$/, (route) => json(state.sources)(route)], + // 数据源的变化、取值观察、后台任务条(next-round 第 4 波):这里没有,给空的(不放行到服务端去查一个假的源) + [/^GET \/datasources\/check-prof-[a-z]+\/changes$/, json({ items: [], total: 0, counts: { pending: 0, open: 0, by_severity: { action: 0, warning: 0, info: 0 } } })], + [/^GET \/datasources\/check-prof-[a-z]+\/observations$/, json({ table: '', series: [] })], + // 后台任务条的队列:照服务端(同源队先后),在做的在前、排着的按建的先后(05 (g):界面不自己排) + [new RegExp(`^GET /datasources/${P}/background-jobs$`), (route, { key }) => { + const src = parts(key)[2] + const active = [...state.djobs.map((j) => ['draft', draftOut(j)]), ...state.pjobs.map((j) => ['profile', jobOut(j)])] + .filter(([, j]) => j.source_id === src && JOB_ACTIVE.has(j.status)) + .sort(([, a], [, b]) => (b.status === 'running') - (a.status === 'running') || a.created_at.localeCompare(b.created_at)) + return json({ items: active.map(([kind, j], i) => ({ kind, id: j.id, status: j.status, total: j.total, done: j.done, + waiting_for: j.status === 'queued' ? j.waiting_for ?? null : null, position: i })) })(route) + }], + [/^GET \/datasources\/upkeep$/, json({ items: [] })], [new RegExp(`^GET /datasources/${P}/schema$`), (route, { key }) => json({ tables: state.tables[parts(key)[2]].filter((t) => !t.missing).map((t) => t.name), summary: '', synced_at: T0 })(route)], [new RegExp(`^GET /datasources/${P}/catalog$`), (route, { key }) => json(listOf(state, parts(key)[2]))(route)], @@ -650,6 +663,10 @@ const textOf = async (loc) => (await loc.first().innerText({ timeout: 800 }).cat const inBox = (page, sel) => textOf(box(page).locator(sel)) const taskLine = (page, name) => box(page).locator(`[data-profile-task="${name}"]`) const head = (page) => page.locator('[data-catalog-profile]') +/** 后台任务条上的一项(进度只在这一处,05 (g)) */ +const stripJob = (page, kind) => page.locator(`[data-catalog-job-strip] [data-catalog-job="${kind}"]`) +const stripText = (page, kind) => textOf(stripJob(page, kind).locator('[data-catalog-job-text]')) +const stripKinds = (page) => page.locator('[data-catalog-job-strip] [data-catalog-job]').evaluateAll((els) => els.map((e) => e.getAttribute('data-catalog-job'))) /** 表清单一行的「剖析」一格(去掉给读屏的「数据剖析:」);没有这一格为空串 */ const rowProfile = async (page, name) => (await textOf(page.locator(`[data-catalog-row="${name}"] [data-row-profile]`))).replace(/^数据剖析:\s*/, '') const toasts = (page) => page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => '') @@ -906,21 +923,23 @@ await section('剖析 · 进行中:逐表进度、后台继续与停止', asyn await shot(page, 'profile-running') await cleanCopy(page, '进行中') - // 关掉对话框:任务不停,页头换成进度入口;表清单的「剖析」一格写排队、剖析中 + // 关掉对话框:任务不停,页头只换图标、读屏写「进行中」,进度在下方的后台任务条上;表清单的「剖析」一格写排队、剖析中 settle(state, job, 'visits', { status: 'running', done: { relation: 0, codes: 1, date: 0 }, queries: 3, elapsed_ms: 1500, current: '正在取 visits.page 的码值候选' }) await until(async () => (await inBox(page, '[data-profile-task="visits"] [data-profile-task-status]')) === '剖析中') await page.locator('[data-profile-hide]').click() check('「在后台继续」:对话框关上,没有发停止', await until(async () => (await box(page).count()) === 0) && count(sent, /\/cancel$/) === 0) - check('……页头换成「剖析 1/5」,读屏念得出', await until(async () => (await head(page).getAttribute('data-catalog-profile')) === 'running') - && (await head(page).getAttribute('aria-label')) === '剖析 1/5' && (await textOf(head(page))) === '剖析 1/5', await textOf(head(page))) + check('……页头只做入口:读屏写「数据剖析(进行中)」,不写数字', await until(async () => (await head(page).getAttribute('data-catalog-profile')) === 'running') + && (await head(page).getAttribute('aria-label')) === '数据剖析(进行中)' && !/\d/.test(await textOf(head(page))), await head(page).getAttribute('aria-label')) + check('……进度在后台任务条上:「剖析 1/5」', await until(async () => (await stripText(page, 'profile')) === '剖析 1/5'), await stripText(page, 'profile')) check('……表清单:剖析完的写「刚刚剖析」,正在剖析的写「剖析中」,后面的写「排队剖析」', await until(async () => (await rowProfile(page, 'orders')) === '刚刚剖析' && (await rowProfile(page, 'visits')) === '剖析中' && (await rowProfile(page, 'products')) === '排队剖析'), [await rowProfile(page, 'orders'), await rowProfile(page, 'visits'), await rowProfile(page, 'products')].join(' / ')) - // 最近查询多数超时:服务端给提醒,关着对话框时页头的入口带提醒色,悬停写全文 + // 最近查询多数超时:服务端给提醒,关着对话框时写在后台任务条上剖析那一行(全文,窄屏、触屏也看得见),页头不再挂提醒 const NOTICE = '最近 6 条查询有 4 条超时,数据库响应变慢了;剖析会继续,可以停止,换在业务不忙的时候再剖析' job.notice = NOTICE - check('多数超时的提醒:页头入口带提醒标记,悬停写全文', await until(async () => (await page.locator('[data-catalog-profile-notice]').count()) === 1) - && (await head(page).getAttribute('title')).includes(NOTICE)) + check('多数超时的提醒:写在后台任务条上剖析那一行,写全文', await until(async () => + (await textOf(stripJob(page, 'profile').locator('[data-catalog-job-notice]'))) === NOTICE)) + check('……页头只做入口,不带提醒标记', await page.locator('[data-catalog-profile-notice]').count() === 0) await shot(page, 'profile-header') await head(page).click() check('点页头:回到对话框,直接是进行中的任务,提醒写在进度条下面', await until(async () => (await phase(page)) === 'running') @@ -946,7 +965,8 @@ await section('剖析 · 进行中:逐表进度、后台继续与停止', asyn check('……汇总照实写:5 张表,完成 1,部分完成 1,已停止 3', (await inBox(page, '[data-profile-counts]')) === '5 张表:完成 1,部分完成 1,已停止 3', await inBox(page, '[data-profile-counts]')) check('……可以重试没做完的 4 张', (await textOf(page.locator('[data-profile-retry]'))).includes('重试没做完的表(4 张)')) - check('……页头回到「数据剖析」', await until(async () => (await head(page).getAttribute('data-catalog-profile')) === '')) + check('……页头回到「数据剖析」,任务条收起', await until(async () => (await head(page).getAttribute('data-catalog-profile')) === '' + && (await head(page).getAttribute('aria-label')) === '数据剖析' && await stripJob(page, 'profile').count() === 0)) const polls = () => count(sent, new RegExp(`^GET .*profile-jobs/${job.id}$`)) const n = polls() await sleep(3200) @@ -1342,7 +1362,9 @@ await section('剖析 · 和起草排队', async () => { Object.assign(draft.tasks[0], { status: 'ok', finished_at: isoNow() }) const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state) }) await page.locator('[data-catalog-row]').first().waitFor() - check('页头的起草入口写进度「起草 1/3」', await until(async () => (await page.locator('[data-catalog-draft-head="running"]').getAttribute('aria-label')) === '起草 1/3')) + check('页头的起草入口读屏写「起草(进行中)」,进度「起草 1/3」在后台任务条上', await until(async () => + (await page.locator('[data-catalog-draft-head="running"]').getAttribute('aria-label')) === '起草(进行中)' && (await stripText(page, 'draft')) === '起草 1/3'), + await stripText(page, 'draft')) await openDialog(page) check('同源有起草任务:按钮写「排队剖析」,下面写起草任务结束后自动开始', (await textOf(page.locator('[data-profile-start]'))).includes('排队剖析') && (await page.locator('[data-profile-start]').getAttribute('data-profile-start')) === 'queue' @@ -1353,16 +1375,18 @@ await section('剖析 · 和起草排队', async () => { check('……建出来是排队,写「排在起草任务后面,起草结束后自动开始」、开始时按起草后的目录再定', job.waiting_for === 'draft' && (await inBox(page, '[data-profile-title]')).startsWith('排队中') && (await inBox(page, '[data-profile-waiting="draft"]')) === '排在起草任务后面,起草结束后自动开始。要剖析哪些表,开始时按起草后的目录再定') await page.locator('[data-profile-hide]').click() - check('……页头写「剖析 排队中」,悬停写排在起草任务后面', await until(async () => (await head(page).getAttribute('data-catalog-profile')) === 'queued') - && (await textOf(head(page))) === '剖析 排队中' && (await head(page).getAttribute('title')).includes('排在起草任务后面')) + check('……页头读屏写「数据剖析(排队中)」,任务条按服务端的先后:起草在前,剖析「排队中,起草结束后开始」', await until(async () => + (await head(page).getAttribute('data-catalog-profile')) === 'queued' && (await head(page).getAttribute('aria-label')) === '数据剖析(排队中)' + && JSON.stringify(await stripKinds(page)) === '["draft","profile"]' && (await stripText(page, 'profile')) === '剖析 排队中,起草结束后开始'), + `${JSON.stringify(await stripKinds(page))} ${await stripText(page, 'profile')}`) await shot(page, 'profile-queued') // 起草结束、剖析开始 for (const t of draft.tasks) Object.assign(t, { status: 'ok', finished_at: isoNow() }) Object.assign(draft, { status: 'succeeded', finished_at: isoNow() }) settle(state, job, job.tasks[0].table_name, { status: 'running', queries: 2 }) await goto(page, `/data/catalog/${S1}`) - check('起草结束后剖析开始:页头写「剖析 0/5」', await until(async () => (await head(page).getAttribute('aria-label')) === `剖析 0/${job.tasks.length}`), - await head(page).getAttribute('aria-label').catch(() => '')) + check('起草结束后剖析开始:任务条写「剖析 0/5」,页头读屏写「进行中」', await until(async () => (await stripText(page, 'profile')) === `剖析 0/${job.tasks.length}` + && (await head(page).getAttribute('aria-label')) === '数据剖析(进行中)'), await stripText(page, 'profile')) // 反过来:剖析在进行时起草,排在剖析后面 await page.locator('[data-catalog-draft]').first().click() @@ -1373,10 +1397,10 @@ await section('剖析 · 和起草排队', async () => { check('……建出来的起草任务排在剖析后面:写「排在剖析任务后面,剖析结束后自动开始」', await until(async () => (await textOf(draftBox.locator('[data-draft-waiting="profile"]'))) === '排在剖析任务后面,剖析结束后自动开始')) await page.locator('[data-draft-hide]').click() - check('……目录页上方的起草进度也这么写', await until(async () => (await textOf(page.locator('[data-catalog-draft-progress]'))).includes('起草任务排在剖析任务后面,剖析结束后自动开始')), - await textOf(page.locator('[data-catalog-draft-progress]'))) - check('……页头的起草入口写「起草 排队中」,悬停写排在剖析任务后面', (await textOf(page.locator('[data-catalog-draft-head="queued"]'))) === '起草 排队中' - && (await page.locator('[data-catalog-draft-head="queued"]').getAttribute('title')).includes('排在剖析任务后面')) + check('……任务条按服务端的先后:剖析在前,起草「排队中,剖析结束后开始」', await until(async () => JSON.stringify(await stripKinds(page)) === '["profile","draft"]' + && (await stripText(page, 'draft')) === '起草 排队中,剖析结束后开始'), `${JSON.stringify(await stripKinds(page))} ${await stripText(page, 'draft')}`) + check('……页头的起草入口读屏写「起草(排队中)」,不写数字', (await page.locator('[data-catalog-draft-head="queued"]').getAttribute('aria-label')) === '起草(排队中)' + && !/\d/.test(await textOf(page.locator('[data-catalog-draft-head="queued"]')))) stopJob(job) check('写请求只有建剖析任务、建起草任务', writes(sent, /./).every((s) => /(profile|draft)-jobs$/.test(s.key)), JSON.stringify(writes(sent, /./).map((s) => s.key))) check('没有运行时报错', errors.length === 0, errors[0] ?? '') @@ -1534,6 +1558,14 @@ await section('剖析 · 窄屏', async () => { const inner = await box(page).evaluate((el) => el.scrollWidth - el.clientWidth) check('……进行中、展开一张表:对话框在视口里,正文不横向滚动', !!d && d.x >= 0 && d.x + d.width <= 390 && inner <= 1, `${JSON.stringify(d)} ${inner}`) await shot(page, 'profile-narrow') + // 关掉对话框:后台任务条在窄屏上看得见,「最近查询多数超时」写全文(没有悬停也读得到),不撑宽页面 + job.notice = '最近 6 条查询有 4 条超时,数据库响应变慢了;剖析会继续,可以停止,换在业务不忙的时候再剖析' + await page.locator('[data-profile-hide]').click() + await until(async () => (await stripJob(page, 'profile').locator('[data-catalog-job-notice]').count()) === 1) + const strip = await page.locator('[data-catalog-job-strip]').boundingBox() + check('……窄屏:后台任务条看得见、提醒写全文,不横向滚动', !!strip && strip.width <= 390 && await stripJob(page, 'profile').isVisible() + && (await textOf(stripJob(page, 'profile').locator('[data-catalog-job-notice]'))).includes('换在业务不忙的时候再剖析') && await overflow() <= 1, + `${JSON.stringify(strip)} ${await overflow()}`) stopJob(job) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await close() diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index b9f15f08..6f70230d 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -636,6 +636,11 @@ function catalogHandlers(state, replies = {}) { } return [ [/^GET \/datasources$/, (route) => json(state.sources)(route)], + // 数据源的变化、取值观察、后台任务条(next-round 第 4 波):这里没有,给空的(不放行到服务端去查一个假的源) + [/^GET \/datasources\/check-cat-[a-z]+\/changes$/, json({ items: [], total: 0, counts: { pending: 0, open: 0, by_severity: { action: 0, warning: 0, info: 0 } } })], + [/^GET \/datasources\/check-cat-[a-z]+\/observations$/, json({ table: '', series: [] })], + [/^GET \/datasources\/check-cat-[a-z]+\/background-jobs$/, json({ items: [] })], + [/^GET \/datasources\/upkeep$/, json({ items: [] })], // 卡片展开时取的表清单(导入表格的源默认展开) [/^GET \/datasources\/check-cat-[a-z]+\/schema$/, (route, { key }) => json({ tables: state.tables[parts(key)[2]].filter((t) => !t.missing).map((t) => t.name), summary: '', synced_at: T0 })(route)], @@ -1169,20 +1174,23 @@ await section('数据目录 · 起草:停止、后台继续与再打开', asyn runJob(job) check('……轮到它了:「正在起草…」', await until(async () => (await draftText(page, '[data-draft-title]')) === '正在起草…'), await draftText(page, '[data-draft-title]')) - // 关掉对话框:任务不停,目录页上方显示进度 + // 关掉对话框:任务不停,进度在页头下方的后台任务条上(只有这一处,05 (g));页头的「起草」只换图标,读屏写状态、不写数字 await page.locator('[data-draft-hide]').click() check('「在后台继续」:对话框关上,没有发停止', await until(async () => (await draftBox(page).count()) === 0) && count(sent, /\/cancel$/) === 0) - const strip = page.locator('[data-catalog-draft-progress]') - check('……目录页上方显示一条进度', await until(async () => (await strip.count()) === 1) && (await strip.innerText()).includes('已完成 0 / 20 张表'), + const strip = page.locator('[data-catalog-job-strip] [data-catalog-job="draft"]') + check('……后台任务条上写「起草 0/20」', await until(async () => (await strip.count()) === 1) && (await strip.innerText()).includes('起草 0/20'), await strip.innerText({ timeout: 500 }).catch(() => '')) + const draftHead = page.locator('[data-catalog-draft-head]') + check('……进度只在任务条上一处:页头的「起草」读屏写「起草(进行中)」,不写数字', (await draftHead.getAttribute('aria-label')) === '起草(进行中)' + && !/\d/.test(await draftHead.innerText()), await draftHead.getAttribute('aria-label')) settle(state, job, 'visits', { added: 2 }) settle(state, job, 'orders', { added: 1 }) - check('……进度跟着任务走', await until(async () => (await strip.getAttribute('data-catalog-draft-progress', { timeout: 500 }).catch(() => '')) === '2/20'), - await strip.getAttribute('data-catalog-draft-progress', { timeout: 500 }).catch(() => '')) + check('……进度跟着任务走', await until(async () => (await strip.getAttribute('data-progress', { timeout: 500 }).catch(() => '')) === '2/20'), + await strip.getAttribute('data-progress', { timeout: 500 }).catch(() => '')) check('……进度条读屏可读', (await strip.locator('[role="progressbar"]').getAttribute('aria-valuenow').catch(() => '')) === '2') await shot(page, 'catalog-draft-strip') - // 点开回到对话框:直接是进行中的那个任务 - await strip.locator('[data-catalog-draft-open]').click() + // 点「查看」回到对话框:直接是进行中的那个任务 + await strip.locator('[data-catalog-job-open]').click() check('点开:回到对话框,直接显示进行中的任务', await until(async () => (await draftPhase(page)) === 'running') && (await draftText(page, '[data-draft-progress]')) === '已完成 2 / 20 张表', await draftText(page, '[data-draft-progress]')) // 停止:调停止接口;服务端马上取消进行中的模型调用,只等手上那张表写完(停止接口等它收尾才答)。这期间写「正在停止」, @@ -1214,7 +1222,7 @@ await section('数据目录 · 起草:停止、后台继续与再打开', asyn check('按 Esc 关掉对话框也不停任务', await until(async () => (await draftBox(page).count()) === 0) && count(sent, new RegExp(`^POST .*draft-jobs/${job3.id}/cancel$`)) === 0) await goto(page, `/data/catalog/${S1}`) - check('重新载入页面:清单带着进行中的任务,上方照样显示进度', await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 1)) + check('重新载入页面:清单带着进行中的任务,任务条上照样显示进度', await until(async () => (await page.locator('[data-catalog-job="draft"]').count()) === 1)) await page.locator('[data-catalog-draft]').first().click() check('……点「起草」直接显示进行中的任务,不是重新选范围', await until(async () => (await draftPhase(page)) === 'running') && await draftBox(page).locator('[data-draft-scope]').count() === 0) @@ -1225,7 +1233,7 @@ await section('数据目录 · 起草:停止、后台继续与再打开', asyn const toastBox = page.locator('[data-toast]').filter({ hasText: '起草完成' }) check('关着对话框时结束:提示「数据目录起草完成」,带「查看结果」', await until(async () => (await toastBox.count()) > 0), await page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => '')) - check('……上方的进度收起', (await page.locator('[data-catalog-draft-progress]').count()) === 0) + check('……任务条收起', (await page.locator('[data-catalog-job-strip]').count()) === 0) await toastBox.getByRole('button', { name: '查看结果' }).click() check('……点「查看结果」打开这一次的结果', await until(async () => (await draftPhase(page)) === 'done') && (await draftText(page, '[data-draft-title]')) === '起草完成', await draftText(page, '[data-draft-title]')) @@ -1407,17 +1415,17 @@ await section('数据目录 · 起草:接连没写出来的提醒、超时与 await draftText(page, '[data-draft-part-columns]')) await shot(page, 'catalog-draft-notice') - // 关掉对话框:上方的进度也带着提醒;好转后服务端清掉提醒,两处都收起 + // 关掉对话框:任务条上起草那一行也带着提醒(写全文:窄屏、触屏没有悬停);好转后服务端清掉提醒,两处都收起 await page.locator('[data-draft-hide]').click() - const strip = page.locator('[data-catalog-draft-progress]') - const stripNotice = strip.locator('[data-catalog-draft-notice]') - check('在后台继续:上方的进度也带着提醒(全文在悬停里)', await until(async () => (await stripNotice.count()) === 1) - && (await stripNotice.getAttribute('title')) === DRAFT_NOTICE && (await stripNotice.innerText()).length > 0, - await stripNotice.getAttribute('title', { timeout: 500 }).catch(() => '')) + const strip = page.locator('[data-catalog-job-strip] [data-catalog-job="draft"]') + const stripNotice = strip.locator('[data-catalog-job-notice]') + check('在后台继续:任务条上起草那一行带着提醒,写全文', await until(async () => (await stripNotice.count()) === 1) + && (await stripNotice.innerText()).replace(/\s+/g, ' ').trim() === DRAFT_NOTICE, + await stripNotice.innerText({ timeout: 500 }).catch(() => '')) await shot(page, 'catalog-draft-strip-notice') job.notice = null - check('……好转后服务端清掉提醒:上方的进度不再带着', await until(async () => (await stripNotice.count()) === 0)) - await strip.locator('[data-catalog-draft-open]').click() + check('……好转后服务端清掉提醒:任务条上不再带着', await until(async () => (await stripNotice.count()) === 0)) + await strip.locator('[data-catalog-job-open]').click() check('……点开对话框:提醒也不再显示,超时和没写出来的次数照旧', await until(async () => (await draftPhase(page)) === 'running') && await notice.count() === 0 && (await usageLine()).includes('其中超时 3 次'), await usageLine()) @@ -2474,12 +2482,12 @@ await section('数据目录 · 窄屏', async () => { && await draftBox(page).locator('[data-draft-task-blank-sample]').isVisible().catch(() => false), JSON.stringify(runFit)) await shot(page, 'catalog-narrow-draft') await page.locator('[data-draft-hide]').click() - await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 1) - check('……上方的起草进度(带着提醒)不撑宽页面', await overflow() <= 1 && !!(await page.locator('[data-catalog-draft-progress]').boundingBox()) - && (await page.locator('[data-catalog-draft-progress]').boundingBox()).width <= 390 - && await page.locator('[data-catalog-draft-notice]').count() === 1, String(await overflow())) + await until(async () => (await page.locator('[data-catalog-job="draft"]').count()) === 1) + check('……后台任务条(带着提醒)看得见、不撑宽页面', await overflow() <= 1 && !!(await page.locator('[data-catalog-job-strip]').boundingBox()) + && (await page.locator('[data-catalog-job-strip]').boundingBox()).width <= 390 && await page.locator('[data-catalog-job="draft"]').isVisible() + && await page.locator('[data-catalog-job="draft"] [data-catalog-job-notice]').count() === 1, String(await overflow())) stopJob(narrowJob) - await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 0) + await until(async () => (await page.locator('[data-catalog-job-strip]').count()) === 0) // 宽屏照旧是表格(表头在,列表格 860px 起,框里横向滚动) await page.setViewportSize({ width: 1440, height: 900 }) await openDetail(page, S1, 'visits')