Skip to content

Commit cd721b6

Browse files
Merge branch 'develop' into fix.voip-buttons-regressions
2 parents c5f744b + 6cead90 commit cd721b6

243 files changed

Lines changed: 1889 additions & 673 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/README.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Maps which event triggers which workflow and how they call each other.
66

77
| Workflow | Trigger | What runs |
88
|---|---|---|
9-
| [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) |
9+
| [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) |
1010
| [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 |
1111
| [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 |
1212
| [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
1818
classDef entry fill:#d4e6f1,stroke:#2980b9
1919
classDef reusable fill:#d5f5e3,stroke:#27ae60
2020
classDef action fill:#fef9e7,stroke:#f39c12
21+
classDef gate fill:#f5e6f8,stroke:#8e44ad
2122
2223
PR([build-pr.yml]):::entry
2324
DEV([build-develop.yml]):::entry
@@ -44,13 +45,22 @@ flowchart TD
4445
PREAND[preinstall-android-sdk]:::action
4546
E2EACC[e2e-account]:::action
4647
48+
ESHARD[e2e-shards preflight]:::gate
49+
ERESULT[e2e-result required check]:::gate
50+
4751
PR --> ESLINT
4852
PR --> BUILDAND
4953
PR --> BUILDIOS
50-
PR --> E2EAND
51-
PR --> E2EIOS
52-
PR --> MASTAND
53-
PR --> MASIOS
54+
PR --> ESHARD
55+
56+
ESHARD -->|should_run| E2EAND
57+
ESHARD -->|should_run| E2EIOS
58+
ESHARD -->|should_run| MASTAND
59+
ESHARD -->|should_run| MASIOS
60+
61+
MASTAND --> ERESULT
62+
MASIOS --> ERESULT
63+
ESHARD --> ERESULT
5464
5565
DEV --> ESLINT
5666
DEV --> CHANGELOG
@@ -90,4 +100,4 @@ flowchart TD
90100
| `android_build` | [build-android.yml](workflows/build-android.yml)`build-hold` | Called with `trigger == pr` (i.e. from `build-pr.yml`) |
91101
| `upload_android` | [build-android.yml](workflows/build-android.yml)`upload-hold` | Called with `trigger == pr`, after the Android build completes |
92102
| `ios_build` | [build-ios.yml](workflows/build-ios.yml)`build-hold` | Called with `trigger == pr` (i.e. from `build-pr.yml`) |
93-
| `approve_e2e_testing` | [build-pr.yml](workflows/build-pr.yml)`e2e-hold` | Every `pull_request` run |
103+
| `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. |
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Tests for e2e-changed.sh: the local `pnpm e2e:changed <android|ios>` runner.
2+
// Unlike select-impacted-shards.sh, this script never writes to $GITHUB_OUTPUT —
3+
// it is a pure CLI that gathers a CHANGED file set from git and execs straight
4+
// into `pnpm exec sniffler run --changed ... -- maestro test ...`. Maestro is
5+
// stubbed throughout: these tests prove the arg validation, the maestro guard,
6+
// the merge-base fallback, and the CHANGED-gathering/invocation shape — actual
7+
// Maestro flow execution needs a booted device and is out of scope here.
8+
'use strict';
9+
10+
const path = require('path');
11+
const { runScript } = require('../testlib/runScript');
12+
13+
const SCRIPT = path.join(__dirname, '..', 'e2e-changed.sh');
14+
15+
// No-op maestro: its presence alone satisfies the `command -v maestro` guard.
16+
const MAESTRO_NOOP = 'exit 0';
17+
18+
// Records its own invocation to stdout so the trailing `maestro test ...`
19+
// tail (once sniffler forwards to it) shows up in the captured process output.
20+
const MAESTRO_RECORDING = `echo "MAESTRO_ARGS:$*"\nexit 0`;
21+
22+
// git stub: branches on the subcommand the script actually calls
23+
// (merge-base / diff / ls-files). `mergeBase: null` simulates the
24+
// unresolved-merge-base failure the script falls back on.
25+
function gitStub({ mergeBase = 'deadbeef', diffFiles = [], untrackedFiles = [] } = {}) {
26+
const diffBody = diffFiles.map(f => `echo '${f}'`).join('\n\t\t') || ':';
27+
const untrackedBody = untrackedFiles.map(f => `echo '${f}'`).join('\n\t\t') || ':';
28+
return `
29+
case "$1" in
30+
merge-base)
31+
${mergeBase === null ? 'exit 1' : `echo '${mergeBase}'`}
32+
;;
33+
diff)
34+
${diffBody}
35+
;;
36+
ls-files)
37+
${untrackedBody}
38+
;;
39+
*)
40+
exit 0
41+
;;
42+
esac
43+
`;
44+
}
45+
46+
// pnpm stub: only intercepts `pnpm exec sniffler ...` (the script's real
47+
// invocation shape). Echoes its own args (proves the --changed set / tail
48+
// command sniffler received), then either reports a confident zero or execs
49+
// the trailing `maestro test ...` command so it shows up in stdout too.
50+
function pnpmStub({ zero = false } = {}) {
51+
return `
52+
if [ "$1 $2" = "exec sniffler" ]; then
53+
echo "PNPM_ARGS:$*"
54+
if [ "${zero}" = "true" ]; then
55+
echo "sniffler: no impacted flows (confident zero)"
56+
exit 0
57+
fi
58+
while [ $# -gt 0 ] && [ "$1" != "--" ]; do
59+
shift
60+
done
61+
shift
62+
exec "$@"
63+
fi
64+
exit 0
65+
`;
66+
}
67+
68+
describe('e2e-changed.sh', () => {
69+
describe('platform arg validation', () => {
70+
test('missing platform arg prints usage and exits 2', () => {
71+
const result = runScript(SCRIPT, { args: [] });
72+
expect(result.status).toBe(2);
73+
expect(result.stderr).toContain('usage: pnpm e2e:changed <android|ios>');
74+
});
75+
76+
test('invalid platform arg prints usage and exits 2', () => {
77+
const result = runScript(SCRIPT, { args: ['windows'] });
78+
expect(result.status).toBe(2);
79+
expect(result.stderr).toContain('usage: pnpm e2e:changed <android|ios>');
80+
});
81+
});
82+
83+
describe('missing-maestro guard', () => {
84+
test('valid platform but no maestro on PATH fires the guard', () => {
85+
// Override PATH to a maestro-free set of dirs (no binDir stub either —
86+
// the guard fires before any git/sniffler call, so none is needed).
87+
const result = runScript(SCRIPT, {
88+
args: ['android'],
89+
env: { PATH: '/usr/bin:/bin:/opt/homebrew/bin' }
90+
});
91+
expect(result.status).toBe(2);
92+
expect(result.stderr).toContain('ERROR: maestro not found in PATH');
93+
});
94+
});
95+
96+
describe('merge-base failure fallback', () => {
97+
test('unresolved merge-base against the default base exits 1 with an actionable error', () => {
98+
const result = runScript(SCRIPT, {
99+
args: ['android'],
100+
stubs: { maestro: MAESTRO_NOOP, git: gitStub({ mergeBase: null }) }
101+
});
102+
expect(result.status).toBe(1);
103+
expect(result.stderr).toContain("cannot resolve merge-base against 'origin/develop'");
104+
expect(result.stderr).toContain('set E2E_BASE');
105+
});
106+
107+
test('E2E_BASE override is reflected in the failure message', () => {
108+
const result = runScript(SCRIPT, {
109+
args: ['ios'],
110+
env: { E2E_BASE: 'origin/custom-base' },
111+
stubs: { maestro: MAESTRO_NOOP, git: gitStub({ mergeBase: null }) }
112+
});
113+
expect(result.status).toBe(1);
114+
expect(result.stderr).toContain("cannot resolve merge-base against 'origin/custom-base'");
115+
});
116+
});
117+
118+
describe('CHANGED set gathering', () => {
119+
test('android: committed + uncommitted-tracked + untracked files are deduped, sorted, and forwarded to sniffler', () => {
120+
const result = runScript(SCRIPT, {
121+
args: ['android'],
122+
stubs: {
123+
maestro: MAESTRO_RECORDING,
124+
git: gitStub({
125+
diffFiles: ['app/views/RoomView.tsx', 'app/actions/room.ts', 'app/actions/room.ts'],
126+
untrackedFiles: ['app/actions/room.ts', 'app/views/NewFeature.tsx']
127+
}),
128+
pnpm: pnpmStub()
129+
}
130+
});
131+
expect(result.status).toBe(0);
132+
expect(result.stdout).toContain(
133+
'PNPM_ARGS:exec sniffler run --changed ' +
134+
'app/actions/room.ts app/views/NewFeature.tsx app/views/RoomView.tsx -- ' +
135+
'maestro test -e APP_ID=chat.rocket.android --exclude-tags=util --exclude-tags=ios-only'
136+
);
137+
});
138+
139+
test('ios: platform selects the ios APP_ID and excludes android-only flows', () => {
140+
const result = runScript(SCRIPT, {
141+
args: ['ios'],
142+
stubs: {
143+
maestro: MAESTRO_RECORDING,
144+
git: gitStub({ diffFiles: ['app/views/RoomView.tsx'] }),
145+
pnpm: pnpmStub()
146+
}
147+
});
148+
expect(result.status).toBe(0);
149+
expect(result.stdout).toContain(
150+
'PNPM_ARGS:exec sniffler run --changed app/views/RoomView.tsx -- ' +
151+
'maestro test -e APP_ID=chat.rocket.ios --exclude-tags=util --exclude-tags=android-only'
152+
);
153+
});
154+
155+
test('sniffler forwards to maestro, which is invoked with the built command tail', () => {
156+
const result = runScript(SCRIPT, {
157+
args: ['android'],
158+
stubs: {
159+
maestro: MAESTRO_RECORDING,
160+
git: gitStub({ diffFiles: ['app/views/RoomView.tsx'] }),
161+
pnpm: pnpmStub()
162+
}
163+
});
164+
expect(result.status).toBe(0);
165+
expect(result.stdout).toContain(
166+
'MAESTRO_ARGS:test -e APP_ID=chat.rocket.android --exclude-tags=util --exclude-tags=ios-only'
167+
);
168+
});
169+
});
170+
171+
describe('confident zero', () => {
172+
test('no changes at all vs base exits 0 cleanly without calling sniffler', () => {
173+
const result = runScript(SCRIPT, {
174+
args: ['android'],
175+
stubs: { maestro: MAESTRO_NOOP, git: gitStub() }
176+
// no pnpm stub: if the script reached the exec line, the real (unstubbed)
177+
// pnpm would run and this test would fail or hang instead of passing.
178+
});
179+
expect(result.status).toBe(0);
180+
expect(result.stdout).toContain('No changes vs origin/develop — nothing to run.');
181+
expect(result.stdout).not.toContain('PNPM_ARGS');
182+
});
183+
184+
test('changes exist but sniffler reports no impacted flow: clean exit 0, maestro never runs', () => {
185+
const result = runScript(SCRIPT, {
186+
args: ['android'],
187+
stubs: {
188+
maestro: MAESTRO_RECORDING,
189+
git: gitStub({ diffFiles: ['app/views/RoomView.tsx'] }),
190+
pnpm: pnpmStub({ zero: true })
191+
}
192+
});
193+
expect(result.status).toBe(0);
194+
expect(result.stdout).toContain('sniffler: no impacted flows (confident zero)');
195+
expect(result.stdout).not.toContain('MAESTRO_ARGS');
196+
});
197+
});
198+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Proves invariant (1) "no under-selection" against the REAL .sniffler/test-map.json
2+
// and .sniffler/config.json: for each map-assertable diff in scenario-catalog.json
3+
// (rows C1..C8), replicates sniffler's documented selection semantics in JS —
4+
// root/ignore filtering, dependsOn glob matching, then flow -> test-N extraction
5+
// the same way select-impacted-shards.sh does — and asserts the exact shard set.
6+
// sniffler's own recommendation algorithm is trusted/out-of-scope; this only
7+
// tests OUR map's globs and OUR config against real flow files on disk.
8+
'use strict';
9+
10+
const fs = require('fs');
11+
const path = require('path');
12+
const micromatch = require('micromatch');
13+
14+
const REPO_ROOT = path.resolve(__dirname, '../../..');
15+
16+
const config = require('../../../.sniffler/config.json');
17+
const testMap = require('../../../.sniffler/test-map.json');
18+
const catalog = require('./fixtures/scenario-catalog.json');
19+
20+
// Mirrors the grep pattern in select-impacted-shards.sh: `^\s*-\s*['"]?test-N`.
21+
const TEST_N_PATTERN = /^[ \t]*-[ \t]*['"]?test-(\d+)/gm;
22+
23+
function extractShardsFromFlow(flowPath) {
24+
const contents = fs.readFileSync(path.join(REPO_ROOT, flowPath), 'utf8');
25+
const shards = new Set();
26+
let match;
27+
while ((match = TEST_N_PATTERN.exec(contents)) !== null) {
28+
shards.add(Number(match[1]));
29+
}
30+
return shards;
31+
}
32+
33+
function isUnderSourceRoots(diffPath) {
34+
return config.source.roots.some(root => micromatch.isMatch(diffPath, `${root}/**`));
35+
}
36+
37+
function isIgnored(diffPath) {
38+
return config.source.ignore.some(glob => micromatch.isMatch(diffPath, glob));
39+
}
40+
41+
function matchedFlowsFor(diffPath) {
42+
return testMap.filter(entry => entry.dependsOn.some(glob => micromatch.isMatch(diffPath, glob))).map(entry => entry.test);
43+
}
44+
45+
// Replicates select-impacted-shards.sh's documented happy path against the real
46+
// map: runAllWhenChanged -> full; else filter by source roots/ignore, match
47+
// dependsOn globs, then union the matched flows' `- test-N` tags.
48+
function computeSelection(diffPaths) {
49+
const fullShards = [...catalog.fullShards].sort((a, b) => a - b);
50+
51+
if (diffPaths.some(p => config.tests.runAllWhenChanged.includes(p))) {
52+
return { shards: fullShards, shouldRun: true };
53+
}
54+
55+
const survivors = diffPaths.filter(p => isUnderSourceRoots(p) && !isIgnored(p));
56+
if (survivors.length === 0) {
57+
return { shards: [], shouldRun: false };
58+
}
59+
60+
const matchedFlows = new Set(survivors.flatMap(matchedFlowsFor));
61+
if (matchedFlows.size === 0) {
62+
return { shards: [], shouldRun: false };
63+
}
64+
65+
const shardSet = new Set();
66+
for (const flow of matchedFlows) {
67+
for (const shard of extractShardsFromFlow(flow)) shardSet.add(shard);
68+
}
69+
70+
// Defensive: an impacted flow with no derivable tag must fall to full rather
71+
// than under-select (mirrors select-impacted-shards.sh's own fallback).
72+
if (shardSet.size === 0) {
73+
return { shards: fullShards, shouldRun: true };
74+
}
75+
76+
const shards = [...shardSet].sort((a, b) => a - b);
77+
return { shards, shouldRun: true };
78+
}
79+
80+
describe('sniffler shard selection against the real .sniffler map', () => {
81+
const scenarios = catalog.scenarios.filter(s => s.assertableIn.includes('map'));
82+
83+
test('catalog has map-assertable scenarios to run', () => {
84+
expect(scenarios.length).toBeGreaterThan(0);
85+
});
86+
87+
test.each(scenarios)('$id: $name', scenario => {
88+
const { shards, shouldRun } = computeSelection(scenario.input.diff);
89+
expect(shards).toEqual(scenario.expectedShards);
90+
expect(shouldRun).toBe(scenario.expectedShouldRun);
91+
});
92+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
appId: ${APP_ID}
2+
name: No Tag Test
3+
tags:
4+
- android-only

0 commit comments

Comments
 (0)