screenci 0.0.89 → 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.
- package/dist/docs/video-sources/assets/StyleCard.d.ts +4 -0
- package/dist/docs/video-sources/assets/StyleCard.d.ts.map +1 -0
- package/dist/docs/video-sources/assets/StyleCard.js +27 -0
- package/dist/docs/video-sources/assets/StyleCard.js.map +1 -0
- package/dist/docs/video-sources/locator-highlight-animated.screenci.js +2 -15
- package/dist/docs/video-sources/locator-highlight-animated.screenci.js.map +1 -1
- package/dist/docs/video-sources/locator-highlight-still.screenci.js +2 -1
- package/dist/docs/video-sources/locator-highlight-still.screenci.js.map +1 -1
- package/dist/docs/video-sources/speaking-style.screenci.js +4 -28
- package/dist/docs/video-sources/speaking-style.screenci.js.map +1 -1
- package/dist/e2e/fixtures/ClientCounter.d.ts +5 -0
- package/dist/e2e/fixtures/ClientCounter.d.ts.map +1 -0
- package/dist/e2e/fixtures/ClientCounter.js +29 -0
- package/dist/e2e/fixtures/ClientCounter.js.map +1 -0
- package/dist/e2e/htmlRasterizer.e2e.js +104 -23
- package/dist/e2e/htmlRasterizer.e2e.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/src/asset.d.ts +50 -85
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +116 -278
- package/dist/src/asset.js.map +1 -1
- package/dist/src/clientOverlay.d.ts +29 -0
- package/dist/src/clientOverlay.d.ts.map +1 -0
- package/dist/src/clientOverlay.js +82 -0
- package/dist/src/clientOverlay.js.map +1 -0
- package/dist/src/events.d.ts +6 -4
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js.map +1 -1
- package/dist/src/htmlRasterizer.d.ts +21 -42
- package/dist/src/htmlRasterizer.d.ts.map +1 -1
- package/dist/src/htmlRasterizer.js +47 -66
- package/dist/src/htmlRasterizer.js.map +1 -1
- package/dist/src/init.d.ts.map +1 -1
- package/dist/src/init.js +56 -13
- package/dist/src/init.js.map +1 -1
- package/dist/src/overlayFlush.js +6 -6
- package/dist/src/overlayFlush.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -1
- 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 {
|
|
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
|
|
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.
|
|
245
|
-
//
|
|
246
|
-
//
|
|
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
|
-
|
|
260
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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 (
|
|
292
|
-
(
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
344
|
-
if (
|
|
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
|
|
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,
|
|
314
|
+
return createAnimatedOverlayController(name, getDocument, placementSource, fullScreen, pinToScreen, overMouse, config.fps, configDurationMs, renderOpts);
|
|
349
315
|
}
|
|
350
|
-
return createRenderedOverlayController(name,
|
|
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 (
|
|
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 (
|
|
358
|
-
validateCrop(`Overlay "${name}" (${path})`,
|
|
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
|
-
...(
|
|
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 (
|
|
377
|
-
(!Number.isFinite(
|
|
378
|
-
|
|
379
|
-
|
|
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})`,
|
|
383
|
-
if (
|
|
384
|
-
validateCrop(`Overlay "${name}" (${path})`,
|
|
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})`,
|
|
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
|
-
...(
|
|
396
|
-
...(
|
|
397
|
-
...(
|
|
398
|
-
...(
|
|
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
|
|
779
|
-
*
|
|
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,
|
|
782
|
-
// The
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
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
|
-
|
|
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
|
|
668
|
+
const document = await getDocument();
|
|
844
669
|
resolvedHtml =
|
|
845
|
-
sizePx !== undefined
|
|
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,
|
|
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
|
-
|
|
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
|
|
744
|
+
const document = await getDocument();
|
|
919
745
|
resolved = {
|
|
920
|
-
html: sizePx !== undefined
|
|
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 === '.
|
|
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
|
-
|
|
1073
|
-
|
|
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
|
-
*
|
|
1117
|
-
* PNG carries the element's
|
|
1118
|
-
*
|
|
1119
|
-
* content should fill the
|
|
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
|
|
1122
|
-
|
|
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') {
|