Skip to content

Commit 47a736a

Browse files
authored
ci(e2e): wait for stable doc screenshots, strip exclude-from-example-doc lines (#4429)
Doc screenshots are now taken once two consecutive screenshots match (status bar left out), with a 10 s cap. UserLocationUpdates is excluded from Percy as it shows a wall-clock timestamp. example-docs.ts drops lines ending with // exclude-from-example-doc and blocks between exclude-from-example-doc:start/end.
1 parent 7dae91b commit 47a736a

7 files changed

Lines changed: 113 additions & 10 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ The project uses Mapbox Maps SDK v11 and React Native's New Architecture (Fabric
128128
- Component docs are auto-generated from JSDoc comments
129129
- Don't edit `.md` files in `docs/` directly - edit source files and run `yarn generate`
130130
- Examples in `example/src/examples/` are used for documentation
131+
- Example doc pages show the source up to `/* end-example-doc */` (keep metadata after it), without lines ending in `// exclude-from-example-doc` or between `/* exclude-from-example-doc:start */` and `/* exclude-from-example-doc:end */`
131132

132133
### Fixing Bugs
133134

‎CONTRIBUTING.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ The full documentation generation process looks like this:
123123
bun scripts/example-docs.ts
124124
```
125125
126+
The doc page shows the example source up to `/* end-example-doc */`, so metadata and test-only code go after it. Lines ending with `// exclude-from-example-doc`, and lines between `/* exclude-from-example-doc:start */` and `/* exclude-from-example-doc:end */`, are left out too.
127+
126128
Runs when `main` is changed by `update-docs` gh action workflow
127129
128130
6.) publish doc site

‎docs/examples.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,8 @@
273273
"UserLocation#onUpdate"
274274
],
275275
"docs": "\nRetrieves and shows location updates from UserLocation component via the `onUpdate` callback\n",
276-
"waitForMapIdle": false
276+
"waitForMapIdle": false,
277+
"excludeFromPercy": true
277278
},
278279
"fullPath": "example/src/examples/UserLocation/UserLocationUpdates.tsx",
279280
"relPath": "UserLocation/UserLocationUpdates.tsx",

‎example/e2e/changedPixels.js‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @file node changedPixels.js <a.png> <b.png> <top> <screenWidth>
3+
* Prints the number of pixels that differ between two screenshots, leaving out their top <top> points, <screenWidth> being the screen width in points.
4+
*/
5+
6+
const fs = require('fs');
7+
const { PNG } = require('pngjs');
8+
9+
const [aPath, bPath, top, screenWidth] = process.argv.slice(2);
10+
11+
const a = PNG.sync.read(fs.readFileSync(aPath));
12+
const b = PNG.sync.read(fs.readFileSync(bPath));
13+
if (a.width !== b.width || a.height !== b.height) {
14+
process.stdout.write(String(a.width * a.height));
15+
process.exit(0);
16+
}
17+
const topPx = Math.round((Number(top) * a.width) / Number(screenWidth));
18+
const a32 = new Uint32Array(
19+
a.data.buffer,
20+
a.data.byteOffset,
21+
a.data.length / 4,
22+
);
23+
const b32 = new Uint32Array(
24+
b.data.buffer,
25+
b.data.byteOffset,
26+
b.data.length / 4,
27+
);
28+
let changed = 0;
29+
for (let i = topPx * a.width; i < a32.length; i++) {
30+
if (a32[i] !== b32[i]) {
31+
changed++;
32+
}
33+
}
34+
process.stdout.write(String(changed));

‎example/e2e/docScreenshots.e2e.js‎

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
const { mkdir, copyFile } = require('fs/promises');
88
const fs = require('fs');
99
const path = require('path');
10-
const { execSync, execFileSync } = require('child_process');
10+
const { promisify } = require('util');
11+
const { execSync, execFileSync, execFile } = require('child_process');
1112

1213
const { device } = require('detox');
1314

@@ -83,8 +84,7 @@ async function setSampleLocation() {
8384
execSync(`xcrun simctl location ${device.id} set ${latitude},${longitude}`);
8485
}
8586

86-
async function saveImage(basePath, suffix = null) {
87-
const imagePath = await device.takeScreenshot(`example-${suffix || ''}`);
87+
async function saveImage(imagePath, basePath, suffix = null) {
8888
const destDir = path.dirname(basePath);
8989
await mkdir(destDir, { recursive: true });
9090
var fullDestPath = basePath;
@@ -124,6 +124,44 @@ async function savePercyImage(imagePath, groupName, name) {
124124
]);
125125
}
126126

