Skip to content

Commit b3db5c3

Browse files
author
iexitdev
committed
test(release): add native QA capture helper
1 parent 79432e9 commit b3db5c3

6 files changed

Lines changed: 516 additions & 2 deletions

docs/release/native-qa-checklists.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<!-- prettier-ignore-start -->
44

5-
Generated from native and Skia evidence matrices last updated 2026-05-06. Regenerate with `npm run release:qa:checklists`. Record row evidence with `npm run release:qa:record -- --matrix runtime --row ios-line-charts --status pass --evidence docs/release/artifacts/example.md` or `--matrix skia` for Skia rows. Do not mark a row as `pass` without evidence links in the source matrix.
5+
Generated from native and Skia evidence matrices last updated 2026-05-06. Regenerate with `npm run release:qa:checklists`. Capture a row screenshot with `npm run release:qa:capture -- --matrix runtime --row ios-line-charts --platform ios --output docs/release/artifacts/example.png`, then record row evidence with `npm run release:qa:record -- --matrix runtime --row ios-line-charts --status pass --evidence docs/release/artifacts/example.png` or `--matrix skia` for Skia rows. Do not mark a row as `pass` without evidence links in the source matrix.
66

77
## Matrix Summary
88

docs/release/native-runtime-qa.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,28 @@ EXPO_PUBLIC_CHARTKIT_SHOWCASE_QA_QUERY="view=charts&page=bar" \
109109

110110
The value accepts the same query params as the deep-link and web URLs, including full values such as `chartkitshowcase://showcase?story=v2-range-selector&theme=dark&preset=analytics`. This is only a launch helper; row evidence still requires the manual checks and screenshots or recordings listed below.
111111

112+
For row-by-row screenshot evidence, use the capture helper after the release app is installed and launchable on a simulator, emulator, or attached device:
113+
114+
```sh
115+
npm run release:qa:capture -- \
116+
--matrix runtime \
117+
--row ios-line-charts \
118+
--platform ios \
119+
--output docs/release/artifacts/ios-line-charts-runtime.png
120+
```
121+
122+
Android accepts the same matrix row metadata:
123+
124+
```sh
125+
npm run release:qa:capture -- \
126+
--matrix runtime \
127+
--row android-bar-charts \
128+
--platform android \
129+
--output docs/release/artifacts/android-bar-charts-runtime.png
130+
```
131+
132+
Use `--device <simulator-udid-or-adb-serial>` for a specific target, `--dry-run` to print the native commands, and `--no-launch` when the row is already open and only the current screen should be captured. After manual checks pass, attach the captured file to the structured matrix with `npm run release:qa:record -- --matrix runtime --row <row-id> --status pass --evidence <artifact>`.
133+
112134
## Required Pages
113135

