@open-assets/open-assets 0.4.0 → 0.5.1

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/lib/render.mjs CHANGED
@@ -1,24 +1,66 @@
1
- import { resolve, join } from "path";
2
- import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
1
+ import { resolve, join, relative } from "path";
2
+ import { mkdirSync, copyFileSync, existsSync } from "fs";
3
+ import { writeFile } from "fs/promises";
4
+ import { cpus } from "os";
3
5
  import { filterCollections, filterTemplates, resolveRenderSizes, flatOutputs } from "./manifest.mjs";
4
6
  import { computeExportChecksum } from "./lockfile.mjs";
5
7
  import { loadLocalizations, resolveStrings, getLocales, getDirection } from "./localization.mjs";
6
8
 
9
+ // Chromium serializes the capture path, so a unit's screenshots cannot overlap
10
+ // each other no matter how many workers ask. A second worker still pays off: it
11
+ // navigates and lays out its page while the first is capturing. A third has
12
+ // nothing left to hide, and each one past that adds a renderer process and a
13
+ // full set of framebuffers while screenshot latency grows in proportion.
14
+ //
15
+ // Measured on a 10-core M-series Mac, 8 templates, median of 5 runs:
16
+ // 4 App Store sizes 1:9.2s 2:9.4s 3:9.7s 4:9.6s 6:9.9s 8:10.4s
17
+ // 1 small size 1:2.0s 2:1.8s 3:2.0s 4:2.0s 6:2.5s 8:3.5s
18
+ const CONCURRENCY_CAP = 2;
19
+
20
+ /**
21
+ * Pick how many render units run concurrently.
22
+ *
23
+ * The smallest of three ceilings wins: the work actually available, the CPU
24
+ * count (leaving a core for this process and Chromium's browser process), and
25
+ * the cap above. OPEN_ASSETS_CONCURRENCY overrides the result, which is the
26
+ * escape hatch for templates heavy enough to change the arithmetic.
27
+ *
28
+ * @param {unknown[]} units - Render units to be drained by the worker pool
29
+ * @param {{ override?: string|number, cores?: number }} [opts]
30
+ * @returns {number}
31
+ */
32
+ export function resolveConcurrency(units, opts = {}) {
33
+ if (units.length === 0) return 1;
34
+
35
+ const raw = opts.override ?? process.env.OPEN_ASSETS_CONCURRENCY;
36
+ const override = parseInt(raw, 10);
37
+ if (Number.isFinite(override) && override > 0) {
38
+ return Math.min(override, units.length);
39
+ }
40
+
41
+ const cores = opts.cores ?? cpus().length;
42
+ return Math.max(1, Math.min(units.length, cores - 1, CONCURRENCY_CAP));
43
+ }
44
+
7
45
  /**
8
46
  * @typedef {object} RenderDeps
9
- * @property {(projectDir: string, htmlFile: string, w: number, h: number, sw: number, sh: number) => Promise<Buffer>} renderScreenshot
47
+ * @property {(projectDir: string, htmlFile: string, sourceW: number, sourceH: number, variants: {width: number, height: number, format?: string}[], options?: object) => Promise<Buffer[]>} renderVariants
10
48
  * @property {(projectDir: string, collection: object, outputConfig: object) => Promise<string>} runXcodeOutput
11
49
  * @property {() => Promise<void>} closeBrowser
12
50
  * @property {(projectDir: string) => object} readLockfile
13
51
  * @property {(projectDir: string, data: object) => void} writeLockfile
14
52
  * @property {(filePath: string) => string|null} computeChecksum
15
- * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string, exportChecksum?: string) => boolean} isUpToDate
53
+ * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string, exportChecksum?: string, projectDir?: string) => boolean} isUpToDate
16
54
  * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string, exportChecksum?: string) => void} recordExport
17
55
  */
18
56
 
