import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { classifyRuntimeImpact } from '../../scripts/runtime-impact.mjs'; const root = path.resolve(import.meta.dirname, '../..'); function git(cwd, ...args) { return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); } test('runtime impact keeps affected components separate from restart scope', () => { assert.deepEqual(classifyRuntimeImpact(['README.md', 'workpack/STATUS.md', 'test/example.test.js']), { affectedComponents: [], restartScope: 'none', }); assert.deepEqual(classifyRuntimeImpact(['scripts/clean-test-dist.mjs']), { affectedComponents: [], restartScope: 'none', }); assert.deepEqual(classifyRuntimeImpact(['.tmp-tests/harbor-route-test/network.json']), { affectedComponents: [], restartScope: 'none', }); assert.deepEqual(classifyRuntimeImpact(['context.md']), { affectedComponents: [], restartScope: 'none', }); assert.deepEqual(classifyRuntimeImpact(['src/web/App.tsx']), { affectedComponents: ['control'], restartScope: 'control', }); assert.deepEqual(classifyRuntimeImpact(['src/server/services/domainTrafficService.ts']), { affectedComponents: ['dataplane'], restartScope: 'both', }); assert.deepEqual(classifyRuntimeImpact(['package-lock.json']), { affectedComponents: ['control', 'dataplane'], restartScope: 'both', }); }); test('every displaced regex path keeps dataplane restart coverage in JS and TS', () => { const legacyPaths = [ 'src/server/config', 'src/server/dataplane', 'src/server/gatewayRouting', 'src/server/singbox', 'src/server/singboxRuntime', 'src/server/version', 'src/server/adapters/neighbors', 'src/server/services/connectivityDiagnosticsService', 'src/server/services/deviceTrafficService', 'src/server/services/devicePolicyService', 'src/server/services/singboxSelectorService', 'src/shared/connectivityDiagnostics', 'src/shared/errors', ]; for (const file of legacyPaths) { assert.equal(classifyRuntimeImpact([`${file}.js`]).restartScope, 'both', `${file}.js`); assert.equal(classifyRuntimeImpact([`${file}.ts`]).restartScope, 'both', `${file}.ts`); } for (const file of ['Dockerfile', 'entrypoint.sh', 'package.json', 'package-lock.json', 'scripts/build-runtime-base.sh', '.gitea/workflows/gateway-build.yml']) { assert.equal(classifyRuntimeImpact([file]).restartScope, 'both', file); } }); test('classifier covers current dataplane reachability without promoting its client adapter', () => { for (const file of ['src/server/main.js', 'src/server/main.ts']) { assert.deepEqual(classifyRuntimeImpact([file]), { affectedComponents: ['control', 'dataplane'], restartScope: 'both', }, file); } assert.deepEqual(classifyRuntimeImpact(['src/server/services/domainTrafficService.js']), { affectedComponents: ['dataplane'], restartScope: 'both', }); assert.deepEqual(classifyRuntimeImpact(['src/server/services/deviceInventoryService.js']), { affectedComponents: ['control', 'dataplane'], restartScope: 'both', }); assert.deepEqual(classifyRuntimeImpact(['src/server/services/singboxSelectorService.ts']), { affectedComponents: ['control', 'dataplane'], restartScope: 'both', }); assert.deepEqual(classifyRuntimeImpact(['src/server/dataplaneClient.ts']), { affectedComponents: ['control'], restartScope: 'control', }); }); test('native traffic contracts and collector restart both Gateway processes', () => { for (const file of [ 'buf.gen.yaml', 'proto/sing-box/v1.14.0-rc.5/daemon/started_service.proto', 'src/server/gatewayNativeRuntime.ts', 'src/server/generated/daemon/started_service_pb.ts', 'src/server/services/liveTrafficService.ts', 'src/shared/liveTraffic.ts', ]) { assert.deepEqual(classifyRuntimeImpact([file]), { affectedComponents: ['control', 'dataplane'], restartScope: 'both', }, file); } for (const file of [ 'tools/test-singbox-client-rc.sh', 'tools/test-singbox-gateway-native-traffic.sh', 'tools/test-singbox-native-traffic.sh', ]) { assert.deepEqual(classifyRuntimeImpact([file]), { affectedComponents: [], restartScope: 'none', }, file); } }); test('version bumps do not promote a control-only change to a dataplane restart', () => { assert.deepEqual(classifyRuntimeImpact(['src/web/App.tsx', 'src/shared/versions.ts']), { affectedComponents: ['control'], restartScope: 'control', }); }); test('release-critical inputs restart both and unknown paths fail closed', () => { for (const file of [ '.dockerignore', '.gitea/workflows/another.yml', 'docker-compose.gateway.yml', 'scripts/deploy-gateway.sh', ]) { assert.deepEqual(classifyRuntimeImpact([file]), { affectedComponents: ['control', 'dataplane'], restartScope: 'both', }, file); } assert.throws(() => classifyRuntimeImpact(['src/new-owner.ts']), /Unclassified path/); assert.throws(() => classifyRuntimeImpact(['scripts/new-runtime.sh']), /Unclassified path/); }); test('every repository path has an explicit ownership class', () => { const tracked = execFileSync( 'git', ['ls-files', '--cached', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8' }, ) .trim() .split('\n'); assert.doesNotThrow(() => classifyRuntimeImpact(tracked)); }); test('workflow CLI reads changed paths from stdin', () => { const result = spawnSync( process.execPath, ['scripts/runtime-impact.mjs', '--stdin'], { cwd: root, input: 'src/web/App.tsx\nsrc/server/dataplane.ts\n', encoding: 'utf8' }, ); assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout.trim(), 'affected-components=control+dataplane\nrestart-scope=both'); }); test('push range keeps a dataplane change from an earlier commit', (t) => { const repository = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-impact-')); t.after(() => fs.rmSync(repository, { recursive: true, force: true })); git(repository, 'init', '-q'); fs.writeFileSync(path.join(repository, 'README.md'), 'base\n'); git(repository, 'add', '.'); git(repository, '-c', 'user.name=Harbor', '-c', 'user.email=harbor@example.test', 'commit', '-qm', 'base'); const before = git(repository, 'rev-parse', 'HEAD'); const dataplane = path.join(repository, 'src/server/services/domainTrafficService.ts'); fs.mkdirSync(path.dirname(dataplane), { recursive: true }); fs.writeFileSync(dataplane, 'export {};\n'); git(repository, 'add', '.'); git(repository, '-c', 'user.name=Harbor', '-c', 'user.email=harbor@example.test', 'commit', '-qm', 'dataplane'); fs.writeFileSync(path.join(repository, 'README.md'), 'tip docs change\n'); git(repository, 'add', '.'); git(repository, '-c', 'user.name=Harbor', '-c', 'user.email=harbor@example.test', 'commit', '-qm', 'tip'); const changed = git(repository, 'diff', '--name-only', before, 'HEAD').split('\n'); assert.deepEqual(classifyRuntimeImpact(changed), { affectedComponents: ['dataplane'], restartScope: 'both', }); }); test('rename range keeps ownership of the displaced dataplane path', (t) => { const repository = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-impact-rename-')); t.after(() => fs.rmSync(repository, { recursive: true, force: true })); git(repository, 'init', '-q'); const oldPath = path.join(repository, 'src/server/services/domainTrafficService.ts'); fs.mkdirSync(path.dirname(oldPath), { recursive: true }); fs.writeFileSync(oldPath, 'export function dataplaneOwned() { return true; }\n'); git(repository, 'add', '.'); git(repository, '-c', 'user.name=Harbor', '-c', 'user.email=harbor@example.test', 'commit', '-qm', 'base'); const before = git(repository, 'rev-parse', 'HEAD'); fs.renameSync(oldPath, path.join(path.dirname(oldPath), 'newService.ts')); git(repository, 'add', '-A'); git(repository, '-c', 'user.name=Harbor', '-c', 'user.email=harbor@example.test', 'commit', '-qm', 'rename'); const changed = git(repository, 'diff', '--no-renames', '--name-only', before, 'HEAD').split('\n'); assert.deepEqual(classifyRuntimeImpact(changed), { affectedComponents: ['control', 'dataplane'], restartScope: 'both', }); });