@decocms/blocks-cli 7.35.0 → 7.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.35.0",
3
+ "version": "7.37.0",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "lint:unused": "knip"
32
32
  },
33
33
  "dependencies": {
34
- "@decocms/blocks": "7.35.0",
34
+ "@decocms/blocks": "7.37.0",
35
35
  "ts-morph": "^27.0.0",
36
36
  "tsx": "^4.22.5"
37
37
  },
@@ -20,6 +20,7 @@ import { generateSectionLoaders } from "./templates/section-loaders";
20
20
  import { generateCacheConfig } from "./templates/cache-config";
21
21
  import { generateSdkFiles } from "./templates/sdk-gen";
22
22
  import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
23
+ import { generatePerfFiles } from "./templates/perf-yml";
23
24
  // `lib-utils` is imported lazily — see end of phase-cleanup. Eager
24
25
  // generation of all 11 shims left every site with dead code that had
25
26
  // to be cleaned up by hand.
@@ -87,6 +88,11 @@ export function scaffold(ctx: MigrationContext): void {
87
88
  generateLockfileCheckYml(CANONICAL_BUN_VERSION),
88
89
  );
89
90
 
91
+ // Advisory per-PR performance workflow: detects which sections changed,
92
+ // maps them to CMS page paths, runs Lighthouse against the CF preview URL
93
+ // (PR vs main), and posts a comparison comment. Gate = CLS + TBT only.
94
+ writeMultiFile(ctx, generatePerfFiles(ctx.siteName));
95
+
90
96
  // Server entry files (server.ts, worker-entry.ts, router.tsx, runtime.ts, context.ts)
91
97
  writeMultiFile(ctx, generateServerEntry(ctx));