114136
Review these showcase pages:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"release:native-workflow:record": "node scripts/record-native-workflow-evidence.mjs",
8787
"release:qa:checklists": "node scripts/generate-native-qa-checklists.mjs",
8888
"release:qa:checklists:check": "node scripts/generate-native-qa-checklists.mjs --check",
89+
"release:qa:capture": "node scripts/capture-native-qa-screenshot.mjs",
8990
"release:qa:record": "node scripts/record-native-qa-evidence.mjs",
9091
"release:owner:record": "node scripts/record-owner-gate-decision.mjs",
9192
"release:publish:status": "node scripts/check-npm-publish-state.mjs",
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import { mkdir, writeFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import process from "node:process";
4+
import { spawnSync } from "node:child_process";
5+
import { fileURLToPath } from "node:url";
6+
7+
import { listNativeQaRows } from "./record-native-qa-evidence.mjs";
8+
9+
const defaultRepoRoot = process.cwd();
10+
const defaultAndroidPackage = "io.chartkit.showcase";
11+
const validPlatforms = new Set(["android", "ios"]);
12+
13+
const usage = `Usage:
14+
node scripts/capture-native-qa-screenshot.mjs --matrix <runtime|accessibility|performance> --row <row-id> --platform <ios|android> [options]
15+
16+
Options:
17+
--device <id> iOS simulator UDID or Android adb serial. Defaults to booted/default device.
18+
--dry-run Print launch and screenshot commands without executing them.
19+
--no-launch Capture current screen without opening the row deep link first.
20+
--output <path> Repo-relative screenshot path. Defaults to docs/release/artifacts/<row-id>-screenshot.png.
21+
--package <id> Android package id. Defaults to io.chartkit.showcase.
22+
--wait-ms <number> Delay between launch and capture. Defaults to 1500.
23+
--help Show this help.
24+
`;
25+
26+
const parseArgs = (argv) => {
27+
const options = {
28+
androidPackage: defaultAndroidPackage,
29+
launch: true,
30+
waitMs: 1500
31+
};
32+
33+
for (let index = 0; index < argv.length; index += 1) {
34+
const arg = argv[index];
35+
const readValue = () => {
36+
const value = argv[index + 1];
37+
38+
if (!value || value.startsWith("--")) {
39+
throw new Error(`${arg} requires a value`);
40+
}
41+
42+
index += 1;
43+
return value;
44+
};
45+
46+
if (arg === "--device") {
47+
options.device = readValue();
48+
} else if (arg === "--dry-run") {
49+
options.dryRun = true;
50+
} else if (arg === "--help" || arg === "-h") {
51+
options.help = true;
52+
} else if (arg === "--matrix") {
53+
options.matrixName = readValue();
54+
} else if (arg === "--no-launch") {
55+
options.launch = false;
56+
} else if (arg === "--output") {
57+
options.output = readValue();
58+
} else if (arg === "--package") {
59+
options.androidPackage = readValue();
60+
} else if (arg === "--platform") {
61+
options.platform = readValue();
62+
} else if (arg === "--row") {
63+
options.rowId = readValue();
64+
} else if (arg === "--wait-ms") {
65+
options.waitMs = Number(readValue());
66+
} else {
67+
throw new Error(`Unknown argument: ${arg}`);
68+
}
69+
}
70+
71+
if (options.help) {
72+
return options;
73+
}
74+
75+
if (!options.matrixName) {
76+
throw new Error("--matrix is required");
77+
}
78+
79+
if (!options.rowId) {
80+
throw new Error("--row is required");
81+
}
82+
83+
if (!validPlatforms.has(options.platform)) {
84+
throw new Error("--platform must be ios or android");
85+
}
86+
87+
if (!Number.isFinite(options.waitMs) || options.waitMs < 0) {
88+
throw new Error("--wait-ms must be a non-negative number");
89+
}
90+
91+
return options;
92+
};
93+
94+
const shellQuote = (value) => {
95+
const text = String(value);
96+
97+
return /^[A-Za-z0-9_./:@?=&%+-]+$/.test(text)
98+
? text
99+
: `'${text.replaceAll("'", "'\\''")}'`;
100+
};
101+
102+
const commandText = (command, args) =>
103+
[command, ...args.map(shellQuote)].join(" ");
104+
105+
const defaultOutputForRow = (rowId) =>
106+
`docs/release/artifacts/${rowId}-screenshot.png`;
107+
108+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
109+
110+
const runCommand = ({ args, command, encoding = "utf8" }) => {
111+
const result = spawnSync(command, args, {
112+
encoding,
113+
maxBuffer: 1024 * 1024 * 20,
114+
stdio: encoding === "buffer" ? ["ignore", "pipe", "pipe"] : "pipe"
115+
});
116+
117+
if (result.error) {
118+
throw result.error;
119+
}
120+
121+
if (result.status !== 0) {
122+
const stderr =
123+
encoding === "buffer"
124+
? result.stderr?.toString("utf8")
125+
: result.stderr;
126+
const stdout =
127+
encoding === "buffer"
128+
? result.stdout?.toString("utf8")
129+
: result.stdout;
130+
const detail = (stderr || stdout || "").trim();
131+
const suffix = detail ? `\n\n${detail}` : "";
132+
133+
throw new Error(
134+
`${commandText(command, args)} failed with exit code ${
135+
result.status ?? 1
136+
}${suffix}`
137+
);
138+
}
139+
140+
return result.stdout;
141+
};
142+
143+
const getIosDeviceTarget = (device) => device ?? "booted";
144+
145+
const buildIosCommands = ({ device, launch, launchUrl, outputPath }) => {
146+
const target = getIosDeviceTarget(device);
147+
const commands = [];
148+
149+
if (launch) {
150+
commands.push({
151+
args: ["simctl", "openurl", target, launchUrl],
152+
command: "xcrun"
153+
});
154+
}
155+
156+
commands.push({
157+
args: ["simctl", "io", target, "screenshot", outputPath],
158+
command: "xcrun"
159+
});
160+
161+
return commands;
162+
};
163+
164+
const buildAndroidLaunchArgs = ({ androidPackage, device, launchUrl }) => [
165+
...(device ? ["-s", device] : []),
166+
"shell",
167+
"am",
168+
"start",
169+
"-W",
170+
"-a",
171+
"android.intent.action.VIEW",
172+
"-d",
173+
launchUrl,
174+
androidPackage
175+
];
176+
177+
const buildAndroidCommands = ({
178+
androidPackage,
179+
device,
180+
launch,
181+
launchUrl
182+
}) => {
183+
const commands = [];
184+
185+
if (launch) {
186+
commands.push({
187+
args: buildAndroidLaunchArgs({ androidPackage, device, launchUrl }),
188+
command: "adb"
189+
});
190+
}
191+
192+
commands.push({
193+
args: [...(device ? ["-s", device] : []), "exec-out", "screencap", "-p"],
194+
command: "adb",
195+
writesStdoutToFile: true
196+
});
197+
198+
return commands;
199+
};
200+
201+
const findRow = async ({ matrixName, repoRoot, rowId }) => {
202+
const rows = await listNativeQaRows({
203+
includeDetails: false,
204+
matrixName,
205+
repoRoot
206+
});
207+
const row = rows.find((item) => item.id === rowId);
208+
209+
if (!row) {
210+
throw new Error(`Unknown ${matrixName} QA row: ${rowId}`);
211+
}
212+
213+
if (!row.launchUrl) {
214+
throw new Error(
215+
`${rowId} does not have a showcase deep link. Use manual evidence for this row.`
216+
);
217+
}
218+
219+
return row;
220+
};
221+
222+
export const createNativeQaScreenshotPlan = async ({
223+
androidPackage = defaultAndroidPackage,
224+
device,
225+
launch = true,
226+
matrixName,
227+
output,
228+
platform,
229+
repoRoot = defaultRepoRoot,
230+
rowId,
231+
waitMs = 1500
232+
}) => {
233+
const row = await findRow({ matrixName, repoRoot, rowId });
234+
const outputPath = output ?? defaultOutputForRow(rowId);
235+
const absoluteOutputPath = path.resolve(repoRoot, outputPath);
236+
const commandOptions = {
237+
androidPackage,
238+
device,
239+
launch,
240+
launchUrl: row.launchUrl,
241+
outputPath: absoluteOutputPath
242+
};
243+
const commands =
244+
platform === "ios"
245+
? buildIosCommands(commandOptions)
246+
: buildAndroidCommands(commandOptions);
247+
248+
return {
249+
absoluteOutputPath,
250+
commands,
251+
launchUrl: row.launchUrl,
252+
outputPath,
253+
recordCommand: `npm run release:qa:record -- --matrix ${matrixName} --row ${rowId} --status partial --evidence ${outputPath}`,
254+
row,
255+
waitMs
256+
};
257+
};
258+
259+
export const captureNativeQaScreenshot = async ({
260+
dryRun = false,
261+
runner = runCommand,
262+
...options
263+
}) => {
264+
const plan = await createNativeQaScreenshotPlan(options);
265+
266+
if (dryRun) {
267+
return { ...plan, dryRun };
268+
}
269+
270+
await mkdir(path.dirname(plan.absoluteOutputPath), { recursive: true });
271+
272+
for (const [index, command] of plan.commands.entries()) {
273+
const isLastCommand = index === plan.commands.length - 1;
274+
275+
if (isLastCommand && command.writesStdoutToFile) {
276+
const screenshot = runner({
277+
args: command.args,
278+
command: command.command,
279+
encoding: "buffer"
280+
});
281+
await writeFile(plan.absoluteOutputPath, screenshot);
282+
} else {
283+
runner(command);
284+
}
285+
286+
if (!isLastCommand && plan.waitMs > 0) {
287+
await sleep(plan.waitMs);
288+
}
289+
}
290+
291+
return { ...plan, dryRun };
292+
};
293+
294+
const main = async () => {
295+
const options = parseArgs(process.argv.slice(2));
296+
297+
if (options.help) {
298+
console.log(usage.trim());
299+
return;
300+
}
301+
302+
const result = await captureNativeQaScreenshot(options);
303+
304+
for (const command of result.commands) {
305+
const suffix = command.writesStdoutToFile
306+
? ` > ${shellQuote(result.outputPath)}`
307+
: "";
308+
console.log(`$ ${commandText(command.command, command.args)}${suffix}`);
309+
}
310+
311+
if (result.dryRun) {
312+
console.log(`Would capture ${result.outputPath}`);
313+
} else {
314+
console.log(`Captured ${result.outputPath}`);
315+
}
316+
317+
console.log(`Record evidence with: ${result.recordCommand}`);
318+
};
319+
320+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
321+
try {
322+
await main();
323+
} catch (error) {
324+
console.error(error instanceof Error ? error.message : String(error));
325+
process.exitCode = 1;
326+
}
327+
}

0 commit comments

Comments
 (0)