@t3lnet/sceneforge 1.0.4 → 1.0.6

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.
@@ -0,0 +1,305 @@
1
+ import * as fs from "fs/promises";
2
+ import * as path from "path";
3
+ import { chromium } from "@playwright/test";
4
+ import {
5
+ loadDemoDefinition,
6
+ runDemo,
7
+ } from "@t3lnet/sceneforge";
8
+ import { config as loadEnv } from "dotenv";
9
+ import { getFlagValue, hasFlag } from "../utils/args.js";
10
+ import {
11
+ ensureDir,
12
+ getOutputPaths,
13
+ resolveEnvFile,
14
+ resolveRoot,
15
+ toAbsolute,
16
+ } from "../utils/paths.js";
17
+ import { getMediaDuration } from "../utils/media.js";
18
+
19
+ function printHelp() {
20
+ console.log(`
21
+ Run a YAML demo definition with Playwright and generate scripts
22
+
23
+ Usage:
24
+ sceneforge record [options]
25
+
26
+ Options:
27
+ --definition <path> Path to the YAML demo definition
28
+ --demo <name> Demo name (resolved in --definitions-dir)
29
+ --definitions-dir <p> Directory for demo YAML files (default: examples)
30
+ --base-url <url> Base URL for the demo (required)
31
+ --start-path <path> Optional path/URL to open before running actions
32
+ --asset-root <path> Base directory for relative upload files
33
+ --env-file <path> Env file for secrets (defaults to .env if present)
34
+ --locale <locale> Locale for requests (default: en-US)
35
+ --root <path> Project root (defaults to cwd)
36
+ --output-dir <path> Output directory (defaults to output or e2e/output)
37
+ --storage-state <path> Playwright storage state JSON
38
+ --viewport <WxH> Viewport size, e.g. 1440x900 (default)
39
+ --width <px> Viewport width (overrides --viewport)
40
+ --height <px> Viewport height (overrides --viewport)
41
+ --headed Run browser headed
42
+ --slowmo <ms> Slow down Playwright actions
43
+ --no-video Skip video recording
44
+ --help, -h Show this help message
45
+
46
+ Examples:
47
+ sceneforge record --definition demo-definitions/create-quote.yaml --base-url http://localhost:5173
48
+ sceneforge record --demo create-quote --definitions-dir examples --base-url http://localhost:5173
49
+ `);
50
+ }
51
+
52
+ function parseViewport(args) {
53
+ const viewportValue = getFlagValue(args, "--viewport");
54
+ const widthValue = getFlagValue(args, "--width");
55
+ const heightValue = getFlagValue(args, "--height");
56
+
57
+ const defaultViewport = { width: 1440, height: 900 };
58
+
59
+ if (widthValue || heightValue) {
60
+ const width = widthValue ? Number(widthValue) : defaultViewport.width;
61
+ const height = heightValue ? Number(heightValue) : defaultViewport.height;
62
+ return { width, height };
63
+ }
64
+
65
+ if (!viewportValue) {
66
+ return defaultViewport;
67
+ }
68
+
69
+ const match = viewportValue.match(/^(\d+)x(\d+)$/i);
70
+ if (!match) {
71
+ return defaultViewport;
72
+ }
73
+
74
+ return { width: Number(match[1]), height: Number(match[2]) };
75
+ }
76
+
77
+ function resolveStartUrl(startPath, baseUrl) {
78
+ if (!startPath) return null;
79
+ const interpolated = startPath
80
+ .replace("{baseURL}", baseUrl);
81
+
82
+ if (interpolated.startsWith("http://") || interpolated.startsWith("https://")) {
83
+ return interpolated;
84
+ }
85
+
86
+ if (interpolated.startsWith("/")) {
87
+ return `${baseUrl}${interpolated}`;
88
+ }
89
+
90
+ return `${baseUrl}/${interpolated}`;
91
+ }
92
+
93
+ async function alignScriptToVideo(scriptPath, videoPath, recordingStartTimeMs) {
94
+ if (!scriptPath || !videoPath) {
95
+ return;
96
+ }
97
+
98
+ try {
99
+ const [scriptContent, videoDurationSec] = await Promise.all([
100
+ fs.readFile(scriptPath, "utf-8"),
101
+ getMediaDuration(videoPath),
102
+ ]);
103
+ const script = JSON.parse(scriptContent);
104
+ const videoDurationMs = Math.round(videoDurationSec * 1000);
105
+ const scriptDurationMs = Number(script.totalDurationMs ?? 0);
106
+ const safeScriptDurationMs = Number.isFinite(scriptDurationMs) ? scriptDurationMs : 0;
107
+ const alignmentOffsetMs = Math.max(0, safeScriptDurationMs - videoDurationMs);
108
+
109
+ const clampTime = (value) => {
110
+ const adjusted = Number(value ?? 0) - alignmentOffsetMs;
111
+ return Math.max(0, Math.round(adjusted));
112
+ };
113
+
114
+ const alignedSegments = Array.isArray(script.segments)
115
+ ? script.segments.map((segment) => ({
116
+ ...segment,
117
+ startTimeMs: clampTime(segment.startTimeMs),
118
+ endTimeMs: clampTime(segment.endTimeMs),
119
+ }))
120
+ : script.segments;
121
+
122
+ const alignedBoundaries = Array.isArray(script.stepBoundaries)
123
+ ? script.stepBoundaries.map((boundary) => ({
124
+ ...boundary,
125
+ videoStartMs: clampTime(boundary.videoStartMs),
126
+ videoEndMs: clampTime(boundary.videoEndMs),
127
+ }))
128
+ : script.stepBoundaries;
129
+
130
+ const updated = {
131
+ ...script,
132
+ totalDurationMs: videoDurationMs,
133
+ segments: alignedSegments,
134
+ stepBoundaries: alignedBoundaries,
135
+ videoMetadata: {
136
+ videoPath,
137
+ durationMs: videoDurationMs,
138
+ alignmentOffsetMs,
139
+ recordingStartTimeMs,
140
+ alignedAt: new Date().toISOString(),
141
+ },
142
+ };
143
+
144
+ await fs.writeFile(scriptPath, JSON.stringify(updated, null, 2));
145
+ } catch (error) {
146
+ console.warn("[record] Failed to align script timings:", error);
147
+ }
148
+ }
149
+
150
+ async function resolveDefinitionPath(rootDir, demo, definitionsDir) {
151
+ const dir = toAbsolute(rootDir, definitionsDir);
152
+ const yamlPath = path.join(dir, `${demo}.yaml`);
153
+ const ymlPath = path.join(dir, `${demo}.yml`);
154
+
155
+ try {
156
+ await fs.access(yamlPath);
157
+ return yamlPath;
158
+ } catch {
159
+ // continue
160
+ }
161
+
162
+ try {
163
+ await fs.access(ymlPath);
164
+ return ymlPath;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ async function moveVideo(sourcePath, targetPath) {
171
+ try {
172
+ await fs.rename(sourcePath, targetPath);
173
+ } catch {
174
+ await fs.copyFile(sourcePath, targetPath);
175
+ await fs.rm(sourcePath, { force: true });
176
+ }
177
+ }
178
+
179
+ export async function runRecordDemoCommand(argv) {
180
+ const args = argv ?? process.argv.slice(2);
181
+ const help = hasFlag(args, "--help") || hasFlag(args, "-h");
182
+
183
+ if (help) {
184
+ printHelp();
185
+ return;
186
+ }
187
+
188
+ const root = getFlagValue(args, "--root");
189
+ const outputDirOverride = getFlagValue(args, "--output-dir");
190
+ const baseUrl = getFlagValue(args, "--base-url");
191
+ const definitionArg = getFlagValue(args, "--definition");
192
+ const demo = getFlagValue(args, "--demo");
193
+ const definitionsDir = getFlagValue(args, "--definitions-dir") ?? "examples";
194
+ const storageState = getFlagValue(args, "--storage-state");
195
+ const startPath = getFlagValue(args, "--start-path") || getFlagValue(args, "--start-url");
196
+ const assetRoot = getFlagValue(args, "--asset-root");
197
+ const envFile = getFlagValue(args, "--env-file");
198
+ const localeFlag = getFlagValue(args, "--locale");
199
+ const headed = hasFlag(args, "--headed");
200
+ const slowMo = getFlagValue(args, "--slowmo");
201
+ const noVideo = hasFlag(args, "--no-video");
202
+
203
+ if (!baseUrl) {
204
+ console.error("[error] --base-url is required");
205
+ printHelp();
206
+ process.exit(1);
207
+ }
208
+
209
+ const rootDir = resolveRoot(root);
210
+ const outputPaths = await getOutputPaths(rootDir, outputDirOverride);
211
+ const resolvedEnvFile = await resolveEnvFile(rootDir, envFile);
212
+ if (resolvedEnvFile) {
213
+ loadEnv({ path: resolvedEnvFile });
214
+ }
215
+ const locale = localeFlag ?? process.env.DEMO_LOCALE ?? "en-US";
216
+
217
+ const definitionPath = definitionArg
218
+ ? toAbsolute(rootDir, definitionArg)
219
+ : demo
220
+ ? await resolveDefinitionPath(rootDir, demo, definitionsDir)
221
+ : null;
222
+
223
+ if (!definitionPath) {
224
+ console.error("[error] Demo definition not found. Provide --definition or --demo.");
225
+ printHelp();
226
+ process.exit(1);
227
+ }
228
+
229
+ const definition = await loadDemoDefinition(definitionPath, {
230
+ resolveSecrets: (key) => process.env[key],
231
+ });
232
+
233
+ await ensureDir(outputPaths.outputDir);
234
+ await ensureDir(outputPaths.videosDir);
235
+
236
+ const viewport = parseViewport(args);
237
+
238
+ const recordDir = path.join(outputPaths.videosDir, ".recordings", definition.name);
239
+ if (!noVideo) {
240
+ await ensureDir(recordDir);
241
+ }
242
+
243
+ const browser = await chromium.launch({
244
+ headless: !headed,
245
+ slowMo: slowMo ? Number(slowMo) : undefined,
246
+ });
247
+
248
+ const context = await browser.newContext({
249
+ viewport,
250
+ recordVideo: noVideo ? undefined : { dir: recordDir, size: viewport },
251
+ storageState: storageState ? toAbsolute(rootDir, storageState) : undefined,
252
+ locale: locale || undefined,
253
+ extraHTTPHeaders: locale
254
+ ? {
255
+ "Accept-Language": `${locale},en;q=0.9`,
256
+ }
257
+ : undefined,
258
+ });
259
+
260
+ const page = await context.newPage();
261
+ const video = page.video();
262
+ const videoRecordingStartTime = Date.now();
263
+ const startUrl = resolveStartUrl(startPath, baseUrl);
264
+
265
+ if (startUrl) {
266
+ await page.goto(startUrl, { waitUntil: "networkidle" });
267
+ }
268
+ const result = await runDemo(
269
+ definition,
270
+ {
271
+ page,
272
+ baseURL: baseUrl,
273
+ outputDir: outputPaths.outputDir,
274
+ assetBaseDir: assetRoot ? toAbsolute(rootDir, assetRoot) : undefined,
275
+ videoRecordingStartTime,
276
+ },
277
+ {
278
+ generateScripts: true,
279
+ scriptOutputDir: path.join(outputPaths.outputDir, "scripts"),
280
+ }
281
+ );
282
+
283
+ await context.close();
284
+ await browser.close();
285
+
286
+ if (video) {
287
+ const recordedPath = await video.path();
288
+ const finalVideoPath = path.join(outputPaths.videosDir, `${definition.name}.webm`);
289
+ await moveVideo(recordedPath, finalVideoPath);
290
+ console.log(`[record] Video saved: ${finalVideoPath}`);
291
+ if (result.scriptPath) {
292
+ await alignScriptToVideo(result.scriptPath, finalVideoPath, videoRecordingStartTime);
293
+ }
294
+ }
295
+
296
+ if (result.success) {
297
+ console.log(`[record] ✓ Completed ${definition.name}`);
298
+ if (result.scriptPath) {
299
+ console.log(`[record] Script: ${result.scriptPath}`);
300
+ }
301
+ } else {
302
+ console.error(`[record] ✗ Failed: ${result.error?.message ?? "Unknown error"}`);
303
+ process.exit(1);
304
+ }
305
+ }
@@ -0,0 +1,218 @@
1
+ import * as fs from "fs/promises";
2
+ import * as path from "path";
3
+ import { chromium } from "@playwright/test";
4
+ import { loadDemoDefinition, runDemo } from "@t3lnet/sceneforge";
5
+ import { config as loadEnv } from "dotenv";
6
+ import { getFlagValue, hasFlag } from "../utils/args.js";
7
+ import {
8
+ ensureDir,
9
+ getOutputPaths,
10
+ resolveEnvFile,
11
+ resolveRoot,
12
+ toAbsolute,
13
+ } from "../utils/paths.js";
14
+
15
+ function printHelp() {
16
+ console.log(`
17
+ Run a setup YAML to capture and cache login storage state
18
+
19
+ Usage:
20
+ sceneforge setup [options]
21
+
22
+ Options:
23
+ --definition <path> Path to the YAML setup definition
24
+ --demo <name> Demo name (resolved in --definitions-dir)
25
+ --definitions-dir <p> Directory for YAML files (default: examples)
26
+ --base-url <url> Base URL for the app (required)
27
+ --start-path <path> Optional path/URL to open before running actions
28
+ --asset-root <path> Base directory for relative upload files
29
+ --root <path> Project root (defaults to cwd)
30
+ --output-dir <path> Output directory (defaults to output or e2e/output)
31
+ --storage-state <path> Where to save storage state JSON (defaults to output/storage/<name>.json)
32
+ --env-file <path> Env file for secrets (defaults to .env if present)
33
+ --locale <locale> Locale for requests (default: en-US)
34
+ --viewport <WxH> Viewport size, e.g. 1440x900 (default)
35
+ --width <px> Viewport width (overrides --viewport)
36
+ --height <px> Viewport height (overrides --viewport)
37
+ --headed Run browser headed (recommended for login)
38
+ --slowmo <ms> Slow down Playwright actions
39
+ --help, -h Show this help message
40
+
41
+ Examples:
42
+ sceneforge setup --definition examples/setup-login.yaml --base-url http://localhost:5173 --start-path /app --headed
43
+ sceneforge setup --demo setup-login --definitions-dir examples --base-url http://localhost:5173 --storage-state output/storage/login.json
44
+ `);
45
+ }
46
+
47
+ function parseViewport(args) {
48
+ const viewportValue = getFlagValue(args, "--viewport");
49
+ const widthValue = getFlagValue(args, "--width");
50
+ const heightValue = getFlagValue(args, "--height");
51
+
52
+ const defaultViewport = { width: 1440, height: 900 };
53
+
54
+ if (widthValue || heightValue) {
55
+ const width = widthValue ? Number(widthValue) : defaultViewport.width;
56
+ const height = heightValue ? Number(heightValue) : defaultViewport.height;
57
+ return { width, height };
58
+ }
59
+
60
+ if (!viewportValue) {
61
+ return defaultViewport;
62
+ }
63
+
64
+ const match = viewportValue.match(/^(\d+)x(\d+)$/i);
65
+ if (!match) {
66
+ return defaultViewport;
67
+ }
68
+
69
+ return { width: Number(match[1]), height: Number(match[2]) };
70
+ }
71
+
72
+ function resolveStartUrl(startPath, baseUrl) {
73
+ if (!startPath) return null;
74
+ const interpolated = startPath.replace("{baseURL}", baseUrl);
75
+
76
+ if (interpolated.startsWith("http://") || interpolated.startsWith("https://")) {
77
+ return interpolated;
78
+ }
79
+
80
+ if (interpolated.startsWith("/")) {
81
+ return `${baseUrl}${interpolated}`;
82
+ }
83
+
84
+ return `${baseUrl}/${interpolated}`;
85
+ }
86
+
87
+ async function resolveDefinitionPath(rootDir, demo, definitionsDir) {
88
+ const dir = toAbsolute(rootDir, definitionsDir);
89
+ const yamlPath = path.join(dir, `${demo}.yaml`);
90
+ const ymlPath = path.join(dir, `${demo}.yml`);
91
+
92
+ try {
93
+ await fs.access(yamlPath);
94
+ return yamlPath;
95
+ } catch {
96
+ // continue
97
+ }
98
+
99
+ try {
100
+ await fs.access(ymlPath);
101
+ return ymlPath;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ export async function runSetupCommand(argv) {
108
+ const args = argv ?? process.argv.slice(2);
109
+ const help = hasFlag(args, "--help") || hasFlag(args, "-h");
110
+
111
+ if (help) {
112
+ printHelp();
113
+ return;
114
+ }
115
+
116
+ const root = getFlagValue(args, "--root");
117
+ const outputDirOverride = getFlagValue(args, "--output-dir");
118
+ const baseUrl = getFlagValue(args, "--base-url");
119
+ const definitionArg = getFlagValue(args, "--definition");
120
+ const demo = getFlagValue(args, "--demo");
121
+ const definitionsDir = getFlagValue(args, "--definitions-dir") ?? "examples";
122
+ const startPath = getFlagValue(args, "--start-path") || getFlagValue(args, "--start-url");
123
+ const storageStateArg = getFlagValue(args, "--storage-state");
124
+ const envFile = getFlagValue(args, "--env-file");
125
+ const localeFlag = getFlagValue(args, "--locale");
126
+ const assetRoot = getFlagValue(args, "--asset-root");
127
+ const headed = hasFlag(args, "--headed");
128
+ const slowMo = getFlagValue(args, "--slowmo");
129
+
130
+ if (!baseUrl) {
131
+ console.error("[error] --base-url is required");
132
+ printHelp();
133
+ process.exit(1);
134
+ }
135
+
136
+ const rootDir = resolveRoot(root);
137
+ const outputPaths = await getOutputPaths(rootDir, outputDirOverride);
138
+ const resolvedEnvFile = await resolveEnvFile(rootDir, envFile);
139
+ if (resolvedEnvFile) {
140
+ loadEnv({ path: resolvedEnvFile });
141
+ }
142
+ const locale = localeFlag ?? process.env.DEMO_LOCALE ?? "en-US";
143
+
144
+ const definitionPath = definitionArg
145
+ ? toAbsolute(rootDir, definitionArg)
146
+ : demo
147
+ ? await resolveDefinitionPath(rootDir, demo, definitionsDir)
148
+ : null;
149
+
150
+ if (!definitionPath) {
151
+ console.error("[error] Setup definition not found. Provide --definition or --demo.");
152
+ printHelp();
153
+ process.exit(1);
154
+ }
155
+
156
+ const definition = await loadDemoDefinition(definitionPath, {
157
+ resolveSecrets: (key) => process.env[key],
158
+ });
159
+
160
+ const storageStatePath = storageStateArg
161
+ ? toAbsolute(rootDir, storageStateArg)
162
+ : path.join(outputPaths.outputDir, "storage", `${definition.name}.json`);
163
+
164
+ await ensureDir(path.dirname(storageStatePath));
165
+ await ensureDir(outputPaths.outputDir);
166
+
167
+ const viewport = parseViewport(args);
168
+
169
+ const browser = await chromium.launch({
170
+ headless: !headed,
171
+ slowMo: slowMo ? Number(slowMo) : undefined,
172
+ });
173
+
174
+ let context;
175
+ try {
176
+ context = await browser.newContext({
177
+ viewport,
178
+ locale: locale || undefined,
179
+ extraHTTPHeaders: locale
180
+ ? {
181
+ "Accept-Language": `${locale},en;q=0.9`,
182
+ }
183
+ : undefined,
184
+ });
185
+ const page = await context.newPage();
186
+ const startUrl = resolveStartUrl(startPath, baseUrl);
187
+
188
+ if (startUrl) {
189
+ await page.goto(startUrl, { waitUntil: "networkidle" });
190
+ }
191
+
192
+ const result = await runDemo(
193
+ definition,
194
+ {
195
+ page,
196
+ baseURL: baseUrl,
197
+ outputDir: outputPaths.outputDir,
198
+ assetBaseDir: assetRoot ? toAbsolute(rootDir, assetRoot) : undefined,
199
+ },
200
+ {
201
+ generateScripts: false,
202
+ }
203
+ );
204
+
205
+ if (result.success) {
206
+ await context.storageState({ path: storageStatePath });
207
+ console.log(`[setup] ✓ Saved storage state: ${storageStatePath}`);
208
+ } else {
209
+ console.error(`[setup] ✗ Failed: ${result.error?.message ?? "Unknown error"}`);
210
+ process.exit(1);
211
+ }
212
+ } finally {
213
+ if (context) {
214
+ await context.close();
215
+ }
216
+ await browser.close();
217
+ }
218
+ }