'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, 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); // 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/pull_request_template.md'), 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.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); }); 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(['build.yaml']).android_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); }); }); 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.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); }); });