@matte97p/demowright 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/cli.js +103 -0
- package/examples/geosuite-howitworks.config.js +127 -0
- package/examples/geosuite-workspace-audit.config.js +83 -0
- package/examples/local-demo.config.js +36 -0
- package/examples/site/index.html +141 -0
- package/package.json +67 -0
- package/src/index.js +41 -0
- package/src/overlay.js +262 -0
- package/src/render.js +137 -0
- package/src/runner.js +262 -0
- package/src/scaffold.js +46 -0
- package/src/steps.js +150 -0
package/src/runner.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capture stage: drive the browser with Playwright while the overlay paints
|
|
3
|
+
* the polish into the same frames Playwright records. Output is a raw .webm.
|
|
4
|
+
*/
|
|
5
|
+
import { chromium } from 'playwright'
|
|
6
|
+
import { mkdir } from 'node:fs/promises'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { buildInitScript } from './overlay.js'
|
|
9
|
+
|
|
10
|
+
const sleep = (page, ms) => page.waitForTimeout(Math.max(0, ms | 0))
|
|
11
|
+
|
|
12
|
+
/** Real on-screen center of a selector (viewport coords), for genuine hover. */
|
|
13
|
+
async function centerOf(page, selector) {
|
|
14
|
+
const box = await page.locator(selector).first().boundingBox()
|
|
15
|
+
if (!box) return null
|
|
16
|
+
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function dw(page, fn, ...args) {
|
|
20
|
+
return page.evaluate(
|
|
21
|
+
([f, a]) => (window.__dw && window.__dw[f] ? window.__dw[f](...a) : false),
|
|
22
|
+
[fn, args]
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** type → async executor. Each receives (page, step, demo). */
|
|
27
|
+
const EXECUTORS = {
|
|
28
|
+
async caption(page, step) {
|
|
29
|
+
await dw(page, 'caption', step.text, step.hold ? 0 : step.duration)
|
|
30
|
+
if (!step.hold) await sleep(page, step.duration)
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async captionHide(page) {
|
|
34
|
+
await dw(page, 'captionHide')
|
|
35
|
+
await sleep(page, 340)
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async goto(page, step) {
|
|
39
|
+
await page.goto(step.url, { waitUntil: 'load' })
|
|
40
|
+
await page.waitForFunction(() => !!window.__dw).catch(() => {})
|
|
41
|
+
await dw(page, 'ready')
|
|
42
|
+
await sleep(page, 400)
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async move(page, step) {
|
|
46
|
+
if (step.selector) {
|
|
47
|
+
await dw(page, 'cursorToSelector', step.selector, step.duration)
|
|
48
|
+
const c = await centerOf(page, step.selector)
|
|
49
|
+
if (c) await page.mouse.move(c.x, c.y, { steps: 12 })
|
|
50
|
+
} else {
|
|
51
|
+
await dw(page, 'cursorTo', step.x, step.y, step.duration)
|
|
52
|
+
await page.mouse.move(step.x, step.y, { steps: 12 })
|
|
53
|
+
}
|
|
54
|
+
await sleep(page, step.duration + 120)
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async click(page, step) {
|
|
58
|
+
await dw(page, 'cursorToSelector', step.selector, step.duration)
|
|
59
|
+
const c = await centerOf(page, step.selector)
|
|
60
|
+
if (c) await page.mouse.move(c.x, c.y, { steps: 12 })
|
|
61
|
+
await sleep(page, step.duration)
|
|
62
|
+
await dw(page, 'click')
|
|
63
|
+
// .first(): demos often have a selector that legitimately matches more than
|
|
64
|
+
// one node (e.g. a desktop + mobile copy of the same nav). Click the first
|
|
65
|
+
// rather than failing Playwright's strict-mode check.
|
|
66
|
+
await page.locator(step.selector).first().click()
|
|
67
|
+
await sleep(page, step.settle)
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async type(page, step) {
|
|
71
|
+
const loc = page.locator(step.selector).first()
|
|
72
|
+
if (step.clear) await loc.fill('')
|
|
73
|
+
await loc.focus()
|
|
74
|
+
await loc.pressSequentially(step.text, { delay: step.perChar })
|
|
75
|
+
await sleep(page, 250)
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async key(page, step) {
|
|
79
|
+
await page.keyboard.press(step.key)
|
|
80
|
+
await sleep(page, 200)
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// Pick an option in a native <select>. Target by `value`, `label`, or `index`.
|
|
84
|
+
async select(page, step) {
|
|
85
|
+
await dw(page, 'cursorToSelector', step.selector, step.duration)
|
|
86
|
+
const c = await centerOf(page, step.selector)
|
|
87
|
+
if (c) await page.mouse.move(c.x, c.y, { steps: 10 })
|
|
88
|
+
await sleep(page, step.duration)
|
|
89
|
+
const loc = page.locator(step.selector).first()
|
|
90
|
+
if (step.contains != null) {
|
|
91
|
+
// Match the option whose visible text contains a substring, then select
|
|
92
|
+
// by its value — robust to decorated labels (e.g. "Linear · linear.app").
|
|
93
|
+
const value = await loc.evaluate((el, sub) => {
|
|
94
|
+
const o = [...el.options].find((opt) => opt.text.toLowerCase().includes(String(sub).toLowerCase()))
|
|
95
|
+
return o ? o.value : null
|
|
96
|
+
}, step.contains)
|
|
97
|
+
if (value == null) throw new Error('no <option> containing "' + step.contains + '" in ' + step.selector)
|
|
98
|
+
await loc.selectOption(value)
|
|
99
|
+
} else if (step.index != null) await loc.selectOption({ index: step.index })
|
|
100
|
+
else if (step.label != null) await loc.selectOption({ label: step.label })
|
|
101
|
+
else await loc.selectOption(step.value)
|
|
102
|
+
await sleep(page, step.settle)
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async highlight(page, step) {
|
|
106
|
+
await dw(page, 'highlight', step.selector, step.pad)
|
|
107
|
+
if (step.duration) {
|
|
108
|
+
await sleep(page, step.duration)
|
|
109
|
+
await dw(page, 'highlightHide')
|
|
110
|
+
await sleep(page, 260)
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async highlightHide(page) {
|
|
115
|
+
await dw(page, 'highlightHide')
|
|
116
|
+
await sleep(page, 260)
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async zoom(page, step) {
|
|
120
|
+
await dw(page, 'zoom', step.selector, step.scale, step.duration)
|
|
121
|
+
await sleep(page, step.duration + 100)
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async zoomReset(page, step) {
|
|
125
|
+
await dw(page, 'zoomReset', step.duration)
|
|
126
|
+
await sleep(page, step.duration + 100)
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async scroll(page, step) {
|
|
130
|
+
if (step.selector) {
|
|
131
|
+
await page.evaluate(
|
|
132
|
+
(sel) => document.querySelector(sel)?.scrollIntoView({ behavior: 'smooth', block: 'center' }),
|
|
133
|
+
step.selector
|
|
134
|
+
)
|
|
135
|
+
} else {
|
|
136
|
+
await page.evaluate((y) => window.scrollTo({ top: y, behavior: 'smooth' }), step.y || 0)
|
|
137
|
+
}
|
|
138
|
+
await sleep(page, step.duration)
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async wait(page, step) {
|
|
142
|
+
if (step.selector) {
|
|
143
|
+
await page.waitForSelector(step.selector, { state: 'visible', timeout: step.timeout })
|
|
144
|
+
} else {
|
|
145
|
+
await sleep(page, step.duration)
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async endcard(page, step) {
|
|
150
|
+
await dw(page, 'captionHide')
|
|
151
|
+
await dw(page, 'endcard', step.title, step.subtitle || '', step.duration)
|
|
152
|
+
await sleep(page, step.duration)
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Log in once in a throwaway, non-recorded context and return its storageState
|
|
158
|
+
* (cookies + localStorage). Field values come from env vars referenced by name,
|
|
159
|
+
* so credentials never live in the config or appear in the recording.
|
|
160
|
+
*/
|
|
161
|
+
async function authenticate(browser, auth, viewport, { locale = null, init = null } = {}) {
|
|
162
|
+
const ctx = await browser.newContext({ viewport, ...(locale ? { locale } : {}) })
|
|
163
|
+
const page = await ctx.newPage()
|
|
164
|
+
if (init) await page.addInitScript(init)
|
|
165
|
+
await page.goto(auth.url, { waitUntil: 'load' })
|
|
166
|
+
for (const f of auth.fields) {
|
|
167
|
+
const value = f.env != null ? process.env[f.env] : f.value
|
|
168
|
+
if (value == null || value === '') {
|
|
169
|
+
throw new Error('auth field "' + f.selector + '" resolved empty (set env ' + (f.env || '') + ')')
|
|
170
|
+
}
|
|
171
|
+
const loc = page.locator(f.selector).first()
|
|
172
|
+
await loc.fill('')
|
|
173
|
+
await loc.pressSequentially(String(value), { delay: auth.perChar })
|
|
174
|
+
}
|
|
175
|
+
await page.click(auth.submit)
|
|
176
|
+
if (auth.waitUrl) await page.waitForURL(auth.waitUrl, { timeout: 30000 })
|
|
177
|
+
else if (auth.waitFor) await page.waitForSelector(auth.waitFor, { state: 'visible', timeout: 30000 })
|
|
178
|
+
else await page.waitForLoadState('networkidle')
|
|
179
|
+
// Best-effort post-login dismissals (e.g. first-run tour) so the recording
|
|
180
|
+
// starts on a clean page. Each is optional — a missing element is ignored.
|
|
181
|
+
for (const sel of auth.after || []) {
|
|
182
|
+
await page.locator(sel).first().click({ timeout: 10000, force: true }).catch(() => {})
|
|
183
|
+
}
|
|
184
|
+
await page.waitForTimeout(500)
|
|
185
|
+
const state = await ctx.storageState()
|
|
186
|
+
await ctx.close()
|
|
187
|
+
return state
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Run a normalized demo and return { rawVideoPath }. The caller is responsible
|
|
192
|
+
* for the render stage (render.js) and any cleanup of the work directory.
|
|
193
|
+
*/
|
|
194
|
+
export async function runDemo(demo, opts = {}) {
|
|
195
|
+
const workDir = opts.workDir || path.join(process.cwd(), '.demowright-tmp')
|
|
196
|
+
await mkdir(workDir, { recursive: true })
|
|
197
|
+
|
|
198
|
+
const browser = await chromium.launch({ headless: true })
|
|
199
|
+
|
|
200
|
+
let storageState
|
|
201
|
+
if (demo.auth) {
|
|
202
|
+
if (opts.onAuth) opts.onAuth()
|
|
203
|
+
storageState = await authenticate(browser, demo.auth, demo.viewport, {
|
|
204
|
+
locale: demo.locale,
|
|
205
|
+
init: demo.init,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const context = await browser.newContext({
|
|
210
|
+
viewport: demo.viewport,
|
|
211
|
+
deviceScaleFactor: 1,
|
|
212
|
+
recordVideo: { dir: workDir, size: demo.viewport },
|
|
213
|
+
reducedMotion: 'no-preference',
|
|
214
|
+
...(demo.locale ? { locale: demo.locale } : {}),
|
|
215
|
+
...(storageState ? { storageState } : {}),
|
|
216
|
+
})
|
|
217
|
+
// Video recording starts when the context is created; measure timelapse
|
|
218
|
+
// ranges as second-offsets from this moment so they line up with the capture.
|
|
219
|
+
const recordStart = Date.now()
|
|
220
|
+
const page = await context.newPage()
|
|
221
|
+
await page.addInitScript(buildInitScript(demo.theme))
|
|
222
|
+
if (demo.init) await page.addInitScript(demo.init)
|
|
223
|
+
|
|
224
|
+
const log = opts.onStep || (() => {})
|
|
225
|
+
const timelapses = [] // { start, end, factor } in seconds — dead waits to speed up
|
|
226
|
+
let video
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
await page.goto(demo.url, { waitUntil: 'load' })
|
|
230
|
+
await page.waitForFunction(() => !!window.__dw).catch(() => {})
|
|
231
|
+
await dw(page, 'ready')
|
|
232
|
+
await sleep(page, 700)
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < demo.steps.length; i++) {
|
|
235
|
+
const step = demo.steps[i]
|
|
236
|
+
log(i, step)
|
|
237
|
+
const exec = EXECUTORS[step.type]
|
|
238
|
+
// A `wait` may be marked `timelapse: N` to speed that recorded span up N×
|
|
239
|
+
// in the final video (e.g. waiting out a multi-minute audit).
|
|
240
|
+
const tlStart = step.type === 'wait' && step.timelapse > 1 ? (Date.now() - recordStart) / 1000 : null
|
|
241
|
+
try {
|
|
242
|
+
await exec(page, step, demo)
|
|
243
|
+
} catch (err) {
|
|
244
|
+
throw new Error('step ' + i + ' (' + step.type + ') failed: ' + err.message)
|
|
245
|
+
}
|
|
246
|
+
if (tlStart != null) {
|
|
247
|
+
timelapses.push({ start: tlStart, end: (Date.now() - recordStart) / 1000, factor: step.timelapse })
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
// Close the context/browser even on failure (flushes the video). Do NOT
|
|
252
|
+
// `return` here — a return inside finally swallows a thrown step error and
|
|
253
|
+
// makes a failed run look successful. Capture the handle and resolve after.
|
|
254
|
+
video = page.video()
|
|
255
|
+
await context.close() // flushes the video file
|
|
256
|
+
await browser.close()
|
|
257
|
+
}
|
|
258
|
+
// Only reached when the step loop completed without throwing.
|
|
259
|
+
if (!video) throw new Error('[demowright] no video was recorded')
|
|
260
|
+
const rawVideoPath = await video.path()
|
|
261
|
+
return { rawVideoPath, workDir, timelapses }
|
|
262
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** `demowright init` — drop a runnable starter config in the target directory. */
|
|
2
|
+
import { writeFile, access } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const STARTER = `import { defineDemo } from 'demowright'
|
|
6
|
+
|
|
7
|
+
// Edit the selectors/text to match your app, then:
|
|
8
|
+
// npx demowright run demowright.config.js -o output/demo.mp4
|
|
9
|
+
export default defineDemo({
|
|
10
|
+
name: 'my-demo',
|
|
11
|
+
url: 'http://localhost:3000',
|
|
12
|
+
viewport: { width: 1280, height: 720 },
|
|
13
|
+
theme: { accent: '#e91e63' },
|
|
14
|
+
// music: './assets/track.mp3',
|
|
15
|
+
formats: ['landscape'], // add 'square' and/or 'vertical' for social crops
|
|
16
|
+
steps: [
|
|
17
|
+
{ type: 'caption', text: 'This is my app.', duration: 2400 },
|
|
18
|
+
{ type: 'highlight', selector: 'nav', duration: 1600 },
|
|
19
|
+
{ type: 'zoom', selector: 'nav', scale: 1.3 },
|
|
20
|
+
{ type: 'wait', duration: 800 },
|
|
21
|
+
{ type: 'zoomReset' },
|
|
22
|
+
{ type: 'click', selector: 'a[href="/pricing"]' },
|
|
23
|
+
{ type: 'wait', selector: 'h1' },
|
|
24
|
+
{ type: 'caption', text: 'And here is the thing it does.', duration: 2600 },
|
|
25
|
+
{ type: 'endcard', title: 'My Product', subtitle: 'myproduct.com', duration: 2600 },
|
|
26
|
+
],
|
|
27
|
+
})
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
async function exists(p) {
|
|
31
|
+
try {
|
|
32
|
+
await access(p)
|
|
33
|
+
return true
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function scaffold(dir = process.cwd()) {
|
|
40
|
+
const target = path.join(dir, 'demowright.config.js')
|
|
41
|
+
if (await exists(target)) {
|
|
42
|
+
return { created: false, path: target }
|
|
43
|
+
}
|
|
44
|
+
await writeFile(target, STARTER, 'utf8')
|
|
45
|
+
return { created: true, path: target }
|
|
46
|
+
}
|
package/src/steps.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo schema: validation + normalization.
|
|
3
|
+
*
|
|
4
|
+
* A demo is a plain object: { url, viewport?, theme?, music?, steps: [...] }.
|
|
5
|
+
* Each step is { type, ...fields }. This module is intentionally Playwright-free
|
|
6
|
+
* so it can be unit-tested without a browser — the executors live in runner.js.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Default timing (ms) per step kind. Tuned to read well at normal playback. */
|
|
10
|
+
const DEFAULTS = {
|
|
11
|
+
caption: { duration: 2600 },
|
|
12
|
+
move: { duration: 650 },
|
|
13
|
+
click: { duration: 650, settle: 280 },
|
|
14
|
+
type: { perChar: 42 },
|
|
15
|
+
select: { duration: 450, settle: 350 },
|
|
16
|
+
highlight: { pad: 8 },
|
|
17
|
+
zoom: { scale: 1.35, duration: 750 },
|
|
18
|
+
zoomReset: { duration: 600 },
|
|
19
|
+
scroll: { duration: 600 },
|
|
20
|
+
endcard: { duration: 2800 },
|
|
21
|
+
wait: { timeout: 30000 },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every supported step type and the fields it requires. */
|
|
25
|
+
export const STEP_TYPES = {
|
|
26
|
+
caption: { required: ['text'] },
|
|
27
|
+
captionHide: { required: [] },
|
|
28
|
+
goto: { required: ['url'] },
|
|
29
|
+
move: { required: [] }, // selector OR (x,y)
|
|
30
|
+
click: { required: ['selector'] },
|
|
31
|
+
type: { required: ['selector', 'text'] },
|
|
32
|
+
select: { required: ['selector'] }, // + one of: value | label | index
|
|
33
|
+
key: { required: ['key'] },
|
|
34
|
+
highlight: { required: ['selector'] },
|
|
35
|
+
highlightHide: { required: [] },
|
|
36
|
+
zoom: { required: ['selector'] },
|
|
37
|
+
zoomReset: { required: [] },
|
|
38
|
+
scroll: { required: [] }, // selector OR y
|
|
39
|
+
wait: { required: [] }, // duration OR selector
|
|
40
|
+
endcard: { required: ['title'] },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fail(msg) {
|
|
44
|
+
throw new Error('[demowright] invalid demo: ' + msg)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate and return a normalized copy of the demo with defaults applied.
|
|
49
|
+
* Throws an Error with an actionable message on the first problem found.
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeDemo(demo) {
|
|
52
|
+
if (!demo || typeof demo !== 'object') fail('expected a demo object')
|
|
53
|
+
if (!demo.url || typeof demo.url !== 'string') {
|
|
54
|
+
fail('"url" is required (the page the demo starts on)')
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(demo.steps) || demo.steps.length === 0) {
|
|
57
|
+
fail('"steps" must be a non-empty array')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const viewport = {
|
|
61
|
+
width: (demo.viewport && demo.viewport.width) || 1280,
|
|
62
|
+
height: (demo.viewport && demo.viewport.height) || 720,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const steps = demo.steps.map((raw, i) => {
|
|
66
|
+
if (!raw || typeof raw !== 'object') fail('step ' + i + ' is not an object')
|
|
67
|
+
const spec = STEP_TYPES[raw.type]
|
|
68
|
+
if (!spec) {
|
|
69
|
+
fail('step ' + i + ' has unknown type "' + raw.type + '". Valid: ' + Object.keys(STEP_TYPES).join(', '))
|
|
70
|
+
}
|
|
71
|
+
for (const field of spec.required) {
|
|
72
|
+
if (raw[field] == null) fail('step ' + i + ' (' + raw.type + ') is missing "' + field + '"')
|
|
73
|
+
}
|
|
74
|
+
if (raw.type === 'move' && raw.selector == null && (raw.x == null || raw.y == null)) {
|
|
75
|
+
fail('step ' + i + ' (move) needs either "selector" or both "x" and "y"')
|
|
76
|
+
}
|
|
77
|
+
if (raw.type === 'wait' && raw.duration == null && raw.selector == null) {
|
|
78
|
+
fail('step ' + i + ' (wait) needs either "duration" (ms) or "selector"')
|
|
79
|
+
}
|
|
80
|
+
return { ...DEFAULTS[raw.type], ...raw }
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
name: demo.name || 'demo',
|
|
85
|
+
url: demo.url,
|
|
86
|
+
viewport,
|
|
87
|
+
theme: demo.theme || {},
|
|
88
|
+
music: demo.music || null,
|
|
89
|
+
musicVolume: demo.musicVolume == null ? 0.18 : demo.musicVolume,
|
|
90
|
+
formats: demo.formats || ['landscape'],
|
|
91
|
+
fps: demo.fps || 30,
|
|
92
|
+
// Browser UI locale (e.g. 'it-IT') for the recording context.
|
|
93
|
+
locale: demo.locale || null,
|
|
94
|
+
// JS run before the app's own scripts on every page (addInitScript) — for
|
|
95
|
+
// seeding state that must exist at boot, e.g. dismissing a first-run tour.
|
|
96
|
+
init: typeof demo.init === 'string' ? demo.init : null,
|
|
97
|
+
auth: normalizeAuth(demo.auth),
|
|
98
|
+
steps,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Optional login block. Performed in a throwaway, NON-recorded context to
|
|
104
|
+
* capture an authenticated storageState — so the recording starts already
|
|
105
|
+
* logged in and the password never appears on screen. Field values are read
|
|
106
|
+
* from env at run time (referenced by name) so secrets stay out of the config.
|
|
107
|
+
*/
|
|
108
|
+
function normalizeAuth(auth) {
|
|
109
|
+
if (!auth) return null
|
|
110
|
+
if (!auth.url) fail('auth.url is required when auth is set')
|
|
111
|
+
if (!Array.isArray(auth.fields) || !auth.fields.length) {
|
|
112
|
+
fail('auth.fields must list the login inputs ({ selector, env|value })')
|
|
113
|
+
}
|
|
114
|
+
for (const f of auth.fields) {
|
|
115
|
+
if (!f.selector) fail('each auth.field needs a "selector"')
|
|
116
|
+
if (f.env == null && f.value == null) fail('auth.field "' + f.selector + '" needs "env" or "value"')
|
|
117
|
+
}
|
|
118
|
+
if (!auth.submit) fail('auth.submit (selector of the login button) is required')
|
|
119
|
+
return {
|
|
120
|
+
url: auth.url,
|
|
121
|
+
fields: auth.fields,
|
|
122
|
+
submit: auth.submit,
|
|
123
|
+
waitFor: auth.waitFor || null,
|
|
124
|
+
waitUrl: auth.waitUrl || null,
|
|
125
|
+
perChar: auth.perChar || 20,
|
|
126
|
+
// Best-effort selectors to click after login, before capturing the session
|
|
127
|
+
// (e.g. dismiss a first-run tour) — so the recording starts on a clean page.
|
|
128
|
+
after: Array.isArray(auth.after) ? auth.after : [],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Identity helper — gives editors a hook for autocompletion on demo configs. */
|
|
133
|
+
export function defineDemo(demo) {
|
|
134
|
+
return demo
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Sum of caption/zoom/wait/etc. durations — a rough estimate of clip length (ms). */
|
|
138
|
+
export function estimateDurationMs(demo) {
|
|
139
|
+
let total = 700 // intro settle
|
|
140
|
+
for (const s of demo.steps) {
|
|
141
|
+
if (s.type === 'caption') total += s.duration || 0
|
|
142
|
+
else if (s.type === 'type') total += (s.text ? s.text.length : 0) * (s.perChar || 42) + 300
|
|
143
|
+
else if (s.type === 'wait') total += s.duration || 600
|
|
144
|
+
else if (s.type === 'click' || s.type === 'move') total += (s.duration || 0) + 200
|
|
145
|
+
else if (s.type === 'zoom' || s.type === 'zoomReset' || s.type === 'scroll') total += s.duration || 0
|
|
146
|
+
else if (s.type === 'endcard') total += s.duration || 0
|
|
147
|
+
else total += 250
|
|
148
|
+
}
|
|
149
|
+
return total
|
|
150
|
+
}
|