diff --git a/apps/docs/src/app/commands/page.mdx b/apps/docs/src/app/commands/page.mdx
index 90fe0bc..88bd1eb 100644
--- a/apps/docs/src/app/commands/page.mdx
+++ b/apps/docs/src/app/commands/page.mdx
@@ -1,7 +1,7 @@
export const metadata = {
title: "Commands",
description:
- "CLI commands available in webreel: init, record, preview, composite, install, and validate.",
+ "CLI commands available in webreel: init, record, preview, composite, list, install, and validate.",
};
# Commands
@@ -53,11 +53,15 @@ Record videos:
webreel record
webreel record hero login
webreel record -c custom.config.json
+webreel record -c web.json -c mobile.json
+webreel record -f "dashboard-*"
webreel record --watch
```
When run without arguments, webreel finds the nearest config file (searching parent directories) and records all videos. Provide video names to record specific videos only.
+The `-c` flag can be repeated to load multiple config files, and supports globs (e.g. `-c "configs/*.json"`). The `-f` flag filters videos by name or glob pattern.
+
@@ -70,7 +74,13 @@ When run without arguments, webreel finds the nearest config file (searching par
-c, --config
|
- Path to config file (default: auto-discovered) |
+ Config file path or glob (repeatable, default: auto-discovered) |
+
+
+
+ -f, --filter
+ |
+ Filter videos by name or glob pattern (repeatable) |
|
@@ -90,6 +100,12 @@ When run without arguments, webreel finds the nearest config file (searching par
|
Print the fully resolved config and step list without recording |
+
+
+ --frames
+ |
+ Save raw frames as JPEGs in .webreel/frames/ |
+
@@ -117,7 +133,13 @@ When run without a video name, the first video in the config is previewed.
-c, --config
|
- Path to config file (default: webreel.config.json) |
+ Config file path or glob (repeatable) |
+
+
+
+ -f, --filter
+ |
+ Filter videos by name or glob pattern (repeatable) |
|
@@ -136,6 +158,7 @@ Re-composite videos from stored raw recordings and timelines. This re-applies cu
webreel composite
webreel composite hero
webreel composite -c custom.config.json
+webreel composite -f "mobile-*"
```
Raw video and timeline data are stored in `.webreel/raw/` and `.webreel/timelines/` after the first recording.
@@ -152,7 +175,55 @@ Raw video and timeline data are stored in `.webreel/raw/` and `.webreel/timeline
|
-c, --config
|
- Path to config file (default: webreel.config.json) |
+ Config file path or glob (repeatable) |
+
+
+
+ -f, --filter
+ |
+ Filter videos by name or glob pattern (repeatable) |
+
+
+
+
+## list
+
+List all videos across config file(s):
+
+```bash
+webreel list
+webreel list -c web.json -c mobile.json
+webreel list -f "dashboard-*"
+webreel list --json
+```
+
+Shows video names, URLs, source config files, step counts, and viewports. Use `--json` for machine-readable output.
+
+
+
+
+ | Flag |
+ Description |
+
+
+
+
+
+ -c, --config
+ |
+ Config file path or glob (repeatable) |
+
+
+
+ -f, --filter
+ |
+ Filter videos by name or glob pattern (repeatable) |
+
+
+
+ --json
+ |
+ Output as JSON |
@@ -185,13 +256,16 @@ webreel install --force
## validate
-Check a config file for errors without running it:
+Check config file(s) for errors without running them:
```bash
webreel validate
webreel validate -c custom.config.json
+webreel validate -c web.json -c mobile.json
```
+When multiple configs are specified, cross-config validation checks for duplicate video names.
+
@@ -204,7 +278,7 @@ webreel validate -c custom.config.json
-c, --config
|
- Path to config file (default: webreel.config.json) |
+ Config file path or glob (repeatable) |
diff --git a/apps/docs/src/app/configuration/page.mdx b/apps/docs/src/app/configuration/page.mdx
index 4f3ccce..7caee8f 100644
--- a/apps/docs/src/app/configuration/page.mdx
+++ b/apps/docs/src/app/configuration/page.mdx
@@ -131,12 +131,29 @@ These fields apply as defaults to all videos and can be overridden per-video.
- |
Sound effects configuration for click and keystroke sounds |
+
+
+ extends
+ |
+ - |
+ Path to a base config file to inherit from |
+
+
+
+ scenarios
+ |
+ - |
+ Array of glob patterns and/or inline scenario configs |
+
videos
|
- required |
- Object mapping video names to their configurations |
+ required* |
+
+ Object mapping video names to their configurations. Optional when{" "}
+ scenarios is present.
+ |
@@ -907,6 +924,114 @@ import type { InputWebreelConfig } from "webreel";
JavaScript configs (`webreel.config.js`, `webreel.config.mjs`) work the same way: just `export default` a config object.
+## extends
+
+A config file can inherit from a base config using the `extends` field. The base config's top-level options, theme, sfx, and videos are merged with the child config. Child values take precedence.
+
+```json
+// base.json
+{
+ "baseUrl": "https://myapp.com",
+ "viewport": "desktop",
+ "theme": { "cursor": { "size": 32 } },
+ "videos": {
+ "shared-intro": { "url": "/", "steps": [] }
+ }
+}
+```
+
+```json
+// webreel.config.json
+{
+ "extends": "./base.json",
+ "theme": { "cursor": { "hotspot": "center" } },
+ "videos": {
+ "hero": { "url": "/hero", "steps": [] }
+ }
+}
+```
+
+The resolved config inherits `baseUrl`, `viewport`, and the `shared-intro` video from the base. The `theme.cursor` is shallow-merged: `{ "size": 32, "hotspot": "center" }`. The child's `hero` video is added alongside the base's `shared-intro`.
+
+Chains are supported (a base can extend another base), up to 10 levels deep. Circular references are detected and reported.
+
+## scenarios
+
+For projects with many demo videos, the `scenarios` field lets you organize configs across multiple files or inline groups. Each scenario is loaded independently, and all videos are collected into a single run.
+
+```json
+{
+ "baseUrl": "https://myapp.com",
+ "scenarios": [
+ "./demos/*.webreel.json",
+ {
+ "extends": true,
+ "videos": {
+ "login": { "url": "/login", "steps": [] }
+ }
+ }
+ ],
+ "videos": {
+ "hero": { "url": "/", "steps": [] }
+ }
+}
+```
+
+Scenario entries can be:
+
+- **Glob strings** that match config files (e.g. `"./demos/*.webreel.json"`). Each matched file is loaded as a standalone config.
+- **Inline objects** with their own `videos`, `baseUrl`, `theme`, etc. Use `"extends": true` to inherit top-level options from the root config.
+
+The `videos` field is optional when `scenarios` is present. This lets you use a root config purely as an orchestrator.
+
+### defineScenario
+
+For TypeScript configs, use the `defineScenario` helper for inline scenario objects:
+
+```ts
+import { defineConfig, defineScenario } from "webreel";
+
+export default defineConfig({
+ baseUrl: "https://myapp.com",
+ scenarios: [
+ "./demos/*.webreel.ts",
+ defineScenario({
+ extends: true,
+ videos: {
+ login: { url: "/login", steps: [] },
+ },
+ }),
+ ],
+});
+```
+
+## Multiple configs
+
+Use `--config` (or `-c`) multiple times to load several config files in a single run. All videos are merged and deduplicated by name.
+
+```bash
+webreel record -c web.config.json -c mobile.config.json
+webreel list -c ./configs/*.json
+```
+
+Glob patterns are supported. If any two configs define a video with the same name, an error is reported.
+
+## --filter
+
+The `--filter` flag (or `-f`) filters videos by name using exact match or glob patterns. It can be repeated.
+
+```bash
+webreel record -f hero -f "dashboard-*"
+webreel list -f "mobile-*"
+```
+
+When combined with positional video names, the results are unioned:
+
+```bash
+# Records "hero" (by name) and all videos matching "dashboard-*" (by filter)
+webreel record hero -f "dashboard-*"
+```
+
## Dry run
Use `webreel record --dry-run` to print the fully resolved config (with defaults applied, includes merged) and step list without launching a browser. This is useful for debugging complex configs.
diff --git a/packages/webreel/package.json b/packages/webreel/package.json
index c4af5c8..766b82e 100644
--- a/packages/webreel/package.json
+++ b/packages/webreel/package.json
@@ -54,10 +54,13 @@
"@webreel/core": "workspace:*",
"commander": "^14.0.3",
"jiti": "^2.6.1",
- "jsonc-parser": "^3.3.1"
+ "jsonc-parser": "^3.3.1",
+ "picomatch": "^4.0.3",
+ "tinyglobby": "^0.2.15"
},
"devDependencies": {
"@types/node": "25.3.0",
+ "@types/picomatch": "^4.0.2",
"typescript": "5.9.3"
}
}
diff --git a/packages/webreel/src/commands/__tests__/record.test.ts b/packages/webreel/src/commands/__tests__/record.test.ts
index 35a7df9..db7c8a6 100644
--- a/packages/webreel/src/commands/__tests__/record.test.ts
+++ b/packages/webreel/src/commands/__tests__/record.test.ts
@@ -1,96 +1,80 @@
import { describe, it, expect } from "vitest";
-import { resolve, dirname } from "node:path";
+import { resolve } from "node:path";
import { collectIncludePaths } from "../record.js";
-import type { WebreelConfig } from "../../lib/types.js";
+import type { FullConfig, VideoConfig } from "../../lib/types.js";
describe("collectIncludePaths", () => {
- const configPath = "/project/webreel.config.json";
- const configDir = dirname(configPath);
+ const configDir = "/project";
- function makeConfig(overrides: Partial = {}): WebreelConfig {
+ function makeFull(videos: Partial[]): FullConfig {
return {
- videos: [
- {
- name: "test",
- url: "https://example.com",
- steps: [],
- },
- ],
- ...overrides,
+ videos: videos.map((v, i) => ({
+ name: `v${i}`,
+ configDir,
+ url: "https://example.com",
+ steps: [],
+ ...v,
+ })) as VideoConfig[],
+ videoSources: new Map(),
};
}
it("returns empty array when no includes exist", () => {
- const config = makeConfig();
- expect(collectIncludePaths(config, configPath)).toEqual([]);
+ const full = makeFull([{}]);
+ expect(collectIncludePaths(full)).toEqual([]);
});
- it("resolves top-level include paths relative to config dir", () => {
- const config = makeConfig({ include: ["steps/setup.json"] });
- const result = collectIncludePaths(config, configPath);
+ it("resolves per-video include paths relative to video configDir", () => {
+ const full = makeFull([{ include: ["steps/setup.json"] }]);
+ const result = collectIncludePaths(full);
expect(result).toEqual([resolve(configDir, "steps/setup.json")]);
});
- it("resolves per-video include paths", () => {
- const config = makeConfig({
- videos: [
- {
- name: "v1",
- url: "https://example.com",
- steps: [],
- include: ["steps/v1-setup.json"],
- },
- ],
- });
- const result = collectIncludePaths(config, configPath);
- expect(result).toEqual([resolve(configDir, "steps/v1-setup.json")]);
- });
-
- it("combines top-level and per-video includes", () => {
- const config = makeConfig({
- include: ["steps/shared.json"],
- videos: [
- {
- name: "v1",
- url: "https://example.com",
- steps: [],
- include: ["steps/v1.json"],
- },
- {
- name: "v2",
- url: "https://example.com",
- steps: [],
- include: ["steps/v2.json"],
- },
- ],
- });
- const result = collectIncludePaths(config, configPath);
+ it("combines includes from multiple videos", () => {
+ const full = makeFull([
+ { include: ["steps/v1.json"] },
+ { include: ["steps/v2.json"] },
+ ]);
+ const result = collectIncludePaths(full);
expect(result).toEqual([
- resolve(configDir, "steps/shared.json"),
resolve(configDir, "steps/v1.json"),
resolve(configDir, "steps/v2.json"),
]);
});
it("deduplicates identical paths", () => {
- const config = makeConfig({
- include: ["steps/setup.json"],
+ const full = makeFull([
+ { include: ["steps/setup.json"] },
+ { include: ["steps/setup.json"] },
+ ]);
+ const result = collectIncludePaths(full);
+ expect(result).toEqual([resolve(configDir, "steps/setup.json")]);
+ });
+
+ it("uses different configDir per video", () => {
+ const full: FullConfig = {
videos: [
{
- name: "v1",
+ name: "a",
+ configDir: "/projectA",
url: "https://example.com",
steps: [],
- include: ["steps/setup.json"],
- },
+ include: ["steps/a.json"],
+ } as VideoConfig,
{
- name: "v2",
+ name: "b",
+ configDir: "/projectB",
url: "https://example.com",
steps: [],
- include: ["steps/setup.json"],
- },
+ include: ["steps/b.json"],
+ } as VideoConfig,
],
- });
- const result = collectIncludePaths(config, configPath);
- expect(result).toEqual([resolve(configDir, "steps/setup.json")]);
+ videoSources: new Map(),
+ };
+ const result = collectIncludePaths(full);
+ expect(result).toEqual([
+ resolve("/projectA", "steps/a.json"),
+ resolve("/projectB", "steps/b.json"),
+ ]);
});
});
diff --git a/packages/webreel/src/commands/composite.ts b/packages/webreel/src/commands/composite.ts
index d1c1363..8544187 100644
--- a/packages/webreel/src/commands/composite.ts
+++ b/packages/webreel/src/commands/composite.ts
@@ -2,28 +2,30 @@ import { Command } from "commander";
import { readFileSync, existsSync, mkdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { compose, type TimelineData } from "@webreel/core";
-import {
- loadWebreelConfig,
- resolveConfigPath,
- getConfigDir,
- filterVideosByName,
-} from "../lib/config.js";
+import { loadFullConfig, resolveConfigPaths, filterVideos } from "../lib/config.js";
import { extractThumbnailIfConfigured } from "../lib/runner.js";
+function accumulate(val: string, prev: string[]): string[] {
+ return [...prev, val];
+}
+
export const compositeCommand = new Command("composite")
.description("Re-composite videos from stored raw recordings and timelines")
.argument("[videos...]", "Video names to composite (default: all)")
- .option("-c, --config ", "Path to config file (default: webreel.config.json)")
- .action(async (videoNames: string[], opts: { config?: string }) => {
- const configPath = resolveConfigPath(opts.config);
- const configDir = getConfigDir(configPath);
- const webreelConfig = await loadWebreelConfig(configPath);
- const videos = filterVideosByName(webreelConfig.videos, videoNames);
+ .option("-c, --config ", "Config file (repeatable)", accumulate, [])
+ .option("-f, --filter ", "Filter by video name/glob (repeatable)", accumulate, [])
+ .action(async (videoNames: string[], opts: { config: string[]; filter: string[] }) => {
+ const configPaths = await resolveConfigPaths(
+ opts.config.length > 0 ? opts.config : undefined,
+ );
+ const fullConfig = await loadFullConfig(configPaths);
+ const videos = filterVideos(fullConfig.videos, videoNames, opts.filter);
for (const video of videos) {
- const rawPath = resolve(configDir, ".webreel", "raw", `${video.name}.mp4`);
+ const videoDir = video.configDir;
+ const rawPath = resolve(videoDir, ".webreel", "raw", `${video.name}.mp4`);
const timelinePath = resolve(
- configDir,
+ videoDir,
".webreel",
"timelines",
`${video.name}.timeline.json`,
@@ -44,8 +46,7 @@ export const compositeCommand = new Command("composite")
} catch (err) {
throw new Error(`Invalid timeline file: ${timelinePath}`, { cause: err });
}
- const outputPath =
- video.output ?? resolve(configDir, "videos", `${video.name}.mp4`);
+ const outputPath = video.output ?? resolve(videoDir, "videos", `${video.name}.mp4`);
mkdirSync(dirname(outputPath), { recursive: true });
console.log(`Compositing: ${video.name}`);
diff --git a/packages/webreel/src/commands/list.ts b/packages/webreel/src/commands/list.ts
new file mode 100644
index 0000000..bf883d1
--- /dev/null
+++ b/packages/webreel/src/commands/list.ts
@@ -0,0 +1,51 @@
+import { Command } from "commander";
+import { loadFullConfig, resolveConfigPaths, filterVideos } from "../lib/config.js";
+
+function accumulate(val: string, prev: string[]): string[] {
+ return [...prev, val];
+}
+
+export const listCommand = new Command("list")
+ .description("List all videos across config(s)")
+ .option("-c, --config ", "Config file (repeatable)", accumulate, [])
+ .option("-f, --filter ", "Filter by video name/glob (repeatable)", accumulate, [])
+ .option("--json", "Output as JSON")
+ .action(async (opts: { config: string[]; filter: string[]; json?: boolean }) => {
+ const configPaths = await resolveConfigPaths(
+ opts.config.length > 0 ? opts.config : undefined,
+ );
+ const fullConfig = await loadFullConfig(configPaths);
+ const videos = filterVideos(fullConfig.videos, [], opts.filter);
+
+ if (opts.json) {
+ const data = videos.map((v) => ({
+ name: v.name,
+ url: v.url,
+ source: fullConfig.videoSources.get(v.name) ?? null,
+ steps: v.steps.length,
+ viewport: v.viewport ?? null,
+ output: v.output ?? null,
+ }));
+ console.log(JSON.stringify(data, null, 2));
+ return;
+ }
+
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
+ const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
+
+ console.log(`\n${videos.length} video(s):\n`);
+
+ for (const video of videos) {
+ const source = fullConfig.videoSources.get(video.name);
+ const sourceLabel = source ? dim(` (${source})`) : "";
+ const viewport = video.viewport
+ ? dim(` ${video.viewport.width}x${video.viewport.height}`)
+ : "";
+ const steps = dim(` ${video.steps.length} step(s)`);
+
+ console.log(` ${cyan(video.name)}${sourceLabel}`);
+ console.log(` ${video.url}${viewport}${steps}`);
+ }
+
+ console.log();
+ });
diff --git a/packages/webreel/src/commands/preview.ts b/packages/webreel/src/commands/preview.ts
index 9fea74b..f5c138a 100644
--- a/packages/webreel/src/commands/preview.ts
+++ b/packages/webreel/src/commands/preview.ts
@@ -1,39 +1,46 @@
import { Command } from "commander";
-import { loadWebreelConfig, resolveConfigPath, getConfigDir } from "../lib/config.js";
+import { loadFullConfig, resolveConfigPaths, filterVideos } from "../lib/config.js";
import { runVideo } from "../lib/runner.js";
+function accumulate(val: string, prev: string[]): string[] {
+ return [...prev, val];
+}
+
export const previewCommand = new Command("preview")
.description("Run a video in a visible browser without recording")
.argument("[video]", "Video name to preview (default: first video)")
- .option("-c, --config ", "Path to config file (default: webreel.config.json)")
+ .option("-c, --config ", "Config file (repeatable)", accumulate, [])
+ .option("-f, --filter ", "Filter by video name/glob (repeatable)", accumulate, [])
.option("--verbose", "Log each step as it executes")
.action(
async (
videoName: string | undefined,
- opts: { config?: string; verbose?: boolean },
+ opts: { config: string[]; filter: string[]; verbose?: boolean },
) => {
- const configPath = resolveConfigPath(opts.config);
- const configDir = getConfigDir(configPath);
+ const configPaths = await resolveConfigPaths(
+ opts.config.length > 0 ? opts.config : undefined,
+ );
const verbose = opts.verbose ?? false;
- const webreelConfig = await loadWebreelConfig(configPath);
+ const fullConfig = await loadFullConfig(configPaths);
+ const filtered = filterVideos(
+ fullConfig.videos,
+ videoName ? [videoName] : [],
+ opts.filter,
+ );
+
+ if (filtered.length === 0) {
+ throw new Error("No videos defined in config.");
+ }
- let video;
- if (videoName) {
- video = webreelConfig.videos.find((v) => v.name === videoName);
- if (!video) {
- throw new Error(
- `Video "${videoName}" not found. Available: ${webreelConfig.videos.map((v) => v.name).join(", ")}`,
- );
- }
- } else {
- video = webreelConfig.videos[0];
- if (!video) {
- throw new Error("No videos defined in config.");
- }
+ const video = filtered[0];
+ if (filtered.length > 1) {
+ console.log(
+ `Matched ${filtered.length} video(s). Previewing first match. Use video name for a specific video.`,
+ );
}
console.log(`\nPreviewing: ${video.name}`);
- await runVideo(video, { record: false, verbose, configDir });
+ await runVideo(video, { record: false, verbose, configDir: video.configDir });
},
);
diff --git a/packages/webreel/src/commands/record.ts b/packages/webreel/src/commands/record.ts
index d3a2ec7..ca29e71 100644
--- a/packages/webreel/src/commands/record.ts
+++ b/packages/webreel/src/commands/record.ts
@@ -1,48 +1,35 @@
import { Command } from "commander";
import { watch, type FSWatcher } from "node:fs";
import { resolve } from "node:path";
-import {
- loadWebreelConfig,
- resolveConfigPath,
- getConfigDir,
- filterVideosByName,
-} from "../lib/config.js";
+import { loadFullConfig, resolveConfigPaths, filterVideos } from "../lib/config.js";
import { runVideo } from "../lib/runner.js";
-import type { WebreelConfig } from "../lib/types.js";
+import type { FullConfig, VideoConfig } from "../lib/types.js";
-export function collectIncludePaths(config: WebreelConfig, configPath: string): string[] {
- const configDir = getConfigDir(configPath);
+export function collectIncludePaths(full: FullConfig): string[] {
const paths: string[] = [];
- const topIncludes = config.include ?? [];
- for (const inc of topIncludes) {
- paths.push(resolve(configDir, inc));
- }
- for (const video of config.videos) {
+ for (const video of full.videos) {
+ const videoDir = video.configDir;
for (const inc of video.include ?? []) {
- paths.push(resolve(configDir, inc));
+ paths.push(resolve(videoDir, inc));
}
}
return [...new Set(paths)];
}
-function printResolvedConfig(config: WebreelConfig): void {
+function printResolvedConfig(
+ videos: VideoConfig[],
+ videoSources?: Map,
+): void {
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
- console.log(bold("\nResolved configuration:\n"));
-
- if (config.baseUrl) console.log(` baseUrl: ${config.baseUrl}`);
- if (config.outDir) console.log(` outDir: ${config.outDir}`);
- if (config.viewport)
- console.log(` viewport: ${config.viewport.width}x${config.viewport.height}`);
- if (config.defaultDelay !== undefined)
- console.log(` defaultDelay: ${config.defaultDelay}ms`);
+ console.log(bold(`\n${videos.length} video(s):\n`));
- console.log(`\n ${bold(`${config.videos.length} video(s):`)}\n`);
-
- for (const video of config.videos) {
- console.log(` ${cyan(video.name)}`);
+ for (const video of videos) {
+ const source = videoSources?.get(video.name);
+ const label = source ? ` ${dim(`(${source})`)}` : "";
+ console.log(` ${cyan(video.name)}${label}`);
console.log(` url: ${video.url}`);
if (video.viewport)
console.log(` viewport: ${video.viewport.width}x${video.viewport.height}`);
@@ -68,10 +55,15 @@ function printResolvedConfig(config: WebreelConfig): void {
}
}
+function accumulate(val: string, prev: string[]): string[] {
+ return [...prev, val];
+}
+
export const recordCommand = new Command("record")
.description("Record videos")
.argument("[videos...]", "Video names to record (default: all)")
- .option("-c, --config ", "Path to config file (default: webreel.config.json)")
+ .option("-c, --config ", "Config file (repeatable)", accumulate, [])
+ .option("-f, --filter ", "Filter by video name/glob (repeatable)", accumulate, [])
.option("--verbose", "Log each step as it executes")
.option("--watch", "Re-record when config files change")
.option("--dry-run", "Print the resolved config and step list without recording")
@@ -80,28 +72,34 @@ export const recordCommand = new Command("record")
async (
videoNames: string[],
opts: {
- config?: string;
+ config: string[];
+ filter: string[];
verbose?: boolean;
watch?: boolean;
dryRun?: boolean;
frames?: boolean;
},
) => {
- const configPath = resolveConfigPath(opts.config);
- const configDir = getConfigDir(configPath);
+ const configPaths = await resolveConfigPaths(
+ opts.config.length > 0 ? opts.config : undefined,
+ );
const verbose = opts.verbose ?? false;
- const webreelConfig = await loadWebreelConfig(configPath);
- const videos = filterVideosByName(webreelConfig.videos, videoNames);
+ const fullConfig = await loadFullConfig(configPaths);
+ const videos = filterVideos(fullConfig.videos, videoNames, opts.filter);
if (opts.dryRun) {
- const filtered = { ...webreelConfig, videos };
- printResolvedConfig(filtered);
+ printResolvedConfig(videos, fullConfig.videoSources);
return;
}
for (const video of videos) {
- await runVideo(video, { record: true, verbose, configDir, frames: opts.frames });
+ await runVideo(video, {
+ record: true,
+ verbose,
+ configDir: video.configDir,
+ frames: opts.frames,
+ });
}
if (opts.watch) {
@@ -115,10 +113,12 @@ export const recordCommand = new Command("record")
watchers.length = 0;
};
- const setupWatchers = (cfg: WebreelConfig) => {
+ const setupWatchers = (full: FullConfig) => {
closeAllWatchers();
- watchers.push(watch(configPath, onFileChange));
- for (const p of collectIncludePaths(cfg, configPath)) {
+ for (const cp of configPaths) {
+ watchers.push(watch(cp, onFileChange));
+ }
+ for (const p of collectIncludePaths(full)) {
watchers.push(watch(p, onFileChange));
}
};
@@ -129,16 +129,20 @@ export const recordCommand = new Command("record")
timer = setTimeout(async () => {
timer = null;
console.log("\nRe-recording...");
- let latestConfig: WebreelConfig | null = null;
+ let latestConfig: FullConfig | null = null;
const run = (async () => {
try {
- latestConfig = await loadWebreelConfig(configPath);
- const updatedVideos = filterVideosByName(latestConfig.videos, videoNames);
+ latestConfig = await loadFullConfig(configPaths);
+ const updatedVideos = filterVideos(
+ latestConfig.videos,
+ videoNames,
+ opts.filter,
+ );
for (const video of updatedVideos) {
await runVideo(video, {
record: true,
verbose,
- configDir,
+ configDir: video.configDir,
frames: opts.frames,
});
}
@@ -146,7 +150,7 @@ export const recordCommand = new Command("record")
console.error(`Error re-recording:`, err);
} finally {
recordingInProgress = null;
- setupWatchers(latestConfig ?? webreelConfig);
+ setupWatchers(latestConfig ?? fullConfig);
}
})();
recordingInProgress = run;
@@ -154,7 +158,7 @@ export const recordCommand = new Command("record")
}, 300);
};
- setupWatchers(webreelConfig);
+ setupWatchers(fullConfig);
process.on("SIGINT", async () => {
closeAllWatchers();
diff --git a/packages/webreel/src/commands/validate.ts b/packages/webreel/src/commands/validate.ts
index 0f81550..7904786 100644
--- a/packages/webreel/src/commands/validate.ts
+++ b/packages/webreel/src/commands/validate.ts
@@ -1,57 +1,88 @@
import { Command } from "commander";
import { readFileSync } from "node:fs";
-import { basename, extname } from "node:path";
+import { basename, extname, relative } from "node:path";
import { parse as parseJsonc } from "jsonc-parser";
import {
- resolveConfigPath,
+ resolveConfigPaths,
validateWebreelConfig,
parseSchemaVersion,
formatValidationErrors,
buildLineMap,
+ loadFullConfig,
loadWebreelConfig,
} from "../lib/config.js";
const JSON_EXTENSIONS = new Set([".json"]);
+function accumulate(val: string, prev: string[]): string[] {
+ return [...prev, val];
+}
+
export const validateCommand = new Command("validate")
- .description("Validate a webreel config file")
- .option("-c, --config ", "Path to config file (default: webreel.config.json)")
- .action(async (opts: { config?: string }) => {
- const configPath = resolveConfigPath(opts.config);
- const name = basename(configPath);
- const ext = extname(configPath);
-
- if (JSON_EXTENSIONS.has(ext)) {
- const raw = readFileSync(configPath, "utf-8");
- let parsed: unknown;
+ .description("Validate webreel config file(s)")
+ .option("-c, --config ", "Config file (repeatable)", accumulate, [])
+ .action(async (opts: { config: string[] }) => {
+ const configPaths = await resolveConfigPaths(
+ opts.config.length > 0 ? opts.config : undefined,
+ );
+
+ const errors: string[] = [];
+
+ for (const configPath of configPaths) {
+ const name = basename(configPath);
+ const ext = extname(configPath);
+
try {
- parsed = parseJsonc(raw);
+ if (JSON_EXTENSIONS.has(ext)) {
+ const raw = readFileSync(configPath, "utf-8");
+ let parsed: unknown;
+ try {
+ parsed = parseJsonc(raw);
+ } catch (err) {
+ errors.push(
+ `${name}: invalid JSON - ${err instanceof Error ? err.message : String(err)}`,
+ );
+ continue;
+ }
+
+ const schemaUrl =
+ typeof parsed === "object" && parsed !== null
+ ? (parsed as Record).$schema
+ : undefined;
+ const version = parseSchemaVersion(
+ typeof schemaUrl === "string" ? schemaUrl : undefined,
+ );
+ const validationErrors = validateWebreelConfig(parsed, version);
+
+ if (validationErrors.length > 0) {
+ const lineMap = buildLineMap(raw);
+ errors.push(formatValidationErrors(name, validationErrors, lineMap));
+ continue;
+ }
+
+ await loadWebreelConfig(configPath);
+ console.log(`${name}: valid`);
+ } else {
+ await loadWebreelConfig(configPath);
+ console.log(`${name}: valid`);
+ }
} catch (err) {
- throw new Error(
- `${name}: invalid JSON - ${err instanceof Error ? err.message : String(err)}`,
- {
- cause: err,
- },
+ errors.push(
+ `${relative(process.cwd(), configPath)}: ${err instanceof Error ? err.message : String(err)}`,
);
}
+ }
- const schemaUrl =
- typeof parsed === "object" && parsed !== null
- ? (parsed as Record).$schema
- : undefined;
- const version = parseSchemaVersion(
- typeof schemaUrl === "string" ? schemaUrl : undefined,
- );
- const errors = validateWebreelConfig(parsed, version);
-
- if (errors.length === 0) {
- console.log(`${name}: valid`);
- } else {
- const lineMap = buildLineMap(raw);
- throw new Error(formatValidationErrors(name, errors, lineMap));
+ if (configPaths.length > 1) {
+ try {
+ const full = await loadFullConfig(configPaths);
+ console.log(`Cross-config: ${full.videos.length} video(s), no duplicates`);
+ } catch (err) {
+ errors.push(`Cross-config: ${err instanceof Error ? err.message : String(err)}`);
}
- } else {
- await loadWebreelConfig(configPath);
- console.log(`${name}: valid`);
+ }
+
+ if (errors.length > 0) {
+ throw new Error(errors.join("\n\n"));
}
});
diff --git a/packages/webreel/src/config.ts b/packages/webreel/src/config.ts
index 2459135..1d84d26 100644
--- a/packages/webreel/src/config.ts
+++ b/packages/webreel/src/config.ts
@@ -1,6 +1,7 @@
export type {
WebreelConfig,
VideoConfig,
+ FullConfig,
Step,
StepPause,
StepClick,
@@ -21,10 +22,35 @@ export type {
export { VIEWPORT_PRESETS } from "./lib/types.js";
-export type InputVideoConfig = Omit;
+export {
+ loadWebreelConfig,
+ loadFullConfig,
+ resolveConfigPath,
+ resolveConfigPaths,
+ filterVideosByName,
+} from "./lib/config.js";
+
+export type InputVideoConfig = Omit<
+ import("./lib/types.js").VideoConfig,
+ "name" | "configDir"
+>;
+
+export interface InputScenarioConfig {
+ extends?: boolean | string;
+ outDir?: string;
+ baseUrl?: string;
+ viewport?: string | { width: number; height: number };
+ theme?: import("./lib/types.js").ThemeConfig;
+ sfx?: import("./lib/types.js").SfxConfig;
+ include?: string[];
+ defaultDelay?: number;
+ clickDwell?: number;
+ videos: Record;
+}
export interface InputWebreelConfig {
$schema?: string;
+ extends?: string;
outDir?: string;
baseUrl?: string;
viewport?: string | { width: number; height: number };
@@ -33,9 +59,14 @@ export interface InputWebreelConfig {
include?: string[];
defaultDelay?: number;
clickDwell?: number;
- videos: Record;
+ scenarios?: (string | InputScenarioConfig)[];
+ videos?: Record;
}
export function defineConfig(config: InputWebreelConfig): InputWebreelConfig {
return config;
}
+
+export function defineScenario(config: InputScenarioConfig): InputScenarioConfig {
+ return config;
+}
diff --git a/packages/webreel/src/index.ts b/packages/webreel/src/index.ts
index e098e87..083e544 100644
--- a/packages/webreel/src/index.ts
+++ b/packages/webreel/src/index.ts
@@ -9,6 +9,7 @@ import { initCommand } from "./commands/init.js";
import { validateCommand } from "./commands/validate.js";
import { previewCommand } from "./commands/preview.js";
import { compositeCommand } from "./commands/composite.js";
+import { listCommand } from "./commands/list.js";
import { installCommand } from "./commands/install.js";
let version = "0.0.0";
@@ -35,6 +36,7 @@ program.addCommand(initCommand);
program.addCommand(validateCommand);
program.addCommand(previewCommand);
program.addCommand(compositeCommand);
+program.addCommand(listCommand);
program.addCommand(installCommand);
program.parseAsync().catch((err: Error) => {
diff --git a/packages/webreel/src/lib/__tests__/config.test.ts b/packages/webreel/src/lib/__tests__/config.test.ts
index ae99a10..fd2f423 100644
--- a/packages/webreel/src/lib/__tests__/config.test.ts
+++ b/packages/webreel/src/lib/__tests__/config.test.ts
@@ -1,13 +1,17 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi } from "vitest";
import {
validateWebreelConfig,
loadWebreelConfig,
+ loadFullConfig,
+ resolveConfigPaths,
parseSchemaVersion,
resolveConfigPath,
formatValidationErrors,
buildLineMap,
getConfigDir,
filterVideosByName,
+ filterVideosByPattern,
+ filterVideos,
type ValidationError,
} from "../config.js";
import type { VideoConfig } from "../types.js";
@@ -685,6 +689,290 @@ describe("filterVideosByName", () => {
});
});
+describe("extends resolution", () => {
+ const dir = resolve(tmpdir(), `webreel-extends-test-${Date.now()}`);
+
+ function cleanup() {
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+ }
+
+ it("inherits top-level defaults from base config", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "base.json"),
+ JSON.stringify({
+ baseUrl: "https://base.com",
+ viewport: { width: 1920, height: 1080 },
+ defaultDelay: 300,
+ videos: {},
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "child.json"),
+ JSON.stringify({
+ extends: "./base.json",
+ videos: {
+ hero: { url: "/", steps: [] },
+ },
+ }),
+ );
+ const config = await loadWebreelConfig(resolve(dir, "child.json"));
+ expect(config.baseUrl).toBe("https://base.com");
+ expect(config.viewport).toEqual({ width: 1920, height: 1080 });
+ expect(config.defaultDelay).toBe(300);
+ expect(config.videos).toHaveLength(1);
+ expect(config.videos[0].name).toBe("hero");
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("child overrides base top-level values", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "base.json"),
+ JSON.stringify({
+ baseUrl: "https://base.com",
+ defaultDelay: 300,
+ videos: {},
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "child.json"),
+ JSON.stringify({
+ extends: "./base.json",
+ baseUrl: "https://child.com",
+ videos: {
+ hero: { url: "/", steps: [] },
+ },
+ }),
+ );
+ const config = await loadWebreelConfig(resolve(dir, "child.json"));
+ expect(config.baseUrl).toBe("https://child.com");
+ expect(config.defaultDelay).toBe(300);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("inherits videos from base config", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "base.json"),
+ JSON.stringify({
+ videos: {
+ base_video: { url: "https://example.com", steps: [] },
+ },
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "child.json"),
+ JSON.stringify({
+ extends: "./base.json",
+ videos: {
+ child_video: { url: "https://example.com/child", steps: [] },
+ },
+ }),
+ );
+ const config = await loadWebreelConfig(resolve(dir, "child.json"));
+ expect(config.videos).toHaveLength(2);
+ const names = config.videos.map((v) => v.name).sort();
+ expect(names).toEqual(["base_video", "child_video"]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("child video overrides base video with same name", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "base.json"),
+ JSON.stringify({
+ videos: {
+ hero: { url: "https://base.com", steps: [] },
+ },
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "child.json"),
+ JSON.stringify({
+ extends: "./base.json",
+ videos: {
+ hero: { url: "https://child.com", steps: [] },
+ },
+ }),
+ );
+ const config = await loadWebreelConfig(resolve(dir, "child.json"));
+ expect(config.videos).toHaveLength(1);
+ expect(config.videos[0].url).toBe("https://child.com");
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("supports chained extends (3 levels)", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "grandparent.json"),
+ JSON.stringify({
+ baseUrl: "https://gp.com",
+ videos: {},
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "parent.json"),
+ JSON.stringify({
+ extends: "./grandparent.json",
+ defaultDelay: 500,
+ videos: {},
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "child.json"),
+ JSON.stringify({
+ extends: "./parent.json",
+ videos: { hero: { url: "/", steps: [] } },
+ }),
+ );
+ const config = await loadWebreelConfig(resolve(dir, "child.json"));
+ expect(config.baseUrl).toBe("https://gp.com");
+ expect(config.defaultDelay).toBe(500);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("detects circular extends", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "a.json"),
+ JSON.stringify({ extends: "./b.json", videos: { x: { url: "u", steps: [] } } }),
+ );
+ writeFileSync(
+ resolve(dir, "b.json"),
+ JSON.stringify({ extends: "./a.json", videos: { x: { url: "u", steps: [] } } }),
+ );
+ await expect(loadWebreelConfig(resolve(dir, "a.json"))).rejects.toThrow("Circular");
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("throws on missing extends target", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "child.json"),
+ JSON.stringify({
+ extends: "./nonexistent.json",
+ videos: { x: { url: "u", steps: [] } },
+ }),
+ );
+ await expect(loadWebreelConfig(resolve(dir, "child.json"))).rejects.toThrow(
+ "not found",
+ );
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("rejects broken extends path during full resolution", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "broken.json"),
+ JSON.stringify({
+ extends: "./nonexistent-base.json",
+ videos: { hero: { url: "/", steps: [] } },
+ }),
+ );
+ await expect(loadWebreelConfig(resolve(dir, "broken.json"))).rejects.toThrow(
+ "not found",
+ );
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("rejects extends: true in file-based config", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "child.json"),
+ JSON.stringify({
+ extends: true,
+ videos: { x: { url: "u", steps: [] } },
+ }),
+ );
+ await expect(loadWebreelConfig(resolve(dir, "child.json"))).rejects.toThrow(
+ "only valid in inline scenario",
+ );
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("shallow-merges theme sub-keys", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "base.json"),
+ JSON.stringify({
+ theme: {
+ cursor: { size: 32, hotspot: "top-left" },
+ hud: { background: "black" },
+ },
+ videos: {},
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "child.json"),
+ JSON.stringify({
+ extends: "./base.json",
+ theme: {
+ cursor: { size: 48 },
+ hud: { color: "white" },
+ },
+ videos: { hero: { url: "/", steps: [] } },
+ }),
+ );
+ const config = await loadWebreelConfig(resolve(dir, "child.json"));
+ expect(config.theme?.cursor).toEqual({ size: 48, hotspot: "top-left" });
+ expect(config.theme?.hud).toEqual({ background: "black", color: "white" });
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("returned config does not contain extends field", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(resolve(dir, "base.json"), JSON.stringify({ videos: {} }));
+ writeFileSync(
+ resolve(dir, "child.json"),
+ JSON.stringify({
+ extends: "./base.json",
+ videos: { hero: { url: "/", steps: [] } },
+ }),
+ );
+ const config = await loadWebreelConfig(resolve(dir, "child.json"));
+ expect("extends" in config).toBe(false);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
describe("loadWebreelConfig with env substitution", () => {
const dir = resolve(tmpdir(), `webreel-env-test-${Date.now()}`);
@@ -724,3 +1012,661 @@ describe("loadWebreelConfig with env substitution", () => {
}
});
});
+
+describe("scenarios and loadFullConfig", () => {
+ const dir = resolve(tmpdir(), `webreel-scenarios-test-${Date.now()}`);
+
+ function cleanup() {
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+ }
+
+ it("loads config with scenarios glob", async () => {
+ const demos = resolve(dir, "demos");
+ mkdirSync(demos, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(demos, "login.webreel.json"),
+ JSON.stringify({
+ videos: { login: { url: "/login", steps: [] } },
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "webreel.config.json"),
+ JSON.stringify({
+ scenarios: ["./demos/*.webreel.json"],
+ videos: { hero: { url: "/", steps: [] } },
+ }),
+ );
+ const full = await loadFullConfig([resolve(dir, "webreel.config.json")]);
+ expect(full.videos).toHaveLength(2);
+ const names = full.videos.map((v) => v.name).sort();
+ expect(names).toEqual(["hero", "login"]);
+ expect(full.videoSources.get("hero")).toContain("webreel.config.json");
+ expect(full.videoSources.get("login")).toContain("login.webreel.json");
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("loads config with inline scenario extends: true", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "webreel.config.json"),
+ JSON.stringify({
+ baseUrl: "https://example.com",
+ defaultDelay: 400,
+ scenarios: [
+ {
+ extends: true,
+ videos: { login: { url: "/login", steps: [] } },
+ },
+ ],
+ videos: { hero: { url: "/", steps: [] } },
+ }),
+ );
+ const full = await loadFullConfig([resolve(dir, "webreel.config.json")]);
+ expect(full.videos).toHaveLength(2);
+ const login = full.videos.find((v) => v.name === "login")!;
+ expect(login.baseUrl).toBe("https://example.com");
+ expect(login.defaultDelay).toBe(400);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("loads config with inline scenario standalone", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "webreel.config.json"),
+ JSON.stringify({
+ baseUrl: "https://root.com",
+ scenarios: [
+ {
+ baseUrl: "https://standalone.com",
+ videos: { login: { url: "/login", steps: [] } },
+ },
+ ],
+ videos: { hero: { url: "/", steps: [] } },
+ }),
+ );
+ const full = await loadFullConfig([resolve(dir, "webreel.config.json")]);
+ const login = full.videos.find((v) => v.name === "login")!;
+ expect(login.baseUrl).toBe("https://standalone.com");
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("detects duplicate video names across configs", async () => {
+ const demos = resolve(dir, "demos");
+ mkdirSync(demos, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(demos, "dup.webreel.json"),
+ JSON.stringify({
+ videos: { hero: { url: "/dup", steps: [] } },
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "webreel.config.json"),
+ JSON.stringify({
+ scenarios: ["./demos/*.webreel.json"],
+ videos: { hero: { url: "/", steps: [] } },
+ }),
+ );
+ await expect(loadFullConfig([resolve(dir, "webreel.config.json")])).rejects.toThrow(
+ "Duplicate video name",
+ );
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("allows config with only scenarios and no videos", async () => {
+ const demos = resolve(dir, "demos");
+ mkdirSync(demos, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(demos, "a.webreel.json"),
+ JSON.stringify({
+ videos: { demo: { url: "/demo", steps: [] } },
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "webreel.config.json"),
+ JSON.stringify({
+ scenarios: ["./demos/*.webreel.json"],
+ }),
+ );
+ const full = await loadFullConfig([resolve(dir, "webreel.config.json")]);
+ expect(full.videos).toHaveLength(1);
+ expect(full.videos[0].name).toBe("demo");
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("numbers inline scenarios in videoSources", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "webreel.config.json"),
+ JSON.stringify({
+ scenarios: [
+ { videos: { a: { url: "/a", steps: [] } } },
+ { videos: { b: { url: "/b", steps: [] } } },
+ ],
+ }),
+ );
+ const full = await loadFullConfig([resolve(dir, "webreel.config.json")]);
+ expect(full.videoSources.get("a")).toContain("[scenario #1]");
+ expect(full.videoSources.get("b")).toContain("[scenario #2]");
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("loads multiple configs via loadFullConfig", async () => {
+ const dirA = resolve(dir, "a");
+ const dirB = resolve(dir, "b");
+ mkdirSync(dirA, { recursive: true });
+ mkdirSync(dirB, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dirA, "webreel.config.json"),
+ JSON.stringify({ videos: { alpha: { url: "/a", steps: [] } } }),
+ );
+ writeFileSync(
+ resolve(dirB, "webreel.config.json"),
+ JSON.stringify({ videos: { beta: { url: "/b", steps: [] } } }),
+ );
+ const full = await loadFullConfig([
+ resolve(dirA, "webreel.config.json"),
+ resolve(dirB, "webreel.config.json"),
+ ]);
+ expect(full.videos).toHaveLength(2);
+ const names = full.videos.map((v) => v.name).sort();
+ expect(names).toEqual(["alpha", "beta"]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("validates scenarios in validateWebreelConfig", () => {
+ const errors = validateWebreelConfig({
+ scenarios: [{ videos: { x: { url: "u", steps: [] } } }, "./demos/*.json"],
+ });
+ expect(errors).toEqual([]);
+ });
+
+ it("rejects scenarios with invalid elements", () => {
+ const errors = validateWebreelConfig({
+ scenarios: [123],
+ videos: { x: { url: "u", steps: [] } },
+ });
+ expect(errors.length).toBeGreaterThan(0);
+ expect(errors[0].path).toContain("scenarios[0]");
+ });
+
+ it("rejects inline scenario without videos", () => {
+ const errors = validateWebreelConfig({
+ scenarios: [{ baseUrl: "https://example.com" }],
+ });
+ expect(errors.length).toBeGreaterThan(0);
+ expect(errors[0].path).toContain("scenarios[0].videos");
+ });
+});
+
+describe("filterVideosByPattern", () => {
+ const videos: VideoConfig[] = [
+ { name: "hero", url: "/", steps: [], configDir: "/tmp" },
+ { name: "login", url: "/login", steps: [], configDir: "/tmp" },
+ { name: "signup", url: "/signup", steps: [], configDir: "/tmp" },
+ { name: "dashboard-light", url: "/dash", steps: [], configDir: "/tmp" },
+ { name: "dashboard-dark", url: "/dash-dark", steps: [], configDir: "/tmp" },
+ ] as VideoConfig[];
+
+ it("returns all with empty patterns", () => {
+ expect(filterVideosByPattern(videos, [])).toEqual(videos);
+ });
+
+ it("filters by exact name", () => {
+ const result = filterVideosByPattern(videos, ["hero"]);
+ expect(result.map((v) => v.name)).toEqual(["hero"]);
+ });
+
+ it("filters by glob pattern", () => {
+ const result = filterVideosByPattern(videos, ["dashboard-*"]);
+ expect(result.map((v) => v.name)).toEqual(["dashboard-light", "dashboard-dark"]);
+ });
+
+ it("throws on missing exact name", () => {
+ expect(() => filterVideosByPattern(videos, ["nonexistent"])).toThrow(
+ "Pattern(s) not found",
+ );
+ });
+
+ it("does not throw on empty glob match", () => {
+ expect(() => filterVideosByPattern(videos, ["nothing*"])).toThrow(
+ "No videos matched",
+ );
+ });
+
+ it("combines exact and glob", () => {
+ const result = filterVideosByPattern(videos, ["hero", "dashboard-*"]);
+ expect(result.map((v) => v.name)).toEqual([
+ "hero",
+ "dashboard-light",
+ "dashboard-dark",
+ ]);
+ });
+});
+
+describe("filterVideos (union)", () => {
+ const videos: VideoConfig[] = [
+ { name: "hero", url: "/", steps: [], configDir: "/tmp" },
+ { name: "login", url: "/login", steps: [], configDir: "/tmp" },
+ { name: "signup", url: "/signup", steps: [], configDir: "/tmp" },
+ ] as VideoConfig[];
+
+ it("returns all with empty filters", () => {
+ expect(filterVideos(videos, [], [])).toEqual(videos);
+ });
+
+ it("union of names and patterns", () => {
+ const result = filterVideos(videos, ["hero"], ["login"]);
+ expect(result.map((v) => v.name)).toEqual(["hero", "login"]);
+ });
+
+ it("deduplicates overlapping filters", () => {
+ const result = filterVideos(videos, ["hero"], ["hero"]);
+ expect(result.map((v) => v.name)).toEqual(["hero"]);
+ });
+});
+
+describe("defineConfig and defineScenario", () => {
+ it("defineConfig returns the same object", async () => {
+ const { defineConfig } = await import("../../config.js");
+ const input = {
+ baseUrl: "https://example.com",
+ videos: { hero: { url: "/", steps: [] } },
+ };
+ expect(defineConfig(input)).toBe(input);
+ });
+
+ it("defineScenario returns the same object", async () => {
+ const { defineScenario } = await import("../../config.js");
+ const input = {
+ extends: true as const,
+ videos: { hero: { url: "/", steps: [] } },
+ };
+ expect(defineScenario(input)).toBe(input);
+ });
+});
+
+describe("resolveConfigPaths", () => {
+ const dir = resolve(tmpdir(), `webreel-resolve-paths-test-${Date.now()}`);
+
+ function cleanup() {
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+ }
+
+ it("resolves explicit paths", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(resolve(dir, "a.json"), JSON.stringify({ videos: {} }));
+ writeFileSync(resolve(dir, "b.json"), JSON.stringify({ videos: {} }));
+ const paths = await resolveConfigPaths([
+ resolve(dir, "a.json"),
+ resolve(dir, "b.json"),
+ ]);
+ expect(paths).toHaveLength(2);
+ expect(paths[0]).toBe(resolve(dir, "a.json"));
+ expect(paths[1]).toBe(resolve(dir, "b.json"));
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("resolves glob patterns", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(resolve(dir, "x.webreel.json"), "{}");
+ writeFileSync(resolve(dir, "y.webreel.json"), "{}");
+ const paths = await resolveConfigPaths([resolve(dir, "*.webreel.json")]);
+ expect(paths).toHaveLength(2);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("throws on missing explicit path", async () => {
+ await expect(resolveConfigPaths([resolve(dir, "nonexistent.json")])).rejects.toThrow(
+ "not found",
+ );
+ });
+
+ it("throws on glob with no matches", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ await expect(resolveConfigPaths([resolve(dir, "*.nope.json")])).rejects.toThrow(
+ "No config files matched",
+ );
+ } finally {
+ cleanup();
+ }
+ });
+
+ it("deduplicates paths", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(resolve(dir, "a.json"), "{}");
+ const paths = await resolveConfigPaths([
+ resolve(dir, "a.json"),
+ resolve(dir, "a.json"),
+ ]);
+ expect(paths).toHaveLength(1);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe("inline scenario with file extends", () => {
+ const dir = resolve(tmpdir(), `webreel-scenario-file-extends-${Date.now()}`);
+
+ function cleanup() {
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+ }
+
+ it("inline scenario extends a file path", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "base.json"),
+ JSON.stringify({
+ baseUrl: "https://base.com",
+ defaultDelay: 300,
+ videos: {},
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "root.json"),
+ JSON.stringify({
+ scenarios: [
+ {
+ extends: "./base.json",
+ videos: { demo: { url: "/demo", steps: [] } },
+ },
+ ],
+ }),
+ );
+ const full = await loadFullConfig([resolve(dir, "root.json")]);
+ expect(full.videos).toHaveLength(1);
+ expect(full.videos[0].name).toBe("demo");
+ expect(full.videos[0].baseUrl).toBe("https://base.com");
+ expect(full.videos[0].defaultDelay).toBe(300);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe("inline scenario extends: true inherits from root extends chain", () => {
+ const dir = resolve(
+ tmpdir(),
+ `webreel-scenario-root-extends-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ );
+
+ function cleanup() {
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+ }
+
+ it("inline scenario with extends: true sees values from root extends", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "base.json"),
+ JSON.stringify({
+ baseUrl: "https://from-base.com",
+ defaultDelay: 999,
+ videos: {},
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "root.json"),
+ JSON.stringify({
+ extends: "./base.json",
+ scenarios: [
+ {
+ extends: true,
+ videos: { demo: { url: "/demo", steps: [] } },
+ },
+ ],
+ }),
+ );
+ const full = await loadFullConfig([resolve(dir, "root.json")]);
+ expect(full.videos).toHaveLength(1);
+ expect(full.videos[0].baseUrl).toBe("https://from-base.com");
+ expect(full.videos[0].defaultDelay).toBe(999);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe("nested scenarios warning", () => {
+ const dir = resolve(tmpdir(), `webreel-nested-scenarios-${Date.now()}`);
+
+ function cleanup() {
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+ }
+
+ it("warns and ignores nested scenarios", async () => {
+ const subdir = resolve(dir, "sub");
+ mkdirSync(subdir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(subdir, "child.webreel.json"),
+ JSON.stringify({
+ scenarios: ["./nonexistent/*.json"],
+ videos: { nested: { url: "/nested", steps: [] } },
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "root.json"),
+ JSON.stringify({
+ scenarios: ["./sub/*.webreel.json"],
+ }),
+ );
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ const full = await loadFullConfig([resolve(dir, "root.json")]);
+ expect(full.videos).toHaveLength(1);
+ expect(full.videos[0].name).toBe("nested");
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("scenarios"));
+ } finally {
+ warnSpy.mockRestore();
+ }
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe("loadFullConfig duplicate between root and scenario", () => {
+ const dir = resolve(tmpdir(), `webreel-dup-root-scenario-${Date.now()}`);
+
+ function cleanup() {
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+ }
+
+ it("detects duplicate between root videos and scenario videos", async () => {
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "root.json"),
+ JSON.stringify({
+ scenarios: [{ videos: { hero: { url: "/inline", steps: [] } } }],
+ videos: { hero: { url: "/root", steps: [] } },
+ }),
+ );
+ await expect(loadFullConfig([resolve(dir, "root.json")])).rejects.toThrow(
+ "Duplicate video name",
+ );
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe("TS config with defineConfig and defineScenario", () => {
+ it("loads TS config with defineConfig", async () => {
+ const dir = resolve(
+ tmpdir(),
+ `webreel-ts-simple-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ );
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "webreel.config.ts"),
+ `export default {
+ baseUrl: "https://ts-test.com",
+ videos: {
+ demo: { url: "/demo", steps: [{ action: "pause", ms: 100 }] },
+ },
+ };`,
+ );
+ const config = await loadWebreelConfig(resolve(dir, "webreel.config.ts"));
+ expect(config.videos).toHaveLength(1);
+ expect(config.videos[0].name).toBe("demo");
+ expect(config.baseUrl).toBe("https://ts-test.com");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("loads TS config with scenarios glob and inline extends", async () => {
+ const dir = resolve(
+ tmpdir(),
+ `webreel-ts-scenarios-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ );
+ const scenariosDir = resolve(dir, "scenarios");
+ mkdirSync(scenariosDir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(scenariosDir, "extra.webreel.json"),
+ JSON.stringify({
+ videos: {
+ "glob-video": { url: "https://example.com/glob", steps: [] },
+ },
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "webreel.config.ts"),
+ `export default {
+ baseUrl: "https://root.com",
+ viewport: { width: 1920, height: 1080 },
+ scenarios: [
+ "./scenarios/*.webreel.json",
+ {
+ extends: true,
+ videos: {
+ "inline-video": { url: "/inline", steps: [] },
+ },
+ },
+ ],
+ videos: {
+ root: { url: "/root", steps: [] },
+ },
+ };`,
+ );
+ const full = await loadFullConfig([resolve(dir, "webreel.config.ts")]);
+ expect(full.videos).toHaveLength(3);
+ const names = full.videos.map((v) => v.name).sort();
+ expect(names).toEqual(["glob-video", "inline-video", "root"]);
+
+ const inlineVideo = full.videos.find((v) => v.name === "inline-video")!;
+ expect(inlineVideo.baseUrl).toBe("https://root.com");
+ expect(inlineVideo.viewport).toEqual({ width: 1920, height: 1080 });
+
+ expect(full.videoSources.get("root")).toContain("webreel.config.ts");
+ expect(full.videoSources.get("glob-video")).toContain("extra.webreel.json");
+ expect(full.videoSources.get("inline-video")).toContain("[scenario #1]");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("loads TS config with extends", async () => {
+ const dir = resolve(
+ tmpdir(),
+ `webreel-ts-extends-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ );
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ resolve(dir, "base.json"),
+ JSON.stringify({
+ baseUrl: "https://base.com",
+ defaultDelay: 250,
+ videos: {},
+ }),
+ );
+ writeFileSync(
+ resolve(dir, "child.ts"),
+ `export default {
+ extends: "./base.json",
+ videos: {
+ child: { url: "/child", steps: [] },
+ },
+ };`,
+ );
+ const config = await loadWebreelConfig(resolve(dir, "child.ts"));
+ expect(config.baseUrl).toBe("https://base.com");
+ expect(config.defaultDelay).toBe(250);
+ expect(config.videos).toHaveLength(1);
+ expect(config.videos[0].name).toBe("child");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
+
+describe("FullConfig public type export", () => {
+ it("FullConfig is importable from config.ts", async () => {
+ const mod = await import("../../config.js");
+ expect(mod.loadFullConfig).toBeTypeOf("function");
+ expect(mod.resolveConfigPaths).toBeTypeOf("function");
+ expect(mod.loadWebreelConfig).toBeTypeOf("function");
+ expect(mod.filterVideosByName).toBeTypeOf("function");
+ expect(mod.defineConfig).toBeTypeOf("function");
+ expect(mod.defineScenario).toBeTypeOf("function");
+ });
+});
diff --git a/packages/webreel/src/lib/config.ts b/packages/webreel/src/lib/config.ts
index c9fef3c..1397214 100644
--- a/packages/webreel/src/lib/config.ts
+++ b/packages/webreel/src/lib/config.ts
@@ -1,8 +1,10 @@
import { readFileSync, existsSync } from "node:fs";
-import { resolve, dirname, isAbsolute, extname } from "node:path";
+import { resolve, dirname, isAbsolute, extname, relative } from "node:path";
import { parse as parseJsonc, parseTree, getNodePath } from "jsonc-parser";
import { createJiti } from "jiti";
-import type { VideoConfig, WebreelConfig } from "./types.js";
+import { glob, isDynamicPattern } from "tinyglobby";
+import picomatch from "picomatch";
+import type { VideoConfig, WebreelConfig, FullConfig } from "./types.js";
import { VIEWPORT_PRESETS } from "./types.js";
export const DEFAULT_CONFIG_NAME = "webreel.config";
@@ -51,7 +53,10 @@ async function resolveIncludes(
parsed = parseJsonc(raw) as Record;
} else {
try {
- const mod = await loadTsConfig(absPath);
+ let mod = await loadTsConfig(absPath);
+ if (typeof mod === "object" && mod !== null && "default" in mod) {
+ mod = (mod as Record).default;
+ }
if (typeof mod !== "object" || mod === null) {
throw new Error(`Include file must export an object: ${absPath}`);
}
@@ -155,28 +160,207 @@ function substituteEnvVars(obj: unknown): unknown {
return obj;
}
+const MAX_EXTENDS_DEPTH = 10;
+
+const MERGEABLE_TOP_LEVEL_KEYS = [
+ "baseUrl",
+ "viewport",
+ "outDir",
+ "include",
+ "defaultDelay",
+ "clickDwell",
+] as const;
+
+function mergeTheme(
+ base: Record | undefined,
+ child: Record | undefined,
+): Record | undefined {
+ if (!base && !child) return undefined;
+ if (!base) return child;
+ if (!child) return base;
+ const merged: Record = {};
+ const allKeys = new Set([...Object.keys(base), ...Object.keys(child)]);
+ for (const key of allKeys) {
+ const bVal = base[key];
+ const cVal = child[key];
+ if (
+ typeof bVal === "object" &&
+ bVal !== null &&
+ typeof cVal === "object" &&
+ cVal !== null
+ ) {
+ merged[key] = { ...bVal, ...cVal };
+ } else {
+ merged[key] = cVal !== undefined ? cVal : bVal;
+ }
+ }
+ return merged;
+}
+
+function mergeSfx(
+ base: Record | undefined,
+ child: Record | undefined,
+): Record | undefined {
+ if (!base && !child) return undefined;
+ if (!base) return child;
+ if (!child) return base;
+ return { ...base, ...child };
+}
+
+async function loadRawConfig(filePath: string): Promise> {
+ const ext = extname(filePath);
+ if (JSON_EXTENSIONS.has(ext)) {
+ const raw = readFileSync(filePath, "utf-8");
+ return substituteEnvVars(parseJsonc(raw)) as Record;
+ }
+ let mod = await loadTsConfig(filePath);
+ if (typeof mod === "object" && mod !== null && "default" in mod) {
+ mod = (mod as Record).default;
+ }
+ if (typeof mod !== "object" || mod === null) {
+ throw new Error(`Config file must export an object: ${filePath}`);
+ }
+ return substituteEnvVars(mod) as Record;
+}
+
+async function resolveExtends(
+ parsed: Record,
+ filePath: string,
+ seen: Set,
+ depth: number,
+): Promise> {
+ const extendsValue = parsed.extends;
+ if (extendsValue === undefined) return parsed;
+
+ if (extendsValue === true) {
+ throw new Error(
+ `"extends: true" is only valid in inline scenario configs. Use a file path instead. (in ${filePath})`,
+ );
+ }
+
+ if (typeof extendsValue !== "string" || extendsValue.length === 0) {
+ throw new Error(`"extends" must be a non-empty string (in ${filePath})`);
+ }
+
+ if (depth >= MAX_EXTENDS_DEPTH) {
+ throw new Error(`"extends" chain too deep (max ${MAX_EXTENDS_DEPTH})`);
+ }
+
+ const baseDir = dirname(resolve(filePath));
+ const basePath = resolve(baseDir, extendsValue);
+
+ if (!existsSync(basePath)) {
+ throw new Error(
+ `"extends" target not found: ${extendsValue} (referenced from ${resolve(filePath)})`,
+ );
+ }
+
+ const absBasePath = resolve(basePath);
+ if (seen.has(absBasePath)) {
+ const chain = [...seen, absBasePath].join(" -> ");
+ throw new Error(`Circular "extends" detected: ${chain}`);
+ }
+ seen.add(absBasePath);
+
+ let baseParsed = await loadRawConfig(absBasePath);
+ baseParsed = await resolveExtends(baseParsed, absBasePath, seen, depth + 1);
+
+ const baseIncludeDir = dirname(absBasePath);
+ if (Array.isArray(baseParsed.include)) {
+ baseParsed.include = baseParsed.include.map((inc: unknown) =>
+ typeof inc === "string" && !isAbsolute(inc) ? resolve(baseIncludeDir, inc) : inc,
+ );
+ }
+
+ const merged: Record = {};
+
+ for (const key of MERGEABLE_TOP_LEVEL_KEYS) {
+ if (baseParsed[key] !== undefined) merged[key] = baseParsed[key];
+ }
+
+ merged.theme = mergeTheme(
+ baseParsed.theme as Record | undefined,
+ parsed.theme as Record | undefined,
+ );
+ merged.sfx = mergeSfx(
+ baseParsed.sfx as Record | undefined,
+ parsed.sfx as Record | undefined,
+ );
+
+ for (const key of MERGEABLE_TOP_LEVEL_KEYS) {
+ if (parsed[key] !== undefined) merged[key] = parsed[key];
+ }
+
+ if (parsed.include !== undefined) {
+ const childDir = dirname(resolve(filePath));
+ merged.include = (parsed.include as unknown[]).map((inc: unknown) =>
+ typeof inc === "string" && !isAbsolute(inc) ? resolve(childDir, inc) : inc,
+ );
+ }
+
+ const baseVideos =
+ baseParsed.videos != null &&
+ typeof baseParsed.videos === "object" &&
+ !Array.isArray(baseParsed.videos)
+ ? { ...(baseParsed.videos as Record) }
+ : {};
+
+ const childVideos =
+ parsed.videos != null &&
+ typeof parsed.videos === "object" &&
+ !Array.isArray(parsed.videos)
+ ? (parsed.videos as Record)
+ : {};
+
+ const mergedVideos = { ...baseVideos, ...childVideos };
+
+ return {
+ ...merged,
+ $schema: parsed.$schema,
+ videos: Object.keys(mergedVideos).length > 0 ? mergedVideos : undefined,
+ scenarios: parsed.scenarios,
+ };
+}
+
+interface BuildResult {
+ config: WebreelConfig;
+ resolved: Record;
+}
+
async function buildConfigFromParsed(
parsed: Record,
filePath: string,
-): Promise {
- if (
- !parsed.videos ||
- typeof parsed.videos !== "object" ||
- Array.isArray(parsed.videos)
- ) {
- throw new Error(`Config must contain a "videos" object`);
+): Promise {
+ const resolved = await resolveExtends(
+ parsed,
+ filePath,
+ new Set([resolve(filePath)]),
+ 0,
+ );
+
+ const hasVideos =
+ resolved.videos != null &&
+ typeof resolved.videos === "object" &&
+ !Array.isArray(resolved.videos);
+ const hasScenarios = Array.isArray(resolved.scenarios) && resolved.scenarios.length > 0;
+
+ if (!hasVideos && !hasScenarios) {
+ throw new Error(`Config must contain a "videos" object or a "scenarios" array`);
}
- const videosObj = parsed.videos as Record>;
+
+ const videosObj = hasVideos
+ ? (resolved.videos as Record>)
+ : {};
const configDir = dirname(resolve(filePath));
- const outDir = resolve(configDir, (parsed.outDir as string) ?? "videos");
+ const outDir = resolve(configDir, (resolved.outDir as string) ?? "videos");
const defaults = {
- baseUrl: parsed.baseUrl as string | undefined,
- viewport: resolveViewportValue(parsed.viewport),
- theme: parsed.theme as WebreelConfig["theme"],
- sfx: parsed.sfx as WebreelConfig["sfx"],
- include: parsed.include as string[] | undefined,
- defaultDelay: parsed.defaultDelay as number | undefined,
- clickDwell: parsed.clickDwell as number | undefined,
+ baseUrl: resolved.baseUrl as string | undefined,
+ viewport: resolveViewportValue(resolved.viewport),
+ theme: resolved.theme as WebreelConfig["theme"],
+ sfx: resolved.sfx as WebreelConfig["sfx"],
+ include: resolved.include as string[] | undefined,
+ defaultDelay: resolved.defaultDelay as number | undefined,
+ clickDwell: resolved.clickDwell as number | undefined,
};
const videoList: VideoConfig[] = [];
@@ -186,26 +370,28 @@ async function buildConfigFromParsed(
videoBody.viewport =
resolveViewportPreset(videoBody.viewport as string) ?? videoBody.viewport;
}
- const video = { ...videoBody, name } as unknown as VideoConfig;
- const resolved = resolveVideoDefaults(video, defaults, outDir, configDir);
- videoList.push(await resolveVideo(resolved, filePath));
+ const video = { ...videoBody, name, configDir } as unknown as VideoConfig;
+ const resolvedVideo = resolveVideoDefaults(video, defaults, outDir, configDir);
+ videoList.push(await resolveVideo(resolvedVideo, filePath));
}
- return {
- $schema: parsed.$schema as string | undefined,
- outDir: parsed.outDir as string | undefined,
- baseUrl: parsed.baseUrl as string | undefined,
- viewport: resolveViewportValue(parsed.viewport),
- theme: parsed.theme as WebreelConfig["theme"],
- sfx: parsed.sfx as WebreelConfig["sfx"],
- include: parsed.include as string[] | undefined,
- defaultDelay: parsed.defaultDelay as number | undefined,
- clickDwell: parsed.clickDwell as number | undefined,
+ const config: WebreelConfig = {
+ $schema: resolved.$schema as string | undefined,
+ outDir: resolved.outDir as string | undefined,
+ baseUrl: resolved.baseUrl as string | undefined,
+ viewport: resolveViewportValue(resolved.viewport),
+ theme: resolved.theme as WebreelConfig["theme"],
+ sfx: resolved.sfx as WebreelConfig["sfx"],
+ include: resolved.include as string[] | undefined,
+ defaultDelay: resolved.defaultDelay as number | undefined,
+ clickDwell: resolved.clickDwell as number | undefined,
videos: videoList,
};
+
+ return { config, resolved };
}
-export async function loadWebreelConfig(filePath: string): Promise {
+async function loadWebreelConfigFull(filePath: string): Promise {
const ext = extname(filePath);
if (JSON_EXTENSIONS.has(ext)) {
@@ -228,7 +414,11 @@ export async function loadWebreelConfig(filePath: string): Promise, filePath);
}
- const raw = await loadTsConfig(filePath);
+ let raw = await loadTsConfig(filePath);
+
+ if (typeof raw === "object" && raw !== null && "default" in raw) {
+ raw = (raw as Record).default;
+ }
if (typeof raw !== "object" || raw === null) {
throw new Error(`Config file must export an object: ${filePath}`);
@@ -243,6 +433,11 @@ export async function loadWebreelConfig(filePath: string): Promise {
+ const { config } = await loadWebreelConfigFull(filePath);
+ return config;
+}
+
async function resolveVideo(video: VideoConfig, filePath: string): Promise {
if (video.include && video.include.length > 0) {
const absConfigPath = resolve(filePath);
@@ -294,6 +489,7 @@ const VALID_ACTIONS = new Set([
const KNOWN_TOP_LEVEL_KEYS = new Set([
"$schema",
+ "extends",
"outDir",
"baseUrl",
"viewport",
@@ -302,6 +498,7 @@ const KNOWN_TOP_LEVEL_KEYS = new Set([
"include",
"defaultDelay",
"clickDwell",
+ "scenarios",
"videos",
]);
@@ -803,6 +1000,118 @@ function validateTheme(theme: unknown, prefix: string): ValidationError[] {
return errors;
}
+const KNOWN_SCENARIO_KEYS = new Set([
+ "extends",
+ "outDir",
+ "baseUrl",
+ "viewport",
+ "theme",
+ "sfx",
+ "include",
+ "defaultDelay",
+ "clickDwell",
+ "videos",
+]);
+
+function validateScenarios(scenarios: unknown, prefix: string): ValidationError[] {
+ const errors: ValidationError[] = [];
+ if (!Array.isArray(scenarios)) {
+ errors.push({ path: prefix, message: "Must be an array" });
+ return errors;
+ }
+ for (let i = 0; i < scenarios.length; i++) {
+ const item = scenarios[i];
+ const itemPath = `${prefix}[${i}]`;
+
+ if (typeof item === "string") {
+ if (item.length === 0) {
+ errors.push({ path: itemPath, message: "Must be a non-empty string" });
+ }
+ continue;
+ }
+
+ if (typeof item !== "object" || item === null) {
+ errors.push({
+ path: itemPath,
+ message: "Must be a glob string or an inline scenario object",
+ });
+ continue;
+ }
+
+ const s = item as Record;
+
+ errors.push(...checkUnknownKeys(s, KNOWN_SCENARIO_KEYS, itemPath));
+
+ if (s.extends !== undefined) {
+ if (
+ typeof s.extends !== "boolean" &&
+ (typeof s.extends !== "string" || s.extends.length === 0)
+ ) {
+ errors.push({
+ path: `${itemPath}.extends`,
+ message: "Must be true, false, or a non-empty string",
+ });
+ }
+ }
+
+ if (
+ s.videos === undefined ||
+ s.videos === null ||
+ typeof s.videos !== "object" ||
+ Array.isArray(s.videos)
+ ) {
+ errors.push({
+ path: `${itemPath}.videos`,
+ message: "Required, must be an object mapping names to video configs",
+ });
+ continue;
+ }
+
+ const scenarioVideos = s.videos as Record;
+ for (const [name, video] of Object.entries(scenarioVideos)) {
+ const vPrefix = `${itemPath}.videos.${name}`;
+ if (typeof video !== "object" || video === null) {
+ errors.push({ path: vPrefix, message: "Must be a video config object" });
+ continue;
+ }
+ const d = video as Record;
+ errors.push(...checkUnknownKeys(d, KNOWN_VIDEO_KEYS, vPrefix));
+ if (typeof d.url !== "string" || d.url.length === 0) {
+ errors.push({
+ path: `${vPrefix}.url`,
+ message: "Required, must be a non-empty string",
+ });
+ }
+ if (!Array.isArray(d.steps)) {
+ errors.push({ path: `${vPrefix}.steps`, message: "Required, must be an array" });
+ } else {
+ for (let j = 0; j < d.steps.length; j++) {
+ errors.push(
+ ...validateStep(d.steps[j], j).map((e) => ({
+ ...e,
+ path: `${vPrefix}.${e.path}`,
+ })),
+ );
+ }
+ }
+ }
+
+ if (s.viewport !== undefined) {
+ errors.push(...validateViewport(s.viewport, `${itemPath}.viewport`));
+ }
+ if (s.theme !== undefined) {
+ errors.push(...validateTheme(s.theme, `${itemPath}.theme`));
+ }
+ if (s.sfx !== undefined) {
+ errors.push(...validateSfx(s.sfx, `${itemPath}.sfx`));
+ }
+ if (s.include !== undefined) {
+ errors.push(...validateInclude(s.include, `${itemPath}.include`));
+ }
+ }
+ return errors;
+}
+
export function validateWebreelConfig(
config: unknown,
version: number = CURRENT_SCHEMA_VERSION,
@@ -827,6 +1136,20 @@ export function validateWebreelConfig(
errors.push(...checkUnknownKeys(c, KNOWN_TOP_LEVEL_KEYS, ""));
+ if (c.extends !== undefined) {
+ if (typeof c.extends === "boolean") {
+ if (c.extends !== true) {
+ errors.push({ path: "extends", message: "Must be true or a non-empty string" });
+ }
+ } else if (typeof c.extends !== "string" || c.extends.length === 0) {
+ errors.push({ path: "extends", message: "Must be true or a non-empty string" });
+ }
+ }
+
+ if (c.scenarios !== undefined) {
+ errors.push(...validateScenarios(c.scenarios, "scenarios"));
+ }
+
if (c.outDir !== undefined && (typeof c.outDir !== "string" || c.outDir.length === 0)) {
errors.push({ path: "outDir", message: "Must be a non-empty string" });
}
@@ -865,15 +1188,22 @@ export function validateWebreelConfig(
errors.push(...validateSfx(c.sfx, "sfx"));
}
- if (
- c.videos === undefined ||
- c.videos === null ||
- typeof c.videos !== "object" ||
- Array.isArray(c.videos)
- ) {
+ const hasScenarios = Array.isArray(c.scenarios) && c.scenarios.length > 0;
+
+ if (c.videos === undefined || c.videos === null) {
+ if (!hasScenarios) {
+ errors.push({
+ path: "videos",
+ message: "Required, must be an object mapping names to video configs",
+ });
+ }
+ return errors;
+ }
+
+ if (typeof c.videos !== "object" || Array.isArray(c.videos)) {
errors.push({
path: "videos",
- message: "Required, must be an object mapping names to video configs",
+ message: "Must be an object mapping names to video configs",
});
return errors;
}
@@ -881,7 +1211,7 @@ export function validateWebreelConfig(
const videos = c.videos as Record;
const names = Object.keys(videos);
- if (names.length === 0) {
+ if (names.length === 0 && !hasScenarios) {
errors.push({ path: "videos", message: "Must contain at least one video" });
}
@@ -1124,6 +1454,66 @@ export function filterVideosByName(
return filtered;
}
+export function filterVideosByPattern(
+ videos: VideoConfig[],
+ patterns: string[],
+): VideoConfig[] {
+ if (patterns.length === 0) return videos;
+
+ const result = new Set();
+ const exactMissing: string[] = [];
+
+ for (const pattern of patterns) {
+ if (isDynamicPattern(pattern)) {
+ const matcher = picomatch(pattern);
+ for (const v of videos) {
+ if (matcher(v.name)) result.add(v.name);
+ }
+ } else {
+ const found = videos.find((v) => v.name === pattern);
+ if (found) {
+ result.add(found.name);
+ } else {
+ exactMissing.push(pattern);
+ }
+ }
+ }
+
+ if (exactMissing.length > 0) {
+ const available = videos.map((v) => v.name).join(", ");
+ throw new Error(
+ `Pattern(s) not found: ${exactMissing.join(", ")}. Available: ${available}`,
+ );
+ }
+
+ if (result.size === 0) {
+ throw new Error(`No videos matched --filter patterns: ${patterns.join(", ")}`);
+ }
+
+ return videos.filter((v) => result.has(v.name));
+}
+
+export function filterVideos(
+ videos: VideoConfig[],
+ names: string[],
+ patterns: string[],
+): VideoConfig[] {
+ if (names.length === 0 && patterns.length === 0) return videos;
+ if (names.length > 0 && patterns.length === 0) return filterVideosByName(videos, names);
+ if (names.length === 0 && patterns.length > 0)
+ return filterVideosByPattern(videos, patterns);
+
+ const byName = new Set(filterVideosByName(videos, names).map((v) => v.name));
+ const byPattern = new Set(filterVideosByPattern(videos, patterns).map((v) => v.name));
+ const union = new Set([...byName, ...byPattern]);
+
+ if (union.size === 0) {
+ throw new Error("No videos matched the given filters.");
+ }
+
+ return videos.filter((v) => union.has(v.name));
+}
+
export function resolveConfigPath(configPath?: string): string {
if (configPath) {
const resolved = resolve(configPath);
@@ -1153,3 +1543,229 @@ export function resolveConfigPath(configPath?: string): string {
`No config file found. Create a ${DEFAULT_CONFIG_FILE} or specify one with --config.`,
);
}
+
+export async function resolveConfigPaths(configOpt?: string[]): Promise {
+ if (!configOpt || configOpt.length === 0) {
+ return [resolveConfigPath()];
+ }
+
+ const resolved = new Set();
+
+ for (const pattern of configOpt) {
+ if (isDynamicPattern(pattern)) {
+ const matches = await glob([pattern], {
+ cwd: process.cwd(),
+ absolute: true,
+ });
+ if (matches.length === 0) {
+ throw new Error(`No config files matched: "${pattern}"`);
+ }
+ for (const m of matches) resolved.add(resolve(m));
+ } else {
+ const abs = resolve(pattern);
+ if (!existsSync(abs)) {
+ throw new Error(`Config file not found: ${abs}`);
+ }
+ resolved.add(abs);
+ }
+ }
+
+ if (resolved.size === 0) {
+ throw new Error("No config files found.");
+ }
+
+ return [...resolved];
+}
+
+async function resolveScenarios(
+ rootResolved: Record,
+ configPath: string,
+): Promise<{ videos: VideoConfig[]; sources: Map }> {
+ const scenarios = rootResolved.scenarios;
+ if (!Array.isArray(scenarios) || scenarios.length === 0) {
+ return { videos: [], sources: new Map() };
+ }
+
+ const configDir = dirname(resolve(configPath));
+ const relConfigPath = relative(process.cwd(), resolve(configPath));
+ const allVideos: VideoConfig[] = [];
+ const sources = new Map();
+ const loadedPaths = new Set();
+ const errors: string[] = [];
+ let scenarioIndex = 0;
+
+ for (const item of scenarios) {
+ if (typeof item === "string") {
+ const matches = await glob([item], {
+ cwd: configDir,
+ absolute: true,
+ });
+ for (const matchPath of matches) {
+ const abs = resolve(matchPath);
+ if (loadedPaths.has(abs)) continue;
+ loadedPaths.add(abs);
+
+ try {
+ const { config: childConfig, resolved: childResolved } =
+ await loadWebreelConfigFull(abs);
+
+ if (
+ Array.isArray(childResolved.scenarios) &&
+ childResolved.scenarios.length > 0
+ ) {
+ console.warn(
+ `Warning: "scenarios" in ${relative(process.cwd(), abs)} ignored (nested scenarios not supported)`,
+ );
+ }
+
+ const relChild = relative(process.cwd(), abs);
+ for (const video of childConfig.videos) {
+ sources.set(video.name, relChild);
+ allVideos.push(video);
+ }
+ } catch (err) {
+ errors.push(
+ `${relative(process.cwd(), abs)}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+ continue;
+ }
+
+ if (typeof item === "object" && item !== null) {
+ scenarioIndex++;
+ const scenario = item as Record;
+ const sourceLabel = `${relConfigPath} [scenario #${scenarioIndex}]`;
+
+ let scenarioParsed: Record;
+
+ if (scenario.extends === true) {
+ scenarioParsed = { ...scenario };
+ delete scenarioParsed.extends;
+
+ for (const key of MERGEABLE_TOP_LEVEL_KEYS) {
+ if (scenarioParsed[key] === undefined && rootResolved[key] !== undefined) {
+ scenarioParsed[key] = rootResolved[key];
+ }
+ }
+ if (scenarioParsed.theme === undefined && rootResolved.theme !== undefined) {
+ scenarioParsed.theme = rootResolved.theme;
+ } else if (rootResolved.theme !== undefined) {
+ scenarioParsed.theme = mergeTheme(
+ rootResolved.theme as Record | undefined,
+ scenarioParsed.theme as Record | undefined,
+ );
+ }
+ if (scenarioParsed.sfx === undefined && rootResolved.sfx !== undefined) {
+ scenarioParsed.sfx = rootResolved.sfx;
+ } else if (rootResolved.sfx !== undefined) {
+ scenarioParsed.sfx = mergeSfx(
+ rootResolved.sfx as Record | undefined,
+ scenarioParsed.sfx as Record | undefined,
+ );
+ }
+ } else if (typeof scenario.extends === "string") {
+ scenarioParsed = await resolveExtends(
+ scenario,
+ configPath,
+ new Set([resolve(configPath)]),
+ 0,
+ );
+ } else {
+ scenarioParsed = scenario;
+ }
+
+ if (
+ scenarioParsed.videos != null &&
+ typeof scenarioParsed.videos === "object" &&
+ !Array.isArray(scenarioParsed.videos)
+ ) {
+ const scenarioDir = configDir;
+ const outDir = resolve(
+ scenarioDir,
+ (scenarioParsed.outDir as string) ?? "videos",
+ );
+ const defaults = {
+ baseUrl: scenarioParsed.baseUrl as string | undefined,
+ viewport: resolveViewportValue(scenarioParsed.viewport),
+ theme: scenarioParsed.theme as WebreelConfig["theme"],
+ sfx: scenarioParsed.sfx as WebreelConfig["sfx"],
+ include: scenarioParsed.include as string[] | undefined,
+ defaultDelay: scenarioParsed.defaultDelay as number | undefined,
+ clickDwell: scenarioParsed.clickDwell as number | undefined,
+ };
+
+ const videosObj = scenarioParsed.videos as Record<
+ string,
+ Record
+ >;
+ for (const [name, body] of Object.entries(videosObj)) {
+ const videoBody = { ...body };
+ if (typeof videoBody.viewport === "string") {
+ videoBody.viewport =
+ resolveViewportPreset(videoBody.viewport as string) ?? videoBody.viewport;
+ }
+ const video = {
+ ...videoBody,
+ name,
+ configDir: scenarioDir,
+ } as unknown as VideoConfig;
+ const resolvedVideo = resolveVideoDefaults(
+ video,
+ defaults,
+ outDir,
+ scenarioDir,
+ );
+ const finalVideo = await resolveVideo(resolvedVideo, configPath);
+ sources.set(name, sourceLabel);
+ allVideos.push(finalVideo);
+ }
+ }
+ }
+ }
+
+ if (errors.length > 0) {
+ throw new Error(`Failed to load scenario configs:\n ${errors.join("\n ")}`);
+ }
+
+ return { videos: allVideos, sources };
+}
+
+export async function loadFullConfig(paths: string[]): Promise {
+ const allVideos: VideoConfig[] = [];
+ const videoSources = new Map();
+
+ for (const configPath of paths) {
+ const { config, resolved } = await loadWebreelConfigFull(configPath);
+ const relPath = relative(process.cwd(), resolve(configPath));
+
+ for (const video of config.videos) {
+ if (videoSources.has(video.name)) {
+ throw new Error(
+ `Duplicate video name "${video.name}" in ${videoSources.get(video.name)} and ${relPath}`,
+ );
+ }
+ videoSources.set(video.name, relPath);
+ allVideos.push(video);
+ }
+
+ if (Array.isArray(resolved.scenarios) && resolved.scenarios.length > 0) {
+ const { videos: scenarioVideos, sources } = await resolveScenarios(
+ resolved,
+ configPath,
+ );
+ for (const video of scenarioVideos) {
+ const source = sources.get(video.name) ?? relPath;
+ if (videoSources.has(video.name)) {
+ throw new Error(
+ `Duplicate video name "${video.name}" in ${videoSources.get(video.name)} and ${source}`,
+ );
+ }
+ videoSources.set(video.name, source);
+ allVideos.push(video);
+ }
+ }
+ }
+
+ return { videos: allVideos, videoSources };
+}
diff --git a/packages/webreel/src/lib/types.ts b/packages/webreel/src/lib/types.ts
index 2aed93c..0ad6c5e 100644
--- a/packages/webreel/src/lib/types.ts
+++ b/packages/webreel/src/lib/types.ts
@@ -174,6 +174,7 @@ import type { SfxConfig } from "@webreel/core";
export interface VideoConfig {
name: string;
+ configDir: string;
url: string;
baseUrl?: string;
viewport?: { width: number; height: number };
@@ -203,3 +204,8 @@ export interface WebreelConfig {
clickDwell?: number;
videos: VideoConfig[];
}
+
+export interface FullConfig {
+ videos: VideoConfig[];
+ videoSources: Map;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a024cef..b29e8c5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -145,10 +145,19 @@ importers:
jsonc-parser:
specifier: ^3.3.1
version: 3.3.1
+ picomatch:
+ specifier: ^4.0.3
+ version: 4.0.3
+ tinyglobby:
+ specifier: ^0.2.15
+ version: 0.2.15
devDependencies:
'@types/node':
specifier: 25.3.0
version: 25.3.0
+ '@types/picomatch':
+ specifier: ^4.0.2
+ version: 4.0.2
typescript:
specifier: 5.9.3
version: 5.9.3
@@ -1157,6 +1166,9 @@ packages:
'@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
+ '@types/picomatch@4.0.2':
+ resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -3567,6 +3579,8 @@ snapshots:
dependencies:
undici-types: 7.18.2
+ '@types/picomatch@4.0.2': {}
+
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14