screenci 0.0.88 → 0.0.90

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.
Files changed (53) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/cli.js.map +1 -1
  3. package/dist/docs/manifest.d.ts +36 -5
  4. package/dist/docs/manifest.d.ts.map +1 -1
  5. package/dist/docs/manifest.js +12 -1
  6. package/dist/docs/manifest.js.map +1 -1
  7. package/dist/docs/video-sources/assets/StyleCard.d.ts +4 -0
  8. package/dist/docs/video-sources/assets/StyleCard.d.ts.map +1 -0
  9. package/dist/docs/video-sources/assets/StyleCard.js +27 -0
  10. package/dist/docs/video-sources/assets/StyleCard.js.map +1 -0
  11. package/dist/docs/video-sources/locator-highlight-animated.screenci.js +2 -15
  12. package/dist/docs/video-sources/locator-highlight-animated.screenci.js.map +1 -1
  13. package/dist/docs/video-sources/locator-highlight-still.screenci.js +2 -1
  14. package/dist/docs/video-sources/locator-highlight-still.screenci.js.map +1 -1
  15. package/dist/docs/video-sources/speaking-style.screenci.js +4 -28
  16. package/dist/docs/video-sources/speaking-style.screenci.js.map +1 -1
  17. package/dist/e2e/fixtures/ClientCounter.d.ts +5 -0
  18. package/dist/e2e/fixtures/ClientCounter.d.ts.map +1 -0
  19. package/dist/e2e/fixtures/ClientCounter.js +29 -0
  20. package/dist/e2e/fixtures/ClientCounter.js.map +1 -0
  21. package/dist/e2e/htmlRasterizer.e2e.js +104 -23
  22. package/dist/e2e/htmlRasterizer.e2e.js.map +1 -1
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/src/asset.d.ts +50 -85
  28. package/dist/src/asset.d.ts.map +1 -1
  29. package/dist/src/asset.js +116 -278
  30. package/dist/src/asset.js.map +1 -1
  31. package/dist/src/clientOverlay.d.ts +29 -0
  32. package/dist/src/clientOverlay.d.ts.map +1 -0
  33. package/dist/src/clientOverlay.js +82 -0
  34. package/dist/src/clientOverlay.js.map +1 -0
  35. package/dist/src/events.d.ts +6 -4
  36. package/dist/src/events.d.ts.map +1 -1
  37. package/dist/src/events.js.map +1 -1
  38. package/dist/src/hide.d.ts +4 -0
  39. package/dist/src/hide.d.ts.map +1 -1
  40. package/dist/src/hide.js +11 -1
  41. package/dist/src/hide.js.map +1 -1
  42. package/dist/src/htmlRasterizer.d.ts +21 -42
  43. package/dist/src/htmlRasterizer.d.ts.map +1 -1
  44. package/dist/src/htmlRasterizer.js +47 -66
  45. package/dist/src/htmlRasterizer.js.map +1 -1
  46. package/dist/src/init.d.ts.map +1 -1
  47. package/dist/src/init.js +56 -13
  48. package/dist/src/init.js.map +1 -1
  49. package/dist/src/overlayFlush.js +6 -6
  50. package/dist/src/overlayFlush.js.map +1 -1
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/package.json +6 -1
  53. package/skills/screenci/references/init.md +8 -1
package/dist/src/asset.js CHANGED
@@ -3,12 +3,12 @@ import { parseTimelineOffset } from './timelineOffset.js';
3
3
  import { validateCrop, resolveSourceTrim } from './sourceTrim.js';
4
4
  import { overlayRect } from './overlayRect.js';
5
5
  import { captureCallerFile } from './callerFile.js';
6
+ import { buildClientOverlayDocument } from './clientOverlay.js';
6
7
  import { logMissingAsset } from './missingAssetLog.js';
7
8
  import { access, readFile } from 'fs/promises';
8
9
  import { dirname, resolve } from 'path';
9
10
  import { resolveRecordingTimingDuration } from './runtimeMode.js';
10
- import { resolveOverlayCss, DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR, DEFAULT_ANIMATION_FPS, } from './htmlRasterizer.js';
11
- export { setOverlayCss } from './htmlRasterizer.js';
11
+ import { DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR, DEFAULT_ANIMATION_FPS, } from './htmlRasterizer.js';
12
12
  import { getScreenCIRuntimeContext, getRuntimeAssetRecorder, getRuntimePage, getRuntimeRecordingDir, setRuntimeAssetRecorder, resetAssetRuntimeState, } from './runtimeContext.js';
13
13
  /**
14
14
  * Upper bound for an audio level (linear gain). `4` is +12 dB, plenty of
@@ -224,56 +224,43 @@ export function createOverlays(overlays) {
224
224
  }
225
225
  return result;
226
226
  }
227
- function isReactElementLike(value) {
228
- if (typeof value !== 'object' || value === null)
229
- return false;
230
- // A real React element carries a symbol `$$typeof`.
231
- if (typeof value.$$typeof === 'symbol')
232
- return true;
233
- // Playwright transpiles JSX in `.screenci.tsx` files with its own automatic
234
- // runtime, which produces `{ __pw_type: 'jsx', ... }` nodes instead.
235
- return value.__pw_type === 'jsx';
236
- }
237
227
  function buildOverlayController(name, input) {
238
228
  // A render dependency (selected(...)) embeds another render's output. It is a
239
229
  // plain (branded) object, so it is matched before the generic config path,
240
- // which would otherwise reject it for having no path/element/html.
230
+ // which would otherwise reject it for having no path.
241
231
  if (isDependencyOverlayInput(input)) {
242
232
  return createDependencyOverlayController(name, input);
243
233
  }
244
- // A factory is the only callable input. React elements and config objects are
245
- // plain objects, so this branch never captures them. The config (and its
246
- // validation) is built per call so props can vary placement and content.
234
+ // A factory is the only callable input. Config objects are plain objects, so
235
+ // this branch never captures them. The config (and its validation) is built
236
+ // per call so props can vary placement and content.
247
237
  if (typeof input === 'function') {
248
238
  return (props) => buildOverlayFromConfig(name, input(props));
249
239
  }
250
240
  if (typeof input === 'string') {
251
241
  return buildOverlayFromConfig(name, { path: input });
252
242
  }
253
- if (isReactElementLike(input)) {
254
- return buildOverlayFromConfig(name, { element: input });
255
- }
256
243
  return buildOverlayFromConfig(name, input);
257
244
  }
258
245
  function buildOverlayFromConfig(name, config) {
259
- const hasPath = config.path !== undefined;
260
- const hasElement = config.element !== undefined;
261
- const hasHtml = config.html !== undefined;
262
- const sourceCount = Number(hasPath) + Number(hasElement) + Number(hasHtml);
263
- if (sourceCount > 1) {
264
- throw new Error(`[screenci] Overlay "${name}" must provide only one of "path", "element", or "html".`);
265
- }
266
- if (sourceCount === 0) {
267
- throw new Error(`[screenci] Overlay "${name}" must provide a "path", an "element", or inline "html".`);
268
- }
269
- if (hasHtml) {
270
- validateInlineHtmlFragment(name, config.html);
246
+ if (config.path === undefined) {
247
+ throw new Error(`[screenci] Overlay "${name}" must provide a "path" (a .tsx, .html, .svg, .png, or .mp4 file).`);
271
248
  }
272
- const placementSource = resolvePlacementSource(name, config, {
273
- hasElement,
274
- hasHtml,
275
- hasPath,
276
- });
249
+ const path = config.path;
250
+ const extension = getAssetExtension(path);
251
+ if (extension === null) {
252
+ throw new Error(`[screenci] Overlay "${name}" must use one of: .tsx, .html, .svg, .png, .mp4. Received: ${path}`);
253
+ }
254
+ const isRendered = extension === '.tsx' || extension === '.html';
255
+ const tsxConfig = config;
256
+ // The video-only fields live on MediaOverlayConfig; read them through this
257
+ // accessor since the union member is selected at runtime by the extension.
258
+ const media = config;
259
+ // `props` are a .tsx-page-only concept (enforced at the type level too).
260
+ if (tsxConfig.props !== undefined && extension !== '.tsx') {
261
+ throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "props": props are only supported for .tsx page overlays.`);
262
+ }
263
+ const placementSource = resolvePlacementSource(name, config, { isRendered });
277
264
  const fullScreen = config.fill === 'screen';
278
265
  const pinToScreen = config.pinToScreen === true;
279
266
  const overMouse = config.overMouse === true;
@@ -288,74 +275,58 @@ function buildOverlayFromConfig(name, config) {
288
275
  throw new Error(`[screenci] Overlay "${name}" must provide a finite "fps" greater than 0. Received: ${String(config.fps)}`);
289
276
  }
290
277
  }
291
- if (config.capturePadding !== undefined &&
292
- (!Number.isFinite(config.capturePadding) || config.capturePadding < 0)) {
293
- throw new Error(`[screenci] Overlay "${name}" must provide a finite "capturePadding" greater than or equal to 0. Received: ${String(config.capturePadding)}`);
278
+ if (animate && !isRendered) {
279
+ throw new Error(`[screenci] Overlay "${name}" (${path}) cannot animate: "animate" is only supported for .html and .tsx page overlays.`);
294
280
  }
295
- const renderOpts = {
296
- ...(config.css !== undefined && { css: config.css }),
297
- ...(config.capturePadding !== undefined && {
298
- capturePadding: config.capturePadding,
299
- }),
300
- };
301
- // speed/time re-time a moving picture, so they only apply to .mp4 video
302
- // overlays. Images, HTML, React, and animated overlays have no source rate.
303
- if ((config.speed !== undefined || config.time !== undefined) &&
304
- (hasElement || hasHtml || getAssetExtension(config.path ?? '') !== '.mp4')) {
281
+ // speed/time/start/end re-time a moving picture, so they only apply to .mp4
282
+ // video overlays.
283
+ if ((media.speed !== undefined || media.time !== undefined) &&
284
+ extension !== '.mp4') {
305
285
  throw new Error(`[screenci] Overlay "${name}" only supports speed/time on .mp4 video overlays.`);
306
286
  }
307
- // React element or inline HTML fragment: rendered to markup lazily at
308
- // recording time. Both follow the identical placement/animate/css/padding
309
- // path; only how the markup is produced differs.
310
- if (hasElement || hasHtml) {
311
- const getMarkup = hasElement
312
- ? () => renderElementToMarkup(name, config.element)
313
- : () => Promise.resolve(config.html);
314
- if (animate) {
315
- return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, overMouse, config.fps, configDurationMs, renderOpts);
316
- }
317
- return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, overMouse, configDurationMs, renderOpts);
318
- }
319
- const path = config.path;
320
- const extension = getAssetExtension(path);
321
- // Image/video file overlays never use `over` (rejected in
322
- // resolvePlacementSource), so the source is always a concrete placement for
323
- // them. The `.html` file branch supports `over`, so it keeps the source.
324
- const placement = placementSource.kind === 'fixed'
325
- ? placementSource.placement
326
- : resolveOverlayPlacement(name, config);
327
- if (animate && extension !== '.html') {
328
- throw new Error(`[screenci] Overlay "${name}" (${path}) cannot animate: "animate" is only supported for HTML files and React elements.`);
329
- }
330
- if ((config.css !== undefined || config.capturePadding !== undefined) &&
331
- extension !== '.html') {
332
- throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "css" or "capturePadding": they are only supported for HTML files and React elements.`);
333
- }
334
- // crop applies to image and video files (not rasterized .html); source trim
335
- // (start/end) re-times a moving picture, so it applies to .mp4 videos only.
336
- if (config.crop !== undefined && extension === '.html') {
337
- throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "crop": crop is only supported for image (.svg/.png) and video (.mp4) file overlays.`);
338
- }
339
- if ((config.start !== undefined || config.end !== undefined) &&
287
+ if ((media.start !== undefined || media.end !== undefined) &&
340
288
  extension !== '.mp4') {
341
289
  throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "start"/"end": source trim is only supported for .mp4 video overlays.`);
342
290
  }
343
- // HTML file: read + rasterize to a transparent PNG (or an animated clip).
344
- if (extension === '.html') {
291
+ // crop applies to image and video files only (not rendered pages).
292
+ if (media.crop !== undefined && isRendered) {
293
+ throw new Error(`[screenci] Overlay "${name}" (${path}) cannot use "crop": crop is only supported for image (.svg/.png) and video (.mp4) file overlays.`);
294
+ }
295
+ // .tsx (client-rendered React page) and .html (full document) both rasterize
296
+ // to a transparent PNG (or animated clip). They differ only in how the full
297
+ // document is produced: a .tsx is bundled and mounts its React app into the
298
+ // overlay root (so we wait for it to mount before measuring); a .html file is
299
+ // loaded as-is.
300
+ if (isRendered) {
345
301
  registerAssetPath(path);
346
- const getMarkup = () => readHtmlOverlayFile(path);
302
+ const awaitMount = extension === '.tsx';
303
+ const getDocument = extension === '.tsx'
304
+ ? () => {
305
+ const testFilePath = getScreenCIRuntimeContext().testFilePath;
306
+ const entryPath = testFilePath !== null
307
+ ? resolve(dirname(testFilePath), path)
308
+ : resolve(path);
309
+ return buildClientOverlayDocument(entryPath, tsxConfig.props);
310
+ }
311
+ : () => readHtmlOverlayFile(path);
312
+ const renderOpts = awaitMount ? { awaitMount: true } : {};
347
313
  if (animate) {
348
- return createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, overMouse, config.fps, configDurationMs, renderOpts);
314
+ return createAnimatedOverlayController(name, getDocument, placementSource, fullScreen, pinToScreen, overMouse, config.fps, configDurationMs, renderOpts);
349
315
  }
350
- return createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, overMouse, configDurationMs, renderOpts);
316
+ return createRenderedOverlayController(name, getDocument, placementSource, fullScreen, pinToScreen, overMouse, configDurationMs, renderOpts);
351
317
  }
318
+ // Image/video file overlays never use `over` (rejected in
319
+ // resolvePlacementSource), so the source is always a concrete placement.
320
+ const placement = placementSource.kind === 'fixed'
321
+ ? placementSource.placement
322
+ : resolveOverlayPlacement(name, config);
352
323
  // File-backed image / video overlays.
353
324
  if (extension === '.svg' || extension === '.png') {
354
- if (config.volume !== undefined) {
325
+ if (media.volume !== undefined) {
355
326
  throw new Error(`[screenci] Overlay "${name}" (${path}) is an image and must not provide volume. Use duration instead.`);
356
327
  }
357
- if (config.crop !== undefined) {
358
- validateCrop(`Overlay "${name}" (${path})`, config.crop);
328
+ if (media.crop !== undefined) {
329
+ validateCrop(`Overlay "${name}" (${path})`, media.crop);
359
330
  }
360
331
  registerAssetPath(path);
361
332
  return createFileOverlayController(name, {
@@ -366,24 +337,24 @@ function buildOverlayFromConfig(name, config) {
366
337
  ...(pinToScreen && { pinToScreen: true }),
367
338
  ...(overMouse && { overMouse: true }),
368
339
  ...(configDurationMs !== undefined && { durationMs: configDurationMs }),
369
- ...(config.crop !== undefined && { crop: config.crop }),
340
+ ...(media.crop !== undefined && { crop: media.crop }),
370
341
  });
371
342
  }
372
343
  if (extension === '.mp4') {
373
344
  if (config.duration !== undefined) {
374
345
  throw new Error(`[screenci] Overlay "${name}" (${path}) is a video and must not provide duration. Its natural media duration is used instead.`);
375
346
  }
376
- if (config.volume !== undefined &&
377
- (!Number.isFinite(config.volume) ||
378
- config.volume < 0 ||
379
- config.volume > MAX_AUDIO_LEVEL)) {
347
+ if (media.volume !== undefined &&
348
+ (!Number.isFinite(media.volume) ||
349
+ media.volume < 0 ||
350
+ media.volume > MAX_AUDIO_LEVEL)) {
380
351
  throw new Error(`[screenci] Overlay "${name}" (${path}) must provide a finite volume between 0 and ${MAX_AUDIO_LEVEL} for .mp4 overlays. 1 is the natural level, 0 is silent, and values above 1 boost it.`);
381
352
  }
382
- validateSpeedTime(`Overlay "${name}" (${path})`, config.speed, config.time);
383
- if (config.crop !== undefined) {
384
- validateCrop(`Overlay "${name}" (${path})`, config.crop);
353
+ validateSpeedTime(`Overlay "${name}" (${path})`, media.speed, media.time);
354
+ if (media.crop !== undefined) {
355
+ validateCrop(`Overlay "${name}" (${path})`, media.crop);
385
356
  }
386
- const { sourceStart, sourceEnd } = resolveSourceTrim(`Overlay "${name}" (${path})`, config.start, config.end);
357
+ const { sourceStart, sourceEnd } = resolveSourceTrim(`Overlay "${name}" (${path})`, media.start, media.end);
387
358
  registerAssetPath(path);
388
359
  return createFileOverlayController(name, {
389
360
  kind: 'video',
@@ -392,161 +363,15 @@ function buildOverlayFromConfig(name, config) {
392
363
  fullScreen,
393
364
  ...(pinToScreen && { pinToScreen: true }),
394
365
  ...(overMouse && { overMouse: true }),
395
- ...(config.volume !== undefined && { audio: config.volume }),
396
- ...(config.speed !== undefined && { speed: config.speed }),
397
- ...(config.time !== undefined && { time: config.time }),
398
- ...(config.crop !== undefined && { crop: config.crop }),
366
+ ...(media.volume !== undefined && { audio: media.volume }),
367
+ ...(media.speed !== undefined && { speed: media.speed }),
368
+ ...(media.time !== undefined && { time: media.time }),
369
+ ...(media.crop !== undefined && { crop: media.crop }),
399
370
  ...(sourceStart !== undefined && { sourceStart }),
400
371
  ...(sourceEnd !== undefined && { sourceEnd }),
401
372
  });
402
373
  }
403
- throw new Error(`[screenci] Overlay "${name}" must use one of: .html, .svg, .png, .mp4. Received: ${path}`);
404
- }
405
- /**
406
- * Validates an inline `html` overlay fragment. It must be non-empty and must
407
- * not contain document-level tags (`<!doctype>`, `<html>`, `<head>`, `<body>`):
408
- * screenci wraps the markup in its own document before rasterizing, so a full
409
- * document here would nest documents and break the capture. Mirrors the
410
- * fragment contract of a React `element`.
411
- */
412
- function validateInlineHtmlFragment(name, html) {
413
- if (html.trim().length === 0) {
414
- throw new Error(`[screenci] Overlay "${name}" inline "html" must not be empty.`);
415
- }
416
- const lower = html.toLowerCase();
417
- const forbidden = [
418
- { token: '<!doctype', label: '<!doctype>' },
419
- { token: '<html', label: '<html>' },
420
- { token: '<head', label: '<head>' },
421
- { token: '<body', label: '<body>' },
422
- ];
423
- for (const { token, label } of forbidden) {
424
- if (lower.includes(token)) {
425
- throw new Error(`[screenci] Overlay "${name}" inline "html" must be a fragment, not a full HTML document. Remove the ${label} tag; screenci wraps the markup in a document for you.`);
426
- }
427
- }
428
- validateSingleRootElement(name, html);
429
- }
430
- /**
431
- * HTML void elements: they never have a closing tag, so they do not open a
432
- * nesting level when counting top-level nodes.
433
- */
434
- const VOID_ELEMENTS = new Set([
435
- 'area',
436
- 'base',
437
- 'br',
438
- 'col',
439
- 'embed',
440
- 'hr',
441
- 'img',
442
- 'input',
443
- 'link',
444
- 'meta',
445
- 'param',
446
- 'source',
447
- 'track',
448
- 'wbr',
449
- ]);
450
- /**
451
- * Ensures an inline `html` fragment has exactly one top-level (root) element and
452
- * no loose top-level text, so it wraps cleanly into screenci's overlay document
453
- * and sizes predictably. Multiple siblings (for example `<div/><div/>`) or stray
454
- * text outside the root are rejected. The markup inside the root may be anything,
455
- * including `<script>`/`<style>`, which are left to the overlay renderer.
456
- *
457
- * This is a lightweight tag scanner, not a full HTML parser: it tracks nesting
458
- * depth across opening, closing, void, and self-closing tags (skipping comments
459
- * and quoted attribute values) which covers ordinary fragment markup.
460
- */
461
- function validateSingleRootElement(name, html) {
462
- const fail = () => {
463
- throw new Error(`[screenci] Overlay "${name}" inline "html" must contain a single root element (for example '<div class="badge">New</div>'). Wrap multiple top-level nodes in one container.`);
464
- };
465
- // Drop comments so they never count as top-level content.
466
- const stripped = html.replace(/<!--[\s\S]*?-->/g, '');
467
- const tagRe = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
468
- let depth = 0;
469
- let rootElements = 0;
470
- let lastIndex = 0;
471
- let match;
472
- while ((match = tagRe.exec(stripped)) !== null) {
473
- const before = stripped.slice(lastIndex, match.index);
474
- if (depth === 0 && before.trim().length > 0)
475
- fail();
476
- lastIndex = match.index + match[0].length;
477
- const isClosing = match[1] === '/';
478
- const tagName = match[2].toLowerCase();
479
- const selfClosing = match[4] === '/';
480
- if (isClosing) {
481
- depth = Math.max(0, depth - 1);
482
- continue;
483
- }
484
- if (depth === 0)
485
- rootElements += 1;
486
- if (!selfClosing && !VOID_ELEMENTS.has(tagName))
487
- depth += 1;
488
- }
489
- if (stripped.slice(lastIndex).trim().length > 0 && depth === 0)
490
- fail();
491
- if (rootElements !== 1)
492
- fail();
493
- }
494
- async function renderElementToMarkup(name, element) {
495
- let reactDomServer;
496
- let react;
497
- try {
498
- reactDomServer = (await import('react-dom/server'));
499
- react = (await import('react'));
500
- }
501
- catch {
502
- throw new Error(`[screenci] Overlay "${name}" is a React element, which requires "react" and "react-dom" to be installed. Run: npm i react react-dom (plus @types/react @types/react-dom for TypeScript). Re-run "screenci init" and answer yes to React overlay support to scaffold this.`);
503
- }
504
- // Playwright's JSX runtime produces `__pw_type` nodes rather than real React
505
- // elements; convert them (invoking function components, whose bodies are also
506
- // pw-jsx) before handing the tree to react-dom.
507
- const renderable = isPwJsxNode(element)
508
- ? pwJsxToReactNode(element, react)
509
- : element;
510
- return reactDomServer.renderToStaticMarkup(renderable);
511
- }
512
- function isPwJsxNode(value) {
513
- return (typeof value === 'object' &&
514
- value !== null &&
515
- value.__pw_type === 'jsx');
516
- }
517
- function isPwFragment(type) {
518
- return (typeof type === 'object' &&
519
- type !== null &&
520
- type.__pw_jsx_fragment === true);
521
- }
522
- function pwChildrenToArray(children) {
523
- if (children === undefined || children === null)
524
- return [];
525
- return Array.isArray(children) ? children : [children];
526
- }
527
- /**
528
- * Converts a Playwright JSX node tree into real React nodes. Function components
529
- * are invoked (their bodies are pw-jsx too) and their output converted, so the
530
- * result is a host-element/primitive tree that react-dom can render statically.
531
- */
532
- function pwJsxToReactNode(node, react) {
533
- const createElement = react.createElement;
534
- if (Array.isArray(node)) {
535
- return node.map((child) => pwJsxToReactNode(child, react));
536
- }
537
- if (!isPwJsxNode(node))
538
- return node;
539
- const { type, props } = node;
540
- if (isPwFragment(type)) {
541
- const kids = pwChildrenToArray(props?.children).map((c) => pwJsxToReactNode(c, react));
542
- return createElement(react.Fragment, null, ...kids);
543
- }
544
- if (typeof type === 'function') {
545
- return pwJsxToReactNode(type(props ?? {}), react);
546
- }
547
- const { children, ...rest } = props ?? {};
548
- const kids = pwChildrenToArray(children).map((c) => pwJsxToReactNode(c, react));
549
- return createElement(type, rest, ...kids);
374
+ throw new Error(`[screenci] Overlay "${name}" must use one of: .tsx, .html, .svg, .png, .mp4. Received: ${path}`);
550
375
  }
551
376
  async function readHtmlOverlayFile(path) {
552
377
  const testFilePath = getScreenCIRuntimeContext().testFilePath;
@@ -775,14 +600,15 @@ function createFileOverlayController(name, resolved) {
775
600
  });
776
601
  }
777
602
  /**
778
- * An overlay rendered to a transparent PNG at recording time, from either an
779
- * HTML file or a React element. `getMarkup` produces the HTML to rasterize.
603
+ * An overlay rendered to a transparent PNG at recording time, from either a full
604
+ * `.html` document or a bundled `.tsx` page. `getDocument` produces the full
605
+ * overlay document to rasterize.
780
606
  */
781
- function createRenderedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, overMouse, durationMs, renderOpts = {}) {
782
- // The markup and placement are resolved during the test (cheap:
783
- // renderToStaticMarkup / a string, plus a boundingBox read for `over`), but
784
- // rasterization (a browser screenshot) is deferred to after the test so
785
- // identical overlays render once. See overlayFlush.ts.
607
+ function createRenderedOverlayController(name, getDocument, placementSource, fullScreen, pinToScreen, overMouse, durationMs, renderOpts = {}) {
608
+ // The document and placement are resolved during the test (cheap: a file read
609
+ // or an esbuild bundle, plus a boundingBox read for `over`), but rasterization
610
+ // (a browser screenshot) is deferred to after the test so identical overlays
611
+ // render once. See overlayFlush.ts.
786
612
  let resolvedHtml;
787
613
  let resolvedPlacement;
788
614
  let skipped = false;
@@ -822,8 +648,7 @@ function createRenderedOverlayController(name, getMarkup, placementSource, fullS
822
648
  kind: 'image',
823
649
  name,
824
650
  html: resolvedHtml,
825
- css: resolveOverlayCss(renderOpts.css),
826
- capturePadding: renderOpts.capturePadding ?? 0,
651
+ ...(renderOpts.awaitMount === true && { awaitMount: true }),
827
652
  deviceScaleFactor: DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR,
828
653
  },
829
654
  });
