Repository navigation
ci: route Maestro e2e selection through sniffler impact analysis #7476
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e6667b4
ci: route Maestro e2e selection through sniffler impact analysis
diegolmello 60de378
chore: format code and fix lint issues
diegolmello 74385d5
test: cover e2e-selection scripts and harden the test-map validator
diegolmello fb09c46
Merge branch 'develop' into rich-albertosaurus
diegolmello 87f3280
Merge branch 'develop' into rich-albertosaurus
diegolmello File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| #!/usr/bin/env bash | ||
| set -uo pipefail | ||
|
|
||
| # Runs only the Maestro flows sniffler flags as impacted by the local change | ||
| # set, against an already-booted device with the app installed. Uses the same | ||
| # sniffler config as CI, but flow-granular (no shard matrix locally) and | ||
| # working-tree-aware, so an in-progress edit selects its flows before commit. | ||
| # | ||
| # Usage: pnpm e2e:changed <android|ios> | ||
| # E2E_BASE base ref to diff against (default: origin/develop) | ||
|
|
||
| PLATFORM="${1:-}" | ||
| case "$PLATFORM" in | ||
| android) APP_ID="chat.rocket.android" OTHER_ONLY="ios-only" ;; | ||
| ios) APP_ID="chat.rocket.ios" OTHER_ONLY="android-only" ;; | ||
| *) | ||
| echo "usage: pnpm e2e:changed <android|ios>" >&2 | ||
| exit 2 | ||
| ;; | ||
| esac | ||
|
|
||
| command -v maestro >/dev/null 2>&1 || { | ||
| echo "ERROR: maestro not found in PATH — install it and boot a device with the app." >&2 | ||
| exit 2 | ||
| } | ||
|
|
||
| BASE="${E2E_BASE:-origin/develop}" | ||
| MERGE_BASE="$(git merge-base "$BASE" HEAD 2>/dev/null)" || { | ||
| echo "ERROR: cannot resolve merge-base against '$BASE' — fetch it or set E2E_BASE." >&2 | ||
| exit 1 | ||
| } | ||
|
|
||
| # Committed branch work + uncommitted tracked edits + untracked files. | ||
| # Plain read loop (not mapfile) so it runs on macOS's bash 3.2. | ||
| CHANGED=() | ||
| while IFS= read -r file; do | ||
| [ -n "$file" ] && CHANGED+=("$file") | ||
| done < <( | ||
| { | ||
| git diff --name-only "$MERGE_BASE" -- | ||
| git ls-files --others --exclude-standard | ||
| } | sort -u | ||
| ) | ||
|
|
||
| if [ "${#CHANGED[@]}" -eq 0 ]; then | ||
| echo "No changes vs $BASE — nothing to run." | ||
| exit 0 | ||
| fi | ||
|
|
||
| # sniffler selects impacted flows and appends them to the command; a confident | ||
| # zero (no impacted flow) runs nothing and exits 0. Tag excludes mirror | ||
| # run-maestro.sh so util + wrong-platform flows don't run locally. | ||
| exec pnpm exec sniffler run --changed "${CHANGED[@]}" -- \ | ||
| maestro test -e APP_ID="$APP_ID" --exclude-tags=util --exclude-tags="$OTHER_ONLY" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| #!/usr/bin/env bash | ||
| set -uo pipefail | ||
|
|
||
| # Narrows the Maestro shard matrix to the shards sniffler flags as impacted by | ||
| # the PR diff. Every uncertainty falls back to the full shard list, so | ||
| # under-selection is impossible; over-selection is always acceptable. | ||
| # | ||
| # Emits to $GITHUB_OUTPUT: | ||
| # shards JSON int array of shards to run | ||
| # should_run "true" to run e2e; "false" only on a confident zero (no impacted | ||
| # flow), which skips the whole e2e stage. | ||
| # | ||
| # Env: | ||
| # IS_RELEASE_LANE "true" forces the full suite (release-cut / release label) | ||
| # BASE_REF PR base branch name (github.event.pull_request.base.ref) | ||
| # HEAD_SHA PR head commit sha (github.event.pull_request.head.sha) | ||
| # FULL_SHARDS the guaranteed [1..14] list from assert-maestro-shards.sh | ||
|
|
||
| emit() { | ||
| echo "shards=$1" >>"$GITHUB_OUTPUT" | ||
| echo "should_run=$2" >>"$GITHUB_OUTPUT" | ||
| } | ||
|
|
||
| full() { | ||
| emit "$FULL_SHARDS" "true" | ||
| exit 0 | ||
| } | ||
|
|
||
| # 1. Release lane -> full suite. | ||
| [ "${IS_RELEASE_LANE:-}" = "true" ] && full | ||
|
|
||
| # 2. No base ref (not a PR / unset) -> full. | ||
| [ -z "${BASE_REF:-}" ] && full | ||
|
|
||
| # 3. merge-base fails/empty -> full (absorbs shallow clone, unfetched base, first run). | ||
| git fetch --no-tags --quiet origin "$BASE_REF" 2>/dev/null || true | ||
| BASE_SHA="$(git merge-base "origin/$BASE_REF" "$HEAD_SHA" 2>/dev/null)" || true | ||
| [ -z "$BASE_SHA" ] && full | ||
|
|
||
| # 4. sniffler nonzero exit -> full. | ||
| json="$(pnpm exec sniffler impact --base "$BASE_SHA" --head "$HEAD_SHA" --format json)" || full | ||
|
|
||
| # 5. JSON unparseable -> full. | ||
| echo "$json" | jq -e . >/dev/null 2>&1 || full | ||
|
|
||
| # 5b. Missing recommendedTests key -> full (a valid-but-malformed payload must | ||
| # never fall through to the confident-zero skip below). | ||
| echo "$json" | jq -e 'has("recommendedTests")' >/dev/null 2>&1 || full | ||
|
|
||
| # 6. run-all reason -> full (provably covers 1..14). | ||
| if echo "$json" | jq -e 'any(.recommendedTests[].reasons[]?; .kind == "run-all")' >/dev/null; then | ||
| full | ||
| fi | ||
|
|
||
| # Happy path: recommendedTests[].test (flow paths) -> each flow's test-N tag. | ||
| paths="$(echo "$json" | jq -r '.recommendedTests[].test')" | ||
|
|
||
| # Confident zero: exit 0 + no impacted flow -> skip the whole e2e stage. | ||
| if [ -z "$paths" ]; then | ||
| emit "[]" "false" | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Match the same `- test-N` list-item shape assert-maestro-shards.sh / run-maestro.sh grep. | ||
| shards="$(for f in $paths; do | ||
| grep -hoE "^[[:space:]]*-[[:space:]]*['\"]?test-[0-9]+" "$f" 2>/dev/null | grep -oE '[0-9]+' | ||
| done | sort -n -u | jq -R . | jq -cs 'map(tonumber)')" | ||
|
|
||
| # Defensive: impacted flows with no derivable tag -> full rather than under-select. | ||
| [ "$shards" = "[]" ] && full | ||
|
|
||
| emit "$shards" "true" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| #!/usr/bin/env node | ||
| // Validates the sniffler test-map against the repo so it cannot silently rot. | ||
| // Four checks: | ||
| // Orphan flow ERROR — a `test-N`-tagged YAML under .maestro/tests/ with | ||
| // no test-map entry (sniffler never selects it). | ||
| // Dangling glob ERROR — a dependsOn glob that matches zero files on disk. | ||
| // Uncovered view WARNING — an app/views/ directory no dependsOn glob anchors | ||
| // (a nudge; not every view needs a flow). `__*` dirs | ||
| // (Jest artifacts) are excluded. | ||
| // Stale global ERROR — a runAllWhenChanged path missing on disk (never | ||
| // fires). | ||
| // Decoupled gap ERROR — a saga or stack root neither anchored in a | ||
| // dependsOn glob nor in runAllWhenChanged (the | ||
| // import graph can't reach flows from it, so its | ||
| // changes would silently select zero flows). | ||
| // Emits GitHub Actions annotations + a summary; exits 1 on any error. | ||
|
|
||
| 'use strict'; | ||
|
|
||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const fg = require('fast-glob'); | ||
|
|
||
| const ROOT = path.resolve(__dirname, '..', '..'); | ||
| const TEST_MAP_PATH = path.join(ROOT, '.sniffler', 'test-map.json'); | ||
| const CONFIG_PATH = path.join(ROOT, '.sniffler', 'config.json'); | ||
| const FLOWS_DIR = path.join(ROOT, '.maestro', 'tests'); | ||
| const VIEWS_DIR = path.join(ROOT, 'app', 'views'); | ||
|
|
||
| const ann = (level, file, msg) => console.log(`::${level} file=${file}::${msg}`); | ||
|
|
||
| let errorCount = 0; | ||
| let warnCount = 0; | ||
|
|
||
| const testMap = JSON.parse(fs.readFileSync(TEST_MAP_PATH, 'utf8')); | ||
| const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); | ||
|
|
||
| // Orphan flows: test-N-tagged YAML with no test-map entry. | ||
| const taggedFlows = fg | ||
| .sync(['**/*.yaml', '**/*.yml'], { cwd: FLOWS_DIR, absolute: true }) | ||
| .filter(f => /^\s*-\s*['"]?test-\d+/m.test(fs.readFileSync(f, 'utf8'))) | ||
| .map(f => path.relative(ROOT, f).replace(/\\/g, '/')); | ||
|
|
||
| const mappedTests = new Set(testMap.map(e => e.test)); | ||
| const orphans = taggedFlows.filter(f => !mappedTests.has(f)); | ||
| for (const f of orphans) { | ||
| ann('error', f, `Orphan flow: "${f}" has a test-N tag but no test-map entry — sniffler will never select it.`); | ||
| errorCount++; | ||
| } | ||
|
|
||
| // Dangling dependsOn globs: zero matches on disk. | ||
| const dangling = []; | ||
| for (const entry of testMap) { | ||
| for (const glob of entry.dependsOn || []) { | ||
| if (fg.sync([glob], { cwd: ROOT }).length === 0) { | ||
| ann('error', '.sniffler/test-map.json', `Dangling glob: "${glob}" in test "${entry.test}" resolves to zero files.`); | ||
| dangling.push({ test: entry.test, glob }); | ||
| errorCount++; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Uncovered views (WARNING): app/views/ dir no dependsOn glob anchors. Skip __* dirs. | ||
| const viewDirs = fs | ||
| .readdirSync(VIEWS_DIR, { withFileTypes: true }) | ||
| .filter(d => d.isDirectory() && !d.name.startsWith('__')) | ||
| .map(d => `app/views/${d.name}`); | ||
|
|
||
| const allDependsOn = testMap.flatMap(e => e.dependsOn || []); | ||
| const uncovered = viewDirs.filter(dir => !allDependsOn.some(g => g.startsWith(dir))); | ||
|
diegolmello marked this conversation as resolved.
Resolved
|
||
| for (const dir of uncovered) { | ||
| ann('warning', dir, `Uncovered view: "${dir}" has no dependsOn anchor in any test-map entry — new screen with no Maestro flow?`); | ||
| warnCount++; | ||
| } | ||
|
|
||
| // Stale runAllWhenChanged paths: missing on disk. | ||
| const runAll = config.tests?.runAllWhenChanged || []; | ||
| const staleGlobals = runAll.filter(p => !fs.existsSync(path.join(ROOT, p))); | ||
| for (const p of staleGlobals) { | ||
| ann('error', p, `Stale global: "${p}" in runAllWhenChanged does not exist — will silently never fire.`); | ||
| errorCount++; | ||
| } | ||
|
|
||
| // Decoupled gap (ERROR): sagas + stack roots are consumed via the store / | ||
| // navigator, so the import graph can't trace them to a flow. Each must be | ||
| // anchored in a dependsOn glob (domain) or listed in runAllWhenChanged (global). | ||
| const coveredByGlob = new Set(fg.sync(allDependsOn, { cwd: ROOT })); | ||
| const runAllSet = new Set(runAll); | ||
| const decoupledFiles = fg.sync(['app/sagas/*.{js,ts}', 'app/stacks/*.tsx', 'app/stacks/*/index.tsx'], { | ||
| cwd: ROOT, | ||
| ignore: ['**/__tests__/**'] | ||
| }); | ||
| const uncoveredDecoupled = decoupledFiles.filter(f => !coveredByGlob.has(f) && !runAllSet.has(f)); | ||
| for (const f of uncoveredDecoupled) { | ||
| ann('error', f, `Decoupled gap: "${f}" is neither anchored in a dependsOn glob nor in runAllWhenChanged — its changes would select zero flows.`); | ||
| errorCount++; | ||
| } | ||
|
|
||
| console.log('\n── test-map freshness ──'); | ||
| console.log(` Flows scanned: ${taggedFlows.length}`); | ||
| console.log(` Test-map entries: ${testMap.length}`); | ||
| console.log(` Orphans: ${orphans.length}`); | ||
| console.log(` Dangling globs: ${dangling.length}`); | ||
| console.log(` Uncovered views: ${uncovered.length} (warnings)`); | ||
| console.log(` Stale globals: ${staleGlobals.length}`); | ||
| console.log(` Decoupled gaps: ${uncoveredDecoupled.length}`); | ||
|
|
||
| if (errorCount > 0) { | ||
| console.log(`\n❌ ${errorCount} error(s), ${warnCount} warning(s).`); | ||
| process.exit(1); | ||
| } | ||
| console.log(`\n✅ All checks passed (${warnCount} warning(s) allowed).`); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,3 +95,4 @@ AGENTS.md | |
| /docs/ | ||
| .superset/ | ||
| .jest-cache/ | ||
| .sniffler/cache.json | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.