127+
const STABLE_INTERVAL_MS = 500;
128+
const STABLE_TIMEOUT_MS = 10000;
129+
const STABLE_MAX_CHANGED_PIXELS = 10;
130+
131+
// Compared in a separate process for the same reason as savePercyImage.
132+
async function changedPixels(aPath, bPath) {
133+
const { frame, safeAreaInsets } = rootViewAttributes;
134+
const { stdout } = await promisify(execFile)(process.execPath, [
135+
path.join(__dirname, 'changedPixels.js'),
136+
aPath,
137+
bPath,
138+
String(safeAreaInsets.top),
139+
String(frame.width),
140+
]);
141+
return Number(stdout);
142+
}
143+
144+
// Labels and tiles can still change after the maps report idle, so wait until two
145+
// consecutive screenshots match.
146+
async function takeStableScreenshot(name) {
147+
const start = Date.now();
148+
let previous = await device.takeScreenshot(`${name}-0`);
149+
for (let i = 1; ; i++) {
150+
await new Promise((resolve) => setTimeout(resolve, STABLE_INTERVAL_MS));
151+
const current = await device.takeScreenshot(`${name}-${i}`);
152+
if ((await changedPixels(previous, current)) <= STABLE_MAX_CHANGED_PIXELS) {
153+
return current;
154+
}
155+
if (Date.now() - start >= STABLE_TIMEOUT_MS) {
156+
console.warn(
157+
`${name}: screenshot still changing after ${STABLE_TIMEOUT_MS} ms, keeping the last one.`,
158+
);
159+
return current;
160+
}
161+
previous = current;
162+
}
163+
}
164+
127165
async function wait(ms) {
128166
try {
129167
await waitFor(element(by.id('no-such-view')))
@@ -207,7 +245,11 @@ class ExampleScreenshots {
207245
screenshotsDict[basename] = { images: [] };
208246
}
209247

210-
const imgPath = await saveImage(path.join(groupDir, basename), suffix);
248+
const imgPath = await saveImage(
249+
await takeStableScreenshot(basename),
250+
path.join(groupDir, basename),
251+
suffix,
252+
);
211253
screenshotsDict[basename].images.push(
212254
path.relative(docSiteRootPath, path.resolve(imgPath)),
213255
);
@@ -255,9 +297,7 @@ describe('Example screenshots', () => {
255297
cellularBars: '4',
256298
});
257299
await setSampleLocation();
258-
if (percyScreenshotsPath) {
259-
await readRootViewAttributes();
260-
}
300+
await readRootViewAttributes();
261301

262302
await waitFor(element(by.text(groupMetadata.title)))
263303
.toBeVisible()

‎example/src/examples/UserLocation/UserLocationUpdates.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,6 @@ const metadata: ExampleWithMetadata['metadata'] = {
4848
Retrieves and shows location updates from UserLocation component via the \`onUpdate\` callback
4949
`,
5050
waitForMapIdle: false,
51+
excludeFromPercy: true,
5152
};
5253
UserLocationUpdates.metadata = metadata;

‎scripts/example-docs.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,34 @@ import {
1414
mapsRootPath,
1515
} from './autogenHelpers/docconfig.js';
1616

17-
const endOfExampleMarker = '/* end-example-doc */';
18-
1917
import type { Examples } from './autogenHelpers/examplesJsonSchema';
2018
import type { Screenshots } from './autogenHelpers/screenshotsJsonSchema';
2119

20+
const endOfExampleMarker = '/* end-example-doc */';
21+
const excludeLineMarker = '// exclude-from-example-doc';
22+
const excludeStartMarker = '/* exclude-from-example-doc:start */';
23+
const excludeEndMarker = '/* exclude-from-example-doc:end */';
24+
25+
function stripExcludedLines(code: string): string {
26+
let excluding = false;
27+
return code
28+
.split('\n')
29+
.filter((line) => {
30+
const trimmed = line.trim();
31+
if (trimmed === excludeStartMarker) {
32+
excluding = true;
33+
return false;
34+
}
35+
if (trimmed === excludeEndMarker) {
36+
excluding = false;
37+
return false;
38+
}
39+
return !excluding && !trimmed.endsWith(excludeLineMarker);
40+
})
41+
.join('\n')
42+
.replace(/\n{3,}/g, '\n\n');
43+
}
44+
2245
// Published by .github/workflows/update-docs.yml, see the linking config in example/src/App.js
2346
const webExampleAppURL = 'https://rnmapbox.github.io/example-app/';
2447

@@ -49,6 +72,7 @@ examples.forEach(({ groupName, examples: _examples, metadata: _metadata }) => {
4972
if (endOfMarkerIndex > 0) {
5073
jscode = jscode.slice(0, endOfMarkerIndex);
5174
}
75+
jscode = stripExcludedLines(jscode);
5276

5377
const mdPath = path.join(destGroupDir, `${name}.md`);
5478

0 commit comments

Comments
 (0)