diff --git a/.github/README.md b/.github/README.md index 50e0bb33076..e95718b3428 100644 --- a/.github/README.md +++ b/.github/README.md @@ -6,7 +6,7 @@ Maps which event triggers which workflow and how they call each other. | Workflow | Trigger | What runs | |---|---|---| -| [build-pr.yml](workflows/build-pr.yml) | `pull_request` (all branches) | Lint + tests, PR changelog, Android + iOS store builds (gated), E2E build + Maestro shards on both platforms (gated) | +| [build-pr.yml](workflows/build-pr.yml) | `pull_request` (all branches) | Lint + tests, PR changelog, Android + iOS store builds (gated), E2E build + Maestro shards on both platforms (gated; `e2e-shards` narrows to the sniffler-impacted shards, or skips the stage on a confident-zero diff) | | [build-develop.yml](workflows/build-develop.yml) | `push: develop` | Lint + tests, release changelog, Android + iOS store builds, seeds Android AVD + SDK caches for E2E shards | | [prettier.yml](workflows/prettier.yml) | `push: * except master, develop, single-server` (main repo) | Auto-formats with Oxfmt + Oxlint and commits any fixes back to the branch | | [organize_translations.yml](workflows/organize_translations.yml) | `push` touching `app/i18n/locales/**.json` | Sorts JSON keys and commits the result | @@ -18,6 +18,7 @@ flowchart TD classDef entry fill:#d4e6f1,stroke:#2980b9 classDef reusable fill:#d5f5e3,stroke:#27ae60 classDef action fill:#fef9e7,stroke:#f39c12 + classDef gate fill:#f5e6f8,stroke:#8e44ad PR([build-pr.yml]):::entry DEV([build-develop.yml]):::entry @@ -44,13 +45,22 @@ flowchart TD PREAND[preinstall-android-sdk]:::action E2EACC[e2e-account]:::action + ESHARD[e2e-shards preflight]:::gate + ERESULT[e2e-result required check]:::gate + PR --> ESLINT PR --> BUILDAND PR --> BUILDIOS - PR --> E2EAND - PR --> E2EIOS - PR --> MASTAND - PR --> MASIOS + PR --> ESHARD + + ESHARD -->|should_run| E2EAND + ESHARD -->|should_run| E2EIOS + ESHARD -->|should_run| MASTAND + ESHARD -->|should_run| MASIOS + + MASTAND --> ERESULT + MASIOS --> ERESULT + ESHARD --> ERESULT DEV --> ESLINT DEV --> CHANGELOG @@ -90,4 +100,4 @@ flowchart TD | `android_build` | [build-android.yml](workflows/build-android.yml) — `build-hold` | Called with `trigger == pr` (i.e. from `build-pr.yml`) | | `upload_android` | [build-android.yml](workflows/build-android.yml) — `upload-hold` | Called with `trigger == pr`, after the Android build completes | | `ios_build` | [build-ios.yml](workflows/build-ios.yml) — `build-hold` | Called with `trigger == pr` (i.e. from `build-pr.yml`) | -| `approve_e2e_testing` | [build-pr.yml](workflows/build-pr.yml) — `e2e-hold` | Every `pull_request` run | +| `approve_e2e_testing` | [build-pr.yml](workflows/build-pr.yml) — `e2e-hold` | A `pull_request` run whose diff impacts at least one Maestro flow (`e2e-shards` sets `should_run == true`). A confident-zero diff skips the whole e2e stage, so no approval fires. | diff --git a/.github/scripts/__tests__/changed-runner.test.js b/.github/scripts/__tests__/changed-runner.test.js new file mode 100644 index 00000000000..cfe880495d7 --- /dev/null +++ b/.github/scripts/__tests__/changed-runner.test.js @@ -0,0 +1,198 @@ +// Tests for e2e-changed.sh: the local `pnpm e2e:changed ` runner. +// Unlike select-impacted-shards.sh, this script never writes to $GITHUB_OUTPUT — +// it is a pure CLI that gathers a CHANGED file set from git and execs straight +// into `pnpm exec sniffler run --changed ... -- maestro test ...`. Maestro is +// stubbed throughout: these tests prove the arg validation, the maestro guard, +// the merge-base fallback, and the CHANGED-gathering/invocation shape — actual +// Maestro flow execution needs a booted device and is out of scope here. +'use strict'; + +const path = require('path'); +const { runScript } = require('../testlib/runScript'); + +const SCRIPT = path.join(__dirname, '..', 'e2e-changed.sh'); + +// No-op maestro: its presence alone satisfies the `command -v maestro` guard. +const MAESTRO_NOOP = 'exit 0'; + +// Records its own invocation to stdout so the trailing `maestro test ...` +// tail (once sniffler forwards to it) shows up in the captured process output. +const MAESTRO_RECORDING = `echo "MAESTRO_ARGS:$*"\nexit 0`; + +// git stub: branches on the subcommand the script actually calls +// (merge-base / diff / ls-files). `mergeBase: null` simulates the +// unresolved-merge-base failure the script falls back on. +function gitStub({ mergeBase = 'deadbeef', diffFiles = [], untrackedFiles = [] } = {}) { + const diffBody = diffFiles.map(f => `echo '${f}'`).join('\n\t\t') || ':'; + const untrackedBody = untrackedFiles.map(f => `echo '${f}'`).join('\n\t\t') || ':'; + return ` +case "$1" in + merge-base) + ${mergeBase === null ? 'exit 1' : `echo '${mergeBase}'`} + ;; + diff) + ${diffBody} + ;; + ls-files) + ${untrackedBody} + ;; + *) + exit 0 + ;; +esac +`; +} + +// pnpm stub: only intercepts `pnpm exec sniffler ...` (the script's real +// invocation shape). Echoes its own args (proves the --changed set / tail +// command sniffler received), then either reports a confident zero or execs +// the trailing `maestro test ...` command so it shows up in stdout too. +function pnpmStub({ zero = false } = {}) { + return ` +if [ "$1 $2" = "exec sniffler" ]; then + echo "PNPM_ARGS:$*" + if [ "${zero}" = "true" ]; then + echo "sniffler: no impacted flows (confident zero)" + exit 0 + fi + while [ $# -gt 0 ] && [ "$1" != "--" ]; do + shift + done + shift + exec "$@" +fi +exit 0 +`; +} + +describe('e2e-changed.sh', () => { + describe('platform arg validation', () => { + test('missing platform arg prints usage and exits 2', () => { + const result = runScript(SCRIPT, { args: [] }); + expect(result.status).toBe(2); + expect(result.stderr).toContain('usage: pnpm e2e:changed '); + }); + + test('invalid platform arg prints usage and exits 2', () => { + const result = runScript(SCRIPT, { args: ['windows'] }); + expect(result.status).toBe(2); + expect(result.stderr).toContain('usage: pnpm e2e:changed '); + }); + }); + + describe('missing-maestro guard', () => { + test('valid platform but no maestro on PATH fires the guard', () => { + // Override PATH to a maestro-free set of dirs (no binDir stub either — + // the guard fires before any git/sniffler call, so none is needed). + const result = runScript(SCRIPT, { + args: ['android'], + env: { PATH: '/usr/bin:/bin:/opt/homebrew/bin' } + }); + expect(result.status).toBe(2); + expect(result.stderr).toContain('ERROR: maestro not found in PATH'); + }); + }); + + describe('merge-base failure fallback', () => { + test('unresolved merge-base against the default base exits 1 with an actionable error', () => { + const result = runScript(SCRIPT, { + args: ['android'], + stubs: { maestro: MAESTRO_NOOP, git: gitStub({ mergeBase: null }) } + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("cannot resolve merge-base against 'origin/develop'"); + expect(result.stderr).toContain('set E2E_BASE'); + }); + + test('E2E_BASE override is reflected in the failure message', () => { + const result = runScript(SCRIPT, { + args: ['ios'], + env: { E2E_BASE: 'origin/custom-base' }, + stubs: { maestro: MAESTRO_NOOP, git: gitStub({ mergeBase: null }) } + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("cannot resolve merge-base against 'origin/custom-base'"); + }); + }); + + describe('CHANGED set gathering', () => { + test('android: committed + uncommitted-tracked + untracked files are deduped, sorted, and forwarded to sniffler', () => { + const result = runScript(SCRIPT, { + args: ['android'], + stubs: { + maestro: MAESTRO_RECORDING, + git: gitStub({ + diffFiles: ['app/views/RoomView.tsx', 'app/actions/room.ts', 'app/actions/room.ts'], + untrackedFiles: ['app/actions/room.ts', 'app/views/NewFeature.tsx'] + }), + pnpm: pnpmStub() + } + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain( + 'PNPM_ARGS:exec sniffler run --changed ' + + 'app/actions/room.ts app/views/NewFeature.tsx app/views/RoomView.tsx -- ' + + 'maestro test -e APP_ID=chat.rocket.android --exclude-tags=util --exclude-tags=ios-only' + ); + }); + + test('ios: platform selects the ios APP_ID and excludes android-only flows', () => { + const result = runScript(SCRIPT, { + args: ['ios'], + stubs: { + maestro: MAESTRO_RECORDING, + git: gitStub({ diffFiles: ['app/views/RoomView.tsx'] }), + pnpm: pnpmStub() + } + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain( + 'PNPM_ARGS:exec sniffler run --changed app/views/RoomView.tsx -- ' + + 'maestro test -e APP_ID=chat.rocket.ios --exclude-tags=util --exclude-tags=android-only' + ); + }); + + test('sniffler forwards to maestro, which is invoked with the built command tail', () => { + const result = runScript(SCRIPT, { + args: ['android'], + stubs: { + maestro: MAESTRO_RECORDING, + git: gitStub({ diffFiles: ['app/views/RoomView.tsx'] }), + pnpm: pnpmStub() + } + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain( + 'MAESTRO_ARGS:test -e APP_ID=chat.rocket.android --exclude-tags=util --exclude-tags=ios-only' + ); + }); + }); + + describe('confident zero', () => { + test('no changes at all vs base exits 0 cleanly without calling sniffler', () => { + const result = runScript(SCRIPT, { + args: ['android'], + stubs: { maestro: MAESTRO_NOOP, git: gitStub() } + // no pnpm stub: if the script reached the exec line, the real (unstubbed) + // pnpm would run and this test would fail or hang instead of passing. + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('No changes vs origin/develop — nothing to run.'); + expect(result.stdout).not.toContain('PNPM_ARGS'); + }); + + test('changes exist but sniffler reports no impacted flow: clean exit 0, maestro never runs', () => { + const result = runScript(SCRIPT, { + args: ['android'], + stubs: { + maestro: MAESTRO_RECORDING, + git: gitStub({ diffFiles: ['app/views/RoomView.tsx'] }), + pnpm: pnpmStub({ zero: true }) + } + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('sniffler: no impacted flows (confident zero)'); + expect(result.stdout).not.toContain('MAESTRO_ARGS'); + }); + }); +}); diff --git a/.github/scripts/__tests__/coverage.test.js b/.github/scripts/__tests__/coverage.test.js new file mode 100644 index 00000000000..af69d615ffc --- /dev/null +++ b/.github/scripts/__tests__/coverage.test.js @@ -0,0 +1,92 @@ +// Proves invariant (1) "no under-selection" against the REAL .sniffler/test-map.json +// and .sniffler/config.json: for each map-assertable diff in scenario-catalog.json +// (rows C1..C8), replicates sniffler's documented selection semantics in JS — +// root/ignore filtering, dependsOn glob matching, then flow -> test-N extraction +// the same way select-impacted-shards.sh does — and asserts the exact shard set. +// sniffler's own recommendation algorithm is trusted/out-of-scope; this only +// tests OUR map's globs and OUR config against real flow files on disk. +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const micromatch = require('micromatch'); + +const REPO_ROOT = path.resolve(__dirname, '../../..'); + +const config = require('../../../.sniffler/config.json'); +const testMap = require('../../../.sniffler/test-map.json'); +const catalog = require('./fixtures/scenario-catalog.json'); + +// Mirrors the grep pattern in select-impacted-shards.sh: `^\s*-\s*['"]?test-N`. +const TEST_N_PATTERN = /^[ \t]*-[ \t]*['"]?test-(\d+)/gm; + +function extractShardsFromFlow(flowPath) { + const contents = fs.readFileSync(path.join(REPO_ROOT, flowPath), 'utf8'); + const shards = new Set(); + let match; + while ((match = TEST_N_PATTERN.exec(contents)) !== null) { + shards.add(Number(match[1])); + } + return shards; +} + +function isUnderSourceRoots(diffPath) { + return config.source.roots.some(root => micromatch.isMatch(diffPath, `${root}/**`)); +} + +function isIgnored(diffPath) { + return config.source.ignore.some(glob => micromatch.isMatch(diffPath, glob)); +} + +function matchedFlowsFor(diffPath) { + return testMap.filter(entry => entry.dependsOn.some(glob => micromatch.isMatch(diffPath, glob))).map(entry => entry.test); +} + +// Replicates select-impacted-shards.sh's documented happy path against the real +// map: runAllWhenChanged -> full; else filter by source roots/ignore, match +// dependsOn globs, then union the matched flows' `- test-N` tags. +function computeSelection(diffPaths) { + const fullShards = [...catalog.fullShards].sort((a, b) => a - b); + + if (diffPaths.some(p => config.tests.runAllWhenChanged.includes(p))) { + return { shards: fullShards, shouldRun: true }; + } + + const survivors = diffPaths.filter(p => isUnderSourceRoots(p) && !isIgnored(p)); + if (survivors.length === 0) { + return { shards: [], shouldRun: false }; + } + + const matchedFlows = new Set(survivors.flatMap(matchedFlowsFor)); + if (matchedFlows.size === 0) { + return { shards: [], shouldRun: false }; + } + + const shardSet = new Set(); + for (const flow of matchedFlows) { + for (const shard of extractShardsFromFlow(flow)) shardSet.add(shard); + } + + // Defensive: an impacted flow with no derivable tag must fall to full rather + // than under-select (mirrors select-impacted-shards.sh's own fallback). + if (shardSet.size === 0) { + return { shards: fullShards, shouldRun: true }; + } + + const shards = [...shardSet].sort((a, b) => a - b); + return { shards, shouldRun: true }; +} + +describe('sniffler shard selection against the real .sniffler map', () => { + const scenarios = catalog.scenarios.filter(s => s.assertableIn.includes('map')); + + test('catalog has map-assertable scenarios to run', () => { + expect(scenarios.length).toBeGreaterThan(0); + }); + + test.each(scenarios)('$id: $name', scenario => { + const { shards, shouldRun } = computeSelection(scenario.input.diff); + expect(shards).toEqual(scenario.expectedShards); + expect(shouldRun).toBe(scenario.expectedShouldRun); + }); +}); diff --git a/.github/scripts/__tests__/fixtures/flows/no-tag.yaml b/.github/scripts/__tests__/fixtures/flows/no-tag.yaml new file mode 100644 index 00000000000..be280b0733b --- /dev/null +++ b/.github/scripts/__tests__/fixtures/flows/no-tag.yaml @@ -0,0 +1,4 @@ +appId: ${APP_ID} +name: No Tag Test +tags: + - android-only diff --git a/.github/scripts/__tests__/fixtures/scenario-catalog.json b/.github/scripts/__tests__/fixtures/scenario-catalog.json new file mode 100644 index 00000000000..84af4ba3055 --- /dev/null +++ b/.github/scripts/__tests__/fixtures/scenario-catalog.json @@ -0,0 +1,164 @@ +{ + "_about": "Canonical scenario matrix for the sniffler-driven e2e selection system. Single source of truth consumed by the shard-script tests (select-impacted-shards.sh), the real-test-map coverage fixtures, and the same-PR CI orchestration observation. JSON so both a shell/bats harness (via jq) and a jest harness (via require) can read it. Expected shards are literal test-N integers, matching select-impacted-shards.sh $GITHUB_OUTPUT byte-for-byte. Home may be relocated once the test harness is settled.", + "fullShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "assertableInLegend": { + "script": "select-impacted-shards.sh run in isolation with stubbed sniffler/git/jq", + "map": "computed against the real .sniffler/test-map.json", + "ci": "observed on real build-pr.yml via a throwaway PR commit" + }, + "scenarios": [ + { + "id": "F1", + "name": "release lane forces full suite", + "category": "fall-to-full", + "input": { "env": { "IS_RELEASE_LANE": "true" } }, + "expectedShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["script", "ci"] + }, + { + "id": "F2", + "name": "no base ref (not a PR / unset) falls to full", + "category": "fall-to-full", + "input": { "env": { "BASE_REF": "" } }, + "expectedShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["script"] + }, + { + "id": "F3", + "name": "merge-base empty/fails falls to full (shallow clone, unfetched base, first run)", + "category": "fall-to-full", + "input": { "stub": { "git": "merge-base-empty" } }, + "expectedShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["script"] + }, + { + "id": "F4", + "name": "sniffler nonzero exit falls to full", + "category": "fall-to-full", + "input": { "stub": { "sniffler": "exit1" } }, + "expectedShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["script"] + }, + { + "id": "F5", + "name": "unparseable JSON falls to full", + "category": "fall-to-full", + "input": { "stub": { "sniffler": "stdout:not-json" } }, + "expectedShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["script"] + }, + { + "id": "F5b", + "name": "valid JSON missing recommendedTests key falls to full (never confident-zero)", + "category": "fall-to-full", + "input": { "stub": { "sniffler": "stdout:{\"other\":true}" } }, + "expectedShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["script"] + }, + { + "id": "F6", + "name": "run-all reason falls to full", + "category": "fall-to-full", + "input": { "stub": { "sniffler": "stdout:{\"recommendedTests\":[{\"test\":\"x\",\"reasons\":[{\"kind\":\"run-all\"}]}]}" } }, + "expectedShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["script", "ci"] + }, + { + "id": "F7", + "name": "impacted flow with no derivable test-N tag falls to full (defensive)", + "category": "fall-to-full", + "input": { "stub": { "sniffler": "stdout:recommends a flow file with no `- test-N` tag" } }, + "expectedShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["script"] + }, + { + "id": "Z1", + "name": "confident zero: exit 0 + no impacted flow skips the stage", + "category": "confident-zero", + "input": { "stub": { "sniffler": "stdout:{\"recommendedTests\":[]}" } }, + "expectedShards": [], + "expectedShouldRun": false, + "assertableIn": ["script"] + }, + { + "id": "C1", + "name": "narrow view: LanguageView -> one flow", + "category": "real-domain", + "input": { "diff": ["app/views/LanguageView/LanguageView.tsx"] }, + "expectedShards": [6], + "expectedShouldRun": true, + "assertableIn": ["map", "ci"] + }, + { + "id": "C2", + "name": "shared view fans wide: RoomView -> nine flows", + "category": "real-domain", + "input": { "diff": ["app/views/RoomView/index.tsx"] }, + "expectedShards": [3, 7, 9, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["map", "ci"] + }, + { + "id": "C3", + "name": "shared saga fans wide: sagas/rooms.js -> eleven flows", + "category": "real-domain", + "input": { "diff": ["app/sagas/rooms.js"] }, + "expectedShards": [1, 5, 6, 7, 8, 11, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["map", "ci"] + }, + { + "id": "C4", + "name": "container glob (not a view): containers/markdown -> one flow", + "category": "real-domain", + "input": { "diff": ["app/containers/markdown/index.tsx"] }, + "expectedShards": [9], + "expectedShouldRun": true, + "assertableIn": ["map", "ci"] + }, + { + "id": "C5", + "name": "docs-only outside source roots -> no impact, skip", + "category": "confident-zero", + "input": { "diff": ["README.md"] }, + "expectedShards": [], + "expectedShouldRun": false, + "assertableIn": ["map", "ci"] + }, + { + "id": "C6", + "name": "ignored glob inside root (*.test.*) -> no impact, skip", + "category": "confident-zero", + "input": { "diff": ["app/views/RoomView/RoomView.test.tsx"] }, + "expectedShards": [], + "expectedShouldRun": false, + "assertableIn": ["map", "ci"] + }, + { + "id": "C7", + "name": "runAllWhenChanged (app-internal): sagas/index.js -> run-all -> full", + "category": "real-domain", + "input": { "diff": ["app/sagas/index.js"] }, + "expectedShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["map", "ci"] + }, + { + "id": "C8", + "name": "runAllWhenChanged (non-app): package.json -> run-all -> full", + "category": "real-domain", + "input": { "diff": ["package.json"] }, + "expectedShards": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + "expectedShouldRun": true, + "assertableIn": ["map", "ci"] + } + ] +} diff --git a/.github/scripts/__tests__/select-impacted-shards.test.js b/.github/scripts/__tests__/select-impacted-shards.test.js new file mode 100644 index 00000000000..9f00c11efe3 --- /dev/null +++ b/.github/scripts/__tests__/select-impacted-shards.test.js @@ -0,0 +1,186 @@ +// Tests for select-impacted-shards.sh: proves every uncertainty falls back to +// the full 14-shard suite (under-selection impossible) and that the +// confident-zero skip fires only on a genuinely empty impacted set. +// Expected values are read from scenario-catalog.json so this file stays in +// lockstep with the canonical matrix (rows F1, F2, F3, F4, F5, F5b, F6, F7, Z1). +'use strict'; + +const path = require('path'); +const { runScript } = require('../testlib/runScript'); +const catalog = require('./fixtures/scenario-catalog.json'); + +const REPO_ROOT = path.resolve(__dirname, '../../..'); +const SCRIPT = path.join(__dirname, '..', 'select-impacted-shards.sh'); + +const BASE_ENV = { + BASE_REF: 'develop', + HEAD_SHA: 'abc123', + FULL_SHARDS: JSON.stringify(catalog.fullShards) +}; + +function findScenario(id) { + const scenario = catalog.scenarios.find(s => s.id === id); + if (!scenario) { + throw new Error(`scenario ${id} not found in catalog`); + } + return scenario; +} + +// git stub: fetch always succeeds; merge-base echoes a fake sha, or nothing +// when simulating the shallow-clone / unfetched-base fall-to-full case. +function gitStub(mergeBaseEmpty = false) { + return ` +case "$1" in + fetch) exit 0 ;; + merge-base) + ${mergeBaseEmpty ? 'echo ""' : 'echo deadbeef'} + exit 0 + ;; + *) exit 0 ;; +esac +`; +} + +// pnpm stub: only "exec sniffler" is intercepted (matches the script's real +// invocation shape); anything else exits 0 untouched. +function pnpmStub(json, exitCode = 0) { + return ` +if [ "$1 $2" = "exec sniffler" ]; then + ${json === null ? '' : `echo '${json}'`} + exit ${exitCode} +fi +exit 0 +`; +} + +function expectScenario(result, scenario) { + expect(result.status).toBe(0); + expect(JSON.parse(result.shards)).toEqual(scenario.expectedShards); + expect(result.should_run).toBe(String(scenario.expectedShouldRun)); +} + +describe('select-impacted-shards.sh', () => { + describe('fall-to-full branches', () => { + test('F1: release lane forces full suite', () => { + const scenario = findScenario('F1'); + const result = runScript(SCRIPT, { + env: { ...BASE_ENV, IS_RELEASE_LANE: 'true' }, + stubs: { git: gitStub(), pnpm: pnpmStub('{"recommendedTests":[]}') } + }); + expectScenario(result, scenario); + }); + + test('F2: no base ref falls to full', () => { + const scenario = findScenario('F2'); + const result = runScript(SCRIPT, { + env: { ...BASE_ENV, BASE_REF: '' }, + stubs: { git: gitStub(), pnpm: pnpmStub('{"recommendedTests":[]}') } + }); + expectScenario(result, scenario); + }); + + test('F3: merge-base empty falls to full', () => { + const scenario = findScenario('F3'); + const result = runScript(SCRIPT, { + env: BASE_ENV, + stubs: { git: gitStub(true), pnpm: pnpmStub('{"recommendedTests":[]}') } + }); + expectScenario(result, scenario); + }); + + test('F4: sniffler nonzero exit falls to full', () => { + const scenario = findScenario('F4'); + const result = runScript(SCRIPT, { + env: BASE_ENV, + stubs: { git: gitStub(), pnpm: pnpmStub(null, 1) } + }); + expectScenario(result, scenario); + }); + + test('F5: unparseable JSON falls to full', () => { + const scenario = findScenario('F5'); + const result = runScript(SCRIPT, { + env: BASE_ENV, + stubs: { git: gitStub(), pnpm: pnpmStub('not-json') } + }); + expectScenario(result, scenario); + }); + + test('F5b: JSON missing recommendedTests key falls to full', () => { + const scenario = findScenario('F5b'); + const result = runScript(SCRIPT, { + env: BASE_ENV, + stubs: { git: gitStub(), pnpm: pnpmStub('{"other":true}') } + }); + expectScenario(result, scenario); + }); + + test('F6: run-all reason falls to full', () => { + const scenario = findScenario('F6'); + const result = runScript(SCRIPT, { + env: BASE_ENV, + stubs: { + git: gitStub(), + pnpm: pnpmStub('{"recommendedTests":[{"test":"x","reasons":[{"kind":"run-all"}]}]}') + } + }); + expectScenario(result, scenario); + }); + + test('F7: impacted flow with no derivable test-N tag falls to full', () => { + const scenario = findScenario('F7'); + const flowPath = path.join(REPO_ROOT, '.github/scripts/__tests__/fixtures/flows/no-tag.yaml'); + const result = runScript(SCRIPT, { + env: BASE_ENV, + stubs: { + git: gitStub(), + pnpm: pnpmStub(`{"recommendedTests":[{"test":"${flowPath}"}]}`) + } + }); + expectScenario(result, scenario); + }); + }); + + describe('confident-zero', () => { + test('Z1: exit 0 with no impacted flow skips the e2e stage', () => { + const scenario = findScenario('Z1'); + const result = runScript(SCRIPT, { + env: BASE_ENV, + stubs: { git: gitStub(), pnpm: pnpmStub('{"recommendedTests":[]}') } + }); + expectScenario(result, scenario); + }); + }); + + describe('happy path', () => { + test('single impacted flow maps to its shard', () => { + const flowPath = path.join(REPO_ROOT, '.maestro/tests/assorted/i18n.yaml'); // tags: test-6 + const result = runScript(SCRIPT, { + env: BASE_ENV, + stubs: { + git: gitStub(), + pnpm: pnpmStub(`{"recommendedTests":[{"test":"${flowPath}"}]}`) + } + }); + expect(result.status).toBe(0); + expect(JSON.parse(result.shards)).toEqual([6]); + expect(result.should_run).toBe('true'); + }); + + test('multiple impacted flows map to the sorted unique shard union', () => { + const flows = [ + path.join(REPO_ROOT, '.maestro/tests/assorted/i18n.yaml'), // test-6 + path.join(REPO_ROOT, '.maestro/tests/e2ee/e2e-encryption.yaml'), // test-3 + path.join(REPO_ROOT, '.maestro/tests/room/search.yaml') // test-13 + ]; + const json = JSON.stringify({ recommendedTests: flows.map(test => ({ test })) }); + const result = runScript(SCRIPT, { + env: BASE_ENV, + stubs: { git: gitStub(), pnpm: pnpmStub(json) } + }); + expect(result.status).toBe(0); + expect(JSON.parse(result.shards)).toEqual([3, 6, 13]); + expect(result.should_run).toBe('true'); + }); + }); +}); diff --git a/.github/scripts/__tests__/validate-test-map.test.js b/.github/scripts/__tests__/validate-test-map.test.js new file mode 100644 index 00000000000..6129860a250 --- /dev/null +++ b/.github/scripts/__tests__/validate-test-map.test.js @@ -0,0 +1,73 @@ +// Tests for validate-test-map.js: each of the 5 checks is proven against a +// purpose-built fixture under testlib/fixtures/maps// that triggers +// exactly that check, plus one fully-consistent fixture that passes clean. +// Fixtures live outside any __tests__ dir — jest's default testMatch +// collects every .js/.ts(x) file under __tests__ regardless of name, and the +// decoupled-gap / clean-pass fixtures need real app/sagas & app/stacks files. +'use strict'; + +const { spawnSync } = require('child_process'); +const path = require('path'); + +const SCRIPT = path.join(__dirname, '..', 'validate-test-map.js'); +const FIXTURES = path.join(__dirname, '..', 'testlib', 'fixtures', 'maps'); + +function runValidator(fixture) { + const result = spawnSync('node', [SCRIPT], { + encoding: 'utf8', + env: { ...process.env, TESTMAP_ROOT: path.join(FIXTURES, fixture) } + }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +describe('validate-test-map', () => { + it('flags an orphan flow: test-N-tagged YAML with no test-map entry', () => { + const { status, stdout } = runValidator('orphan-flow'); + expect(status).toBe(1); + expect(stdout).toContain('::error file=.maestro/tests/orphan.yaml::Orphan flow'); + }); + + it('flags a dangling glob: dependsOn glob matching zero files on disk', () => { + const { status, stdout } = runValidator('dangling-glob'); + expect(status).toBe(1); + expect(stdout).toContain('::error file=.sniffler/test-map.json::Dangling glob: "app/views/Nope/**"'); + }); + + it('flags an uncovered view as a warning and exits 0', () => { + const { status, stdout } = runValidator('uncovered-view'); + expect(status).toBe(0); + expect(stdout).toContain('::warning file=app/views/SomeView::Uncovered view'); + }); + + it('does not let a view dir prefix-match a longer covered dir (SomeView vs SomeViewExtra/**)', () => { + const { status, stdout } = runValidator('prefix-collision'); + expect(status).toBe(0); + expect(stdout).toContain('::warning file=app/views/SomeView::Uncovered view'); + expect(stdout).not.toContain('::warning file=app/views/SomeViewExtra::'); + }); + + it('flags a stale runAllWhenChanged path missing on disk', () => { + const { status, stdout } = runValidator('stale-global'); + expect(status).toBe(1); + expect(stdout).toContain('::error file=app/nonexistent-global.txt::Stale global'); + }); + + it('flags a decoupled gap: a saga file anchored in neither a dependsOn glob nor runAllWhenChanged', () => { + const { status, stdout } = runValidator('decoupled-gap'); + expect(status).toBe(1); + expect(stdout).toContain('::error file=app/sagas/foo.js::Decoupled gap'); + }); + + it('passes clean when every flow, glob, view, and global lines up', () => { + const { status, stdout } = runValidator('clean-pass'); + expect(status).toBe(0); + expect(stdout).not.toContain('::error'); + expect(stdout).not.toContain('::warning'); + expect(stdout).toContain('All checks passed'); + }); + + it('behaves identically with TESTMAP_ROOT unset (defaults to the real repo)', () => { + const result = spawnSync('node', [SCRIPT], { encoding: 'utf8', env: process.env }); + expect(result.status).toBe(0); + }); +}); diff --git a/.github/scripts/e2e-changed.sh b/.github/scripts/e2e-changed.sh new file mode 100755 index 00000000000..5ea1fccc1c8 --- /dev/null +++ b/.github/scripts/e2e-changed.sh @@ -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 +# 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 " >&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" diff --git a/.github/scripts/select-impacted-shards.sh b/.github/scripts/select-impacted-shards.sh new file mode 100755 index 00000000000..c1a46b1b72f --- /dev/null +++ b/.github/scripts/select-impacted-shards.sh @@ -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" diff --git a/.github/scripts/testlib/fixtures/maps/clean-pass/.maestro/tests/foo.yaml b/.github/scripts/testlib/fixtures/maps/clean-pass/.maestro/tests/foo.yaml new file mode 100644 index 00000000000..5594b52c46b --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/clean-pass/.maestro/tests/foo.yaml @@ -0,0 +1,2 @@ +tags: + - test-1 diff --git a/.github/scripts/testlib/fixtures/maps/clean-pass/.sniffler/config.json b/.github/scripts/testlib/fixtures/maps/clean-pass/.sniffler/config.json new file mode 100644 index 00000000000..b613255dfb6 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/clean-pass/.sniffler/config.json @@ -0,0 +1,5 @@ +{ + "tests": { + "runAllWhenChanged": ["app/stacks/bar.tsx"] + } +} diff --git a/.github/scripts/testlib/fixtures/maps/clean-pass/.sniffler/test-map.json b/.github/scripts/testlib/fixtures/maps/clean-pass/.sniffler/test-map.json new file mode 100644 index 00000000000..53a05f8dd0c --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/clean-pass/.sniffler/test-map.json @@ -0,0 +1,6 @@ +[ + { + "test": ".maestro/tests/foo.yaml", + "dependsOn": ["app/views/Foo/**", "app/sagas/foo.js"] + } +] diff --git a/.github/scripts/testlib/fixtures/maps/clean-pass/app/sagas/foo.js b/.github/scripts/testlib/fixtures/maps/clean-pass/app/sagas/foo.js new file mode 100644 index 00000000000..f053ebf7976 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/clean-pass/app/sagas/foo.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/.github/scripts/testlib/fixtures/maps/clean-pass/app/stacks/bar.tsx b/.github/scripts/testlib/fixtures/maps/clean-pass/app/stacks/bar.tsx new file mode 100644 index 00000000000..ff8b4c56321 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/clean-pass/app/stacks/bar.tsx @@ -0,0 +1 @@ +export default {}; diff --git a/.github/scripts/testlib/fixtures/maps/clean-pass/app/views/Foo/placeholder.txt b/.github/scripts/testlib/fixtures/maps/clean-pass/app/views/Foo/placeholder.txt new file mode 100644 index 00000000000..48cdce85287 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/clean-pass/app/views/Foo/placeholder.txt @@ -0,0 +1 @@ +placeholder diff --git a/.github/scripts/testlib/fixtures/maps/dangling-glob/.maestro/tests/.gitkeep b/.github/scripts/testlib/fixtures/maps/dangling-glob/.maestro/tests/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/testlib/fixtures/maps/dangling-glob/.sniffler/config.json b/.github/scripts/testlib/fixtures/maps/dangling-glob/.sniffler/config.json new file mode 100644 index 00000000000..6b70da1690a --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/dangling-glob/.sniffler/config.json @@ -0,0 +1,5 @@ +{ + "tests": { + "runAllWhenChanged": [] + } +} diff --git a/.github/scripts/testlib/fixtures/maps/dangling-glob/.sniffler/test-map.json b/.github/scripts/testlib/fixtures/maps/dangling-glob/.sniffler/test-map.json new file mode 100644 index 00000000000..7f7100477d4 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/dangling-glob/.sniffler/test-map.json @@ -0,0 +1,6 @@ +[ + { + "test": ".maestro/tests/flow.yaml", + "dependsOn": ["app/views/Nope/**"] + } +] diff --git a/.github/scripts/testlib/fixtures/maps/dangling-glob/app/views/.gitkeep b/.github/scripts/testlib/fixtures/maps/dangling-glob/app/views/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/testlib/fixtures/maps/decoupled-gap/.maestro/tests/.gitkeep b/.github/scripts/testlib/fixtures/maps/decoupled-gap/.maestro/tests/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/testlib/fixtures/maps/decoupled-gap/.sniffler/config.json b/.github/scripts/testlib/fixtures/maps/decoupled-gap/.sniffler/config.json new file mode 100644 index 00000000000..6b70da1690a --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/decoupled-gap/.sniffler/config.json @@ -0,0 +1,5 @@ +{ + "tests": { + "runAllWhenChanged": [] + } +} diff --git a/.github/scripts/testlib/fixtures/maps/decoupled-gap/.sniffler/test-map.json b/.github/scripts/testlib/fixtures/maps/decoupled-gap/.sniffler/test-map.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/decoupled-gap/.sniffler/test-map.json @@ -0,0 +1 @@ +[] diff --git a/.github/scripts/testlib/fixtures/maps/decoupled-gap/app/sagas/foo.js b/.github/scripts/testlib/fixtures/maps/decoupled-gap/app/sagas/foo.js new file mode 100644 index 00000000000..f053ebf7976 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/decoupled-gap/app/sagas/foo.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/.github/scripts/testlib/fixtures/maps/decoupled-gap/app/views/.gitkeep b/.github/scripts/testlib/fixtures/maps/decoupled-gap/app/views/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/testlib/fixtures/maps/orphan-flow/.maestro/tests/orphan.yaml b/.github/scripts/testlib/fixtures/maps/orphan-flow/.maestro/tests/orphan.yaml new file mode 100644 index 00000000000..5594b52c46b --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/orphan-flow/.maestro/tests/orphan.yaml @@ -0,0 +1,2 @@ +tags: + - test-1 diff --git a/.github/scripts/testlib/fixtures/maps/orphan-flow/.sniffler/config.json b/.github/scripts/testlib/fixtures/maps/orphan-flow/.sniffler/config.json new file mode 100644 index 00000000000..6b70da1690a --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/orphan-flow/.sniffler/config.json @@ -0,0 +1,5 @@ +{ + "tests": { + "runAllWhenChanged": [] + } +} diff --git a/.github/scripts/testlib/fixtures/maps/orphan-flow/.sniffler/test-map.json b/.github/scripts/testlib/fixtures/maps/orphan-flow/.sniffler/test-map.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/orphan-flow/.sniffler/test-map.json @@ -0,0 +1 @@ +[] diff --git a/.github/scripts/testlib/fixtures/maps/orphan-flow/app/views/.gitkeep b/.github/scripts/testlib/fixtures/maps/orphan-flow/app/views/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/testlib/fixtures/maps/prefix-collision/.maestro/tests/some.yaml b/.github/scripts/testlib/fixtures/maps/prefix-collision/.maestro/tests/some.yaml new file mode 100644 index 00000000000..5594b52c46b --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/prefix-collision/.maestro/tests/some.yaml @@ -0,0 +1,2 @@ +tags: + - test-1 diff --git a/.github/scripts/testlib/fixtures/maps/prefix-collision/.sniffler/config.json b/.github/scripts/testlib/fixtures/maps/prefix-collision/.sniffler/config.json new file mode 100644 index 00000000000..6b70da1690a --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/prefix-collision/.sniffler/config.json @@ -0,0 +1,5 @@ +{ + "tests": { + "runAllWhenChanged": [] + } +} diff --git a/.github/scripts/testlib/fixtures/maps/prefix-collision/.sniffler/test-map.json b/.github/scripts/testlib/fixtures/maps/prefix-collision/.sniffler/test-map.json new file mode 100644 index 00000000000..aa26fa9a70a --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/prefix-collision/.sniffler/test-map.json @@ -0,0 +1,6 @@ +[ + { + "test": ".maestro/tests/some.yaml", + "dependsOn": ["app/views/SomeViewExtra/**"] + } +] diff --git a/.github/scripts/testlib/fixtures/maps/prefix-collision/app/views/SomeView/placeholder.txt b/.github/scripts/testlib/fixtures/maps/prefix-collision/app/views/SomeView/placeholder.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/testlib/fixtures/maps/prefix-collision/app/views/SomeViewExtra/placeholder.txt b/.github/scripts/testlib/fixtures/maps/prefix-collision/app/views/SomeViewExtra/placeholder.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/testlib/fixtures/maps/stale-global/.maestro/tests/.gitkeep b/.github/scripts/testlib/fixtures/maps/stale-global/.maestro/tests/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/testlib/fixtures/maps/stale-global/.sniffler/config.json b/.github/scripts/testlib/fixtures/maps/stale-global/.sniffler/config.json new file mode 100644 index 00000000000..e481d8b9617 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/stale-global/.sniffler/config.json @@ -0,0 +1,5 @@ +{ + "tests": { + "runAllWhenChanged": ["app/nonexistent-global.txt"] + } +} diff --git a/.github/scripts/testlib/fixtures/maps/stale-global/.sniffler/test-map.json b/.github/scripts/testlib/fixtures/maps/stale-global/.sniffler/test-map.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/stale-global/.sniffler/test-map.json @@ -0,0 +1 @@ +[] diff --git a/.github/scripts/testlib/fixtures/maps/stale-global/app/views/.gitkeep b/.github/scripts/testlib/fixtures/maps/stale-global/app/views/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/testlib/fixtures/maps/uncovered-view/.maestro/tests/.gitkeep b/.github/scripts/testlib/fixtures/maps/uncovered-view/.maestro/tests/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/testlib/fixtures/maps/uncovered-view/.sniffler/config.json b/.github/scripts/testlib/fixtures/maps/uncovered-view/.sniffler/config.json new file mode 100644 index 00000000000..6b70da1690a --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/uncovered-view/.sniffler/config.json @@ -0,0 +1,5 @@ +{ + "tests": { + "runAllWhenChanged": [] + } +} diff --git a/.github/scripts/testlib/fixtures/maps/uncovered-view/.sniffler/test-map.json b/.github/scripts/testlib/fixtures/maps/uncovered-view/.sniffler/test-map.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/uncovered-view/.sniffler/test-map.json @@ -0,0 +1 @@ +[] diff --git a/.github/scripts/testlib/fixtures/maps/uncovered-view/app/views/SomeView/placeholder.txt b/.github/scripts/testlib/fixtures/maps/uncovered-view/app/views/SomeView/placeholder.txt new file mode 100644 index 00000000000..48cdce85287 --- /dev/null +++ b/.github/scripts/testlib/fixtures/maps/uncovered-view/app/views/SomeView/placeholder.txt @@ -0,0 +1 @@ +placeholder diff --git a/.github/scripts/testlib/runScript.js b/.github/scripts/testlib/runScript.js new file mode 100644 index 00000000000..af6adc35314 --- /dev/null +++ b/.github/scripts/testlib/runScript.js @@ -0,0 +1,54 @@ +// Shared harness for the selection-logic tests: run a shell script under test +// with stubbed executables on $PATH and a scratch $GITHUB_OUTPUT, then read back +// the emitted key=value pairs. Real jq/grep/sort/paste are used (they are correct +// tools, not the unit under test); only external deps like sniffler/git/maestro +// are stubbed via the `stubs` map. +'use strict'; + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// stubs: { binName: 'bash body' | '#!/usr/bin/env bash\n…' } +// env: extra environment variables for the script +// args: argv passed to the script +function runScript(scriptPath, { stubs = {}, env = {}, args = [] } = {}) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'selshard-')); + const binDir = path.join(tmp, 'bin'); + fs.mkdirSync(binDir); + + for (const [name, body] of Object.entries(stubs)) { + const file = path.join(binDir, name); + fs.writeFileSync(file, body.startsWith('#!') ? body : `#!/usr/bin/env bash\n${body}`); + fs.chmodSync(file, 0o755); + } + + const outFile = path.join(tmp, 'github_output'); + fs.writeFileSync(outFile, ''); + + let status = 0; + let stdout = ''; + let stderr = ''; + try { + stdout = execFileSync('bash', [scriptPath, ...args], { + encoding: 'utf8', + env: { ...process.env, PATH: `${binDir}:${process.env.PATH}`, GITHUB_OUTPUT: outFile, ...env } + }); + } catch (e) { + status = e.status ?? 1; + stdout = e.stdout || ''; + stderr = e.stderr || ''; + } + + const output = fs.readFileSync(outFile, 'utf8'); + const get = key => { + const m = output.match(new RegExp(`^${key}=(.*)$`, 'm')); + return m ? m[1] : undefined; + }; + + fs.rmSync(tmp, { recursive: true, force: true }); + return { status, stdout, stderr, output, shards: get('shards'), should_run: get('should_run') }; +} + +module.exports = { runScript }; diff --git a/.github/scripts/validate-test-map.js b/.github/scripts/validate-test-map.js new file mode 100644 index 00000000000..70a48cd3ef9 --- /dev/null +++ b/.github/scripts/validate-test-map.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node +// Validates the sniffler test-map against the repo so it cannot silently rot. +// Five 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 = process.env.TESTMAP_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}/`))); +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).`); diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 6a06b23b334..ed5f2a1c1d0 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -69,9 +69,44 @@ jobs: with: trigger: "pr" + # 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' }} + permissions: + contents: read + runs-on: ubuntu-latest + outputs: + 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: 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' }} + if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' && needs.e2e-shards.outputs.should_run == 'true' }} + needs: [e2e-shards] permissions: {} environment: approve_e2e_testing runs-on: ubuntu-latest @@ -80,32 +115,16 @@ jobs: e2e-build-android: name: E2E Build Android - if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} + if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' && needs.e2e-shards.outputs.should_run == 'true' }} permissions: contents: read uses: ./.github/workflows/e2e-build-android.yml - needs: [e2e-hold] + needs: [e2e-shards, e2e-hold] secrets: inherit - # Validates flow tags and emits the shard list both matrices fan out over. - 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 }} - steps: - - name: Checkout Repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Assert Maestro shard coverage - id: shards - run: bash .github/scripts/assert-maestro-shards.sh - 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' }} permissions: contents: read uses: ./.github/workflows/maestro-android.yml @@ -120,16 +139,16 @@ 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' }} permissions: contents: read 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' }} permissions: contents: read uses: ./.github/workflows/maestro-ios.yml @@ -141,3 +160,39 @@ 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] + permissions: {} + 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 diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 43b92ea764e..2372337e384 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -23,3 +23,6 @@ jobs: - name: Run Tests run: pnpm test --runInBand + + - name: Validate sniffler test-map freshness + run: node .github/scripts/validate-test-map.js diff --git a/.gitignore b/.gitignore index 0f7757ab412..7791d56a76b 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ AGENTS.md /docs/ .superset/ .jest-cache/ +.sniffler/cache.json diff --git a/.sniffler/config.json b/.sniffler/config.json new file mode 100644 index 00000000000..8871f83bbaa --- /dev/null +++ b/.sniffler/config.json @@ -0,0 +1,33 @@ +{ + "source": { + "roots": ["app"], + "ignore": ["**/*.test.*", "**/*.spec.*", "**/__tests__/**", "app/stacks/types.ts", "app/definitions/navigationTypes.ts"] + }, + "tests": { + "manifest": ".sniffler/test-map.json", + "runAllWhenChanged": [ + "pnpm-lock.yaml", + "package.json", + "app/index.tsx", + "app/lib/notifications/push.ts", + "app/lib/notifications/index.ts", + "app/lib/services/connect.ts", + "app/lib/methods/subscriptions/rooms.ts", + "app/lib/methods/getUsersPresence.ts", + "app/sagas/index.js", + "app/sagas/init.js", + "app/sagas/state.js", + "app/sagas/selectServer.ts", + "app/sagas/deepLinking.js", + "app/sagas/videoConf.ts", + "app/ee/omnichannel/sagas/inquiry.js", + "app/AppContainer.tsx", + "app/stacks/InsideStack.tsx", + "app/stacks/OutsideStack.tsx", + "app/stacks/ShareExtensionStack.tsx", + "app/stacks/MasterDetailStack/index.tsx", + "app/lib/store/index.ts", + "app/reducers/index.js" + ] + } +} diff --git a/.sniffler/test-map.json b/.sniffler/test-map.json new file mode 100644 index 00000000000..1d47316cc76 --- /dev/null +++ b/.sniffler/test-map.json @@ -0,0 +1,336 @@ +[ + { + "test": ".maestro/tests/accessibilityAndAppearance/ToastsAndDialogs.yml", + "dependsOn": ["app/views/StatusView/**"] + }, + { + "test": ".maestro/tests/assorted/accessibility-and-appearance.yaml", + "dependsOn": ["app/views/AccessibilityAndAppearanceView/**"] + }, + { + "test": ".maestro/tests/assorted/broadcast.yaml", + "dependsOn": [ + "app/views/NewMessageView/**", + "app/views/SelectedUsersView/**", + "app/views/CreateChannelView/**", + "app/views/RoomInfoView/**", + "app/sagas/createChannel.js" + ] + }, + { + "test": ".maestro/tests/assorted/change-avatar.yaml", + "dependsOn": ["app/views/ChangeAvatarView/**", "app/views/ProfileView/**"] + }, + { + "test": ".maestro/tests/assorted/changeserver.yaml", + "dependsOn": [ + "app/views/NewServerView/**", + "app/views/WorkspaceView/**", + "app/views/RegisterView/**", + "app/views/RoomsListView/**", + "app/sagas/login.js", + "app/sagas/rooms.js" + ] + }, + { + "test": ".maestro/tests/assorted/deeplink.yaml", + "dependsOn": [ + "app/views/NewServerView/**", + "app/views/SelectServerView.tsx", + "app/views/WorkspaceView/**", + "app/views/ShareListView/**", + "app/views/ShareView/**", + "app/views/RoomsListView/**", + "app/views/ThreadMessagesView/**", + "app/sagas/login.js", + "app/sagas/rooms.js", + "app/sagas/inviteLinks.js" + ] + }, + { + "test": ".maestro/tests/assorted/delete-server.yaml", + "dependsOn": [ + "app/views/NewServerView/**", + "app/views/WorkspaceView/**", + "app/views/RegisterView/**", + "app/views/RoomsListView/**", + "app/sagas/login.js", + "app/sagas/rooms.js" + ] + }, + { + "test": ".maestro/tests/assorted/display-perf.yaml", + "dependsOn": ["app/views/DisplayPrefsView.tsx", "app/views/RoomsListView/**", "app/sagas/rooms.js"] + }, + { + "test": ".maestro/tests/assorted/i18n.yaml", + "dependsOn": ["app/views/LanguageView/**"] + }, + { + "test": ".maestro/tests/assorted/in-app-notification.yaml", + "dependsOn": ["app/views/RoomsListView/**", "app/containers/InAppNotification/**", "app/sagas/rooms.js"] + }, + { + "test": ".maestro/tests/assorted/join-from-directory.yaml", + "dependsOn": ["app/views/DirectoryView/**"] + }, + { + "test": ".maestro/tests/assorted/join-protected-room.yaml", + "dependsOn": ["app/views/RoomView/**", "app/sagas/room.js", "app/sagas/inviteLinks.js"] + }, + { + "test": ".maestro/tests/assorted/join-public-room.yaml", + "dependsOn": ["app/views/RoomActionsView/**", "app/views/RoomView/**", "app/sagas/room.js", "app/sagas/inviteLinks.js"] + }, + { + "test": ".maestro/tests/assorted/profile.yaml", + "dependsOn": ["app/views/ProfileView/**", "app/views/ChangePasswordView/**"] + }, + { + "test": ".maestro/tests/assorted/setting.yaml", + "dependsOn": [ + "app/views/SettingsView/**", + "app/views/LegalView.tsx", + "app/views/MediaAutoDownloadView/**", + "app/views/DefaultBrowserView/**", + "app/views/GetHelpView.tsx" + ] + }, + { + "test": ".maestro/tests/assorted/status.yaml", + "dependsOn": ["app/views/StatusView/**", "app/views/SidebarView/**"] + }, + { + "test": ".maestro/tests/assorted/user-preferences.yaml", + "dependsOn": [ + "app/views/UserPreferencesView/**", + "app/views/UserNotificationPreferencesView/**", + "app/views/PushTroubleshootView/**", + "app/sagas/troubleshootingNotification.ts" + ] + }, + { + "test": ".maestro/tests/e2ee/e2e-encryption.yaml", + "dependsOn": [ + "app/views/E2EEncryptionSecurityView/**", + "app/views/E2EEnterYourPasswordView.tsx", + "app/views/SecurityPrivacyView.tsx", + "app/views/RoomView/**", + "app/views/CreateChannelView/**", + "app/views/NewMessageView/**", + "app/views/SelectedUsersView/**", + "app/views/E2EEToggleRoomView/**", + "app/sagas/room.js", + "app/sagas/encryption.js", + "app/sagas/createChannel.js" + ] + }, + { + "test": ".maestro/tests/keyboardNavigation/keyboard-navigation-components.yaml", + "dependsOn": ["app/views/AccessibilityAndAppearanceView/**", "app/views/ThemeView.tsx"] + }, + { + "test": ".maestro/tests/keyboardNavigation/keyboard-navigation-onboarding.yaml", + "dependsOn": ["app/views/NewServerView/**", "app/views/LoginView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/keyboardNavigation/keyboard-navigation-room.yaml", + "dependsOn": ["app/views/RoomView/**", "app/sagas/room.js"] + }, + { + "test": ".maestro/tests/onboarding/change-password.yaml", + "dependsOn": ["app/views/RoomsListView/**", "app/views/ChangePasswordView/**", "app/sagas/login.js", "app/sagas/rooms.js"] + }, + { + "test": ".maestro/tests/onboarding/forgot-password.yaml", + "dependsOn": ["app/views/ForgotPasswordView.tsx", "app/views/LoginView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/onboarding/legal.yaml", + "dependsOn": ["app/views/LegalView.tsx", "app/views/LoginView/**", "app/views/RegisterView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/onboarding/login/invalid-credentials.yaml", + "dependsOn": ["app/views/LoginView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/onboarding/login/login.yaml", + "dependsOn": ["app/views/LoginView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/onboarding/register/create-account.yaml", + "dependsOn": ["app/views/RegisterView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/onboarding/register/email-used.yaml", + "dependsOn": ["app/views/RegisterView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/onboarding/register/username-used.yaml", + "dependsOn": ["app/views/RegisterView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/onboarding/roomslist.yaml", + "dependsOn": ["app/views/RoomsListView/**", "app/sagas/login.js", "app/sagas/rooms.js"] + }, + { + "test": ".maestro/tests/onboarding/server-history.yaml", + "dependsOn": ["app/views/NewServerView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/onboarding/servers-history-small-screen.yaml", + "dependsOn": ["app/views/NewServerView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/onboarding/workspace/invalid-workspace.yaml", + "dependsOn": ["app/views/NewServerView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/onboarding/workspace/valid-workspace.yaml", + "dependsOn": ["app/views/NewServerView/**", "app/sagas/login.js"] + }, + { + "test": ".maestro/tests/room/create-dm-group.yaml", + "dependsOn": [ + "app/views/NewMessageView/**", + "app/views/SelectedUsersView/**", + "app/sagas/messages.js", + "app/sagas/createChannel.js" + ] + }, + { + "test": ".maestro/tests/room/create-room.yaml", + "dependsOn": [ + "app/views/NewMessageView/**", + "app/views/SelectedUsersView/**", + "app/views/CreateChannelView/**", + "app/sagas/messages.js", + "app/sagas/createChannel.js" + ] + }, + { + "test": ".maestro/tests/room/discussion.yaml", + "dependsOn": [ + "app/views/NewMessageView/**", + "app/views/SelectedUsersView/**", + "app/views/CreateDiscussionView/**", + "app/views/RoomActionsView/**", + "app/views/RoomInfoView/**", + "app/views/DiscussionsView/**", + "app/sagas/room.js", + "app/sagas/createDiscussion.js" + ] + }, + { + "test": ".maestro/tests/room/ignoreuser.yaml", + "dependsOn": ["app/views/RoomInfoView/**", "app/views/ReportUserView/**", "app/views/RoomActionsView/**", "app/sagas/room.js"] + }, + { + "test": ".maestro/tests/room/jump-to-message.yaml", + "dependsOn": [ + "app/views/RoomView/**", + "app/views/SearchMessagesView/**", + "app/views/ThreadMessagesView/**", + "app/sagas/room.js" + ] + }, + { + "test": ".maestro/tests/room/mark-as-unread.yaml", + "dependsOn": ["app/views/RoomsListView/**", "app/containers/MessageActions/**", "app/sagas/rooms.js"] + }, + { + "test": ".maestro/tests/room/message-markdown-click.yaml", + "dependsOn": ["app/views/RoomView/**", "app/containers/markdown/**", "app/sagas/room.js"] + }, + { + "test": ".maestro/tests/room/reaction-picker-small-screen.yaml", + "dependsOn": ["app/containers/EmojiPicker/**", "app/containers/ReactionsList/**"] + }, + { + "test": ".maestro/tests/room/room-actions.yaml", + "dependsOn": [ + "app/views/RoomActionsView/**", + "app/views/RoomMembersView/**", + "app/views/MessagesView/**", + "app/views/NotificationPreferencesView/**", + "app/views/SelectedUsersView/**", + "app/sagas/room.js" + ] + }, + { + "test": ".maestro/tests/room/room-info.yaml", + "dependsOn": ["app/views/RoomInfoView/**", "app/views/RoomInfoEditView/**"] + }, + { + "test": ".maestro/tests/room/room-last-message-thread-50-plus.yaml", + "dependsOn": ["app/views/RoomView/**", "app/sagas/room.js"] + }, + { + "test": ".maestro/tests/room/room.yaml", + "dependsOn": ["app/views/RoomView/**", "app/views/RoomInfoView/**", "app/sagas/room.js", "app/sagas/messages.js"] + }, + { + "test": ".maestro/tests/room/search-member.yaml", + "dependsOn": ["app/views/RoomMembersView/**"] + }, + { + "test": ".maestro/tests/room/search.yaml", + "dependsOn": ["app/views/RoomsListView/**", "app/sagas/rooms.js"] + }, + { + "test": ".maestro/tests/room/servers-list-small-screen.yaml", + "dependsOn": ["app/views/NewServerView/**", "app/views/RoomsListView/**", "app/sagas/login.js", "app/sagas/rooms.js"] + }, + { + "test": ".maestro/tests/room/share-message.yaml", + "dependsOn": ["app/views/ForwardMessageView/**", "app/containers/MessageActions/**"] + }, + { + "test": ".maestro/tests/room/threads.yaml", + "dependsOn": ["app/views/RoomView/**", "app/views/ThreadMessagesView/**", "app/sagas/room.js"] + }, + { + "test": ".maestro/tests/room/unread-badge.yaml", + "dependsOn": ["app/views/RoomsListView/**", "app/sagas/rooms.js"] + }, + { + "test": ".maestro/tests/teams/convert-team.yaml", + "dependsOn": [ + "app/views/RoomActionsView/**", + "app/views/SelectListView.tsx", + "app/views/CreateChannelView/**", + "app/views/NewMessageView/**", + "app/views/SelectedUsersView/**", + "app/sagas/room.js", + "app/sagas/createChannel.js" + ] + }, + { + "test": ".maestro/tests/teams/create-team.yaml", + "dependsOn": [ + "app/views/NewMessageView/**", + "app/views/SelectedUsersView/**", + "app/views/CreateChannelView/**", + "app/views/RoomInfoView/**", + "app/views/RoomInfoEditView/**", + "app/views/RoomActionsView/**", + "app/sagas/room.js", + "app/sagas/createChannel.js" + ] + }, + { + "test": ".maestro/tests/teams/team.yaml", + "dependsOn": [ + "app/views/RoomActionsView/**", + "app/views/TeamChannelsView.tsx", + "app/views/AddChannelTeamView.tsx", + "app/views/AddExistingChannelView/**", + "app/views/RoomMembersView/**", + "app/views/SelectedUsersView/**", + "app/views/SelectListView.tsx", + "app/views/CreateChannelView/**", + "app/sagas/room.js", + "app/sagas/createChannel.js" + ] + } +] diff --git a/package.json b/package.json index df92f03d396..3f2689a1c47 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "build-icon-set": "node scripts/build-icon-set.js", "organize-translations": "node scripts/organize-translations.js", "e2e:start": "RUNNING_E2E_TESTS=true react-native start", + "e2e:changed": "bash .github/scripts/e2e-changed.sh", "storybook:start": "USE_STORYBOOK=true react-native start --reset-cache", "storybook-generate": "sb-rn-get-stories", "bugsnag:upload-android": "bugsnag-cli upload react-native-android" @@ -194,6 +195,7 @@ "babel-plugin-transform-remove-console": "^6.9.4", "babel-preset-expo": "~54.0.9", "eslint-plugin-react-native": "~5.0.0", + "fast-glob": "3.3.3", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-cli": "^29.7.0", @@ -203,6 +205,7 @@ "patch-package": "~8.0.1", "react-dom": "19.1.0", "react-native-dotenv": "3.4.8", + "sniffler": "0.4.0", "storybook": "~9.0.9", "typescript": "7.0.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a78158237d..babb4d1d30b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -472,6 +472,9 @@ importers: eslint-plugin-react-native: specifier: ~5.0.0 version: 5.0.0(eslint@8.57.1) + fast-glob: + specifier: 3.3.3 + version: 3.3.3 identity-obj-proxy: specifier: ^3.0.0 version: 3.0.0 @@ -499,6 +502,9 @@ importers: react-native-dotenv: specifier: 3.4.8 version: 3.4.8(@babel/runtime@7.25.9) + sniffler: + specifier: 0.4.0 + version: 0.4.0 storybook: specifier: ~9.0.9 version: 9.0.18(@testing-library/dom@10.4.1)(prettier@3.7.4) @@ -3650,6 +3656,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -6195,6 +6205,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -6982,6 +6996,10 @@ packages: resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} engines: {node: '>=8.0.0'} + sniffler@0.4.0: + resolution: {integrity: sha512-IpD773keVcO308nTJtSOodKNXJWWqUCWtR/fGbF6UeRn+7mHtlsgvw3P6r8QRabkP4eX/AkjlX6FOI0Q6YC8sA==} + hasBin: true + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -11611,6 +11629,8 @@ snapshots: bytes@3.1.2: {} + cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -14645,6 +14665,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.5: {} + pirates@4.0.7: {} pkg-dir@4.2.0: @@ -15548,6 +15570,12 @@ snapshots: slugify@1.6.6: {} + sniffler@0.4.0: + dependencies: + cac: 7.0.0 + fast-glob: 3.3.3 + picomatch: 4.0.5 + source-map-js@1.2.1: {} source-map-support@0.5.13: