/** * Generate or validate testcases.json for the Self-Testing Agent. * * CLI (no subcommands — positional args + flags): * testcases-tool.ts [--validate] * Read `_extracted.json` ({bundle_name, app_name, cases[]}) and write * `testcases.json` (array of {case_name, bundle_name, app_name, test_steps}), * composing test_steps strictly as "动作:..." / "预期结果:..." and * rewriting app_name → bundle_name. With --validate, re-check the output. * * testcases-tool.ts --check-only * Validate an existing testcases.json (array of cases) against the * in-file rules (FORBIDDEN_STRINGS / REQUIRED_LINES / TEST_STEPS_TEMPLATE). * Exit 0 = PASSED, exit 1 = FAILED. * * The forbidden-strings and section-header rules live in this file as the * single source of truth — generation enforces them at write time, validation * re-checks them on existing files. */ import fs from 'node:fs'; import path from 'node:path'; const FORBIDDEN_STRINGS = [ '前置条件', 'Spec:', 'Scenario:', 'ability_name:', 'module_name:', ] as const; const SECTION_HEADER_RE = /【(.+?)】/; const REQUIRED_LINES = ['动作:', '预期结果:'] as const; const TEST_STEPS_TEMPLATE = '动作:{actions}\n预期结果:{expected_results}'; /** Input case shape from the extractor. */ interface InputCase { case_name: string; actions: string; expected_results: string; } /** Input document: extractor output consumed by `generate`. */ interface InputData { bundle_name: string; app_name: string; cases: InputCase[]; } /** Output case shape: what we write to testcases.json. */ interface OutputCase { case_name: string; bundle_name: string; app_name: string; test_steps: string; } /** Safety net: replace remaining app_name references with bundle_name. */ function rewriteAppName(text: string, appName: string, bundleName: string): string { if (appName && text.includes(appName)) { return text.replaceAll(appName, bundleName); } return text; } /** Compose a compliant test_steps string with only 动作 and 预期结果. */ function composeTestSteps( appName: string, bundleName: string, actions: string, expectedResults: string, ): string { const rewrittenActions = rewriteAppName(actions, appName, bundleName); const rewrittenExpected = rewriteAppName(expectedResults, appName, bundleName); // Use function replacements so '$' sequences ($&, $$, $1, ...) in the action / // expected text are inserted literally — String.replace with a STRING argument // would interpret them. let testSteps = TEST_STEPS_TEMPLATE.replace('{actions}', () => rewrittenActions).replace( '{expected_results}', () => rewrittenExpected, ); // Final safety: rewrite once more in case app_name only became visible after composition. testSteps = rewriteAppName(testSteps, appName, bundleName); for (const forbidden of FORBIDDEN_STRINGS) { if (testSteps.includes(forbidden)) { throw new Error( `FATAL: forbidden string '${forbidden}' found in generated test_steps. ` + `This is a bug in testcases-tool.`, ); } } const sectionMatch = SECTION_HEADER_RE.exec(testSteps); if (sectionMatch) { throw new Error( `FATAL: unexpected section header 【${sectionMatch[1]}】 in generated test_steps. ` + `test_steps should only contain 动作 and 预期结果.`, ); } return testSteps; } /** Generate compliant testcases array from input data. */ export function generate(inputData: InputData): OutputCase[] { const { bundle_name: bundleName, app_name: appName, cases } = inputData; // Fail loudly on malformed extractor input instead of silently writing // `undefined`/dropped keys. const missingTop = (['bundle_name', 'app_name', 'cases'] as const).filter( (k) => inputData[k] === undefined, ); if (missingTop.length > 0) { throw new Error(`FATAL: input is missing required key(s): ${missingTop.join(', ')}.`); } const result: OutputCase[] = []; for (const [i, c] of cases.entries()) { const missingCase = (['case_name', 'actions', 'expected_results'] as const).filter( (k) => c[k] === undefined, ); if (missingCase.length > 0) { throw new Error( `FATAL: case ${i} (${c.case_name ?? ''}) is missing required key(s): ` + `${missingCase.join(', ')}.`, ); } const testSteps = composeTestSteps(appName, bundleName, c.actions, c.expected_results); result.push({ case_name: c.case_name, bundle_name: bundleName, app_name: appName, test_steps: testSteps, }); } return result; } /** Return a list of validation error messages. Empty list means PASSED. */ export function validateTestcases(filepath: string): string[] { const raw = fs.readFileSync(filepath, 'utf-8'); const cases: unknown = JSON.parse(raw); if (!Array.isArray(cases)) return ['Root element is not a JSON array']; const errors: string[] = []; const globalSectionRe = new RegExp(SECTION_HEADER_RE.source, 'g'); cases.forEach((c: any, i: number) => { const ts: string = c?.test_steps ?? ''; const name: string = c?.case_name ?? `case_${i}`; const appName: string = c?.app_name ?? ''; const bundleName: string = c?.bundle_name ?? ''; const prefix = `Case ${i + 1} [${name}]`; for (const required of REQUIRED_LINES) { if (!ts.includes(required)) errors.push(`${prefix}: MISSING ${required} line`); } for (const forbidden of FORBIDDEN_STRINGS) { if (ts.includes(forbidden)) { errors.push(`${prefix}: CONTAINS ${forbidden} (must be excluded)`); } } let m: RegExpExecArray | null; while ((m = globalSectionRe.exec(ts)) !== null) { errors.push( `${prefix}: CONTAINS unexpected section header 【${m[1]}】 ` + `(test_steps should only contain 动作 and 预期结果)`, ); } if (appName && bundleName && appName !== bundleName && ts.includes(appName)) { errors.push( `${prefix}: test_steps contains app_name '${appName}' instead of bundle_name ` + `'${bundleName}'. All app name references in test_steps must use bundle_name.`, ); } }); return errors; } function cmdGenerate(input: string, output: string, doValidate: boolean): void { const inputData = JSON.parse(fs.readFileSync(input, 'utf-8')) as InputData; const testcases = generate(inputData); fs.writeFileSync(output, JSON.stringify(testcases, null, 2), 'utf-8'); console.log(`Generated ${testcases.length} test cases -> ${output}`); if (doValidate) { const errors = validateTestcases(output); if (errors.length > 0) { console.log('VALIDATION FAILED:'); for (const e of errors) console.log(` [FAIL] ${e}`); process.exit(1); } console.log(`VALIDATION PASSED: all ${testcases.length} cases conform to the schema`); } } function cmdValidate(filepath: string): void { const errors = validateTestcases(filepath); if (errors.length > 0) { console.log('VALIDATION FAILED:'); for (const e of errors) console.log(` [FAIL] ${e}`); process.exit(1); } const cases = JSON.parse(fs.readFileSync(filepath, 'utf-8')) as unknown[]; console.log(`VALIDATION PASSED: all ${cases.length} cases have correct test_steps format`); } // ---- CLI ---- const args = process.argv.slice(2); if (args.includes('-h') || args.includes('--help')) { console.log(`Usage: testcases-tool.ts [--validate] Arguments: Path to the extracted input JSON (_extracted.json) Path to write testcases.json Options: --validate After writing, re-validate the output --check-only Validate an existing testcases.json without generating `); process.exit(0); } if (args.includes('--check-only')) { // Validate mode const idx = args.indexOf('--check-only'); const filepath = args[idx + 1]; if (!filepath) { console.error('Usage: testcases-tool.ts --check-only '); process.exit(1); } cmdValidate(filepath); } else { // Generate mode (default) const input = args.find((a) => !a.startsWith('-')); const output = args.filter((a) => !a.startsWith('-')).find((a) => a !== input); if (!input || !output) { console.error('Usage: testcases-tool.ts [--validate]'); process.exit(1); } cmdGenerate(input, output, args.includes('--validate')); }