MotorSignal-App/.github/scripts/detect-android-changes.test...

684 lines
27 KiB
JavaScript

'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.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);
});
});
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');
});
});