Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/scripts/e2e-changed.sh
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"
72 changes: 72 additions & 0 deletions .github/scripts/select-impacted-shards.sh
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
Comment thread
diegolmello marked this conversation as resolved.
Resolved

# 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"
112 changes: 112 additions & 0 deletions .github/scripts/validate-test-map.js
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)));
Comment thread
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).`);
98 changes: 75 additions & 23 deletions .github/workflows/build-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,40 +60,57 @@ jobs:
with:
trigger: "pr"

e2e-hold:
name: E2E Hold
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }}
environment: approve_e2e_testing
runs-on: ubuntu-latest
steps:
- run: echo "Waiting for manual approval..."

e2e-build-android:
name: E2E Build Android
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }}
uses: ./.github/workflows/e2e-build-android.yml
needs: [e2e-hold]
secrets: inherit

# Validates flow tags and emits the shard list both matrices fan out over.
# Runs first (cheap, secret-free): validates flow tags, then narrows the shard
# list to the shards sniffler flags as impacted. `should_run=false` is a
# confident zero (no source touched) that skips the whole e2e stage below.
e2e-shards:
name: E2E Shard Preflight
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }}
needs: [e2e-hold]
runs-on: ubuntu-latest
outputs:
shards: ${{ steps.shards.outputs.shards }}
shards: ${{ steps.select.outputs.shards }}
should_run: ${{ steps.select.outputs.should_run }}
steps:
- name: Checkout Repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0

- name: Checkout and Setup Node
uses: ./.github/actions/setup-node

- name: Assert Maestro shard coverage
id: shards
id: full
run: bash .github/scripts/assert-maestro-shards.sh

- name: Select impacted shards
id: select
env:
IS_RELEASE_LANE: ${{ github.event.pull_request.base.ref == 'master' || contains(github.event.pull_request.labels.*.name, 'release') }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
FULL_SHARDS: ${{ steps.full.outputs.shards }}
run: bash .github/scripts/select-impacted-shards.sh

e2e-hold:
name: E2E Hold
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' && needs.e2e-shards.outputs.should_run == 'true' }}
needs: [e2e-shards]
environment: approve_e2e_testing
runs-on: ubuntu-latest
steps:
- run: echo "Waiting for manual approval..."

e2e-build-android:
name: E2E Build Android
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' && needs.e2e-shards.outputs.should_run == 'true' }}
uses: ./.github/workflows/e2e-build-android.yml
needs: [e2e-shards, e2e-hold]
secrets: inherit

e2e-run-android:
name: E2E Run Android
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }}
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' && needs.e2e-shards.outputs.should_run == 'true' }}
uses: ./.github/workflows/maestro-android.yml
needs: [e2e-build-android, e2e-shards]
secrets: inherit
Expand All @@ -106,14 +123,14 @@ jobs:

e2e-build-ios:
name: E2E Build iOS
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }}
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' && needs.e2e-shards.outputs.should_run == 'true' }}
uses: ./.github/workflows/e2e-build-ios.yml
needs: [e2e-hold]
needs: [e2e-shards, e2e-hold]
secrets: inherit

e2e-run-ios:
name: E2E Run iOS
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }}
if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' && needs.e2e-shards.outputs.should_run == 'true' }}
uses: ./.github/workflows/maestro-ios.yml
needs: [e2e-build-ios, e2e-shards]
secrets: inherit
Expand All @@ -123,3 +140,38 @@ jobs:
fail-fast: false
with:
shard: ${{ matrix.shard }}

# Single required e2e status check. Always publishes one stable context, so an
# intentional confident-zero skip merges clean while a real shard failure blocks.
e2e-result:
name: E2E Result
if: ${{ always() && github.repository == 'RocketChat/Rocket.Chat.ReactNative' }}
needs: [e2e-shards, e2e-run-android, e2e-run-ios]
runs-on: ubuntu-latest
steps:
- name: Aggregate e2e outcome
env:
SHARDS_RESULT: ${{ needs.e2e-shards.result }}
SHOULD_RUN: ${{ needs.e2e-shards.outputs.should_run }}
SHARDS: ${{ needs.e2e-shards.outputs.shards }}
ANDROID: ${{ needs.e2e-run-android.result }}
IOS: ${{ needs.e2e-run-ios.result }}
run: |
if [ "$SHARDS_RESULT" != "success" ]; then
echo "::error title=E2E preflight failed::Shard preflight did not succeed (result=$SHARDS_RESULT)."
exit 1
fi
if [ "$SHOULD_RUN" != "true" ]; then
echo "### E2E skipped — 0 impacted flows (no source touched)" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
{
echo "### E2E ran shards \`$SHARDS\`"
echo "- Android: $ANDROID"
echo "- iOS: $IOS"
} >> "$GITHUB_STEP_SUMMARY"
if [ "$ANDROID" = "success" ] && [ "$IOS" = "success" ]; then
exit 0
fi
echo "::error title=E2E failed::One or more Maestro shards failed (android=$ANDROID ios=$IOS)."
exit 1
3 changes: 3 additions & 0 deletions .github/workflows/eslint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ jobs:

- name: Run Tests
run: pnpm test --runInBand

- name: Validate sniffler test-map freshness
run: node .github/scripts/validate-test-map.js
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,4 @@ AGENTS.md
/docs/
.superset/
.jest-cache/
.sniffler/cache.json
Loading
Loading