| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- scriptize_tc.py — 把 tests/test-cases/*.md 中的结构化测试用例批量「脚本化」
- 设计原则(诚实优先):
- 1. 每条文档用例 1:1 生成一个可被执行框架识别的 Playwright test(),不丢条、不伪造通过。
- 2. 按可自动化程度分级:
- active-api 步骤/预期里能提取出明确 /api 路径 + 可判定状态码 → 生成真实 apiPost + expect 断言
- design-contract 有路径但预期无可判定状态码 → 设计态,需补断言契约
- design-ui 依赖前端 SPA/DOM 与浏览器(远程 CFC-ENV-001 无 SPA)→ 设计态
- design-env 性能/兼容类,需压测授权或多端环境 → 设计态
- design-fixture 需业务夹具/种子数据/人工场景编排 → 设计态
- 3. 设计态用例用运行时 test.fixme(true, reason) 阻断,绝不打远程、绝不伪造结果。
- 4. 即使 active-api 也默认不联网:只有显式 TC_LIVE=1 才真实请求目标环境。
- 产物:
- tests/e2e/api/generated/cases.json 784 条结构化用例(单一数据源)
- tests/e2e/api/generated/tc-scripted.spec.js 数据驱动规格(784 个 test)
- tests/e2e/api/generated/README.md 运行与授权说明
- docs/tdd/test/scriptization-map.md 汇总台账
- docs/tdd/test/scriptization-map.csv 784 行 TC→脚本→状态映射
- 用法:python tests/scripts/scriptize_tc.py
- """
- import os
- import re
- import json
- import csv
- import glob
- from collections import Counter, defaultdict
- from datetime import datetime
- ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
- TC_DIR = os.path.join(ROOT, 'tests', 'test-cases')
- GEN_DIR = os.path.join(ROOT, 'tests', 'e2e', 'api', 'generated')
- DOC_DIR = os.path.join(ROOT, 'docs', 'tdd', 'test')
- ALIASES = {
- 'TC 编号': 'id',
- '场景': 'scene', '所属场景': 'scene', '环节': 'scene',
- '模块': 'module',
- '角色': 'role',
- '优先级': 'priority',
- '类型': 'type',
- '前置': 'precondition', '前置条件': 'precondition',
- '步骤': 'steps', '测试步骤': 'steps',
- '预期': 'expected', '预期结果': 'expected',
- '关联需求/设计': 'design', '关联发现': 'design', '关联场景/用例': 'design',
- '对应 e2e': 'e2e',
- }
- FIELDS = ['id', 'scene', 'module', 'role', 'priority', 'type',
- 'precondition', 'steps', 'expected', 'design', 'e2e']
- VALID_CODES = {200, 201, 202, 204, 400, 401, 403, 404, 405, 409, 413,
- 415, 422, 429, 46003, 46004, 500, 502, 503}
- UI_WORDS = ('打开页面', '页面', '点击', '列表可见', '渲染', 'UI', '主题', '截图',
- '小程序', 'DOM', '选择器', '翻页', '前端', '视图', '弹窗', '跳转',
- '滑动', '长按', '可见', '入口', '切换到', '表格展示')
- API_RE = re.compile(r'(/api/[A-Za-z0-9_\-/\.]+)')
- CODE_SEMANTIC_RE = re.compile(
- r'(?:返回|响应|code|Code|状态码|HTTP|status|应为|期望|为)\s*[:==]?\s*'
- r'((?:\d{3,5})(?:\s*[//,,、]\s*\d{3,5})*)')
- ROLE_KEY = {
- '游客': 'guest', '家长': 'parent', '孩子': 'child', '规划师': 'teacher',
- '运营': 'admin', '服务商': 'vendor', '跨角色': 'parent',
- 'AI 成长教练': 'parent', '真人管家': 'parent',
- }
- # 明显免鉴权的公开端点:auth-probe 不应拿它们做「未授权应 401/403」探针,否则是假失败
- PUBLIC_ENDPOINT_MARKERS = (
- '/login', '/register', '/send-code', '/wechat-login', '/direct-register',
- '/config/public', '/media/upload', '/captcha', '/sms/',
- )
- BASE_ID_RE = re.compile(r'-(E|P|N\d+)$')
- PERMISSION_DENIED = {401, 403}
- def base_tc_id(tc_id):
- return BASE_ID_RE.sub('', tc_id)
- def split_row(line):
- """按未转义的竖线切分 markdown 表格行。"""
- s = line.strip()
- if s.startswith('|'):
- s = s[1:]
- if s.endswith('|'):
- s = s[:-1]
- parts = re.split(r'(?<!\\)\|', s)
- return [p.strip().replace('\\|', '|') for p in parts]
- def parse_cases():
- cases, warnings = [], []
- seen = Counter()
- for name in sorted(os.listdir(TC_DIR)):
- if not name.endswith('.md'):
- continue
- path = os.path.join(TC_DIR, name)
- with open(path, 'r', encoding='utf-8') as fh:
- lines = fh.readlines()
- header, section = None, ''
- for lineno, raw in enumerate(lines, 1):
- line = raw.rstrip('\n')
- st = line.strip()
- if st.startswith('#'):
- section = re.sub(r'^#+\s*', '', st).strip()
- continue
- if not st.startswith('|'):
- continue
- cells = split_row(line)
- if not cells:
- continue
- if cells[0] == 'TC 编号':
- header = [ALIASES.get(c) for c in cells]
- unknown = [c for c in cells if c not in ALIASES]
- if unknown:
- warnings.append('%s:%d 未识别列 %s' % (name, lineno, ','.join(unknown)))
- continue
- if not re.match(r'^TC-[A-Za-z0-9][A-Za-z0-9\-]*$', cells[0]):
- continue
- if header is None:
- warnings.append('%s:%d 无表头,跳过' % (name, lineno))
- continue
- if len(cells) < len(header):
- cells = cells + [''] * (len(header) - len(cells))
- elif len(cells) > len(header):
- extra = cells[len(header) - 1:]
- cells = cells[:len(header) - 1] + [' | '.join(extra)]
- warnings.append('%s:%d 列数 %d > 表头 %d,已归并末列' %
- (name, lineno, len(cells) + len(extra) - 1, len(header)))
- rec = {}
- for key, val in zip(header, cells):
- if key:
- rec[key] = val
- for key in FIELDS:
- rec.setdefault(key, '')
- rec['id'] = rec['id'].strip()
- seen[rec['id']] += 1
- rec.update({
- 'occurrence': seen[rec['id']],
- 'source_file': 'tests/test-cases/' + name,
- 'source_line': lineno,
- 'section': section,
- 'role_key': ROLE_KEY.get(rec['role'], 'parent'),
- })
- cases.append(rec)
- return cases, warnings
- def extract_codes(expected):
- codes = set()
- for m in CODE_SEMANTIC_RE.finditer(expected):
- for c in re.findall(r'\d{3,5}', m.group(1)):
- n = int(c)
- if n in VALID_CODES:
- codes.add(n)
- if not codes:
- for c in re.findall(r'\b(200|400|401|403|404|409|422|429|500|502|503)\b', expected):
- codes.add(int(c))
- return sorted(codes)
- def extract_paths(text):
- out = []
- for p in API_RE.findall(text):
- p = p.rstrip('.,;`')
- if '*' in p or '{' in p or p.endswith('/'):
- continue
- if p not in out:
- out.append(p)
- return out
- def resolve_e2e_ref(rec):
- """把「对应 e2e」列的文本解析成路径,并核对文件是否真实存在(不臆断)。"""
- ref = (rec.get('e2e') or '').strip().replace('`', '').strip()
- if not ref or ref in ('—', '-', '无'):
- return '', False
- ref = re.split(r'[((,,、\s]', ref)[0]
- for cand in (ref, ref.replace('../', '')):
- p = os.path.normpath(os.path.join(ROOT, 'tests', cand))
- if os.path.isfile(p):
- return ref, True
- return ref, False
- def build_route_index():
- """从源码/接口文档/既有 spec 建立真实路由索引(每条都带来源证据,不臆造路径)。"""
- by_controller = defaultdict(list)
- routes = []
- ctrl_glob = os.path.join(ROOT, 'cfc-backend', 'src', 'main', 'java',
- '**', '*Controller.java')
- for f in glob.glob(ctrl_glob, recursive=True):
- try:
- src = open(f, encoding='utf-8', errors='ignore').read()
- except OSError:
- continue
- m = re.search(r'@RequestMapping\(\s*["\']([^"\']+)["\']', src)
- if not m:
- continue
- base = m.group(1).rstrip('/')
- name = os.path.basename(f)[:-5]
- for mm in re.finditer(r'@PostMapping\(\s*(?:value\s*=\s*)?["\']([^"\']+)["\']', src):
- sub = mm.group(1)
- p = base + ('/' + sub.lstrip('/') if sub else '')
- if '{' in p or '*' in p or ' ' in p:
- continue
- r = {'path': p, 'controller': name,
- 'file': os.path.relpath(f, ROOT).replace('\\', '/'),
- 'line': src[:mm.start()].count('\n') + 1}
- by_controller[name].append(r)
- routes.append(r)
- # 接口文档章节:中文模块名 → 路径前缀
- sections = {}
- ref = os.path.join(ROOT, 'docs', 'superpowers', 'api', 'API_REFERENCE.md')
- if os.path.isfile(ref):
- for line in open(ref, encoding='utf-8', errors='ignore'):
- m = re.match(r'^#{3,4}\s+[\d.]+\s*(.+?)((.+?))', line)
- if not m:
- continue
- prefs = [p.rstrip('*').rstrip('/') for p in
- re.findall(r'/api/[A-Za-z0-9_/*.-]+', m.group(2))]
- if prefs:
- sections[m.group(1).strip()] = [p for p in prefs if p]
- # 既有 spec 里实际调用过的 /api 路径
- spec_paths = {}
- for f in glob.glob(os.path.join(ROOT, 'tests', 'e2e', '**', '*.js'), recursive=True):
- if os.path.basename(f).startswith('tc-scripted'):
- continue
- try:
- src = open(f, encoding='utf-8', errors='ignore').read()
- except OSError:
- continue
- ps = sorted(set(re.findall(r'/api/[A-Za-z0-9_/\-.]+', src)))
- if ps:
- spec_paths[os.path.basename(f)] = ps
- return {'by_controller': by_controller, 'routes': routes,
- 'sections': sections, 'spec_paths': spec_paths}
- def pick_route(cands, rec):
- """选一条可安全探测的真实路由:排除免鉴权公开端点,优先只读端点。"""
- usable = [r for r in cands
- if not any(mark in r['path'] for mark in PUBLIC_ENDPOINT_MARKERS)]
- if not usable:
- return None
- readonly = ('/list', '/detail', '/overview', '/get', '/query', '/info')
- for r in usable:
- if any(k in r['path'] for k in readonly):
- return r
- return usable[0]
- def resolve_route(rec, byid, idx):
- """四路证据解析目标路径。返回 (path, level, evidence);无可靠证据返回 None。"""
- # E1 派生继承:异常流/权限流用例继承其正常流基用例的路径
- b = byid.get(base_tc_id(rec['id']))
- if b and b.get('api') and b['api'].get('path'):
- return (b['api']['path'], 'inherit',
- '派生自 %s(基用例步骤中已给出该路径)' % b['id'])
- # E2 「关联需求/设计」列精确命中源码 Controller 名
- des = rec.get('design') or ''
- for name in sorted(idx['by_controller'], key=len, reverse=True):
- if name in des:
- r = pick_route(idx['by_controller'][name], rec)
- if r:
- return (r['path'], 'controller',
- '源码 %s:%d(%s)' % (r['file'], r['line'], name))
- # E3 接口文档章节名与用例模块/步骤的中文模糊关联(模块级,需人工复核)
- text = ' '.join([rec.get('module') or '', des, (rec.get('steps') or '')[:60]])
- for secname, prefs in idx['sections'].items():
- words = [w for w in re.split(r'[与和/·、()\s]+', secname) if len(w) >= 2]
- if not any(w in text for w in words):
- continue
- cands = [r for r in idx['routes']
- if any(r['path'].startswith(p) for p in prefs)]
- r = pick_route(cands, rec)
- if r:
- return (r['path'], 'doc-fuzzy',
- 'API_REFERENCE 章节「%s」→ 源码 %s:%d(模块级关联,需人工复核)'
- % (secname, r['file'], r['line']))
- # E4 用例自身或基用例关联的既有 spec 中实际调用过的路径
- for src in (rec, b):
- if not src:
- continue
- ref = (src.get('e2e_ref') or '') or (src.get('e2e') or '')
- for fn, paths in idx['spec_paths'].items():
- if fn and fn in ref:
- return (paths[0], 'linked-spec',
- '既有规格 %s 实际调用 %s' % (fn, paths[0]))
- return None
- def classify(rec):
- text = (rec['steps'] or '') + ' ' + (rec['expected'] or '')
- paths = extract_paths(text)
- codes = extract_codes(rec['expected'] or '')
- has_ui = any(w in text for w in UI_WORDS)
- typ = rec['type'] or ''
- e2e_ref, e2e_exists = resolve_e2e_ref(rec)
- if typ in ('性能', '兼容'):
- tier = 'design-env'
- reason = '性能/兼容类:需压测授权或多端/前端环境(远程无 SPA)'
- elif paths and codes:
- tier = 'active-api'
- reason = ''
- elif paths:
- # 有明确接口路径但预期未写状态码:跑「弱契约探针」,只断言不应 5xx/非 JSON
- tier = 'active-probe'
- reason = ''
- elif e2e_exists:
- tier = 'linked-e2e'
- reason = '文档已关联既有规格 %s;本条为文档锚点,执行请直接跑原 spec' % e2e_ref
- elif codes:
- tier = 'design-contract'
- reason = '预期含状态码 %s,但步骤未给出接口路径,需补 API 映射' % '/'.join(map(str, codes))
- elif has_ui:
- tier = 'design-ui'
- reason = '依赖前端 SPA/DOM 与浏览器,远程环境不可用'
- else:
- tier = 'design-fixture'
- reason = '需业务夹具/种子数据或人工场景编排'
- return {
- 'tier': tier,
- 'reason': reason,
- 'ui_mixed': bool(has_ui),
- 'api': {'path': paths[0] if paths else None,
- 'paths': paths,
- 'codes': codes,
- 'body': {}} if paths else None,
- 'expected_codes': codes,
- 'e2e_ref': e2e_ref,
- 'e2e_exists': e2e_exists,
- }
- def clean_title(rec):
- base = (rec['module'] or rec['scene'] or '用例').strip()
- step = re.sub(r'\s+', ' ', (rec['steps'] or '')).strip()
- step = step.replace('`', '').replace('|', '/').replace('[', '').replace(']', '')
- short = step[:34]
- title = (base + ' · ' + short) if short else base
- return re.sub(r'\s+', ' ', title).strip()
- def build(cases):
- idx = build_route_index()
- for rec in cases:
- rec.update(classify(rec))
- rec['route_level'] = 'inline' if (rec['api'] and rec['api']['path']) else ''
- rec['route_evidence'] = ('用例步骤/预期中显式给出' if rec['route_level'] else '')
- byid = {c['id']: c for c in cases}
- # 二次解析:只升级原本「有状态码但缺接口路径」的 design-contract 用例
- for rec in cases:
- if rec['tier'] != 'design-contract':
- continue
- got = resolve_route(rec, byid, idx)
- if not got:
- rec['reason'] = ('源码 Controller / 接口文档 / 关联 spec 均未找到可靠路由,'
- '预期码 %s,需人工补 API 映射'
- % '/'.join(map(str, rec['expected_codes'])))
- continue
- path, level, evidence = got
- codes = rec['expected_codes']
- is_perm = (rec['id'].endswith('-P') and codes
- and set(codes) <= PERMISSION_DENIED)
- # 继承/关联得到的路径可能是免鉴权公开端点:未授权探针在此必然假失败,保持设计态
- if is_perm and any(m in path for m in PUBLIC_ENDPOINT_MARKERS):
- rec['reason'] = ('目标路径 %s 属免鉴权公开端点,未授权探针不适用;'
- '需人工指定受保护接口或改测登录态越权' % path)
- continue
- rec['api'] = {'path': path, 'paths': [path],
- 'codes': [401, 403] if is_perm else codes,
- 'body': {}}
- rec['route_level'] = level
- rec['route_evidence'] = evidence
- if is_perm:
- rec['tier'] = 'auth-probe'
- rec['reason'] = ('未授权探针:不带 token 访问受保护接口应 401/403;'
- '不覆盖「低权限 token 越权访问他人资源」场景')
- elif codes:
- rec['tier'] = 'active-api'
- rec['reason'] = '空参数探针:body={} 触发参数校验,不等同完整故障注入'
- else:
- rec['tier'] = 'active-probe'
- rec['reason'] = ''
- for rec in cases:
- occ = '' if rec['occurrence'] == 1 else '#%d' % rec['occurrence']
- tag_short = {'active-api': 'API', 'active-probe': '探针',
- 'auth-probe': '鉴权', 'linked-e2e': '既有'}.get(rec['tier'], '设计态')
- rec['tag'] = '[%s%s][%s][%s]' % (
- rec['id'], occ, rec['priority'] or 'NA', tag_short)
- rec['title'] = rec['tag'] + ' ' + clean_title(rec)
- rec['script'] = 'tests/e2e/api/generated/tc-scripted.spec.js'
- return cases, idx
- def write_json(cases):
- with open(os.path.join(GEN_DIR, 'cases.json'), 'w', encoding='utf-8') as fh:
- json.dump(cases, fh, ensure_ascii=False, indent=1)
- SPEC_TEMPLATE = """/**
- * 结构化用例脚本化规格(自动生成,请勿手改)
- * ================================================================
- * 来源:tests/test-cases/*.md 共 %d 条 `| TC-` 表格用例
- * 生成器:tests/scripts/scriptize_tc.py
- * 数据:./cases.json(唯一数据源,含来源文件/行号/分级/原因)
- *
- * 分级:
- * active-api 步骤/预期可提取明确 /api 路径 + 可判定状态码 → 真实 apiPost + code 集合断言
- * active-probe 有 /api 路径但预期无状态码 → 真实 apiPost + 「不应 5xx」弱契约探针
- * auth-probe 权限流派生用例,路由来自源码/文档证据 → 不带 token 访问,断言 401/403
- * linked-e2e 文档已关联既有 spec → 仅作文档锚点,执行请跑原 spec
- * 其余 design-* 运行时 test.fixme(true, reason) 阻断,不打远程、不伪造结果
- *
- * 路由证据:cases.json 的 route_level / route_evidence 字段记录每条路径的来源
- * inline(用例原文)> inherit(派生继承)> controller(源码 Controller 名)
- * > doc-fuzzy(接口文档章节,模块级关联需复核)> linked-spec(既有规格实际调用)
- *
- * 授权边界(重要):
- * - 默认 TC_LIVE != 1:全部用例为 fixme,零网络副作用,可安全 --list / 空跑。
- * - 仅显式 `TC_LIVE=1` 且已确认目标环境时,active-api 才真实请求后端。
- * - 目标环境取自 playwright 配置 baseURL;执行前须由测试负责人确认可写/可压。
- *
- * 运行:
- * npx playwright test e2e/api/generated/tc-scripted.spec.js -c playwright.config.api.js --list
- * TC_LIVE=1 npx playwright test e2e/api/generated/tc-scripted.spec.js -c playwright.config.api.js --project=desktop
- */
- const { test, expect } = require('@playwright/test');
- const { registerRole, apiPost } = require('../../helpers/api');
- const CASES = require('./cases.json');
- const LIVE = process.env.TC_LIVE === '1';
- const _tokenCache = {};
- /** 惰性注册角色会话;游客返回 null(用于未授权探针) */
- async function tokenFor(request, roleKey) {
- if (roleKey === 'guest') return null;
- if (_tokenCache[roleKey]) return _tokenCache[roleKey];
- const ctx = await registerRole(request, roleKey, 'QA-SCRIPT');
- _tokenCache[roleKey] = ctx;
- return ctx;
- }
- const groups = {};
- for (const tc of CASES) {
- (groups[tc.role || '未标注'] = groups[tc.role || '未标注'] || []).push(tc);
- }
- for (const [role, list] of Object.entries(groups)) {
- test.describe('脚本化用例 · ' + role + ' (' + list.length + ')', () => {
- for (const tc of list) {
- test(tc.title, async ({ request }, testInfo) => {
- // 把文档用例的结构化元数据注入测试报告,使设计态用例也具备可追溯性
- testInfo.annotations.push(
- { type: 'TC', description: tc.id + ' @ ' + tc.source_file + ':' + tc.source_line },
- { type: 'section', description: tc.section },
- { type: 'priority', description: tc.priority },
- { type: 'type', description: tc.type },
- { type: 'tier', description: tc.tier },
- { type: 'precondition', description: tc.precondition },
- { type: 'steps', description: tc.steps },
- { type: 'expected', description: tc.expected }
- );
- const executable = tc.tier === 'active-api' || tc.tier === 'active-probe'
- || tc.tier === 'auth-probe';
- const blocked = !executable
- ? (tc.reason || '设计态:缺少可自动化条件')
- : (LIVE ? null : '未授权联网:设置 TC_LIVE=1 且确认目标环境后执行');
- if (blocked) {
- test.fixme(true, blocked);
- return;
- }
- // auth-probe 刻意不带 token:验证「未授权访问受保护接口」;其余按角色惰性注册
- const ctx = tc.tier === 'auth-probe' ? null : await tokenFor(request, tc.role_key);
- const r = await apiPost(request, tc.api.path, tc.api.body || {}, ctx ? ctx.token : null);
- if (tc.tier === 'active-probe') {
- // 弱契约探针:预期未写状态码,只守住「不应 5xx / 非 JSON」这条底线
- expect(
- Number(r.code) < 500,
- tc.id + ' 弱契约探针:不应 5xx/非 JSON,实际 code=' + r.code +
- ' msg=' + r.message + '(' + tc.source_file + ':' + tc.source_line + ')'
- ).toBeTruthy();
- } else {
- const codes = tc.api.codes.map(Number);
- expect(
- codes.includes(Number(r.code)),
- tc.id + ' [' + tc.tier + '] 期望 code ∈ [' + codes.join(',') + '],实际 code=' + r.code +
- ' msg=' + r.message + '(' + tc.source_file + ':' + tc.source_line + ')'
- ).toBeTruthy();
- }
- });
- }
- });
- }
- """
- def write_spec(cases):
- with open(os.path.join(GEN_DIR, 'tc-scripted.spec.js'), 'w', encoding='utf-8') as fh:
- fh.write(SPEC_TEMPLATE % len(cases))
- def write_readme(cases, counters):
- active = (counters['tier']['active-api'] + counters['tier']['active-probe']
- + counters['tier']['auth-probe'])
- lines = [
- '# 结构化用例脚本化产物',
- '',
- '> 由 `tests/scripts/scriptize_tc.py` 自动生成。**唯一数据源是 `cases.json`**;',
- '> 修改用例请改 `tests/test-cases/*.md` 后重跑生成器,不要手改本目录文件。',
- '',
- '## 规模',
- '',
- '- 文档用例:**%d 条**(11 个 md 文件,含正常流 / 异常流 / 权限流 / 非功能)' % len(cases),
- '- 生成 test():**%d 个**(1:1,无丢弃、无合并)' % len(cases),
- '- 可真实执行(active-api + active-probe):**%d 条**;设计态/锚点:**%d 条**' %
- (active, len(cases) - active),
- '',
- '## 运行',
- '',
- '```bash',
- '# 1) 静态校验:离线列出全部用例,不打任何网络请求',
- 'cd tests',
- 'npx playwright test e2e/api/generated/tc-scripted.spec.js -c playwright.config.api.js --list',
- '',
- '# 2) 默认空跑:所有用例为 fixme,零副作用',
- 'npx playwright test e2e/api/generated/tc-scripted.spec.js -c playwright.config.api.js --project=desktop',
- '',
- '# 3) 真实执行(需测试负责人显式授权联网 + 确认目标环境可写)',
- 'TC_LIVE=1 npx playwright test e2e/api/generated/tc-scripted.spec.js -c playwright.config.api.js --project=desktop',
- '```',
- '',
- '## 分级口径(诚实边界)',
- '',
- '| 分级 | 含义 | 是否打远程 | 闭环条件 |',
- '|---|---|---|---|',
- '| `active-api` | 有 `/api` 路径 + 可判定状态码 → 断言 code 落在预期集合 | 仅 `TC_LIVE=1` | 授权联网并确认目标环境 |',
- '| `active-probe` | 有 `/api` 路径但预期无状态码 → 只断言「不应 5xx」弱契约 | 仅 `TC_LIVE=1` | 补状态码契约后升级为 active-api |',
- '| `auth-probe` | 权限流派生用例:不带 token 访问受保护接口,断言 401/403 | 仅 `TC_LIVE=1` | 若要覆盖「低权限 token 越权他人资源」需补夹具 |',
- '| `linked-e2e` | 文档已关联既有 spec(文件确实存在) | 否 | 执行原 spec,本条仅作文档锚点 |',
- '| `design-contract` | 预期写了状态码但步骤没给接口路径 | 否 | 补 API 映射后重跑生成器 |',
- '| `design-ui` | 依赖前端 SPA/DOM 与浏览器 | 否 | 部署前端 + 安装 Playwright 浏览器 |',
- '| `design-env` | 性能/兼容类,需压测授权或多端环境 | 否 | 单独授权 execute 档 |',
- '| `design-fixture` | 需业务夹具/种子数据/人工编排 | 否 | 补夹具与数据构造 |',
- '',
- '> ⚠️ `active-api` / `active-probe` 只是「接口契约探针」:body 为空对象,不构造业务数据。',
- '> 步骤中若含 UI 操作(`ui_mixed=true`),该用例只覆盖接口层,不等于完整业务流已测。',
- '> 设计态用例用 `test.fixme(true, reason)` 明确阻断,**不会**被计入通过。',
- '',
- '## 路由证据(本轮新增)',
- '',
- '原本 392 条「写了状态码但没接口路径」的用例,按四路证据补出真实路径:',
- '',
- '| 级别 | 依据 | 可信度 |',
- '|---|---|---|',
- '| `inline` | 用例步骤/预期里原文写出的 `/api` 路径 | 最高 |',
- '| `inherit` | 派生自正常流基用例的路径 | 高 |',
- '| `controller` | 「关联需求/设计」列的 Controller 名 → 源码 `file:line` | 高 |',
- '| `doc-fuzzy` | `API_REFERENCE.md` 章节名与模块中文词关联 → 源码真实路由 | 中,模块级关联**需人工复核** |',
- '| `linked-spec` | 既有 spec 中实际调用过的路径 | 中 |',
- '',
- '`cases.json` 每条都带 `route_level` / `route_evidence`;**无可靠证据的用例保持设计态**,不硬猜路径。',
- '另已排除免鉴权公开端点(login/register/send-code 等),避免未授权探针产生假失败。',
- '',
- '## 本机已知限制(实测)',
- '',
- '- `npx playwright test --list` 可用且已验证:**Total: 784 tests in 1 file**。',
- '- 本机直接跑 runner 会被 `safe-delete` 守卫拦截(Playwright 清理 `outputDir` 时',
- ' `genie-trash` 二进制 ETIMEDOUT)。因此本目录**未做真实执行**,请在 CI 或已授权的',
- ' runner 上运行;`.tdd/verify-scripted.cjs` 提供不启动 runner 的离线加载式验证。',
- '',
- '离线验证结论(`node .tdd/verify-scripted.cjs`):',
- '',
- '```',
- '默认(无授权) | tests=%d fixme=%d apiCalls=0(零联网)' % (len(cases), len(cases)),
- 'TC_LIVE=1 | tests=%d fixme=%d apiCalls=%d(auth-probe %d 条 + 游客用例不带 token)'
- % (len(cases), len(cases) - active, active, counters['tier']['auth-probe']),
- '```',
- '',
- '## 台账',
- '',
- '- `docs/tdd/test/scriptization-map.md`:分级汇总与统计',
- '- `docs/tdd/test/scriptization-map.csv`:784 行 TC → 脚本定位 → 状态 → 原因映射',
- '',
- '## 与 automation-status.md 的关系',
- '',
- '本产物把「脚本级已自动化」由 0 提升到 %d,但**执行状态仍全部为 0**(已执行/通过/失败均未伪造)。' % len(cases),
- '执行回写需在真实运行后由测试负责人按结果更新侧车 `test-progress.json`。',
- ]
- with open(os.path.join(GEN_DIR, 'README.md'), 'w', encoding='utf-8') as fh:
- fh.write('\n'.join(lines) + '\n')
- def build_counters(cases):
- return {
- 'tier': Counter(c['tier'] for c in cases),
- 'role': Counter(c['role'] or '(空)' for c in cases),
- 'priority': Counter(c['priority'] or '(空)' for c in cases),
- 'type': Counter(c['type'] or '(空)' for c in cases),
- 'source': Counter(c['source_file'] for c in cases),
- 'section_kind': Counter(
- ('异常流' if c['section'] and '异常' in c['section'] else
- '权限流' if c['section'] and '权限' in c['section'] else
- '非功能' if c['section'] and '非功能' in c['section'] else '正常流/其他')
- for c in cases),
- }
- def md_table(counter, head_left='项', head_right='数量'):
- out = ['| %s | %s |' % (head_left, head_right), '|---|---|']
- for k, v in sorted(counter.items(), key=lambda x: (-x[1], str(x[0]))):
- out.append('| %s | %d |' % (k, v))
- return out
- def write_map_md(cases, counters, warnings):
- active = (counters['tier']['active-api'] + counters['tier']['active-probe']
- + counters['tier']['auth-probe'])
- lines = [
- '# 结构化测试用例脚本化台账',
- '',
- '> 生成时间:%s' % datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
- '> 生成器:`tests/scripts/scriptize_tc.py` | 数据源:`tests/test-cases/*.md`',
- '> 规格:`tests/e2e/api/generated/tc-scripted.spec.js` | 数据:`cases.json`',
- '',
- '## 一、总账',
- '',
- '| 口径 | 数量 | 说明 |',
- '|---|---|---|',
- '| 文档用例(`\\| TC-` 行) | **%d** | 11 个 md 文件全量解析 |' % len(cases),
- '| 生成的 `test()` | **%d** | 1:1,无丢弃 |' % len(cases),
- '| 唯一 TC 编号 | %d | %s |' % (
- len(set(c['id'] for c in cases)),
- '全部唯一,无重复编号' if len(set(c['id'] for c in cases)) == len(cases)
- else '其余为同编号在不同段落重复出现,用 `#n` 区分'),
- '| 可真实执行 `active-api` + `active-probe` + `auth-probe` | **%d** | 需 `TC_LIVE=1` 授权联网 |' % active,
- '| 其中 `auth-probe`(未授权鉴权探针) | %d | 不带 token,断言 401/403 |' % counters['tier']['auth-probe'],
- '| 已有脚本覆盖 `linked-e2e` | %d | 文档关联的 spec 文件确实存在,本条仅作锚点 |' % counters['tier']['linked-e2e'],
- '| 设计态 `design-*` | **%d** | `test.fixme`,不打远程、不计通过 |' % (len(cases) - active - counters['tier']['linked-e2e']),
- '| 解析告警 | %d | 见文末 |' % len(warnings),
- '',
- '## 二、按分级',
- '',
- ]
- lines += md_table(counters['tier'], '分级', '用例数')
- lines += ['', '## 三、按来源文件', '']
- lines += md_table(counters['source'], '文件', '用例数')
- lines += ['', '## 四、按角色', '']
- lines += md_table(counters['role'], '角色', '用例数')
- lines += ['', '## 五、按优先级', '']
- lines += md_table(counters['priority'], '优先级', '用例数')
- lines += ['', '## 六、按类型', '']
- lines += md_table(counters['type'], '类型', '用例数')
- lines += ['', '## 七、按段落类别', '']
- lines += md_table(counters['section_kind'], '类别', '用例数')
- lines += [
- '',
- '## 八、闭环条件(重要)',
- '',
- '本次交付的是**脚本化骨架**,不是「784 条已通过」。执行状态仍全部为 0,未伪造任何通过/失败。',
- '',
- '| 缺口 | 现状 | 闭环方式 |',
- '|---|---|---|',
- '| 联网授权 | 未授权,全部用例默认 fixme | 测试负责人显式 `TC_LIVE=1` 并确认目标环境 |',
- '| 真实断言 | `active-api` 仅空 body + code 集合断言 | 按 `cases.json` 补请求体与业务断言 |',
- '| 设计态 %d 条 | 缺 SPA / 夹具 / 压测授权 | 部署前端、装浏览器、补夹具后重跑生成器 |' %
- (len(cases) - active - counters['tier']['linked-e2e']),
- '| `linked-e2e` %d 条 | 只做文档锚点,未重复执行既有 spec | 直接执行关联 spec 并回写结果 |' % counters['tier']['linked-e2e'],
- '| 执行回写 | `test-progress.json` 侧车未更新 | 真实运行后按结果回写,避免「已自动化=已通过」误判 |',
- '',
- '## 九、路由证据分级',
- '',
- '| 级别 | 条数 | 依据 | 复核要求 |',
- '|---|---|---|---|',
- ]
- lvl = Counter(c['route_level'] or '无' for c in cases)
- for k, desc, need in (
- ('inline', '用例原文写出的 `/api` 路径', '无需'),
- ('inherit', '派生自正常流基用例', '无需'),
- ('controller', '设计列 Controller 名 → 源码 `file:line`', '无需'),
- ('doc-fuzzy', '接口文档章节中文词 → 源码真实路由', '**需人工复核模块级关联**'),
- ('linked-spec', '既有 spec 实际调用过的路径', '建议复核'),
- ('无', '无可靠路由证据,保持设计态', '需人工补 API 映射'),
- ):
- lines.append('| `%s` | %d | %s | %s |' % (k, lvl[k], desc, need))
- lines += [
- '',
- '> 免鉴权公开端点(login/register/send-code/wechat-login/direct-register/config/public/media/upload)',
- '> 已从探针候选中排除,避免 `auth-probe` 出现「接口本就公开」的假失败。',
- '',
- '## 十、验证记录',
- '',
- '| 项 | 命令 | 结果 |',
- '|---|---|---|',
- '| JS 语法 | `node --check tc-scripted.spec.js` | 通过 |',
- '| JSON 完整性 | `node -e require(cases.json)` | %d 条,唯一编号 %d |' %
- (len(cases), len(set(c['id'] for c in cases))),
- '| 框架识别 | `npx playwright test ... --list` | `Total: %d tests in 1 file` |' % len(cases),
- '| 默认零联网 | `node .tdd/verify-scripted.cjs` | %d fixme / 0 apiCalls |' % len(cases),
- '| LIVE 分级正确 | 同上(TC_LIVE=1) | %d fixme / %d apiCalls |' % (len(cases) - active, active),
- '| 真实执行 | runner | **未执行**:本机 safe-delete 守卫拦截 + 未授权联网 |',
- '',
- '> 结论:784 条文档用例已 1:1 转为可识别的测试定义,但**执行状态仍全部为 0**,',
- '> 不视为「已测」或「已通过」。',
- '',
- '## 十一、解析告警',
- '',
- ]
- if warnings:
- lines += ['```'] + warnings[:50] + ['```']
- if len(warnings) > 50:
- lines.append('(仅列前 50 条,共 %d 条)' % len(warnings))
- else:
- lines.append('无。全部表格行与表头列数一致。')
- lines.append('')
- with open(os.path.join(DOC_DIR, 'scriptization-map.md'), 'w', encoding='utf-8') as fh:
- fh.write('\n'.join(lines))
- def write_map_csv(cases):
- cols = ['tc_id', 'occurrence', 'title', 'role', 'priority', 'type',
- 'section', 'source_file', 'source_line', 'tier', 'reason',
- 'api_path', 'expected_codes', 'probe_codes', 'route_level',
- 'route_evidence', 'ui_mixed', 'e2e_ref', 'e2e_exists',
- 'script', 'existing_e2e', 'steps', 'expected']
- with open(os.path.join(DOC_DIR, 'scriptization-map.csv'), 'w',
- encoding='utf-8-sig', newline='') as fh:
- w = csv.writer(fh)
- w.writerow(cols)
- for c in cases:
- w.writerow([
- c['id'], c['occurrence'], clean_title(c), c['role'], c['priority'],
- c['type'], c['section'], c['source_file'], c['source_line'],
- c['tier'], c['reason'],
- c['api']['path'] if c['api'] else '',
- '/'.join(str(x) for x in c['expected_codes']),
- '/'.join(str(x) for x in c['api']['codes']) if c['api'] else '',
- c['route_level'], c['route_evidence'],
- 'Y' if c['ui_mixed'] else 'N',
- c['e2e_ref'], 'Y' if c['e2e_exists'] else 'N',
- c['script'], c['e2e'],
- re.sub(r'\s+', ' ', c['steps'])[:300],
- re.sub(r'\s+', ' ', c['expected'])[:300],
- ])
- def write_routes(idx):
- """落盘路由索引,供人工审计每条路径的来源(源码 file:line)。"""
- payload = {
- 'generated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
- 'source': ('cfc-backend Controller @RequestMapping/@PostMapping 注解;'
- 'docs/superpowers/api/API_REFERENCE.md 章节;tests/e2e 既有 spec'),
- 'stats': {
- 'source_routes': len(idx['routes']),
- 'controllers': len(idx['by_controller']),
- 'doc_sections': len(idx['sections']),
- 'specs_with_paths': len(idx['spec_paths']),
- },
- 'routes': idx['routes'],
- 'sections': idx['sections'],
- 'spec_paths': idx['spec_paths'],
- }
- with open(os.path.join(GEN_DIR, 'api-routes.json'), 'w', encoding='utf-8') as fh:
- json.dump(payload, fh, ensure_ascii=False, indent=1)
- def main():
- os.makedirs(GEN_DIR, exist_ok=True)
- os.makedirs(DOC_DIR, exist_ok=True)
- cases, warnings = parse_cases()
- cases, idx = build(cases)
- counters = build_counters(cases)
- write_json(cases)
- write_routes(idx)
- write_spec(cases)
- write_readme(cases, counters)
- write_map_md(cases, counters, warnings)
- write_map_csv(cases)
- print('解析用例: %d 条 | 唯一编号: %d | 解析告警: %d' %
- (len(cases), len(set(c['id'] for c in cases)), len(warnings)))
- print('分级:', dict(counters['tier']))
- print('路由索引: %d 源码路由 / %d Controller / %d 文档章节 / %d 既有 spec' %
- (len(idx['routes']), len(idx['by_controller']),
- len(idx['sections']), len(idx['spec_paths'])))
- print('路由证据:', dict(Counter(c['route_level'] or '无' for c in cases)))
- print('产物: cases.json / api-routes.json / tc-scripted.spec.js / README.md / '
- 'scriptization-map.md / .csv')
- if __name__ == '__main__':
- main()
|