'use strict'; const { describe, it } = require('node:test'); const assert = require('node:assert'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); const { normalizePath, isValidSha, isZeroSha, isAppPath, isCiPath, isNativeBuildPath, classifyFiles, getChangedFiles, detectAndroidChanges } = require('./detect-android-changes.cjs'); describe('Path Normalization and Validation', () => { it('normalizes Windows backslashes and leading dot-slash', () => { assert.strictEqual(normalizePath('lib\\main.dart'), 'lib/main.dart'); assert.strictEqual(normalizePath('.\\android\\app\\build.gradle'), 'android/app/build.gradle'); assert.strictEqual(normalizePath(' test/widget_test.dart '), 'test/widget_test.dart'); assert.strictEqual(normalizePath(null), ''); assert.strictEqual(normalizePath(undefined), ''); }); it('validates 40-character hex git SHAs', () => { assert.strictEqual(isValidSha('03446911949ff8b27543280bfe226d9babd6fd7a'), true); assert.strictEqual(isValidSha('0000000000000000000000000000000000000000'), true); assert.strictEqual(isValidSha('abc123'), false); assert.strictEqual(isValidSha(''), false); assert.strictEqual(isValidSha(null), false); assert.strictEqual(isValidSha('03446911949ff8b27543280bfe226d9babd6fd7z'), false); }); it('identifies 40-zero SHAs', () => { assert.strictEqual(isZeroSha('0000000000000000000000000000000000000000'), true); assert.strictEqual(isZeroSha('03446911949ff8b27543280bfe226d9babd6fd7a'), false); assert.strictEqual(isZeroSha(''), false); }); }); describe('File Path Classification', () => { it('identifies Android/Flutter app paths correctly', () => { assert.strictEqual(isAppPath('lib/main.dart'), true); assert.strictEqual(isAppPath('lib/services/telemetry_service.dart'), true); assert.strictEqual(isAppPath('test/unit/telemetry_test.dart'), true); assert.strictEqual(isAppPath('integration_test/app_test.dart'), true); assert.strictEqual(isAppPath('android/app/build.gradle'), true); assert.strictEqual(isAppPath('android/gradlew'), true); assert.strictEqual(isAppPath('android/local.properties'), true); assert.strictEqual(isAppPath('assets/images/logo.png'), true); assert.strictEqual(isAppPath('pubspec.yaml'), true); assert.strictEqual(isAppPath('pubspec.lock'), true); assert.strictEqual(isAppPath('analysis_options.yaml'), true); assert.strictEqual(isAppPath('build.yaml'), true); // Negative matches for app paths assert.strictEqual(isAppPath('website/components/ops/support-report-modal.tsx'), false); assert.strictEqual(isAppPath('backend/motorsignal_api/app/main.py'), false); assert.strictEqual(isAppPath('docs/agents/issue-tracker.md'), false); assert.strictEqual(isAppPath('firebase/firestore.rules'), false); assert.strictEqual(isAppPath('server/waze_relay/index.js'), false); assert.strictEqual(isAppPath('lib_unrelated.txt'), false); assert.strictEqual(isAppPath('test-runner.js'), false); }); it('identifies Android CI paths correctly', () => { assert.strictEqual(isCiPath('.github/workflows/android-build.yml'), true); assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.cjs'), true); assert.strictEqual(isCiPath('.github/scripts/detect-android-changes.test.cjs'), true); assert.strictEqual(isCiPath('.github/actions/flutter-ci-setup/action.yml'), true); assert.strictEqual(isCiPath('.github/actions/flutter-ci-setup/index.js'), true); // Negative matches for Android CI paths assert.strictEqual(isCiPath('.github/workflows/website-ci.yml'), false); assert.strictEqual(isCiPath('.github/workflows/backend-ci.yml'), false); assert.strictEqual(isCiPath('.github/workflows/waze-relay-ci.yml'), false); assert.strictEqual(isCiPath('.github/workflows/firebase-emulator-tests.yml'), false); assert.strictEqual(isCiPath('.github/actions/website-deploy/action.yml'), false); assert.strictEqual(isCiPath('.github/actions/other-action/index.js'), false); assert.strictEqual(isCiPath('.github/pull_request_template.md'), false); }); it('identifies native build paths correctly', () => { assert.strictEqual(isNativeBuildPath('android/app/build.gradle'), true); assert.strictEqual(isNativeBuildPath('.github/workflows/android-build.yml'), true); assert.strictEqual(isNativeBuildPath('.github/scripts/detect-android-changes.cjs'), true); assert.strictEqual(isNativeBuildPath('.github/actions/flutter-ci-setup/action.yml'), true); assert.strictEqual(isNativeBuildPath('.github/actions/flutter-ci-setup/index.js'), true); assert.strictEqual(isNativeBuildPath('pubspec.yaml'), true); assert.strictEqual(isNativeBuildPath('pubspec.lock'), true); assert.strictEqual(isNativeBuildPath('analysis_options.yaml'), true); assert.strictEqual(isNativeBuildPath('build.yaml'), true); // Negative matches for native build paths assert.strictEqual(isNativeBuildPath('.github/workflows/website-ci.yml'), false); assert.strictEqual(isNativeBuildPath('.github/actions/website-deploy/action.yml'), false); assert.strictEqual(isNativeBuildPath('.github/actions/other-action/action.yml'), false); assert.strictEqual(isNativeBuildPath('lib/main.dart'), false); assert.strictEqual(isNativeBuildPath('website/app/page.tsx'), false); }); }); describe('classifyFiles Scenarios', () => { it('classifies website-only changes as false', () => { const files = [ 'website/components/ops/support-report-modal.tsx', 'website/package.json', 'website/tests/support-report-modal.test.tsx', 'website/app/page.tsx' ]; const result = classifyFiles(files); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); assert.strictEqual(result.appFiles.length, 0); assert.strictEqual(result.ciFiles.length, 0); assert.strictEqual(result.unrelatedFiles.length, 4); }); it('classifies backend-only changes as false', () => { const files = [ 'backend/motorsignal_api/app/main.py', 'backend/motorsignal_api/app/models.py', 'backend/motorsignal_api/tests/test_ops_actions.py', 'backend/motorsignal_api/alembic/versions/f1a2b3c4d5e7_add_ops_actions.py' ]; const result = classifyFiles(files); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); assert.strictEqual(result.appFiles.length, 0); assert.strictEqual(result.ciFiles.length, 0); assert.strictEqual(result.unrelatedFiles.length, 4); }); it('classifies docs-only changes as false', () => { const files = [ 'README.md', 'docs/agents/issue-tracker.md', 'AGENTS.md', 'CHANGELOG.md', 'DESIGN.md' ]; const result = classifyFiles(files); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); assert.strictEqual(result.appFiles.length, 0); assert.strictEqual(result.ciFiles.length, 0); assert.strictEqual(result.unrelatedFiles.length, 5); }); it('classifies Firebase-rules-only and Waze-relay-only changes as false', () => { const files = [ 'firebase/firestore.rules', 'firebase.json', 'server/waze_relay/index.js', 'server/waze_relay/package.json' ]; const result = classifyFiles(files); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); }); it('classifies Flutter lib change as true', () => { const files = ['lib/telemetry/telemetry_service.dart']; const result = classifyFiles(files); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); assert.strictEqual(result.native_build_changed, false); assert.deepStrictEqual(result.appFiles, ['lib/telemetry/telemetry_service.dart']); }); it('classifies Flutter test change as true', () => { const files = ['test/unit/telemetry_test.dart']; const result = classifyFiles(files); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); assert.deepStrictEqual(result.appFiles, ['test/unit/telemetry_test.dart']); }); it('classifies Flutter integration_test change as true', () => { const files = ['integration_test/telemetry_soak_test.dart']; const result = classifyFiles(files); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); }); it('classifies Android Gradle change as true', () => { const files = ['android/app/build.gradle']; const result = classifyFiles(files); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); assert.strictEqual(result.native_build_changed, true); }); it('classifies pubspec change as true', () => { const files = ['pubspec.yaml']; const result = classifyFiles(files); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); const lockFiles = ['pubspec.lock']; const lockResult = classifyFiles(lockFiles); assert.strictEqual(lockResult.android_changed, true); assert.strictEqual(lockResult.app_changed, true); }); it('classifies analysis_options and build.yaml changes as true', () => { assert.strictEqual(classifyFiles(['analysis_options.yaml']).android_changed, true); assert.strictEqual(classifyFiles(['analysis_options.yaml']).native_build_changed, true); assert.strictEqual(classifyFiles(['build.yaml']).android_changed, true); assert.strictEqual(classifyFiles(['build.yaml']).native_build_changed, true); }); it('classifies assets change as true', () => { const files = ['assets/telemetry/sample.json']; const result = classifyFiles(files); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); }); it('classifies mixed website + Flutter change as true', () => { const files = [ 'website/app/page.tsx', 'website/package.json', 'lib/models/ride.dart' ]; const result = classifyFiles(files); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); assert.strictEqual(result.appFiles.length, 1); assert.strictEqual(result.unrelatedFiles.length, 2); }); it('classifies Android workflow change as android_changed=true but app_changed=false', () => { const files = ['.github/workflows/android-build.yml']; const result = classifyFiles(files); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, false); assert.strictEqual(result.ciFiles.length, 1); assert.strictEqual(result.appFiles.length, 0); }); it('classifies detector script change as android_changed=true but app_changed=false', () => { const files = [ '.github/scripts/detect-android-changes.cjs', '.github/scripts/detect-android-changes.test.cjs' ]; const result = classifyFiles(files); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, false); assert.strictEqual(result.ciFiles.length, 2); assert.strictEqual(result.appFiles.length, 0); }); it('classifies other workflow changes (website/backend CI) as false', () => { const files = [ '.github/workflows/website-ci.yml', '.github/workflows/backend-ci.yml', '.github/workflows/waze-relay-ci.yml', '.github/workflows/firebase-emulator-tests.yml' ]; const result = classifyFiles(files); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); }); it('classifies composite action (flutter-ci-setup) change as android_changed=true, native_build_changed=true', () => { const files = ['.github/actions/flutter-ci-setup/action.yml']; const result = classifyFiles(files); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, false); assert.strictEqual(result.native_build_changed, true); assert.strictEqual(result.ciFiles.length, 1); assert.deepStrictEqual(result.ciFiles, ['.github/actions/flutter-ci-setup/action.yml']); }); it('classifies non-Android composite action (e.g., website-deploy) as false', () => { const files = ['.github/actions/website-deploy/action.yml']; const result = classifyFiles(files); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); assert.strictEqual(result.native_build_changed, false); assert.strictEqual(result.ciFiles.length, 0); assert.strictEqual(result.unrelatedFiles.length, 1); }); }); describe('detectAndroidChanges Workflow Event Logic', () => { const sampleBase = '03446911949ff8b27543280bfe226d9babd6fd7a'; const sampleHead = '247ccde71e4378dacd45121c891f45aad5ed2424'; it('always triggers full build on workflow_dispatch', () => { const result = detectAndroidChanges({ eventName: 'workflow_dispatch', baseSha: '', headSha: '' }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); assert.strictEqual(result.native_build_changed, true); assert.match(result.reason, /Manual workflow_dispatch/); }); it('fails safe to true on zero push base SHA', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha: '0000000000000000000000000000000000000000', headSha: sampleHead }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); assert.match(result.reason, /failing safe/); }); it('fails safe to true on missing base SHA', () => { const result = detectAndroidChanges({ eventName: 'pull_request', baseSha: '', headSha: sampleHead }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); }); it('fails safe to true on invalid base SHA format', () => { const result = detectAndroidChanges({ eventName: 'pull_request', baseSha: 'malicious-string; rm -rf /', headSha: sampleHead }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); }); it('fails safe to true on invalid head SHA format', () => { const result = detectAndroidChanges({ eventName: 'pull_request', baseSha: sampleBase, headSha: 'bad-sha' }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); }); it('reports false when base and head SHA are identical', () => { const result = detectAndroidChanges({ eventName: 'pull_request', baseSha: sampleBase, headSha: sampleBase }); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); assert.match(result.reason, /identical/); }); it('fails safe to true when git diff comparison throws an error', () => { const faultyDeps = { getChangedFilesFn: () => { throw new Error('fatal: bad object missing-commit'); } }; const result = detectAndroidChanges({ eventName: 'pull_request', baseSha: sampleBase, headSha: sampleHead }, faultyDeps); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); assert.match(result.reason, /failing safe/); }); it('returns false for pull_request with only website files', () => { const mockDeps = { getChangedFilesFn: () => ['website/app/page.tsx', 'website/package.json'] }; const result = detectAndroidChanges({ eventName: 'pull_request', baseSha: sampleBase, headSha: sampleHead }, mockDeps); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); }); it('returns true for pull_request with Flutter lib file', () => { const mockDeps = { getChangedFilesFn: () => ['lib/main.dart'] }; const result = detectAndroidChanges({ eventName: 'pull_request', baseSha: sampleBase, headSha: sampleHead }, mockDeps); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); }); it('returns android_changed=true, app_changed=false for workflow change', () => { const mockDeps = { getChangedFilesFn: () => ['.github/workflows/android-build.yml'] }; const result = detectAndroidChanges({ eventName: 'push', baseSha: sampleBase, headSha: sampleHead }, mockDeps); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, false); }); }); describe('Live Git Repository Verification', () => { it('correctly compares historical non-Android commit in the repository', () => { // 03446911949ff8b27543280bfe226d9babd6fd7a was the PR #15 merge (website and backend changes only). const result = detectAndroidChanges({ eventName: 'push', baseSha: '7efd29a214d01bd1c167fabf75f5c90cea5da215', headSha: '03446911949ff8b27543280bfe226d9babd6fd7a' }); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); assert.strictEqual(result.appFiles.length, 0); assert.strictEqual(result.ciFiles.length, 0); assert.ok(result.unrelatedFiles.length > 0); }); it('correctly compares historical Flutter commit in the repository', () => { // 68d4539f90199e2428fafbb7de779ff2dfb22b4e modified Flutter lib/ and test/ files. const result = detectAndroidChanges({ eventName: 'push', baseSha: 'e784c4895dcbc6abbfaa516ac34f3cedbc54384a', headSha: '68d4539f90199e2428fafbb7de779ff2dfb22b4e' }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); assert.ok(result.appFiles.length > 0); }); it('deterministically classifies workflow-only changes in a temporary repository fixture', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'detector-fixture-workflow-')); try { execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'ignore' }); execFileSync('git', ['config', 'user.name', 'CI Fixture Test'], { cwd: tmpDir, stdio: 'ignore' }); execFileSync('git', ['config', 'user.email', 'fixture@example.com'], { cwd: tmpDir, stdio: 'ignore' }); execFileSync('git', ['config', 'commit.gpgSign', 'false'], { cwd: tmpDir, stdio: 'ignore' }); // Base commit: unrelated website file fs.mkdirSync(path.join(tmpDir, 'website'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'website', 'page.tsx'), 'export default function Page() {}\n', 'utf8'); execFileSync('git', ['add', 'website/page.tsx'], { cwd: tmpDir, stdio: 'ignore' }); execFileSync('git', ['commit', '-m', 'Add website page'], { cwd: tmpDir, stdio: 'ignore' }); const baseSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf8' }).trim(); // Head commit: .github/workflows/android-build.yml fs.mkdirSync(path.join(tmpDir, '.github', 'workflows'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, '.github', 'workflows', 'android-build.yml'), 'name: MotorSignal Android CI\n', 'utf8'); execFileSync('git', ['add', '.github/workflows/android-build.yml'], { cwd: tmpDir, stdio: 'ignore' }); execFileSync('git', ['commit', '-m', 'Add android-build workflow'], { cwd: tmpDir, stdio: 'ignore' }); const headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf8' }).trim(); const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha, repoDir: tmpDir }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, false); assert.ok(result.ciFiles.includes('.github/workflows/android-build.yml')); assert.strictEqual(result.appFiles.length, 0); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it('deterministically classifies mixed workflow and application changes in a temporary repository fixture', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'detector-fixture-mixed-')); try { execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'ignore' }); execFileSync('git', ['config', 'user.name', 'CI Fixture Test'], { cwd: tmpDir, stdio: 'ignore' }); execFileSync('git', ['config', 'user.email', 'fixture@example.com'], { cwd: tmpDir, stdio: 'ignore' }); execFileSync('git', ['config', 'commit.gpgSign', 'false'], { cwd: tmpDir, stdio: 'ignore' }); // Base commit: unrelated website file fs.mkdirSync(path.join(tmpDir, 'website'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'website', 'page.tsx'), 'export default function Page() {}\n', 'utf8'); execFileSync('git', ['add', 'website/page.tsx'], { cwd: tmpDir, stdio: 'ignore' }); execFileSync('git', ['commit', '-m', 'Add website page'], { cwd: tmpDir, stdio: 'ignore' }); const baseSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf8' }).trim(); // Head commit: both .github/workflows/android-build.yml and lib/main.dart fs.mkdirSync(path.join(tmpDir, '.github', 'workflows'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, '.github', 'workflows', 'android-build.yml'), 'name: MotorSignal Android CI\n', 'utf8'); fs.mkdirSync(path.join(tmpDir, 'lib'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'lib', 'main.dart'), 'void main() {}\n', 'utf8'); execFileSync('git', ['add', '.github/workflows/android-build.yml', 'lib/main.dart'], { cwd: tmpDir, stdio: 'ignore' }); execFileSync('git', ['commit', '-m', 'Add workflow and Flutter app code'], { cwd: tmpDir, stdio: 'ignore' }); const headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, encoding: 'utf8' }).trim(); const result = detectAndroidChanges({ eventName: 'pull_request', baseSha, headSha, repoDir: tmpDir }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); assert.ok(result.ciFiles.includes('.github/workflows/android-build.yml')); assert.ok(result.appFiles.includes('lib/main.dart')); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); }); describe('Workflow Scenario Matrix Verification', () => { const baseSha = '03446911949ff8b27543280bfe226d9babd6fd7a'; const headSha = '247ccde71e4378dacd45121c891f45aad5ed2424'; it('website-only PR skips heavy build and release', () => { const result = detectAndroidChanges({ eventName: 'pull_request', baseSha, headSha }, { getChangedFilesFn: () => ['website/app/page.tsx', 'website/package.json'] }); const heavyBuildRuns = result.android_changed; const releaseRuns = false; // PR event cannot run release assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); assert.strictEqual(heavyBuildRuns, false); assert.strictEqual(releaseRuns, false); }); it('backend-only PR skips heavy build and release', () => { const result = detectAndroidChanges({ eventName: 'pull_request', baseSha, headSha }, { getChangedFilesFn: () => ['backend/motorsignal_api/app/main.py'] }); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); }); it('Flutter PR runs heavy build and skips release', () => { const result = detectAndroidChanges({ eventName: 'pull_request', baseSha, headSha }, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart'] }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); }); it('website + Flutter PR runs heavy build and skips release', () => { const result = detectAndroidChanges({ eventName: 'pull_request', baseSha, headSha }, { getChangedFilesFn: () => ['website/app/page.tsx', 'lib/services/telemetry.dart'] }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); }); it('master push after website merge skips heavy build and release', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['website/components/ops/support-report-modal.tsx'] }); assert.strictEqual(result.android_changed, false); assert.strictEqual(result.app_changed, false); }); it('master push after Flutter merge runs heavy build and signed release', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['lib/telemetry/telemetry_service.dart'] }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); }); it('master push after workflow-only change runs heavy build but skips signed release', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['.github/workflows/android-build.yml'] }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, false); }); it('manual dispatch runs heavy build and signed release', () => { const result = detectAndroidChanges({ eventName: 'workflow_dispatch', baseSha: '', headSha: '' }); assert.strictEqual(result.android_changed, true); assert.strictEqual(result.app_changed, true); assert.strictEqual(result.native_build_changed, true); }); }); describe('Release Job Gating (native_build_changed output)', () => { const baseSha = '0000000000000000000000000000000000000001'; const headSha = '0000000000000000000000000000000000000002'; it('Dart-only changes skip release job (native_build_changed=false)', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart', 'assets/icons/app.png'] }); assert.strictEqual(result.native_build_changed, false, 'Dart-only changes should skip release'); assert.strictEqual(result.app_changed, true, 'app_changed should still be true'); }); it('Android code changes trigger release job (native_build_changed=true)', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['android/app/build.gradle'] }); assert.strictEqual(result.native_build_changed, true, 'Android changes should trigger release'); }); it('CI-only changes (.github/) trigger release job (native_build_changed=true)', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['.github/workflows/other.yml', '.github/scripts/helper.sh'] }); assert.strictEqual(result.native_build_changed, true, 'CI changes should trigger release'); }); it('Config file changes (pubspec.yaml) trigger release job (native_build_changed=true)', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['pubspec.yaml'] }); assert.strictEqual(result.native_build_changed, true, 'pubspec.yaml changes should trigger release'); }); it('Build config changes (build.yaml) trigger release job (native_build_changed=true)', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['build.yaml'] }); assert.strictEqual(result.native_build_changed, true, 'build.yaml changes should trigger release'); }); it('Mixed Dart and Android changes trigger release job (native_build_changed=true)', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['lib/main.dart', 'android/app/build.gradle'] }); assert.strictEqual(result.native_build_changed, true, 'Mixed changes should trigger release'); }); it('Manual dispatch always triggers release job (native_build_changed=true)', () => { const result = detectAndroidChanges({ eventName: 'workflow_dispatch', baseSha: '', headSha: '' }); assert.strictEqual(result.native_build_changed, true, 'Manual dispatch should always trigger release'); }); it('Invalid base SHA fails safe to trigger release (native_build_changed=true)', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha: 'invalid', headSha }, { getChangedFilesFn: () => ['lib/main.dart'] }); assert.strictEqual(result.native_build_changed, true, 'Invalid SHA should fail safe to trigger release'); }); it('Unrelated changes (docs/, website/) skip release job (native_build_changed=false)', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['docs/readme.md', 'website/index.html'] }); assert.strictEqual(result.native_build_changed, false, 'Unrelated changes should skip release'); }); it('dependency changes (pubspec.lock) trigger release job (native_build_changed=true)', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['pubspec.lock'] }); assert.strictEqual(result.native_build_changed, true, 'pubspec.lock changes should trigger release'); }); }); describe('GitHub Actions Workflow Output Wiring and Release Job Conditions', () => { const baseSha = '03446911949ff8b27543280bfe226d9babd6fd7a'; const headSha = '247ccde71e4378dacd45121c891f45aad5ed2424'; it('detector outputs native_build_changed for GitHub Actions output file', () => { // Simulate the detector writing to GITHUB_OUTPUT const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['lib/main.dart'] }); // The detector MUST set native_build_changed=false for Dart-only changes assert.strictEqual(result.native_build_changed, false, 'Dart-only should set native_build_changed=false'); assert.strictEqual(result.android_changed, true, 'But android_changed=true still drives heavy build'); }); it('workflow release condition evaluates correctly: Dart-only changes skip release', () => { // Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master' const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['lib/main.dart', 'test/widget_test.dart'] }); // Release job condition evaluates to false const nativeBuildChanged = result.native_build_changed; const onMaster = true; // github.ref == 'refs/heads/master' const releaseJobShouldRun = nativeBuildChanged && onMaster; assert.strictEqual(nativeBuildChanged, false, 'Dart-only should output false'); assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for Dart-only'); }); it('workflow release condition evaluates correctly: Android changes run release', () => { // Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master' const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['android/app/build.gradle'] }); // Release job condition evaluates to true const nativeBuildChanged = result.native_build_changed; const onMaster = true; // github.ref == 'refs/heads/master' const releaseJobShouldRun = nativeBuildChanged && onMaster; assert.strictEqual(nativeBuildChanged, true, 'Android changes should output true'); assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for Android changes'); }); it('workflow release condition evaluates correctly: CI-critical changes run release', () => { // Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master' const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['.github/workflows/android-build.yml'] }); // Release job condition evaluates to true const nativeBuildChanged = result.native_build_changed; const onMaster = true; // github.ref == 'refs/heads/master' const releaseJobShouldRun = nativeBuildChanged && onMaster; assert.strictEqual(nativeBuildChanged, true, 'CI-critical changes should output true'); assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for CI-critical changes'); }); it('workflow release condition evaluates correctly: Config changes run release', () => { // Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master' const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['pubspec.yaml'] }); // Release job condition evaluates to true const nativeBuildChanged = result.native_build_changed; const onMaster = true; // github.ref == 'refs/heads/master' const releaseJobShouldRun = nativeBuildChanged && onMaster; assert.strictEqual(nativeBuildChanged, true, 'Config changes should output true'); assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for config changes'); }); it('workflow release condition evaluates correctly: Unrelated changes skip release', () => { // Simulate GitHub Actions condition: if: needs.detect.outputs.native_build_changed == 'true' && github.ref == 'refs/heads/master' const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['docs/readme.md', 'website/app.tsx'] }); // Release job condition evaluates to false const nativeBuildChanged = result.native_build_changed; const onMaster = true; // github.ref == 'refs/heads/master' const releaseJobShouldRun = nativeBuildChanged && onMaster; assert.strictEqual(nativeBuildChanged, false, 'Unrelated changes should output false'); assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for unrelated changes'); }); it('workflow release condition evaluates correctly: Composite action changes run release', () => { // flutter-ci-setup is a composite action used by analysis, test, and debug APK jobs const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['.github/actions/flutter-ci-setup/action.yml'] }); const nativeBuildChanged = result.native_build_changed; const onMaster = true; // github.ref == 'refs/heads/master' const releaseJobShouldRun = nativeBuildChanged && onMaster; assert.strictEqual(nativeBuildChanged, true, 'Composite action changes should output true'); assert.strictEqual(releaseJobShouldRun, true, 'Release job SHOULD run for composite action changes'); }); it('workflow release condition evaluates correctly: Non-Android composite action (website-deploy) skips release', () => { const result = detectAndroidChanges({ eventName: 'push', baseSha, headSha }, { getChangedFilesFn: () => ['.github/actions/website-deploy/action.yml'] }); const nativeBuildChanged = result.native_build_changed; const onMaster = true; // github.ref == 'refs/heads/master' const releaseJobShouldRun = nativeBuildChanged && onMaster; assert.strictEqual(nativeBuildChanged, false, 'Non-Android composite actions should output false'); assert.strictEqual(releaseJobShouldRun, false, 'Release job should NOT run for non-Android actions'); }); }); describe('Workflow YAML Verification', () => { it('verifies android-build.yml defines jobs.detect.outputs.native_build_changed', () => { const workflowPath = '.github/workflows/android-build.yml'; const workflowContent = fs.readFileSync(path.join(process.cwd(), workflowPath), 'utf8'); // Check that the workflow file exists and contains the output mapping assert.ok(workflowContent.includes('native_build_changed'), 'Workflow must expose native_build_changed output from detector step'); assert.ok(workflowContent.includes('steps.detector.outputs.native_build_changed'), 'Workflow must reference detector output with ${{ steps.detector.outputs.native_build_changed }}'); }); it('verifies release job condition includes native_build_changed check', () => { const workflowPath = '.github/workflows/android-build.yml'; const workflowContent = fs.readFileSync(path.join(process.cwd(), workflowPath), 'utf8'); // Check that release job condition references the output assert.ok(workflowContent.includes("needs.detect.outputs.native_build_changed == 'true'"), 'Release job condition must check native_build_changed output'); assert.ok(workflowContent.includes('refs/heads/master'), 'Release job condition must ensure it only runs on master branch'); }); });