diff --git a/Jenkinsfile b/Jenkinsfile index 030b6c755..bc2e2cae4 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -67,6 +67,8 @@ def parseAutoBuildState(String description, List allowedServices) { List failedServices = [] if (state.startsWith('RESULTS')) { String summary = state.substring('RESULTS'.length()).trim() + int metadataIndex = summary.indexOf(' | ') + if (metadataIndex >= 0) summary = summary.substring(0, metadataIndex).trim() summary.split(',').collect { it.trim() }.findAll { it }.each { item -> int separator = item.indexOf('=') if (separator > 0) { @@ -103,6 +105,25 @@ def previousAutoBuildState(def previousBuild, List allowedServices) { return null } +// 测试范围必须相对成功门禁,而非失败/取消轮次;否则补跑可能遗漏前次未通过的变更。 +def previousSuccessfulTestCommit(def previousBuild, List requiredServices) { + def candidate = previousBuild + int inspected = 0 + while (candidate != null && inspected < 30) { + String description = candidate.description ?: '' + String marker = ' | TESTS=PASSED ' + int markerIndex = description.lastIndexOf(marker) + if (candidate.result == 'SUCCESS' && markerIndex >= 0) { + List metadata = description.substring(markerIndex + marker.length()).trim().split(' ') + if (metadata.size() == 2 && metadata[0] ==~ /[0-9a-fA-F]{7,40}/ && + requiredServices.every { metadata[1].split(',').contains(it) }) return metadata[0] + } + candidate = candidate.previousBuild + inspected++ + } + return '' +} + def environmentChoicesForJob(String jobBaseName) { return [fixedEnvironmentForJob(jobBaseName) ?: 'test'] } @@ -161,6 +182,7 @@ pipeline { choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)') + choice(name: 'TEST_SCOPE', choices: ['AUTO', 'MODULE'], description: 'AUTO 对已支持的局部变更执行关联测试;公共/未知变更自动完整模块回归;MODULE 显式完整模块回归') // 地址由当前 Job 配置提供;参数声明刷新时保留已配置值,不将环境地址写入源码。 string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库地址;首次配置或更换仓库时填写 host[:port]/owner/image') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') @@ -269,6 +291,7 @@ pipeline { selected = selected.unique() env.SELECTED_SERVICES = selected.join(',') + env.TEST_BASE_COMMIT = previousSuccessfulTestCommit(currentBuild.previousBuild, selected) env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { currentBuild.description = params.BUILD_MODE == 'AUTO' @@ -335,16 +358,22 @@ pipeline { .findAll { it } .join(',') echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" + String testPlan = "${pwd(tmp: true)}/ci-test-plan.json" lock(resource: 'firstleg-test-maven') { timeout(time: 50, unit: 'MINUTES') { sh """ test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete - sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test + node deploy/scripts/ci-test-scope.mjs plan --modules '${selectedModules}' --base '${env.TEST_BASE_COMMIT ?: ''}' --mode '${env.RESOLVED_ENVIRONMENT == 'test' ? (params.TEST_SCOPE ?: 'AUTO') : 'MODULE'}' --plan '${testPlan}' + node deploy/scripts/ci-test-scope.mjs checks --plan '${testPlan}' + test_arguments=\"\$(node deploy/scripts/ci-test-scope.mjs args --plan '${testPlan}')\" + sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test \$test_arguments + node deploy/scripts/ci-test-scope.mjs verify --plan '${testPlan}' """ } } echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" + env.TEST_GATE_PASSED = 'true' } } post { @@ -401,6 +430,9 @@ pipeline { currentBuild.description = params.BUILD_MODE == 'AUTO' ? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}" : "${env.IMAGE_TAG} | ${summary}" + if (env.TEST_GATE_PASSED == 'true') { + currentBuild.description += " | TESTS=PASSED ${env.SOURCE_COMMIT} ${env.SELECTED_SERVICES}" + } if (failed) { unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}") } diff --git a/deploy/scripts/ci-test-scope.mjs b/deploy/scripts/ci-test-scope.mjs new file mode 100644 index 000000000..ae82ee59a --- /dev/null +++ b/deploy/scripts/ci-test-scope.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs' +import { resolve, relative, basename } from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const moduleName = 'lastleg-service' +const testClass = /(?:^Test.+|.+(?:Test|Tests|TestCase))\.java$/ +const words = source => new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || []) +const tables = source => new Set(source.match(/\bll_[a-z0-9_]+\b/g) || []) +const writtenTables = source => new Set([...source.matchAll(/\b(?:UPDATE|INSERT\s+(?:IGNORE\s+)?INTO|DELETE\s+FROM)\s+(ll_[a-z0-9_]+)/gi)].map(match => match[1])) + +// 按完整注解比较 SQL,避免只改 SELECT 时把同一 Mapper 内未改的全部写语句扩大为全域回归。 +function writeStatements(source) { + const result = [] + for (const match of source.matchAll(/@(?:Insert|Update|Delete)\s*\(/g)) { + let depth = 1, quote = '', index = match.index + match[0].length + for (; index < source.length && depth; index++) { + if (quote) { + if (source.startsWith(quote, index)) { index += quote.length - 1; quote = '' } + else if (source[index] === '\\' && quote.length === 1) index++ + } else if (source.startsWith('"""', index)) { quote = '"""'; index += 2 } + else if (source[index] === '"' || source[index] === "'") quote = source[index] + else if (source[index] === '(') depth++ + else if (source[index] === ')') depth-- + } + if (depth) throw new Error('Cannot parse SQL annotation; use MODULE test scope') + result.push(source.slice(match.index, index)) + } + return result +} + +function changedWrites(node) { + if (node.previousSource === undefined) return writtenTables(node.source) + const before = writeStatements(node.previousSource), after = writeStatements(node.source) + const changed = [...before.filter(sql => !after.includes(sql)), ...after.filter(sql => !before.includes(sql))] + const oldRest = before.reduce((text, sql) => text.replace(sql, ''), node.previousSource) + const newRest = after.reduce((text, sql) => text.replace(sql, ''), node.source) + if (oldRest !== newRest) changed.push(oldRest, newRest) + return writtenTables(changed.join('\n')) +} + +/** 宁可扩大范围,不用文件名猜测唯一测试;未识别的输入保留完整模块门禁。 */ +export function planTests({ modules, mode = 'AUTO', changes = [], sources = [], baseline = '' }) { + const architectureScript = `${moduleName}/scripts/analyze-lastleg-dependencies` + const staticChecks = changes.some(path => path.startsWith(architectureScript)) + ? [`${architectureScript}.test.mjs`] : [] + const full = reason => ({ scope: 'MODULE', reason, modules, baseline, tests: [], staticChecks }) + if (mode !== 'AUTO' && mode !== 'MODULE') throw new Error('TEST_SCOPE must be AUTO or MODULE') + if (mode === 'MODULE') return full('显式完整模块回归') + if (!baseline) return full('没有成功测试基线') + if (modules.length !== 1 || modules[0] !== moduleName) return full('多服务或尚未核对的服务采用完整模块回归') + const prefix = `${moduleName}/` + const relevant = changes.filter(path => path.startsWith(prefix)) + const shared = changes.some(path => path !== 'scripts/agents-index-contract.ps1' && !path.startsWith(prefix) + && /^(?:common-[^/]+\/|platform-bom\/|pom\.xml$|Jenkinsfile|deploy\/|config\/|scripts\/.*\.(?:mjs|sh|ps1)$)/.test(path)) + if (shared) return full('公共能力、发布或测试基础设施变更') + if (!relevant.length) return full('补跑服务没有可验证的业务差异') + const java = `${prefix}src/main/java/com/baiyue/lastleg/` + const supported = path => path.startsWith(`${prefix}src/test/java/`) + || path.startsWith(`${prefix}src/main/resources/openapi/`) + || path.startsWith(architectureScript) + || (path.startsWith(java) && path.slice(java.length).includes('/') + && !/^(?:config|security|entity|dto|model|mapper|repository|service|client)\//.test(path.slice(java.length))) + || /\.(?:md)$/.test(path) + if (relevant.some(path => !supported(path))) return full('共享模型、配置、SQL、资源或未识别路径变更') + + const nodes = sources.map(({ path, source, previousSource }) => ({ path, source, previousSource, + symbol: basename(path, '.java'), tokens: words(source), tables: tables(source), + test: path.startsWith(`${prefix}src/test/java/`), + name: `${source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ''}.${basename(path, '.java')}` })) + const byPath = new Map(nodes.map(node => [node.path, node])) + const bySymbol = new Map() + for (const node of nodes) { + if (!bySymbol.has(node.symbol)) bySymbol.set(node.symbol, []) + bySymbol.get(node.symbol).push(node) + } + const reverse = new Map(nodes.map(node => [node.path, new Set()])) + for (const node of nodes) { + // 包含反射字符串、静态 import、同包引用及测试夹具;重名类全部纳入。 + for (const token of node.tokens) for (const dependency of bySymbol.get(token) || []) { + if (dependency !== node) reverse.get(dependency.path).add(node.path) + } + // 实现变化也要覆盖只依赖接口/基类的调用者,不能仅沿显式 import 反查。 + for (const match of node.source.matchAll(/\b(?:implements|extends)\s+([^{};]+)/g)) { + for (const token of words(match[1])) for (const contract of bySymbol.get(token) || []) { + if (contract !== node) reverse.get(node.path).add(contract.path) + } + } + } + const seeds = new Set() + const changedTables = new Set() + for (const path of relevant.filter(path => path.endsWith('.java'))) { + const node = byPath.get(path) + if (node) { + seeds.add(path) + // 读查询变化不改变其他查询的数据;潜在写入变化则纳入同表读写消费者。 + if (!node.test) for (const table of changedWrites(node)) changedTables.add(table) + } else { + // 删除/移动旧类型时,仍按旧类名查现存消费者。 + const symbol = basename(path, '.java') + const consumers = nodes.filter(consumer => consumer.tokens.has(symbol)) + if (!consumers.length) return full('删除类型缺少可追踪的现存消费者') + for (const consumer of consumers) seeds.add(consumer.path) + } + } + if (!seeds.size) return full('没有可追踪的 Java 行为差异') + for (const node of nodes) if ([...node.tables].some(table => changedTables.has(table))) seeds.add(node.path) + const affected = new Set(seeds) + const queue = [...seeds] + for (let index = 0; index < queue.length; index++) { + for (const consumer of reverse.get(queue[index]) || []) if (!affected.has(consumer)) { + affected.add(consumer); queue.push(consumer) + } + } + const allTests = nodes.filter(node => node.test && testClass.test(basename(node.path)) + && /@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/.test(node.source)) + const contractChange = relevant.some(path => path.startsWith(`${prefix}src/main/resources/openapi/`)) + const candidates = allTests.filter(node => affected.has(node.path) + || /(?:Security|Isolation|MapperRegistrationArchitecture|QueryBinding)/.test(node.symbol) + || (contractChange && /(?:Contract|OpenApi|Schema|Architecture|Permission)/.test(node.symbol))) + // 显式依赖真实环境的类仍由 MODULE/专项授权入口负责,不能把它们默认跳过算作执行成功。 + const optIn = node => /@(?:EnabledIf(?:SystemProperty|EnvironmentVariable)|Disabled(?:IfSystemProperty|IfEnvironmentVariable)?\b)/ + .test(node.source.slice(0, node.source.indexOf(`class ${node.symbol}`))) + const tests = candidates.filter(node => !optIn(node)) + if (!tests.some(node => affected.has(node.path))) return full('没有匹配到行为测试,拒绝零测试通过') + if (tests.some(node => !/^com\.[\w.$]+$/.test(node.name))) return full('测试类名无法安全传入 Maven') + return { scope: 'AFFECTED', reason: 'Java 反向引用、接口实现、测试夹具、共享 SQL 表和相关静态合同', + modules, baseline, tests: tests.map(node => node.name).sort(), totalTests: allTests.length, staticChecks, + optInTests: candidates.filter(optIn).map(node => node.name).sort() } +} + +function walk(root) { + const files = [] + for (const item of readdirSync(root, { withFileTypes: true })) { + const path = resolve(root, item.name) + if (item.isDirectory()) files.push(...walk(path)) + else if (item.isFile() && item.name.endsWith('.java')) files.push(path) + } + return files +} + +/** 每个所选类必须有本轮实际执行的报告;允许上游零匹配不能变成整个门禁零测试。 */ +export function verifyReports(plan, reports) { + if (plan.scope !== 'AFFECTED') return + for (const name of plan.tests) { + const matches = reports.filter(report => report.name === name || report.name.startsWith(`${name}$`)) + if (!matches.length || matches.some(report => report.failures || report.errors + || ['tests', 'skipped', 'failures', 'errors'].some(key => !Number.isInteger(report[key]) || report[key] < 0) + || report.skipped > report.tests) + || matches.reduce((sum, report) => sum + report.tests - report.skipped, 0) <= 0) { + throw new Error(`Missing, skipped or failed fresh test report: ${name}`) + } + } +} + +function main(args) { + const [action, ...options] = args + const values = {} + for (let i = 0; i < options.length; i += 2) { + if (!options[i].startsWith('--') || options[i + 1] === undefined) throw new Error('Expected --name value') + values[options[i].slice(2)] = options[i + 1] + } + const root = process.cwd() + const planFile = values.plan + if (!planFile) throw new Error('--plan is required') + if (action === 'plan') { + const modules = (values.modules || '').split(',') + if (modules.some(module => !/^[a-z][a-z0-9-]+$/.test(module))) throw new Error('Invalid modules') + let baseline = values.base || '' + let changes = [] + if (baseline) { + if (!/^[0-9a-f]{7,40}$/i.test(baseline)) throw new Error('Invalid baseline commit') + try { + execFileSync('git', ['merge-base', '--is-ancestor', baseline, 'HEAD'], { stdio: 'pipe' }) + changes = execFileSync('git', ['diff', '--name-only', '-z', baseline, 'HEAD'], { encoding: 'utf8' }).split('\0').filter(Boolean) + } catch { baseline = '' } + } + const sources = modules.length === 1 && modules[0] === moduleName + ? ['main', 'test'].flatMap(set => walk(resolve(root, moduleName, 'src', set, 'java'))) + .map(path => { + const key = relative(root, path).replaceAll('\\', '/') + const entry = { path: key, source: readFileSync(path, 'utf8') } + if (baseline && changes.includes(key)) { + try { entry.previousSource = execFileSync('git', ['show', `${baseline}:${key}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) } + catch { entry.previousSource = '' } + } + return entry + }) : [] + const plan = { ...planTests({ modules, mode: values.mode || 'AUTO', baseline, changes, sources }), startedAt: Date.now() } + writeFileSync(planFile, JSON.stringify(plan)) + console.log(`CI test scope: ${plan.scope}; ${plan.reason}`) + if (plan.scope === 'AFFECTED') console.log(`Selected ${plan.tests.length}/${plan.totalTests} test classes:\n${plan.tests.join('\n')}`) + if (plan.optInTests?.length) console.log(`Explicit environment tests remain opt-in (not counted as passed): ${plan.optInTests.join(',')}`) + return + } + const plan = JSON.parse(readFileSync(planFile, 'utf8')) + if (action === 'args') { + if (plan.scope === 'AFFECTED') process.stdout.write(`-Dtest=${plan.tests.join(',')} -Dsurefire.failIfNoSpecifiedTests=false -Dci.sql-semantic.skip=true`) + } else if (action === 'checks') { + for (const check of plan.staticChecks || []) { + if (check !== `${moduleName}/scripts/analyze-lastleg-dependencies.test.mjs`) throw new Error('Unsupported static check') + execFileSync(process.execPath, ['--test', '--test-concurrency=2', check], { stdio: 'inherit' }) + } + } else if (action === 'verify') { + const reports = [] + if (plan.scope === 'AFFECTED') for (const module of plan.modules) { + const directory = resolve(root, module, 'target/surefire-reports') + for (const filename of readdirSync(directory)) { + if (!/^TEST-.*\.xml$/.test(filename)) continue + const path = resolve(directory, filename) + if (statSync(path).mtimeMs < plan.startedAt - 2000) continue + const xml = readFileSync(path, 'utf8').match(/]+)>/)?.[1] + if (!xml) continue + const attrs = Object.fromEntries([...xml.matchAll(/([\w-]+)="([^"]*)"/g)].map(match => [match[1], match[2]])) + reports.push({ name: attrs.name || '', ...Object.fromEntries(['tests', 'failures', 'errors', 'skipped'].map(key => [key, Number(attrs[key] || 0)])) }) + } + } + verifyReports(plan, reports) + console.log(`CI test reports verified: ${plan.scope}`) + } else throw new Error(`Unknown action: ${action}`) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { main(process.argv.slice(2)) } catch (error) { console.error(error.message); process.exitCode = 1 } +} diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index b8ccfdaa2..6c83fb3c1 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -4,6 +4,10 @@ 4.0.0 com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml lastleg-service + + + false + com.google.zxingcore3.5.3 com.baiyue.sharedcommon-event-contract${project.version} @@ -42,6 +46,7 @@ test test + ${ci.sql-semantic.skip} **/*SqlSemanticTest.java false diff --git a/scripts/README.md b/scripts/README.md index 3bda1eebb..b236798b6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -447,6 +447,8 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块 ## 后端分层检查 +Jenkins 总控的测试范围入口为 `../deploy/scripts/ci-test-scope.mjs`,由 `TEST_SCOPE=AUTO|MODULE` 控制,不部署、不访问业务数据库。`plan` 使用带门禁通过标记且覆盖目标服务的成功构建基线与当前源码生成 Job 临时目录的 JSON,`checks` 执行受影响的既有架构合同,`args` 只输出精确测试类参数,`verify` 拒绝缺失、陈旧、失败或全部跳过的所选类报告。AUTO 当前仅优化已识别的单 Lastleg 局部变更,公共/未知/配置/SQL/多服务等保留完整回归;完整编译保留,真实环境 opt-in 不计通过,JVM 内存与隔离配置不放宽。定向列表在默认 Surefire execution 执行,关闭后置 SQL execution 的重复执行;未指定定向参数时原完整 SQL 回归不变。静态筛选边界及 MODULE 使用条件见工作区 `docs/测试环境Jenkins部署指南.md`;直接合同为 `ci-test-scope.test.mjs`。本地普通任务继续使用下述显式范围入口,不自动触发 CI 或增加完整回归。 + `backend-check.ps1` 是本地日常入口,PowerShell 7 执行,自动经 `local-check.ps1` 占用一个重型名额。全量触发条件唯一见 `../AGENTS.md`;不自动根据 Git diff 猜测跨服务消费者,不自动缓存或跳过测试。 在后端根目录执行,`-Modules` 使用逗号分隔的当前根 POM 模块名: diff --git a/scripts/ci-test-scope.test.mjs b/scripts/ci-test-scope.test.mjs new file mode 100644 index 000000000..d20da774f --- /dev/null +++ b/scripts/ci-test-scope.test.mjs @@ -0,0 +1,153 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' +import { planTests, verifyReports } from '../deploy/scripts/ci-test-scope.mjs' + +const module = 'lastleg-service' +function java(type, body = '', set = 'main') { + const name = type.split('.').at(-1) + const pkg = type.slice(0, -name.length - 1) + return { path: `${module}/src/${set}/java/com/baiyue/lastleg/${type.replaceAll('.', '/')}.java`, + source: `package com.baiyue.lastleg.${pkg}; class ${name} { ${body} }` } +} +const source = java('dispatch.DispatchService') +const fixture = java('dispatch.Fixture', 'DispatchService service;', 'test') +const direct = java('dispatch.DispatchTest', 'Fixture fixture; @Test void check() {}', 'test') +const consumer = java('delivery.DeliveryService', 'DispatchService service;') +const indirect = java('delivery.DeliveryTest', 'DeliveryService service; @Test void check() {}', 'test') +const unrelated = java('selfoperated.AnalyticsTest', '@Test void check() {}', 'test') +const security = java('workflow.OrderSecurityTest', '@Test void check() {}', 'test') +const sources = [source, fixture, direct, consumer, indirect, unrelated, security] +const plan = (options = {}) => planTests({ modules: [module], baseline: '1234567', + changes: [source.path], sources, ...options }) + +test('changed Java selects transitive callers and fixture consumers without unrelated domains', () => { + const result = plan() + assert.equal(result.scope, 'AFFECTED') + assert.deepEqual(result.tests, [ + 'com.baiyue.lastleg.delivery.DeliveryTest', 'com.baiyue.lastleg.dispatch.DispatchTest', + 'com.baiyue.lastleg.workflow.OrderSecurityTest' + ]) +}) + +test('implementation changes reach callers of interfaces, including duplicate simple names', () => { + const contract = java('dispatch.Port') + const implementation = java('dispatch.Adapter', 'implements Port') + const portCaller = java('fleet.PortCaller', 'Port port;') + const portTest = java('fleet.PortCallerTest', 'PortCaller service; @Test void check() {}', 'test') + const result = plan({ changes: [implementation.path], sources: [...sources, contract, implementation, portCaller, portTest] }) + assert.ok(result.tests.includes('com.baiyue.lastleg.fleet.PortCallerTest')) +}) + +test('SQL tables expand to other mappers and their tests', () => { + const mapper = java('dispatch.SourceMapper', 'String sql = "UPDATE ll_dispatch_source SET version=2";') + const sibling = java('delivery.DeliveryMapper', 'String sql = "UPDATE ll_dispatch_source SET version=1";') + const check = java('delivery.DeliveryMapperTest', 'DeliveryMapper mapper; @Test void check() {}', 'test') + const result = plan({ changes: [mapper.path], sources: [...sources, mapper, sibling, check] }) + assert.ok(result.tests.includes('com.baiyue.lastleg.delivery.DeliveryMapperTest')) +}) + +test('unchanged writes beside a changed SELECT do not pull in unrelated table consumers', () => { + const mapper = java('dispatch.QueryMapper', '@Update("UPDATE ll_shared SET value=1") void write(); @Select("SELECT value FROM ll_source") void read();') + mapper.previousSource = mapper.source.replace('SELECT value', 'SELECT old_value') + const check = java('dispatch.QueryMapperTest', 'QueryMapper mapper; @Test void check() {}', 'test') + const unrelatedTable = java('selfoperated.SharedTableTest', 'String query = "SELECT * FROM ll_shared"; @Test void check() {}', 'test') + const result = plan({ changes: [mapper.path], sources: [...sources, mapper, check, unrelatedTable] }) + assert.ok(result.tests.includes('com.baiyue.lastleg.dispatch.QueryMapperTest')) + assert.ok(!result.tests.includes('com.baiyue.lastleg.selfoperated.SharedTableTest')) + mapper.source = mapper.source.replace('value=1', 'value=2') + assert.ok(plan({ changes: [mapper.path], sources: [...sources, mapper, check, unrelatedTable] }) + .tests.includes('com.baiyue.lastleg.selfoperated.SharedTableTest')) +}) + +test('environment opt-in classes are reported separately and do not count as passing tests', () => { + const optional = java('dispatch.RealMysqlTest', 'DispatchService service; @Test void check() {}', 'test') + optional.source = optional.source.replace('class RealMysqlTest', '@EnabledIfSystemProperty(named="live",matches="true") class RealMysqlTest') + const result = plan({ sources: [...sources, optional] }) + assert.ok(!result.tests.includes('com.baiyue.lastleg.dispatch.RealMysqlTest')) + assert.deepEqual(result.optInTests, ['com.baiyue.lastleg.dispatch.RealMysqlTest']) +}) + +test('changed test fixtures and deleted Java types keep their dependent tests', () => { + assert.ok(plan({ changes: [fixture.path] }).tests.includes('com.baiyue.lastleg.dispatch.DispatchTest')) + const deleted = java('dispatch.RemovedService') + const user = java('delivery.ReplacementTest', 'String old = "RemovedService"; @Test void check() {}', 'test') + assert.ok(plan({ changes: [deleted.path], sources: [...sources, user] }).tests.includes('com.baiyue.lastleg.delivery.ReplacementTest')) +}) + +test('public API changes also select the existing contract tests', () => { + const contract = java('selfoperated.AnalyticsOpenApiContractTest', '@Test void check() {}', 'test') + const result = plan({ changes: [source.path, `${module}/src/main/resources/openapi/dispatch.yaml`], sources: [...sources, contract] }) + assert.ok(result.tests.includes('com.baiyue.lastleg.selfoperated.AnalyticsOpenApiContractTest')) +}) + +test('shared, unknown, configuration, schema and missing-baseline changes retain full regression', () => { + for (const path of ['pom.xml', 'Jenkinsfile', 'common-core/src/main/java/Core.java', + 'deploy/scripts/run-ci-maven.sh', 'config/nacos/test/lastleg.yml', + `${module}/pom.xml`, `${module}/src/main/resources/db/lastleg.sql`, + `${module}/src/test/resources/test-config.yml`, `${module}/src/main/java/com/baiyue/lastleg/entity/Order.java`]) { + assert.equal(plan({ changes: [source.path, path] }).scope, 'MODULE', path) + } + assert.equal(plan({ baseline: '' }).scope, 'MODULE') + assert.equal(plan({ mode: 'MODULE' }).scope, 'MODULE') + assert.equal(plan({ modules: ['order'] }).scope, 'MODULE') + assert.equal(plan({ modules: [module, 'iam'] }).scope, 'MODULE') + assert.equal(plan({ changes: [] }).scope, 'MODULE') + assert.equal(plan({ sources: [source] }).scope, 'MODULE') + assert.throws(() => plan({ mode: 'SKIP' })) +}) + +test('architecture script changes run their own checks instead of broadening Java regression', () => { + const result = plan({ changes: [source.path, 'scripts/agents-index-contract.ps1', + `${module}/scripts/analyze-lastleg-dependencies.test.mjs`] }) + assert.equal(result.scope, 'AFFECTED') + assert.deepEqual(result.staticChecks, [`${module}/scripts/analyze-lastleg-dependencies.test.mjs`]) +}) + +test('focused report verification rejects missing, failed and entirely skipped classes', () => { + const focused = { scope: 'AFFECTED', tests: ['com.example.Test'] } + const report = { name: 'com.example.Test', tests: 2, skipped: 0, failures: 0, errors: 0 } + verifyReports(focused, [report]) + verifyReports(focused, [{ ...report, name: 'com.example.Test$Nested' }]) + for (const reports of [[], [{ ...report, tests: 0 }], [{ ...report, skipped: 2 }], + [{ ...report, failures: 1 }], [{ ...report, errors: 1 }], [{ ...report, tests: NaN }]]) { + assert.throws(() => verifyReports(focused, reports)) + } +}) + +test('CLI prevents double execution of selected classes in the SQL semantic execution', () => { + const directory = mkdtempSync(join(tmpdir(), 'ci-test-scope-')) + try { + const file = join(directory, 'plan.json') + writeFileSync(file, JSON.stringify(plan())) + const args = execFileSync(process.execPath, + ['deploy/scripts/ci-test-scope.mjs', 'args', '--plan', file], { encoding: 'utf8' }) + assert.match(args, /-Dtest=com\.baiyue/) + assert.match(args, /-Dsurefire.failIfNoSpecifiedTests=false/) + assert.match(args, /-Dci.sql-semantic.skip=true/) + const pom = readFileSync(new URL('../lastleg-service/pom.xml', import.meta.url), 'utf8') + assert.match(pom, /false<\/ci.sql-semantic.skip>/) + const execution = pom.slice(pom.indexOf('sql-semantic-tests')) + assert.match(execution, /\$\{ci.sql-semantic.skip\}<\/skip>/) + assert.match(execution, /false<\/reuseForks>/) + } finally { rmSync(directory, { recursive: true, force: true }) } +}) + +test('Jenkins plans from a successful baseline, verifies fresh reports and preserves module override', () => { + const pipeline = readFileSync(new URL('../Jenkinsfile', import.meta.url), 'utf8') + assert.match(pipeline, /candidate.result == 'SUCCESS' && markerIndex >= 0/) + assert.match(pipeline, /requiredServices.every \{ metadata\[1\].split\(','\).contains\(it\) \}/) + assert.match(pipeline, /if \(env.TEST_GATE_PASSED == 'true'\)/) + assert.match(pipeline, /TESTS=PASSED \$\{env.SOURCE_COMMIT\} \$\{env.SELECTED_SERVICES\}/) + assert.match(pipeline, /summary.indexOf\(' \| '\)/) + assert.match(pipeline, /env.TEST_BASE_COMMIT = previousSuccessfulTestCommit/) + assert.match(pipeline, /choice\(name: 'TEST_SCOPE', choices: \['AUTO', 'MODULE'\]/) + assert.match(pipeline, /ci-test-scope.mjs plan --modules/) + assert.match(pipeline, /ci-test-scope.mjs checks --plan/) + assert.match(pipeline, /-am test \\\$test_arguments/) + assert.match(pipeline, /ci-test-scope.mjs verify --plan/) + assert.match(pipeline, /booleanParam\(name: 'SKIP_TESTS', value: true\)/) +})