Manual Branch Smoke Test
Use this when an agent needs a quick end-to-end check of the current FVM branch without running the destructive real integration suite.
This smoke test runs the branch CLI through a throwaway project and isolated cache. It uses a local fake Flutter Git remote, so it proves FVM command wiring, git-cache creation, install, project use, prompt handling, project file writes, symlink creation, Melos/VS Code settings, and fvm flutter proxy behavior without cloning the real Flutter repository.
When To Run
Run this before handoff when changes touch:
GitServiceFlutterServiceEnsureCacheWorkflow- install/use/global command behavior
- project reference or SDK symlink behavior
- non-interactive prompt handling
.fvmrc,.gitignore, Melos, or VS Code project updates
This does not replace dart test, dcm analyze lib, or the real fvm integration-test. It is a fast manual confidence check between unit tests and the full destructive integration suite.
Safety
The script sets:
HOMEto a temp directoryFVM_CACHE_PATHto a temp cacheFVM_GIT_CACHE_PATHto a temp bare git cacheFVM_FLUTTER_URLto a localfile://Git URLFVM_USE_GIT_CACHE=true
It should not mutate the user's normal FVM cache or global config. The temp directory is left in place for inspection and printed as Smoke root; remove it manually after reviewing.
FVM_FLUTTER_URL must be a valid Git URL. Use a file:// URL for local remotes; a plain filesystem path is rejected by FVM URL validation.
The SDK install, git cache, home directory, generated project, .fvmrc,
.gitignore, VS Code settings, and Melos settings all live under the printed
Smoke root. The install path is $ROOT/fvm-cache/versions/stable, not the
user's normal FVM cache.
The script uses expect for the interactive Melos confirmation path. macOS includes /usr/bin/expect; install it first if another environment does not have it.
Command
Run from the repository root:
set -euo pipefail
command -v expect >/dev/null || {
echo "expect is required for the interactive Melos prompt check" >&2
exit 1
}
FVM_REPO="$(pwd)"
ROOT="$(mktemp -d /tmp/fvm-branch-smoke.XXXXXX)"
MARKER="$ROOT/.fvm-smoke-root"
FAKE_REMOTE_WORK="$ROOT/flutter_seed"
FAKE_REMOTE_BARE="$ROOT/flutter_remote.git"
CACHE="$ROOT/fvm-cache"
GIT_CACHE="$ROOT/cache.git"
PROJECT="$ROOT/app"
HOME_DIR="$ROOT/home"
: > "$MARKER"
mkdir -p \
"$FAKE_REMOTE_WORK/bin" \
"$FAKE_REMOTE_WORK/bin/cache/dart-sdk/bin" \
"$PROJECT/.vscode" \
"$HOME_DIR"
cd "$FAKE_REMOTE_WORK"
git init -b stable >/dev/null
git config user.email smoke@fvm.app
git config user.name "FVM Smoke"
printf '3.99.0-smoke\n' > version
printf '#!/usr/bin/env sh\necho "Flutter smoke 3.99.0 on stable"\n' > bin/flutter
printf '#!/usr/bin/env sh\necho "Dart smoke 3.99.0"\n' > bin/dart
printf '#!/usr/bin/env sh\necho "Dart smoke 3.99.0"\n' > bin/cache/dart-sdk/bin/dart
chmod +x bin/flutter bin/dart bin/cache/dart-sdk/bin/dart
git add . >/dev/null
git commit -m 'seed fake flutter' >/dev/null
BASE_SHA="$(git rev-parse HEAD)"
git tag smoke-tag "$BASE_SHA"
git checkout --detach >/dev/null 2>&1
printf 'hidden-only\n' > hidden_ref_only
git add hidden_ref_only >/dev/null
git commit -m 'seed hidden-only ref' >/dev/null
HIDDEN_SHA="$(git rev-parse HEAD)"
git update-ref refs/pull/1/head "$HIDDEN_SHA"
git checkout stable >/dev/null 2>&1
git clone --bare "$FAKE_REMOTE_WORK" "$FAKE_REMOTE_BARE" >/dev/null 2>&1
git --git-dir="$FAKE_REMOTE_BARE" symbolic-ref HEAD refs/heads/stable
git --git-dir="$FAKE_REMOTE_BARE" update-ref refs/pull/1/head "$HIDDEN_SHA"
REMOTE_URL="file://$FAKE_REMOTE_BARE"
cd "$PROJECT"
printf 'name: fvm_smoke_app\nenvironment:\n sdk: ">=3.6.0 <4.0.0"\n' > pubspec.yaml
printf 'name: fvm_smoke_workspace\npackages:\n - .\n' > melos.yaml
: > .gitignore
run_fvm_skip() {
HOME="$HOME_DIR" \
FVM_CACHE_PATH="$CACHE" \
FVM_GIT_CACHE_PATH="$GIT_CACHE" \
FVM_FLUTTER_URL="$REMOTE_URL" \
FVM_USE_GIT_CACHE=true \
dart run "$FVM_REPO/bin/main.dart" --fvm-skip-input "$@"
}
run_fvm_interactive_with_yes() {
SMOKE_HOME="$HOME_DIR" \
SMOKE_CACHE="$CACHE" \
SMOKE_GIT_CACHE="$GIT_CACHE" \
SMOKE_REMOTE_URL="$REMOTE_URL" \
SMOKE_REPO="$FVM_REPO" \
expect <<'EOF'
set timeout 90
set main [file join $env(SMOKE_REPO) bin main.dart]
spawn env \
"HOME=$env(SMOKE_HOME)" \
"FVM_CACHE_PATH=$env(SMOKE_CACHE)" \
"FVM_GIT_CACHE_PATH=$env(SMOKE_GIT_CACHE)" \
"FVM_FLUTTER_URL=$env(SMOKE_REMOTE_URL)" \
"FVM_USE_GIT_CACHE=true" \
dart run $main use stable --force --skip-setup --skip-pub-get
expect {
"Would you like to configure melos.yaml to use FVM-managed Flutter SDK?" { send "y\r" }
timeout { puts "Timed out waiting for Melos prompt"; exit 124 }
}
expect eof
catch wait result
exit [lindex $result 3]
EOF
}
printf '\n== fvm version ==\n'
run_fvm_skip --version
printf '\n== install stable --no-setup ==\n'
run_fvm_skip install stable --no-setup
printf '\n== use stable, non-interactive default prompt path ==\n'
USE_SKIP_OUTPUT="$(run_fvm_skip use stable --force --skip-setup --skip-pub-get 2>&1)"
printf '%s\n' "$USE_SKIP_OUTPUT"
case "$USE_SKIP_OUTPUT" in
*"Would you like to configure melos.yaml to use FVM-managed Flutter SDK?"*) ;;
*) echo "Melos prompt was not emitted under skip input" >&2; exit 1 ;;
esac
case "$USE_SKIP_OUTPUT" in
*"Skipping input confirmation"*) ;;
*) echo "Skip-input confirmation warning was not emitted" >&2; exit 1 ;;
esac
case "$USE_SKIP_OUTPUT" in
*"Using default value of false"*) ;;
*) echo "Skip-input default value was not reported as false" >&2; exit 1 ;;
esac
if grep -Fq 'sdkPath:' melos.yaml; then
echo "melos.yaml changed even though skip-input default is false" >&2
exit 1
fi
test -f .fvmrc
grep -Fq '"flutter": "stable"' .fvmrc
test -f .fvm/fvm_config.json
grep -Fq '"flutterSdkVersion": "stable"' .fvm/fvm_config.json
test -f .fvm/version
grep -Fxq '3.99.0-smoke' .fvm/version
test -f .fvm/release
grep -Fxq 'stable' .fvm/release
test -L .fvm/flutter_sdk
FLUTTER_SDK_TARGET="$(readlink .fvm/flutter_sdk)"
case "$FLUTTER_SDK_TARGET" in
"$CACHE/versions/stable") ;;
*) echo "Unexpected flutter_sdk symlink target: $FLUTTER_SDK_TARGET" >&2; exit 1 ;;
esac
test -L .fvm/versions/stable
VERSION_LINK_TARGET="$(readlink .fvm/versions/stable)"
case "$VERSION_LINK_TARGET" in
"$CACHE/versions/stable") ;;
*) echo "Unexpected version symlink target: $VERSION_LINK_TARGET" >&2; exit 1 ;;
esac
grep -Fxq '.fvm/' .gitignore
grep -Fq '"dart.flutterSdkPath": ".fvm/versions/stable"' .vscode/settings.json
printf '\n== use stable, interactive yes prompt path ==\n'
run_fvm_interactive_with_yes
grep -Fq 'sdkPath: .fvm/flutter_sdk' melos.yaml
printf '\n== fvm flutter --version ==\n'
FLUTTER_OUTPUT="$(run_fvm_skip flutter --version)"
printf '%s\n' "$FLUTTER_OUTPUT"
case "$FLUTTER_OUTPUT" in
*"Flutter smoke 3.99.0"*) ;;
*) echo "Unexpected flutter output" >&2; exit 1 ;;
esac
printf '\n== fvm list ==\n'
LIST_OUTPUT="$(run_fvm_skip list)"
printf '%s\n' "$LIST_OUTPUT"
case "$LIST_OUTPUT" in
*"$CACHE/versions"*) ;;
*) echo "fvm list did not use the isolated cache path" >&2; exit 1 ;;
esac
case "$LIST_OUTPUT" in
*"stable"*) ;;
*) echo "fvm list did not include stable" >&2; exit 1 ;;
esac
case "$LIST_OUTPUT" in
*"3.99.0-smoke"*) ;;
*) echo "fvm list did not include the fake Flutter version" >&2; exit 1 ;;
esac
printf '\n== project files ==\n'
printf '.fvmrc:\n'
sed -n '1,20p' .fvmrc
printf '\n'
printf '.fvm/fvm_config.json:\n'
sed -n '1,20p' .fvm/fvm_config.json
printf '\n'
printf '.fvm/version: '
cat .fvm/version
printf '\n'
printf '.fvm/release: '
cat .fvm/release
printf '\n'
printf 'flutter_sdk -> %s\n' "$FLUTTER_SDK_TARGET"
printf 'version link -> %s\n' "$VERSION_LINK_TARGET"
printf '.gitignore:\n'
sed -n '1,20p' .gitignore
printf '\n'
printf '.vscode/settings.json:\n'
sed -n '1,40p' .vscode/settings.json
printf '\n'
printf 'melos.yaml:\n'
sed -n '1,40p' melos.yaml
printf '\n'
printf '\n== git cache checks ==\n'
test "$(git --git-dir="$GIT_CACHE" rev-parse --is-bare-repository)" = true
HEAD_REF="$(git --git-dir="$GIT_CACHE" symbolic-ref --quiet HEAD)"
printf 'HEAD=%s\n' "$HEAD_REF"
case "$HEAD_REF" in
refs/heads/*) ;;
*) echo "Cache HEAD is not a branch" >&2; exit 1 ;;
esac
git --git-dir="$GIT_CACHE" show-ref --verify --quiet "$HEAD_REF"
REFS="$(git --git-dir="$GIT_CACHE" for-each-ref --format='%(refname)')"
printf '%s\n' "$REFS"
if printf '%s\n' "$REFS" | grep -Ev '^(refs/heads/|refs/tags/)' >/dev/null; then
echo "Unexpected non heads/tags ref in git cache" >&2
exit 1
fi
git --git-dir="$GIT_CACHE" show-ref --verify --quiet refs/tags/smoke-tag
if git --git-dir="$GIT_CACHE" show-ref --verify --quiet refs/pull/1/head; then
echo "Unexpected hidden pull ref in git cache" >&2
exit 1
fi
if git --git-dir="$GIT_CACHE" cat-file -e "$HIDDEN_SHA^{commit}" 2>/dev/null; then
echo "Unexpected hidden-only commit object in git cache" >&2
exit 1
fi
FETCH_SPECS="$(git --git-dir="$GIT_CACHE" config --get-all remote.origin.fetch | sort)"
printf 'fetch specs:\n%s\n' "$FETCH_SPECS"
FETCH_SPEC_COUNT="$(printf '%s\n' "$FETCH_SPECS" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
test "$FETCH_SPEC_COUNT" = 2
printf '%s\n' "$FETCH_SPECS" | grep -Fqx '+refs/heads/*:refs/heads/*'
printf '%s\n' "$FETCH_SPECS" | grep -Fqx '+refs/tags/*:refs/tags/*'
TAG_OPTS="$(git --git-dir="$GIT_CACHE" config --get-all remote.origin.tagOpt)"
TAG_OPT_COUNT="$(printf '%s\n' "$TAG_OPTS" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
test "$TAG_OPT_COUNT" = 1
test "$TAG_OPTS" = '--no-tags'
if git --git-dir="$GIT_CACHE" config --get remote.origin.mirror >/dev/null; then
echo "Unexpected mirror config" >&2
exit 1
fi
git --git-dir="$GIT_CACHE" fsck --connectivity-only >/dev/null
printf '\nSmoke root: %s\n' "$ROOT"Expected Result
The command must exit 0.
Expected high-signal output:
fvm --versionprints the branch package version.install stable --no-setupcreates the local git cache and installsstable.- The first
use stable --force --skip-setup --skip-pub-getrun uses--fvm-skip-input, emits the Melos prompt text, logsSkipping input confirmation, and keepsmelos.yamlunchanged because the Melos confirmation default isfalse. - The second
use stable --force --skip-setup --skip-pub-getrun answers the Melos prompt withyesthroughexpectand writessdkPath: .fvm/flutter_sdk. fvm flutter --versionprintsFlutter smoke 3.99.0 on stable.fvm listreports the isolated temp cache,stable, and3.99.0-smoke..fvmrccontains"flutter": "stable"..fvm/fvm_config.jsoncontains"flutterSdkVersion": "stable"..fvm/versioncontains3.99.0-smoke..fvm/releasecontainsstable..fvm/flutter_sdkpoints to the isolated temp cache..fvm/versions/stablepoints to the isolated temp cache..gitignorecontains.fvm/..vscode/settings.jsoncontains"dart.flutterSdkPath": ".fvm/versions/stable".melos.yamlcontainssdkPath: .fvm/flutter_sdk.cache.gitis bare.cache.gitHEADis underrefs/heads/.cache.gitHEADpoints at an existing branch ref.cache.gitincludes the fake tagrefs/tags/smoke-tag.cache.gitexcludes the fake hidden refrefs/pull/1/headand its hidden-only commit object.remote.origin.fetchcontains only:+refs/heads/*:refs/heads/*+refs/tags/*:refs/tags/*
remote.origin.tagOptis exactly one--no-tagsvalue.remote.origin.mirroris absent.git fsck --connectivity-onlypasses.
Coverage Boundaries
This smoke test should stay fast and isolated. It does not replace the targeted
tests for stale cache clone fallback, missing-object fallback, invalid-cache
recreation, cache migration, or real Flutter SDK setup. Keep running the focused
unit/integration tests for those paths when touching GitService,
FlutterService, or EnsureCacheWorkflow.
Cleanup
After inspection, remove only the exact temp root printed by the run. Do not use
fvm remove, fvm destroy, or ad hoc cache deletion for this cleanup, because
running those outside the script environment could target the user's real FVM
configuration.
SMOKE_ROOT="/tmp/fvm-branch-smoke.<suffix>"
case "$SMOKE_ROOT" in
/tmp/fvm-branch-smoke.*) ;;
*) echo "Refusing to remove unexpected path: $SMOKE_ROOT" >&2; exit 1 ;;
esac
test -f "$SMOKE_ROOT/.fvm-smoke-root"
rm -rf "$SMOKE_ROOT"This removes the fake Flutter remote, isolated FVM home, isolated SDK install, isolated git cache, and throwaway project/config updates created by the smoke test. It should not remove anything outside the temp root.
For older runs where only the suffix is known:
rm -rf /tmp/fvm-branch-smoke.<suffix>Use the exact Smoke root path printed by the run.
When To Escalate To Real Integration
Run the real integration workflow instead when you need proof against live Flutter infrastructure, real SDK setup, flutter doctor, destructive cleanup, concurrency, or global symlink behavior:
dart run grinder integration-testThat workflow is intentionally slower and can mutate the real FVM cache. See integration-tests.md before running it locally.