@@ -840,9 +665,11 @@ function createRenderedOverlayController(name, getMarkup, placementSource, fullS
840
665
  }
841
666
  const { placement, sizePx } = await resolvePlacement(placementSource);
842
667
  resolvedPlacement = placement;
843
- const markup = await getMarkup();
668
+ const document = await getDocument();
844
669
  resolvedHtml =
845
- sizePx !== undefined ? sizeWrapMarkup(markup, sizePx) : markup;
670
+ sizePx !== undefined
671
+ ? injectOverlayRootSize(document, sizePx)
672
+ : document;
846
673
  },
847
674
  });
848
675
  }
@@ -857,7 +684,7 @@ function createRenderedOverlayController(name, getMarkup, placementSource, fullS
857
684
  * the recording, the old "hide block" that cut capture wall-clock from the
858
685
  * timeline is no longer needed.
859
686
  */
860
- function createAnimatedOverlayController(name, getMarkup, placementSource, fullScreen, pinToScreen, overMouse, fps, configDurationMs, renderOpts = {}) {
687
+ function createAnimatedOverlayController(name, getDocument, placementSource, fullScreen, pinToScreen, overMouse, fps, configDurationMs, renderOpts = {}) {
861
688
  let resolved;
862
689
  let skipped = false;
863
690
  const resolveDurationMs = (mode) => {
@@ -898,8 +725,7 @@ function createAnimatedOverlayController(name, getMarkup, placementSource, fullS
898
725
  kind: 'animation',
899
726
  name,
900
727
  html: resolved.html,
901
- css: resolveOverlayCss(renderOpts.css),
902
- capturePadding: renderOpts.capturePadding ?? 0,
728
+ ...(renderOpts.awaitMount === true && { awaitMount: true }),
903
729
  deviceScaleFactor: DEFAULT_OVERLAY_DEVICE_SCALE_FACTOR,
904
730
  fps: fps ?? DEFAULT_ANIMATION_FPS,
905
731
  durationMs: resolved.durationMs,
@@ -915,9 +741,11 @@ function createAnimatedOverlayController(name, getMarkup, placementSource, fullS
915
741
  }
916
742
  const durationMs = resolveDurationMs(mode);
917
743
  const { placement, sizePx } = await resolvePlacement(placementSource);
918
- const markup = await getMarkup();
744
+ const document = await getDocument();
919
745
  resolved = {
920
- html: sizePx !== undefined ? sizeWrapMarkup(markup, sizePx) : markup,
746
+ html: sizePx !== undefined
747
+ ? injectOverlayRootSize(document, sizePx)
748
+ : document,
921
749
  durationMs,
922
750
  ...(placement !== undefined && { placement }),
923
751
  };
@@ -929,7 +757,8 @@ function getAssetExtension(path) {
929
757
  if (dotIndex === -1)
930
758
  return null;
931
759
  const extension = path.slice(dotIndex).toLowerCase();
932
- if (extension === '.html' ||
760
+ if (extension === '.tsx' ||
761
+ extension === '.html' ||
933
762
  extension === '.svg' ||
934
763
  extension === '.png' ||
935
764
  extension === '.mp4') {
@@ -1069,11 +898,8 @@ function resolvePlacementSource(name, config, flags) {
1069
898
  if (config.over === undefined) {
1070
899
  return { kind: 'fixed', placement: resolveOverlayPlacement(name, config) };
1071
900
  }
1072
- const isRendered = flags.hasElement ||
1073
- flags.hasHtml ||
1074
- (flags.hasPath && getAssetExtension(config.path ?? '') === '.html');
1075
- if (!isRendered) {
1076
- throw new Error(`[screenci] Overlay "${name}" can only use "over" with a React element, inline "html", or an .html file (the overlay is sized to the element's box).`);
901
+ if (!flags.isRendered) {
902
+ throw new Error(`[screenci] Overlay "${name}" can only use "over" with a .html or .tsx page overlay (the overlay is sized to the element's box).`);
1077
903
  }
1078
904
  if (config.fill !== undefined) {
1079
905
  throw new Error(`[screenci] Overlay "${name}" cannot set both "over" and "fill".`);
@@ -1113,13 +939,25 @@ async function resolvePlacement(source) {
1113
939
  };
1114
940
  }
1115
941
  /**
1116
- * Wraps overlay markup in a box of the given CSS pixel size so the rasterized
1117
- * PNG carries the element's aspect ratio. The renderer then derives the overlay
1118
- * height from that aspect, landing it exactly on the element's box. The wrapped
1119
- * content should fill the box (for example `width:100%;height:100%`).
942
+ * Injects a fixed CSS-pixel size for the overlay root into a full overlay
943
+ * document, so the rasterized PNG carries the element's box (used by `over`,
944
+ * which sizes the overlay to a locator). The renderer then lands it exactly on
945
+ * the element's box. The page's content should fill the root
946
+ * (`width:100%;height:100%`). Applied by inserting a `<style>` right after
947
+ * `<head>` (or at the start of the document if there is no head), sizing
948
+ * `#screenci-overlay-root` and falling back to `body`.
1120
949
  */
1121
- function sizeWrapMarkup(html, size) {
1122
- return `<div style="width:${size.width}px;height:${size.height}px;box-sizing:border-box">${html}</div>`;
950
+ function injectOverlayRootSize(document, size) {
951
+ const style = `<style>html,body{margin:0}` +
952
+ `#screenci-overlay-root,body{` +
953
+ `width:${size.width}px;height:${size.height}px;box-sizing:border-box}` +
954
+ `</style>`;
955
+ const headMatch = /<head[^>]*>/i.exec(document);
956
+ if (headMatch !== null) {
957
+ const at = headMatch.index + headMatch[0].length;
958
+ return document.slice(0, at) + style + document.slice(at);
959
+ }
960
+ return style + document;
1123
961
  }
1124
962
  function toRecordedFileStart(name, resolved, mode) {
1125
963
  if (resolved.kind === 'image') {