92
98
 
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Scaffolds the advisory per-PR performance workflow and its companion tools:
3
+ * .github/workflows/perf.yml — GitHub Actions workflow
4
+ * tools/perf/changed-paths.sh — maps edited sections → CMS page paths
5
+ * tools/perf/compare.mjs — compares two LHCI result dirs, emits markdown
6
+ * lighthouserc.json — LHCI collect settings (3 runs, mobile UA)
7
+ *
8
+ * Design rationale:
9
+ * - CF Workers Builds generates a per-PR preview URL (workers.dev). Both the
10
+ * PR and main previews are workers.dev — no edge cache — so the delta is
11
+ * purely code, not cache state.
12
+ * - Gate signal = CLS + TBT (layout stability / JS work; cache-insensitive).
13
+ * LCP and score are informational only (noisy on cold SSR).
14
+ * - Advisory: `continue-on-error: true`. Remove it once the site stabilises.
15
+ * - LHCI 0.15 does not write manifest.json; compare.mjs reads lhr-*.json
16
+ * directly and selects the median run by performance score.
17
+ */
18
+
19
+ /**
20
+ * @param workerName Cloudflare Worker name (= wrangler `name` field, e.g. "oficina-tanstack").
21
+ * Used in the CF check-run name and the main preview base URL.
22
+ */
23
+ export function generatePerfFiles(workerName: string): Record<string, string> {
24
+ return {
25
+ ".github/workflows/perf.yml": generatePerfYml(workerName),
26
+ "tools/perf/changed-paths.sh": CHANGED_PATHS_SH,
27
+ "tools/perf/compare.mjs": COMPARE_MJS,
28
+ "lighthouserc.json": LIGHTHOUSERC_JSON,
29
+ };
30
+ }
31
+
32
+ function generatePerfYml(workerName: string): string {
33
+ return `name: Perf
34
+
35
+ # Teste de performance por PR (advisory — comenta, não trava; vire gate removendo
36
+ # o \`continue-on-error\` do job quando estabilizar, igual react-doctor/typecheck).
37
+ #
38
+ # Trigger: pull_request (único trigger que roda do branch do PR, não do main).
39
+ # Aguarda o check do CF Workers Builds completar via polling, então extrai a
40
+ # preview URL do output — sem token CF nem build local.
41
+ #
42
+ # Baseline: URL fixa da preview do main (https://${workerName}.deco-cx.workers.dev).
43
+ # Gate: CLS + TBT (estrutura/JS, insensíveis a cache). LCP/score: informativos.
44
+
45
+ on:
46
+ pull_request:
47
+
48
+ permissions:
49
+ contents: read
50
+ pull-requests: write
51
+ checks: read
52
+
53
+ concurrency:
54
+ group: perf-\${{ github.workflow }}-\${{ github.ref }}
55
+ cancel-in-progress: true
56
+
57
+ env:
58
+ MOBILE_UA: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
59
+ BASE_URL: "https://${workerName}.deco-cx.workers.dev"
60
+ PERF_FALLBACK_PATHS: |
61
+ /
62
+
63
+ jobs:
64
+ perf:
65
+ runs-on: ubuntu-latest
66
+ continue-on-error: true
67
+ steps:
68
+ # Aguarda o CF Workers Builds completar e extrai a preview URL do output.
69
+ # Timeout: 10 min (builds normalmente terminam em ~3 min).
70
+ - id: cf
71
+ name: Aguardar CF build e extrair preview URL
72
+ env:
73
+ GH_TOKEN: \${{ github.token }}
74
+ SHA: \${{ github.event.pull_request.head.sha }}
75
+ run: |
76
+ echo "Aguardando CF Workers Builds para \$SHA..."
77
+ for i in \$(seq 1 60); do
78
+ summary=\$(gh api \\
79
+ "repos/\$GITHUB_REPOSITORY/commits/\$SHA/check-runs" \\
80
+ --jq '.check_runs[] | select(.name == "Workers Builds: ${workerName}") | .output.summary' \\
81
+ 2>/dev/null || true)
82
+
83
+ if [ -n "\$summary" ]; then
84
+ conclusion=\$(gh api \\
85
+ "repos/\$GITHUB_REPOSITORY/commits/\$SHA/check-runs" \\
86
+ --jq '.check_runs[] | select(.name == "Workers Builds: ${workerName}") | .conclusion' \\
87
+ 2>/dev/null || true)
88
+
89
+ if [ "\$conclusion" = "success" ]; then
90
+ url=\$(printf '%s' "\$summary" | grep -oP '(?<=Preview URL: )https://\\S+' | head -1)
91
+ if [ -n "\$url" ]; then
92
+ echo "url=\$url" >> "\$GITHUB_OUTPUT"
93
+ echo "run=true" >> "\$GITHUB_OUTPUT"
94
+ echo "CF build OK — preview: \$url"
95
+ exit 0
96
+ fi
97
+ elif [ -n "\$conclusion" ] && [ "\$conclusion" != "null" ]; then
98
+ echo "CF build concluiu com: \$conclusion — pulando perf"
99
+ echo "run=false" >> "\$GITHUB_OUTPUT"
100
+ exit 0
101
+ fi
102
+ fi
103
+
104
+ echo "Tentativa \$i/60 — aguardando 10s..."
105
+ sleep 10
106
+ done
107
+
108
+ echo "Timeout aguardando CF build — pulando perf"
109
+ echo "run=false" >> "\$GITHUB_OUTPUT"
110
+
111
+ - uses: actions/checkout@v4
112
+ if: steps.cf.outputs.run == 'true'
113
+ with:
114
+ fetch-depth: 0
115
+
116
+ - uses: actions/setup-node@v4
117
+ if: steps.cf.outputs.run == 'true'
118
+ with:
119
+ node-version: "22.15.0"
120
+
121
+ - id: paths
122
+ name: Mapear seções editadas → paths
123
+ if: steps.cf.outputs.run == 'true'
124
+ env:
125
+ GH_TOKEN: \${{ github.token }}
126
+ run: |
127
+ # GitHub PR Files API = source of truth (evita problemas de squash merge)
128
+ changed=\$(gh api \\
129
+ "repos/\$GITHUB_REPOSITORY/pulls/\${{ github.event.pull_request.number }}/files" \\
130
+ --paginate --jq '.[].filename' \\
131
+ | grep -E '^src/(sections|previews)/.*\\.tsx\$' || true)
132
+ echo "--- seções alteradas neste PR ---"
133
+ printf '%s\\n' "\$changed"
134
+ chmod +x tools/perf/changed-paths.sh
135
+ CHANGED_FILES="\$changed" tools/perf/changed-paths.sh > perf-paths.txt || true
136
+ echo "--- paths a medir ---"; cat perf-paths.txt
137
+ if [ -s perf-paths.txt ]; then echo "run=true" >> "\$GITHUB_OUTPUT"
138
+ else echo "run=false" >> "\$GITHUB_OUTPUT"; fi
139
+
140
+ - name: Warm previews
141
+ if: steps.cf.outputs.run == 'true' && steps.paths.outputs.run == 'true'
142
+ env:
143
+ PR_URL: \${{ steps.cf.outputs.url }}
144
+ run: |
145
+ # --max-time 30: garante que curl não trava num SSR lento
146
+ while IFS= read -r p; do
147
+ [ -n "\$p" ] || continue
148
+ for origin in "\$PR_URL" "\$BASE_URL"; do
149
+ for _ in 1 2; do
150
+ curl -s -o /dev/null --max-time 30 -A "\$MOBILE_UA" \\
151
+ -w "warm %{http_code} \${origin}\${p} ttfb=%{time_starttransfer}s\\n" \\
152
+ "\${origin}\${p}" || true
153
+ done
154
+ done
155
+ done < perf-paths.txt
156
+
157
+ - name: Lighthouse (PR + base)
158
+ if: steps.cf.outputs.run == 'true' && steps.paths.outputs.run == 'true'
159
+ env:
160
+ PR_URL: \${{ steps.cf.outputs.url }}
161
+ run: |
162
+ collect() {
163
+ local origin="\$1" out="\$2" args=()
164
+ while IFS= read -r p; do [ -n "\$p" ] && args+=(--url="\${origin}\${p}"); done < perf-paths.txt
165
+ rm -rf .lighthouseci "\$out"
166
+ npx @lhci/cli collect --config=lighthouserc.json "\${args[@]}"
167
+ mv .lighthouseci "\$out"
168
+ echo "--- \$out contents ---"; ls -la "\$out" || true
169
+ }
170
+ collect "\$PR_URL" .lhci-pr
171
+ collect "\$BASE_URL" .lhci-base
172
+
173
+ - name: Montar comentário
174
+ if: steps.cf.outputs.run == 'true' && steps.paths.outputs.run == 'true'
175
+ run: |
176
+ node tools/perf/compare.mjs .lhci-base .lhci-pr | tee perf-comment.md >> "\$GITHUB_STEP_SUMMARY"
177
+
178
+ - name: Comentar no PR
179
+ if: steps.cf.outputs.run == 'true' && steps.paths.outputs.run == 'true'
180
+ uses: marocchino/sticky-pull-request-comment@v2
181
+ with:
182
+ header: perf
183
+ path: perf-comment.md
184
+ `;
185
+ }
186
+
187
+ const CHANGED_PATHS_SH = `#!/usr/bin/env bash
188
+ set -euo pipefail
189
+
190
+ # Map the sections changed in this PR to the CMS page path(s) that render them,
191
+ # so the perf workflow only measures pages the PR can actually affect.
192
+ #
193
+ # How: each edited src/sections/**/*.tsx (or its previews/ twin) becomes a
194
+ # resolveType ("site/sections/<subpath>"); we grep the checked-in decofile
195
+ # (.deco/blocks/*.json) for that string and emit the \`path\` of every PAGE block
196
+ # that references it. Reusable/instance blocks (path == null) and param/wildcard
197
+ # paths (/*, /:slug/p, /conta/*) are skipped — they can't be measured as-is.
198
+ #
199
+ # Outcomes:
200
+ # - No section/preview files changed → print PERF_FALLBACK_PATHS (home sanity).
201
+ # - Sections changed, page(s) mapped → print the mapped path(s).
202
+ # - Sections changed, none mapped → print PERF_FALLBACK_PATHS + log why.
203
+ #
204
+ # Output: one URL path per line on stdout. Diagnostics go to stderr.
205
+ #
206
+ # Env:
207
+ # CHANGED_FILES newline-separated list of changed section/preview files
208
+ # (from GitHub PR Files API — preferred, avoids squash-merge issues)
209
+ # BASE_REF git ref/sha to diff against (fallback if CHANGED_FILES unset)
210
+ # PERF_FALLBACK_PATHS newline-separated fallback paths (default: "/")
211
+ # PERF_MAX_PATHS max paths to emit (default: 3). Sections used on many
212
+ # pages (Header/Footer) would otherwise produce 100+ paths;
213
+ # any page is representative for a shared section.
214
+
215
+ ROOT_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")/../.." && pwd)"
216
+ cd "\$ROOT_DIR"
217
+
218
+ BASE_REF="\${BASE_REF:-origin/main}"
219
+ BLOCKS_DIR=".deco/blocks"
220
+
221
+ log() { printf '%s\\n' "\$*" >&2; }
222
+
223
+ emit_fallback() {
224
+ log "perf: sections changed but no page mapped — using PERF_FALLBACK_PATHS"
225
+ printf '%s\\n' "\${PERF_FALLBACK_PATHS:-/}" | sed 's/[[:space:]]*\$//' | sed '/^\$/d'
226
+ }
227
+
228
+ # Prefer CHANGED_FILES (set by the workflow from the GitHub PR Files API) to
229
+ # avoid squash-merge ancestry issues where git log A..B shows all historical commits.
230
+ # Fall back to git log only when running locally without CHANGED_FILES.
231
+ changed=()
232
+ if [ -n "\${CHANGED_FILES:-}" ]; then
233
+ while IFS= read -r line; do
234
+ [ -n "\$line" ] && changed+=("\$line")
235
+ done < <(printf '%s\\n' "\$CHANGED_FILES" | grep -E '\\.tsx\$' | sort -u || true)
236
+ else
237
+ while IFS= read -r line; do
238
+ [ -n "\$line" ] && changed+=("\$line")
239
+ done < <(git log --name-only --format="" "\$BASE_REF"..HEAD -- src/sections src/previews 2>/dev/null | grep -E '\\.tsx\$' | sort -u || true)
240
+ fi
241
+
242
+ # No sections changed → measure home as a baseline sanity check.
243
+ if [ "\${#changed[@]}" -eq 0 ]; then
244
+ log "perf: nenhuma seção alterada — usando PERF_FALLBACK_PATHS como sanity"
245
+ emit_fallback
246
+ exit 0
247
+ fi
248
+
249
+ # Each changed file → the resolveType the decofile references it by.
250
+ resolvetypes=()
251
+ for f in "\${changed[@]}"; do
252
+ case "\$f" in
253
+ src/sections/*) resolvetypes+=("site/\${f#src/}") ;;
254
+ src/previews/*) resolvetypes+=("site/sections/\${f#src/previews/}") ;;
255
+ esac
256
+ done
257
+ [ "\${#resolvetypes[@]}" -eq 0 ] && exit 0
258
+
259
+ # Grep the decofile for each resolveType; collect page paths (non-null).
260
+ paths=()
261
+ for rt in "\${resolvetypes[@]}"; do
262
+ while IFS= read -r block; do
263
+ [ -n "\$block" ] || continue
264
+ while IFS= read -r p; do
265
+ [ -n "\$p" ] && paths+=("\$p")
266
+ done < <(jq -r 'select(.path != null and (.path | type == "string")) | .path' "\$block" 2>/dev/null || true)
267
+ done < <(grep -Fl "\$rt" "\$BLOCKS_DIR"/*.json 2>/dev/null || true)
268
+ done
269
+
270
+ # Dedupe + drop param/wildcard paths (need a concrete slug; can't measure as-is).
271
+ clean=()
272
+ if [ "\${#paths[@]}" -gt 0 ]; then
273
+ while IFS= read -r line; do
274
+ [ -n "\$line" ] && clean+=("\$line")
275
+ done < <(printf '%s\\n' "\${paths[@]}" | grep -vE '[:*]' | sort -u)
276
+ fi
277
+
278
+ if [ "\${#clean[@]}" -eq 0 ]; then
279
+ emit_fallback
280
+ exit 0
281
+ fi
282
+
283
+ MAX="\${PERF_MAX_PATHS:-3}"
284
+ total="\${#clean[@]}"
285
+ if [ "\$total" -gt "\$MAX" ]; then
286
+ log "perf: \${total} pages mapped — capped at \${MAX} (ponytail: PERF_MAX_PATHS). Dropped: \${clean[*]:\$MAX}"
287
+ clean=("\${clean[@]:0:\$MAX}")
288
+ fi
289
+
290
+ log "perf: measuring \${#clean[@]} page(s) mapped from \${#changed[@]} changed file(s)"
291
+ printf '%s\\n' "\${clean[@]}"
292
+ `;
293
+
294
+ const COMPARE_MJS = `#!/usr/bin/env node
295
+ // Compare two Lighthouse CI result dirs (base = main preview, pr = PR preview)
296
+ // and emit a markdown table. Both previews run on *.workers.dev with no edge
297
+ // cache, same runner/region, same mobile UA — so the delta is the PR's code
298
+ // effect, not cache warm/cold.
299
+ //
300
+ // Gate signal = CLS + TBT (page structure / JS work — cache-insensitive).
301
+ // LCP / score are shown but informative only (noisy on cold workers.dev SSR).
302
+ //
303
+ // LHCI 0.15 does not write manifest.json; we read lhr-*.json directly.
304
+ //
305
+ // Usage: node compare.mjs <baseDir> <prDir> → writes markdown to stdout
306
+ // node compare.mjs --selftest → runs assertions, exits 0/1
307
+
308
+ import { readFileSync, readdirSync } from "node:fs";
309
+ import { join } from "node:path";
310
+
311
+ const TBT_ABS = 50; // ms — deltas below this are noise
312
+ const TBT_REL = 0.15; // 15% relative change
313
+ const CLS_ABS = 0.02; // absolute CLS delta that matters
314
+ const CLS_FLOOR = 0.1; // only flag CLS regression when PR is above "good"
315
+
316
+ function verdict(base, pr) {
317
+ const tbtUp = pr.tbt - base.tbt;
318
+ const tbtBad = tbtUp > TBT_ABS && tbtUp > base.tbt * TBT_REL;
319
+ const tbtGood = -tbtUp > TBT_ABS && -tbtUp > base.tbt * TBT_REL;
320
+ const clsUp = pr.cls - base.cls;
321
+ const clsBad = clsUp > CLS_ABS && pr.cls > CLS_FLOOR;
322
+ const clsGood = -clsUp > CLS_ABS && base.cls > CLS_FLOOR;
323
+ if (tbtBad || clsBad) return { icon: "🔴", gate: true };
324
+ if (tbtGood || clsGood) return { icon: "🟢", gate: false };
325
+ return { icon: "⚪", gate: false };
326
+ }
327
+
328
+ function loadDir(dir) {
329
+ const files = readdirSync(dir).filter((f) => f.startsWith("lhr-") && f.endsWith(".json"));
330
+ const byUrl = {};
331
+ for (const f of files) {
332
+ const lhr = JSON.parse(readFileSync(join(dir, f), "utf8"));
333
+ const url = lhr.finalUrl || lhr.requestedUrl;
334
+ if (!byUrl[url]) byUrl[url] = [];
335
+ byUrl[url].push(lhr);
336
+ }
337
+ const byPath = {};
338
+ for (const [url, runs] of Object.entries(byUrl)) {
339
+ runs.sort((a, b) => a.categories.performance.score - b.categories.performance.score);
340
+ const lhr = runs[Math.floor(runs.length / 2)];
341
+ const a = lhr.audits;
342
+ byPath[new URL(url).pathname] = {
343
+ score: Math.round((lhr.categories.performance.score ?? 0) * 100),
344
+ lcp: a["largest-contentful-paint"].numericValue,
345
+ cls: a["cumulative-layout-shift"].numericValue,
346
+ tbt: a["total-blocking-time"].numericValue,
347
+ };
348
+ }
349
+ return byPath;
350
+ }
351
+
352
+ const ms = (v) => \`\${Math.round(v)}ms\`;
353
+ const cls = (v) => v.toFixed(3);
354
+ function delta(base, pr, fmt) {
355
+ const d = pr - base;
356
+ const arrow = d < 0 ? "↓" : d > 0 ? "↑" : "";
357
+ return \`\${d > 0 ? "+" : ""}\${fmt(d)} \${arrow}\`.trim();
358
+ }
359
+
360
+ function render(base, pr) {
361
+ const paths = [...new Set([...Object.keys(base), ...Object.keys(pr)])].sort();
362
+ let anyGate = false;
363
+ const lines = [
364
+ "### ⚡ Perf — preview do PR × preview do main",
365
+ "",
366
+ "Ambas as previews rodam em \`*.workers.dev\` (sem edge cache), mesmo runner e UA mobile — o delta é efeito do código, não de cache quente/frio.",
367
+ "",
368
+ "**Gate:** CLS + TBT (estrutura / JS). LCP e score são informativos (ruído esperado em SSR frio).",
369
+ "",
370
+ "| Página | | CLS (base→PR) | TBT (base→PR) | LCP | Score |",
371
+ "|---|:--:|---|---|---|---|",
372
+ ];
373
+ for (const p of paths) {
374
+ const b = base[p];
375
+ const r = pr[p];
376
+ if (!b || !r) {
377
+ lines.push(\`| \\\`\${p}\\\` | ⚠️ | \${b ? "faltou PR" : "faltou base"} | | | |\`);
378
+ continue;
379
+ }
380
+ const v = verdict(b, r);
381
+ anyGate = anyGate || v.gate;
382
+ lines.push(
383
+ \`| \\\`\${p}\\\` | \${v.icon} | \${cls(b.cls)}→\${cls(r.cls)} (\${delta(b.cls, r.cls, cls)}) | \${ms(b.tbt)}→\${ms(r.tbt)} (\${delta(b.tbt, r.tbt, ms)}) | \${ms(b.lcp)}→\${ms(r.lcp)} | \${b.score}→\${r.score} |\`,
384
+ );
385
+ }
386
+ lines.push("");
387
+ lines.push(
388
+ anyGate
389
+ ? "🔴 **Regressão de CLS/TBT detectada** (advisory — não trava o merge)."
390
+ : "🟢 Sem regressão de CLS/TBT.",
391
+ );
392
+ return { md: lines.join("\\n"), anyGate };
393
+ }
394
+
395
+ function selftest() {
396
+ const ok = (c, m) => { if (!c) throw new Error("FAIL: " + m); };
397
+ ok(verdict({ tbt: 100, cls: 0.01 }, { tbt: 200, cls: 0.01 }).gate, "tbt +100ms should gate");
398
+ ok(!verdict({ tbt: 100, cls: 0.01 }, { tbt: 130, cls: 0.01 }).gate, "tbt +30ms is noise");
399
+ ok(verdict({ tbt: 100, cls: 0.11 }, { tbt: 100, cls: 0.2 }).gate, "cls 0.11→0.20 should gate");
400
+ ok(!verdict({ tbt: 100, cls: 0.02 }, { tbt: 100, cls: 0.08 }).gate, "cls under 0.1 floor no gate");
401
+ ok(verdict({ tbt: 300, cls: 0.01 }, { tbt: 100, cls: 0.01 }).icon === "🟢", "tbt drop is green");
402
+ ok(verdict({ tbt: 100, cls: 0.01 }, { tbt: 105, cls: 0.01 }).icon === "⚪", "tiny change is neutral");
403
+ console.log("compare.mjs selftest: OK");
404
+ }
405
+
406
+ if (process.argv[2] === "--selftest") { selftest(); process.exit(0); }
407
+
408
+ const [baseDir, prDir] = process.argv.slice(2);
409
+ if (!baseDir || !prDir) { console.error("usage: compare.mjs <baseDir> <prDir> | --selftest"); process.exit(2); }
410
+ process.stdout.write(render(loadDir(baseDir), loadDir(prDir)).md + "\\n");
411
+ `;
412
+
413
+ const LIGHTHOUSERC_JSON = `{
414
+ "//": "Lighthouse CI config for the per-PR perf workflow (.github/workflows/perf.yml). Mobile UA matches worker-entry.ts buildSegment (MOBILE_RE) so warmup and measurement hit the same device cache key.",
415
+ "ci": {
416
+ "collect": {
417
+ "numberOfRuns": 3,
418
+ "settings": {
419
+ "onlyCategories": ["performance"],
420
+ "emulatedUserAgent": "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
421
+ }
422
+ }
423
+ }
424
+ }
425
+ `;