19
57
  /**
20
58
  * Render assets according to the manifest and options.
21
59
  *
60
+ * Work is grouped into units of one (template, locale) pair: each unit
61
+ * navigates its page once and screenshots every export size from that single
62
+ * load. Units render concurrently on a small internal pool.
63
+ *
22
64
  * @param {string} projectDir - Absolute path to the project directory
23
65
  * @param {object} manifest - Parsed manifest object
24
66
  * @param {object} options - Render options from CLI
@@ -30,8 +72,9 @@ import { loadLocalizations, resolveStrings, getLocales, getDirection } from "./l
30
72
  * @param {string} [options.format] - Output format override (png or jpg)
31
73
  * @param {string|number} [options.width] - Custom width
32
74
  * @param {string|number} [options.height] - Custom height
33
- * @param {boolean} [options.force] - Export at all sizes and re-render (ignores cache)
75
+ * @param {boolean} [options.force] - Re-render all assets, ignoring the incremental cache
34
76
  * @param {string} [options.output] - Output directory path
77
+ * @param {string|number} [options.renderTimeout] - Puppeteer render timeout in ms
35
78
  * @param {boolean} [options.quiet] - Suppress logs
36
79
  * @param {RenderDeps} deps - Injected dependencies
37
80
  * @param {(msg: string) => void} [log] - Optional log function
@@ -54,216 +97,330 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
54
97
  const outputDir = resolve(options.output || "./exports");
55
98
  mkdirSync(outputDir, { recursive: true });
56
99
 
100
+ const renderTimeout = options.renderTimeout
101
+ ? parseInt(options.renderTimeout, 10)
102
+ : undefined;
103
+
57
104
  const lockData = deps.readLockfile(projectDir);
58
105
  const results = [];
59
106
  let skipped = 0;
60
107
  const startTime = Date.now();
61
108
 
62
- for (const col of collections) {
63
- const sourceW = col.sourceSize.width;
64
- const sourceH = col.sourceSize.height;
65
-
66
- const templates = filterTemplates(col.templates, options.template);
67
-
68
- const { sizes: sizesToRender, warning } = resolveRenderSizes(col, {
69
- width: options.width,
70
- height: options.height,
71
- size: options.size,
72
- force: options.force,
73
- });
74
-
75
- if (warning) {
76
- _log(` Warning: ${warning}`);
77
- continue;
78
- }
79
-
80
- if (sizesToRender.length === 0) {
81
- _log(` No export sizes found for collection "${col.id}"`);
82
- continue;
109
+ // Source checksums are memoized for the run: a template is hashed once no
110
+ // matter how many sizes and locales it renders to.
111
+ const checksumCache = new Map();
112
+ const checksumOf = (srcPath) => {
113
+ if (!checksumCache.has(srcPath)) {
114
+ checksumCache.set(srcPath, deps.computeChecksum(srcPath));
83
115
  }
84
-
85
- // Resolve locales for this collection
86
- let locales = [null]; // null = no localization
87
- let localizations = null;
88
-
89
- if (col.localizations) {
90
- localizations = loadLocalizations(projectDir, col.localizations);
91
- locales = getLocales(localizations, col.locales);
92
- if (options.locale) {
93
- locales = locales.filter((l) => l === options.locale);
94
- if (locales.length === 0) {
95
- _log(` Warning: locale "${options.locale}" not found in collection "${col.id}", skipping`);
96
- continue;
97
- }
116
+ return checksumCache.get(srcPath);
117
+ };
118
+
119
+ // The lockfile and browser are handled in finally so a mid-run failure
120
+ // still persists completed work and never leaks a Chromium process.
121
+ try {
122
+ for (const col of collections) {
123
+ const sourceW = col.sourceSize.width;
124
+ const sourceH = col.sourceSize.height;
125
+
126
+ const templates = filterTemplates(col.templates, options.template);
127
+
128
+ const { sizes: sizesToRender, warning } = resolveRenderSizes(col, {
129
+ width: options.width,
130
+ height: options.height,
131
+ size: options.size,
132
+ });
133
+
134
+ if (warning) {
135
+ _log(` Warning: ${warning}`);
136
+ continue;
98
137
  }
99
- }
100
-
101
- const singleTemplate = templates.length === 1;
102
- const multipleSizes = sizesToRender.length > 1 || options.force;
103
- // Single-template collections use flat layout with entry.name as filename
104
- // Multi-template collections use subdirs per size
105
- const useSubDirs = multipleSizes && !singleTemplate;
106
- const hasLocales = locales.length > 0 && locales[0] !== null;
107
138
 
108
- for (const locale of locales) {
109
- const strings = locale ? resolveStrings(localizations, locale) : null;
110
- const direction = locale ? getDirection(locale) : null;
111
- const localization = locale ? { strings, locale, direction } : undefined;
112
-
113
- if (locale) {
114
- _log(`\n Locale: ${locale} (${direction})`);
139
+ if (sizesToRender.length === 0) {
140
+ _log(` No export sizes found for collection "${col.id}"`);
141
+ continue;
115
142
  }
116
143
 
117
- for (const entry of sizesToRender) {
118
- const w = entry.size.width;
119
- const h = entry.size.height;
120
-
121
- // Build output directory: insert locale level when localizations are active
122
- let sizeDir;
123
- if (hasLocales) {
124
- sizeDir = useSubDirs
125
- ? join(outputDir, col.id, locale, entry.name)
126
- : join(outputDir, col.id, locale);
127
- } else {
128
- sizeDir = useSubDirs
129
- ? join(outputDir, col.id, entry.name)
130
- : join(outputDir, col.id);
144
+ // Resolve locales for this collection
145
+ let locales = [null]; // null = no localization
146
+ let localizations = null;
147
+
148
+ if (col.localizations) {
149
+ localizations = loadLocalizations(projectDir, col.localizations);
150
+ locales = getLocales(localizations, col.locales);
151
+ if (options.locale) {
152
+ locales = locales.filter((l) => l === options.locale);
153
+ if (locales.length === 0) {
154
+ _log(` Warning: locale "${options.locale}" not found in collection "${col.id}", skipping`);
155
+ continue;
156
+ }
131
157
  }
132
- mkdirSync(sizeDir, { recursive: true });
158
+ }
133
159
 
134
- if (sizesToRender.length > 1) {
135
- _log(`\n ${entry.label} (${w}x${h})`);
160
+ // Layout is decided by the collection's full template list, not the
161
+ // filtered one, so --template renders land in the same paths as full runs
162
+ const singleTemplate = col.templates.length === 1;
163
+ // Single-template collections use flat layout with entry.name as filename
164
+ // Multi-template collections use subdirs per size
165
+ const useSubDirs = !singleTemplate;
166
+ const hasLocales = locales.length > 0 && locales[0] !== null;
167
+
168
+ // Enumerate render units: one per (locale, template), each carrying
169
+ // its non-skipped size variants. Skip checks run up front so units
170
+ // whose variants are all cached never open a page.
171
+ const units = [];
172
+
173
+ for (const locale of locales) {
174
+ const strings = locale ? resolveStrings(localizations, locale) : null;
175
+ const direction = locale ? getDirection(locale) : null;
176
+ const localization = locale ? { strings, locale, direction } : undefined;
177
+
178
+ if (locale) {
179
+ _log(`\n Locale: ${locale} (${direction})`);
136
180
  }
137
181
 
138
182
  for (const template of templates) {
139
- // Determine output format from outFile extension or default to png
140
- const outExt = entry.outFile
141
- ? entry.outFile.split(".").pop().toLowerCase()
142
- : "png";
143
- const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
144
- const defaultExt = format === "jpeg" ? "jpg" : "png";
145
-
146
- // Single-template: use entry.name as filename (e.g. logo-1024.png)
147
- // Multi-template: use template.name as filename (e.g. 01-hero.png)
148
- const fileName = singleTemplate && multipleSizes
149
- ? `${entry.name}.${defaultExt}`
150
- : `${template.name}.${defaultExt}`;
151
-
152
- let outPath;
153
- if (entry.outFile) {
154
- let resolved = entry.outFile.replace(/\{template\}/g, template.name);
155
- if (locale) {
156
- resolved = resolved.replace(/\{locale\}/g, locale);
157
- }
158
- outPath = resolve(projectDir, resolved);
159
- } else {
160
- outPath = join(sizeDir, fileName);
161
- }
162
-
163
- // Asset key and variant key include locale for cache isolation
183
+ const srcPath = resolve(projectDir, template.src);
184
+ const checksum = checksumOf(srcPath);
164
185
  const assetKey = locale
165
186
  ? `${col.id}/${locale}/${template.name}`
166
187
  : `${col.id}/${template.name}`;
167
- const variantKey = entry.name;
168
- const srcPath = resolve(projectDir, template.src);
169
- const checksum = deps.computeChecksum(srcPath);
170
- const exportCksum = computeExportChecksum(entry, locale);
171
-
172
- // Skip if unchanged
173
- if (
174
- !options.force &&
175
- checksum &&
176
- deps.isUpToDate(lockData, assetKey, variantKey, checksum, outPath, exportCksum)
177
- ) {
178
- _log(
179
- ` Skipping ${template.name}${locale ? ` [${locale}]` : ""} at ${w}x${h} (unchanged)`
180
- );
181
- skipped++;
182
- continue;
188
+
189
+ const variants = [];
190
+
191
+ for (const entry of sizesToRender) {
192
+ const w = entry.size.width;
193
+ const h = entry.size.height;
194
+
195
+ // Build output directory: insert locale level when localizations are active
196
+ let sizeDir;
197
+ if (hasLocales) {
198
+ sizeDir = useSubDirs
199
+ ? join(outputDir, col.id, locale, entry.name)
200
+ : join(outputDir, col.id, locale);
201
+ } else {
202
+ sizeDir = useSubDirs
203
+ ? join(outputDir, col.id, entry.name)
204
+ : join(outputDir, col.id);
205
+ }
206
+
207
+ // Determine output format from outFile extension or default to png
208
+ const outExt = entry.outFile
209
+ ? entry.outFile.split(".").pop().toLowerCase()
210
+ : "png";
211
+ const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
212
+ const defaultExt = format === "jpeg" ? "jpg" : "png";
213
+
214
+ // Single-template: use entry.name as filename (e.g. logo/1024.png)
215
+ // Multi-template: use template.name as filename (e.g. 01-hero.png)
216
+ const fileName = singleTemplate
217
+ ? `${entry.name}.${defaultExt}`
218
+ : `${template.name}.${defaultExt}`;
219
+
220
+ let outPath;
221
+ if (entry.outFile) {
222
+ let resolved = entry.outFile.replace(/\{template\}/g, template.name);
223
+ if (locale) {
224
+ resolved = resolved.replace(/\{locale\}/g, locale);
225
+ }
226
+ outPath = resolve(projectDir, resolved);
227
+ // Create the destination directory now, before any render or
228
+ // SVG source copy targets it.
229
+ mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
230
+ } else {
231
+ mkdirSync(sizeDir, { recursive: true });
232
+ outPath = join(sizeDir, fileName);
233
+ }
234
+
235
+ const exportCksum = computeExportChecksum(entry, locale);
236
+ // Lockfile paths are stored relative to the project so the cache
237
+ // is portable across machines. The variant is keyed by output path,
238
+ // so the same template rendered to different destinations gets its
239
+ // own cache entry per destination.
240
+ const lockOutPath = relative(projectDir, outPath);
241
+ const variantKey = lockOutPath;
242
+
243
+ // Skip if unchanged
244
+ if (
245
+ !options.force &&
246
+ checksum &&
247
+ deps.isUpToDate(lockData, assetKey, variantKey, checksum, lockOutPath, exportCksum, projectDir)
248
+ ) {
249
+ _log(
250
+ ` Skipping ${template.name}${locale ? ` [${locale}]` : ""} at ${w}x${h} (unchanged)`
251
+ );
252
+ skipped++;
253
+ continue;
254
+ }
255
+
256
+ variants.push({
257
+ w,
258
+ h,
259
+ format,
260
+ defaultExt,
261
+ outPath,
262
+ lockOutPath,
263
+ exportCksum,
264
+ });
183
265
  }
184
266
 
185
- _log(
186
- ` Rendering ${template.name}${locale ? ` [${locale}]` : ""} at ${w}x${h}...`
187
- );
267
+ if (variants.length > 0) {
268
+ units.push({
269
+ template,
270
+ locale,
271
+ localization,
272
+ srcPath,
273
+ checksum,
274
+ assetKey,
275
+ variants,
276
+ logs: [],
277
+ results: [],
278
+ completed: false,
279
+ });
280
+ }
281
+ }
282
+ }
188
283
 
189
- const isSvg = template.src.endsWith(".svg");
190
- if (isSvg && w === sourceW && h === sourceH) {
191
- copyFileSync(srcPath, outPath.replace(`.${defaultExt}`, ".svg"));
284
+ // Drain units through a small worker pool. Logs are buffered per unit
285
+ // and flushed in enumeration order so output stays deterministic.
286
+ let nextUnit = 0;
287
+ let nextFlush = 0;
288
+ const errors = [];
289
+
290
+ const flushLogs = () => {
291
+ while (nextFlush < units.length && units[nextFlush].completed) {
292
+ for (const line of units[nextFlush].logs) _log(line);
293
+ nextFlush++;
294
+ }
295
+ };
296
+
297
+ const runUnit = async (unit) => {
298
+ const isSvg = unit.template.src.endsWith(".svg");
299
+ unit.logs.push(
300
+ ` Rendering ${unit.template.name}${unit.locale ? ` [${unit.locale}]` : ""} (${unit.variants.length} size${unit.variants.length === 1 ? "" : "s"})...`
301
+ );
302
+
303
+ const buffers = await deps.renderVariants(
304
+ projectDir,
305
+ unit.template.src,
306
+ sourceW,
307
+ sourceH,
308
+ unit.variants.map((v) => ({ width: v.w, height: v.h, format: v.format })),
309
+ {
310
+ background: col.background,
311
+ localization: unit.localization,
312
+ timeout: renderTimeout,
192
313
  }
314
+ );
193
315
 
194
- // Ensure output directory exists when using outFile
195
- if (entry.outFile) {
196
- mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
316
+ for (let i = 0; i < unit.variants.length; i++) {
317
+ const v = unit.variants[i];
318
+ const buffer = buffers[i];
319
+
320
+ if (isSvg && v.w === sourceW && v.h === sourceH) {
321
+ copyFileSync(unit.srcPath, v.outPath.replace(`.${v.defaultExt}`, ".svg"));
197
322
  }
198
323
 
199
- const buffer = await deps.renderScreenshot(
200
- projectDir,
201
- template.src,
202
- w,
203
- h,
204
- sourceW,
205
- sourceH,
206
- { format, background: col.background, localization }
207
- );
208
- writeFileSync(outPath, buffer);
209
- results.push({
324
+ await writeFile(v.outPath, buffer);
325
+ // The output write above must complete before the variant is
326
+ // marked rendered; recordExport keys off this flag.
327
+ v.rendered = true;
328
+ unit.results.push({
210
329
  collection: col.id,
211
- template: template.name,
212
- locale: locale || undefined,
213
- path: outPath,
214
- width: w,
215
- height: h,
330
+ template: unit.template.name,
331
+ locale: unit.locale || undefined,
332
+ path: v.outPath,
333
+ width: v.w,
334
+ height: v.h,
216
335
  size: buffer.length,
217
336
  });
218
- if (checksum) {
219
- deps.recordExport(lockData, assetKey, variantKey, checksum, outPath, exportCksum);
337
+ unit.logs.push(` → ${v.outPath} (${v.w}x${v.h})`);
338
+ }
339
+ };
340
+
341
+ const workers = Array.from(
342
+ { length: resolveConcurrency(units) },
343
+ async () => {
344
+ while (nextUnit < units.length && errors.length === 0) {
345
+ const unit = units[nextUnit++];
346
+ try {
347
+ await runUnit(unit);
348
+ } catch (err) {
349
+ unit.logs.push(` Error rendering ${unit.template.name}${unit.locale ? ` [${unit.locale}]` : ""}: ${err.message}`);
350
+ errors.push(err);
351
+ }
352
+ unit.completed = true;
353
+ flushLogs();
354
+ }
355
+ }
356
+ );
357
+ await Promise.all(workers);
358
+ flushLogs();
359
+
360
+ // Collect results and lockfile records in enumeration order so the
361
+ // output arrays and lockfile bytes are stable across runs.
362
+ for (const unit of units) {
363
+ results.push(...unit.results);
364
+ if (!unit.checksum) continue;
365
+ for (const v of unit.variants) {
366
+ if (v.rendered) {
367
+ deps.recordExport(lockData, unit.assetKey, v.lockOutPath, unit.checksum, v.lockOutPath, v.exportCksum);
220
368
  }
221
- _log(` → ${outPath}`);
222
369
  }
223
370
  }
224
- }
225
371
 
226
- // Handle outputs (xcode, copy-source, etc.)
227
- const outputs = flatOutputs(col);
228
- if (outputs.length > 0 && options.force) {
229
- for (const output of outputs) {
230
- if (output.type === "xcode") {
231
- _log(`\n Exporting to Xcode: ${output.path}`);
232
- const path = await deps.runXcodeOutput(projectDir, col, output);
233
- results.push({ collection: col.id, type: "xcode", path });
234
- } else if (output.type === "copy-source") {
235
- const format = output.format || "svg";
236
- const copyDir = join(outputDir, col.id);
237
- mkdirSync(copyDir, { recursive: true });
238
- const matchingTemplates = templates.filter((t) => t.src.endsWith(`.${format}`));
239
- for (const template of matchingTemplates) {
240
- const srcPath = resolve(projectDir, template.src);
241
- // Use entry name when single match, template name when multiple
242
- const baseName = output.name && matchingTemplates.length === 1
243
- ? output.name
244
- : template.name;
245
- const destPath = join(copyDir, `${baseName}.${format}`);
246
- if (existsSync(srcPath)) {
247
- copyFileSync(srcPath, destPath);
248
- results.push({
249
- collection: col.id,
250
- template: template.name,
251
- type: `copy-${format}`,
252
- path: destPath,
253
- });
254
- _log(` → ${destPath}`);
372
+ if (errors.length > 0) {
373
+ throw errors[0];
374
+ }
375
+
376
+ // Handle outputs (xcode, copy-source, etc.)
377
+ const outputs = flatOutputs(col);
378
+ if (outputs.length > 0) {
379
+ for (const output of outputs) {
380
+ if (output.type === "xcode") {
381
+ _log(`\n Exporting to Xcode: ${output.path}`);
382
+ const path = await deps.runXcodeOutput(projectDir, col, output);
383
+ results.push({ collection: col.id, type: "xcode", path });
384
+ } else if (output.type === "copy-source") {
385
+ const format = output.format || "svg";
386
+ const copyDir = join(outputDir, col.id);
387
+ mkdirSync(copyDir, { recursive: true });
388
+ const matchingTemplates = templates.filter((t) => t.src.endsWith(`.${format}`));
389
+ // Naming follows the collection's full match count so --template
390
+ // runs produce the same filenames as full runs
391
+ const collectionMatches = col.templates.filter((t) => t.src.endsWith(`.${format}`));
392
+ for (const template of matchingTemplates) {
393
+ const srcPath = resolve(projectDir, template.src);
394
+ // Use entry name when single match, template name when multiple
395
+ const baseName = output.name && collectionMatches.length === 1
396
+ ? output.name
397
+ : template.name;
398
+ const destPath = join(copyDir, `${baseName}.${format}`);
399
+ if (existsSync(srcPath)) {
400
+ copyFileSync(srcPath, destPath);
401
+ results.push({
402
+ collection: col.id,
403
+ template: template.name,
404
+ type: `copy-${format}`,
405
+ path: destPath,
406
+ });
407
+ _log(` → ${destPath}`);
408
+ }
255
409
  }
256
410
  }
257
411
  }
258
412
  }
259
413
  }
414
+ } finally {
415
+ // Persist whatever completed, even when a render failed mid-run
416
+ deps.writeLockfile(projectDir, lockData);
417
+ try {
418
+ await deps.closeBrowser();
419
+ } catch {
420
+ // A browser-close failure must not mask the original render error
421
+ }
260
422
  }
261
423
 
262
- // Persist lockfile
263
- deps.writeLockfile(projectDir, lockData);
264
-
265
- await deps.closeBrowser();
266
-
267
424
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
268
425
  return { results, skipped, elapsed: `${elapsed}s` };
269
426
  }