@vosjs/cli 0.41.1 → 0.43.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.
@@ -101,8 +101,137 @@ function createReporter(json) {
101
101
  };
102
102
  }
103
103
 
104
+ // src/plugin/setup.ts
105
+ var VERBS = /* @__PURE__ */ new Set(["wait", "click", "type", "press", "goto"]);
106
+ function validateSetup(value) {
107
+ const errors = [];
108
+ if (!Array.isArray(value)) return ["setup must be an array of steps"];
109
+ value.forEach((raw, i) => {
110
+ const at2 = `setup[${i}]`;
111
+ if (typeof raw !== "object" || raw === null) {
112
+ errors.push(`${at2}: must be an object`);
113
+ return;
114
+ }
115
+ const s = raw;
116
+ if (typeof s.do !== "string" || !VERBS.has(s.do)) {
117
+ errors.push(`${at2}: "do" must be one of ${[...VERBS].join(", ")}`);
118
+ return;
119
+ }
120
+ if ((s.do === "click" || s.do === "type") && typeof s.selector !== "string")
121
+ errors.push(`${at2}: ${s.do} needs a selector`);
122
+ if (s.do === "wait" && typeof s.ms !== "number")
123
+ errors.push(`${at2}: wait needs ms`);
124
+ if (s.do === "press" && typeof s.key !== "string")
125
+ errors.push(`${at2}: press needs a key`);
126
+ if (s.do === "goto" && typeof s.url !== "string")
127
+ errors.push(`${at2}: goto needs a url`);
128
+ if (s.do === "type") {
129
+ const t = s.text;
130
+ const isEnv = typeof t === "object" && t !== null && typeof t.env === "string" && Object.keys(t).length === 1;
131
+ if (typeof t !== "string" && !isEnv)
132
+ errors.push(
133
+ `${at2}: type needs text, a string or { "env": "NAME" } read at run time`
134
+ );
135
+ if (typeof t === "string" && /passw|secret|token/i.test(String(s.selector)) && t.length > 0)
136
+ errors.push(
137
+ `${at2}: a literal value typed into ${String(s.selector)} would live in actions.json, which is committed and pushed. Use { "env": "NAME" }`
138
+ );
139
+ }
140
+ });
141
+ return errors;
142
+ }
143
+ function resolveSetupText(t, env = process.env) {
144
+ if (typeof t === "string") return { value: t, secret: false };
145
+ const v = env[t.env];
146
+ if (v === void 0 || v === "")
147
+ throw new SetupEnvError(
148
+ `setup: the environment variable ${t.env} is not set, and a type step reads it. Export it in the shell that runs vos record; it is never written to a file`
149
+ );
150
+ return { value: v, secret: true };
151
+ }
152
+ async function runSetup(page, steps, opts) {
153
+ const lines = [];
154
+ for (const [i, step] of steps.entries()) {
155
+ const settle = "ms" in step && typeof step.ms === "number" ? step.ms : 150;
156
+ try {
157
+ switch (step.do) {
158
+ case "wait":
159
+ await opts.sleep(step.ms);
160
+ lines.push(`setup #${i} wait ${step.ms}ms`);
161
+ break;
162
+ case "goto":
163
+ await page.goto(step.url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => {
164
+ });
165
+ lines.push(`setup #${i} goto ${step.url}`);
166
+ break;
167
+ case "press":
168
+ await page.keyboard.press(step.key);
169
+ await opts.sleep(settle);
170
+ lines.push(`setup #${i} press ${step.key}`);
171
+ break;
172
+ case "click": {
173
+ const loc = page.locator(step.selector).first();
174
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
175
+ await loc.click();
176
+ await opts.sleep(settle);
177
+ lines.push(`setup #${i} click ${step.selector}`);
178
+ break;
179
+ }
180
+ case "type": {
181
+ const loc = page.locator(step.selector).first();
182
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
183
+ const { value, secret } = resolveSetupText(step.text, opts.env);
184
+ await loc.fill(value);
185
+ await opts.sleep(settle);
186
+ lines.push(
187
+ `setup #${i} type ${secret ? `\${${step.text.env}}` : `${value.length} chars`} into ${step.selector}`
188
+ );
189
+ break;
190
+ }
191
+ }
192
+ } catch (e) {
193
+ if (e instanceof SetupEnvError) throw e;
194
+ return {
195
+ ran: i,
196
+ lines,
197
+ failed: {
198
+ step: i,
199
+ do: step.do,
200
+ ..."selector" in step ? { selector: step.selector } : {}
201
+ }
202
+ };
203
+ }
204
+ }
205
+ return { ran: steps.length, lines };
206
+ }
207
+ var SetupEnvError = class extends Error {
208
+ constructor(message) {
209
+ super(message);
210
+ this.name = "SetupEnvError";
211
+ }
212
+ };
213
+ var SetupError = class extends Error {
214
+ constructor(failed2) {
215
+ super(
216
+ `setup #${failed2.step} ${failed2.do}${failed2.selector ? ` ${failed2.selector}` : ""}: the selector never appeared, so the take would begin at a half-finished setup. Nothing was recorded. Fix the setup step (rehearse with --dry-run), then record.`
217
+ );
218
+ this.failed = failed2;
219
+ this.name = "SetupError";
220
+ }
221
+ failed;
222
+ };
223
+ function parseHeaders(given) {
224
+ const out = {};
225
+ for (const h of given ?? []) {
226
+ const i = h.indexOf("=");
227
+ if (i <= 0) throw new Error(`--header wants name=value, got "${h}"`);
228
+ out[h.slice(0, i).trim()] = h.slice(i + 1);
229
+ }
230
+ return out;
231
+ }
232
+
104
233
  // src/plugin/actions.ts
105
- var VERBS = /* @__PURE__ */ new Set([
234
+ var VERBS2 = /* @__PURE__ */ new Set([
106
235
  "wait",
107
236
  "hover",
108
237
  "click",
@@ -129,6 +258,25 @@ function validateActions(value) {
129
258
  }
130
259
  }
131
260
  }
261
+ if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
262
+ if (obj.mask !== void 0) {
263
+ if (!Array.isArray(obj.mask)) {
264
+ errors.push("mask must be an array of { selector, as?, text? }");
265
+ } else {
266
+ obj.mask.forEach((raw, i) => {
267
+ const at2 = `mask[${i}]`;
268
+ const m = raw ?? {};
269
+ if (typeof m.selector !== "string" || !m.selector.trim())
270
+ errors.push(`${at2}: needs a selector`);
271
+ if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
272
+ errors.push(`${at2}: as must be "blur" or "text"`);
273
+ if (m.as === "text" && (typeof m.text !== "string" || !m.text))
274
+ errors.push(`${at2}: as "text" needs the text to show instead`);
275
+ if (m.as !== "text" && m.text !== void 0)
276
+ errors.push(`${at2}: text is only read with as "text"`);
277
+ });
278
+ }
279
+ }
132
280
  if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
133
281
  errors.push("steps must be a non-empty array");
134
282
  return errors;
@@ -141,8 +289,8 @@ function validateActions(value) {
141
289
  return;
142
290
  }
143
291
  const s = raw;
144
- if (typeof s.do !== "string" || !VERBS.has(s.do)) {
145
- errors.push(`${at2}: "do" must be one of ${[...VERBS].join(", ")}`);
292
+ if (typeof s.do !== "string" || !VERBS2.has(s.do)) {
293
+ errors.push(`${at2}: "do" must be one of ${[...VERBS2].join(", ")}`);
146
294
  return;
147
295
  }
148
296
  if (s.id !== void 0) {
@@ -6758,6 +6906,199 @@ var WALL_PROBE = `(() => {
6758
6906
  }
6759
6907
  })()`;
6760
6908
 
6909
+ // src/plugin/exposure.ts
6910
+ var EXPOSURE_MATCHERS_SRC = `(() => {
6911
+ const EMAIL = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,})/g
6912
+ const SAFE_HOST = /(^|\\.)example\\.(com|org|net)$|\\.(test|example|invalid|localhost)$/i
6913
+ const KEY = new RegExp([
6914
+ '\\\\b[srp]k_(?:live|test)_[A-Za-z0-9]{8,}',
6915
+ '\\\\bgh[pousr]_[A-Za-z0-9]{20,}',
6916
+ '\\\\bgithub_pat_[A-Za-z0-9_]{20,}',
6917
+ '\\\\bxox[abprs]-[A-Za-z0-9-]{10,}',
6918
+ '\\\\bAKIA[0-9A-Z]{16}\\\\b',
6919
+ '\\\\bAIza[0-9A-Za-z_-]{30,}',
6920
+ '\\\\bvos_(?:sk|rg|ho)_[A-Za-z0-9]{8,}',
6921
+ '\\\\beyJ[A-Za-z0-9_-]{8,}\\\\.eyJ[A-Za-z0-9_-]{8,}\\\\.[A-Za-z0-9_-]{8,}',
6922
+ ].join('|'))
6923
+ const CARD = /(?:^|[^0-9])((?:[0-9][ -]?){12,18}[0-9])(?![0-9])/g
6924
+ const TAIL = /[\\u2022\\u00b7*xX]{2,}[ -]?[0-9]{4}(?![0-9])/
6925
+ const luhn = (digits) => {
6926
+ let sum = 0, alt = false
6927
+ for (let i = digits.length - 1; i >= 0; i--) {
6928
+ let d = digits.charCodeAt(i) - 48
6929
+ if (alt) { d *= 2; if (d > 9) d -= 9 }
6930
+ sum += d; alt = !alt
6931
+ }
6932
+ return sum % 10 === 0
6933
+ }
6934
+ const kindsIn = (text) => {
6935
+ const kinds = []
6936
+ if (!text || text.length < 6) return kinds
6937
+ EMAIL.lastIndex = 0
6938
+ for (let m; (m = EMAIL.exec(text)); ) {
6939
+ if (!SAFE_HOST.test(m[1])) { kinds.push('email'); break }
6940
+ }
6941
+ if (KEY.test(text)) kinds.push('key')
6942
+ CARD.lastIndex = 0
6943
+ for (let m; (m = CARD.exec(text)); ) {
6944
+ const digits = m[1].replace(/[ -]/g, '')
6945
+ if (digits.length >= 13 && digits.length <= 19 && !/^0+$/.test(digits) && luhn(digits)) { kinds.push('card'); break }
6946
+ }
6947
+ if (TAIL.test(text)) kinds.push('card-tail')
6948
+ return kinds
6949
+ }
6950
+ return { kindsIn }
6951
+ })()`;
6952
+ var EXPOSURE_PROBE = `(() => {
6953
+ const { kindsIn } = ${EXPOSURE_MATCHERS_SRC}
6954
+ const vw = innerWidth, vh = innerHeight
6955
+ // A selector that reaches THIS element and no other, so it can be pasted
6956
+ // into "mask" as it is: climb until the path is unique, and say which
6957
+ // sibling wherever a tag-and-class alone would match several.
6958
+ const segment = (el) => {
6959
+ const tid = el.getAttribute('data-testid')
6960
+ if (tid) return el.tagName.toLowerCase() + '[data-testid="' + tid + '"]'
6961
+ if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id)) return el.tagName.toLowerCase() + '#' + el.id
6962
+ const cls = [...el.classList].filter((c) => /^[A-Za-z_-][\\w-]*$/.test(c)).slice(0, 2)
6963
+ let seg = el.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '')
6964
+ const p = el.parentElement
6965
+ if (p && [...p.children].filter((c) => c.matches(seg)).length > 1)
6966
+ seg += ':nth-child(' + ([...p.children].indexOf(el) + 1) + ')'
6967
+ return seg
6968
+ }
6969
+ const name = (el) => {
6970
+ const parts = []
6971
+ for (let cur = el; cur && cur !== document.body && cur !== document.documentElement; cur = cur.parentElement) {
6972
+ parts.unshift(segment(cur))
6973
+ let n = 2
6974
+ try { n = document.querySelectorAll(parts.join(' > ')).length } catch {}
6975
+ // a bare tag is unique HERE and nowhere else: give it its parent too
6976
+ const bare = parts.length === 1 && !/[#.\\[]/.test(parts[0])
6977
+ if ((n === 1 && !bare) || parts.length >= 5) break
6978
+ }
6979
+ return parts.join(' > ')
6980
+ }
6981
+ const seen = (el) => {
6982
+ if (el.closest('[data-vos-masked]')) return null
6983
+ const r = el.getBoundingClientRect()
6984
+ if (r.width < 2 || r.height < 2) return null
6985
+ if (r.bottom <= 0 || r.right <= 0 || r.top >= vh || r.left >= vw) return null
6986
+ const s = getComputedStyle(el)
6987
+ if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity === 0) return null
6988
+ return r
6989
+ }
6990
+ const out = [], keys = new Set()
6991
+ const add = (el, text) => {
6992
+ const kinds = kindsIn(text)
6993
+ if (!kinds.length) return
6994
+ const r = seen(el)
6995
+ if (!r) return
6996
+ const selector = name(el)
6997
+ for (const kind of kinds) {
6998
+ const k = kind + '|' + selector
6999
+ if (keys.has(k) || out.length >= 40) continue
7000
+ keys.add(k)
7001
+ out.push({ kind, selector, rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })
7002
+ }
7003
+ }
7004
+ const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT)
7005
+ for (let n; (n = walker.nextNode()); ) {
7006
+ const el = n.parentElement
7007
+ if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(el.tagName)) continue
7008
+ add(el, n.nodeValue)
7009
+ }
7010
+ for (const el of document.querySelectorAll('input, textarea')) {
7011
+ if (/^(password|hidden|file|checkbox|radio)$/.test(el.type)) continue
7012
+ add(el, el.value)
7013
+ }
7014
+ const hits = {}
7015
+ for (const [sel, n] of Object.entries(window.__vosMaskHits || {})) hits[sel] = n
7016
+ return { exposures: out, maskHits: hits }
7017
+ })()`;
7018
+ function maskInitScript(masks) {
7019
+ const rules = masks.map((m) => ({
7020
+ selector: m.selector,
7021
+ as: m.as === "text" ? "text" : "blur",
7022
+ text: m.text ?? ""
7023
+ }));
7024
+ return `(() => {
7025
+ const RULES = ${JSON.stringify(rules)}
7026
+ const hits = (window.__vosMaskHits = window.__vosMaskHits || {})
7027
+ for (const r of RULES) hits[r.selector] = hits[r.selector] || 0
7028
+ const css = RULES.map((r) => r.selector + '[data-vos-masked="blur"]{filter:blur(7px) !important;user-select:none !important}').join('\\n')
7029
+ const paint = () => {
7030
+ const root = document.head || document.documentElement
7031
+ if (!root || document.getElementById('__vos-mask-style')) return
7032
+ const style = document.createElement('style')
7033
+ style.id = '__vos-mask-style'
7034
+ style.textContent = css
7035
+ root.appendChild(style)
7036
+ }
7037
+ const apply = () => {
7038
+ paint()
7039
+ for (const r of RULES) {
7040
+ let found
7041
+ try { found = document.querySelectorAll(r.selector) } catch { continue }
7042
+ if (found.length > hits[r.selector]) hits[r.selector] = found.length
7043
+ for (const el of found) {
7044
+ const form = /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)
7045
+ const mode = r.as === 'text' && !form ? 'text' : 'blur'
7046
+ if (el.getAttribute('data-vos-masked') !== mode) el.setAttribute('data-vos-masked', mode)
7047
+ if (mode !== 'text') continue
7048
+ const texts = []
7049
+ const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
7050
+ for (let n; (n = w.nextNode()); ) texts.push(n)
7051
+ if (!texts.length) { if (el.textContent !== r.text) el.textContent = r.text; continue }
7052
+ texts.forEach((n, i) => { const want = i === 0 ? r.text : ''; if (n.nodeValue !== want) n.nodeValue = want })
7053
+ }
7054
+ }
7055
+ }
7056
+ new MutationObserver(apply).observe(document, { childList: true, subtree: true, characterData: true })
7057
+ document.addEventListener('DOMContentLoaded', apply)
7058
+ apply()
7059
+ })()`;
7060
+ }
7061
+ var ExposureLog = class {
7062
+ byKey = /* @__PURE__ */ new Map();
7063
+ maskHits = /* @__PURE__ */ new Map();
7064
+ constructor(masks = []) {
7065
+ for (const m of masks) this.maskHits.set(m.selector, 0);
7066
+ }
7067
+ add(step, scan) {
7068
+ if (!scan) return;
7069
+ for (const e of scan.exposures ?? []) {
7070
+ const key = `${e.kind}|${e.selector}`;
7071
+ const had = this.byKey.get(key);
7072
+ if (had) had.seen += 1;
7073
+ else this.byKey.set(key, { step, ...e, seen: 1 });
7074
+ }
7075
+ for (const [sel, n] of Object.entries(scan.maskHits ?? {})) {
7076
+ if (n > (this.maskHits.get(sel) ?? 0)) this.maskHits.set(sel, n);
7077
+ }
7078
+ }
7079
+ exposures() {
7080
+ return [...this.byKey.values()];
7081
+ }
7082
+ masks(rules) {
7083
+ return rules.map((m) => ({
7084
+ selector: m.selector,
7085
+ as: m.as === "text" ? "text" : "blur",
7086
+ hits: this.maskHits.get(m.selector) ?? 0
7087
+ }));
7088
+ }
7089
+ };
7090
+ var KIND_WORDS = {
7091
+ email: "an email address",
7092
+ key: "something shaped like an API key or token",
7093
+ card: "a card number",
7094
+ "card-tail": "a card's last digits"
7095
+ };
7096
+ function exposureLine(e) {
7097
+ const when = e.step < 0 ? "as the page opened" : `at step #${e.step}`;
7098
+ return `${KIND_WORDS[e.kind]} in ${e.selector} (${when}, ${e.rect.x},${e.rect.y} ${e.rect.w}\xD7${e.rect.h})`;
7099
+ }
7100
+ var EXPOSURE_ADVICE = 'The recording shows these. Record from a demo account, or hide them before the camera rolls with "mask" in actions.json: { "selector": "\u2026", "as": "text", "text": "demo@acme.test" } swaps the words, "as": "blur" blurs. Then re-record.';
7101
+
6761
7102
  // src/plugin/recorder.ts
6762
7103
  var realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
6763
7104
  var DRY_PAUSE_MS = 120;
@@ -6783,20 +7124,42 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6783
7124
  const context = await browser.newContext({
6784
7125
  viewport: { width: vw, height: vh },
6785
7126
  deviceScaleFactor: 1,
6786
- ...opts.storageState ? { storageState: opts.storageState } : {}
7127
+ ...opts.storageState ? { storageState: opts.storageState } : {},
7128
+ ...opts.headers && Object.keys(opts.headers).length ? { extraHTTPHeaders: opts.headers } : {}
6787
7129
  });
7130
+ const masks = actions.mask ?? [];
7131
+ if (masks.length) await context.addInitScript(maskInitScript(masks));
7132
+ const exposureLog = new ExposureLog(masks);
6788
7133
  const page = await context.newPage();
7134
+ const scanExposures = async (step) => {
7135
+ const scan = await page.evaluate(EXPOSURE_PROBE).catch(() => null);
7136
+ exposureLog.add(step, scan);
7137
+ };
6789
7138
  page.on("console", (m) => {
6790
7139
  if (m.type() === "error") log(` [page error] ${m.text()}`);
6791
7140
  });
6792
7141
  log(`goto ${url}`);
6793
7142
  let navTimeout = false;
6794
- const response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => {
7143
+ let response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => {
6795
7144
  navTimeout = true;
6796
7145
  log(" (networkidle timeout \u2014 continuing)");
6797
7146
  return null;
6798
7147
  });
6799
7148
  await sleep(800);
7149
+ const setup = actions.setup ?? [];
7150
+ if (setup.length) {
7151
+ log(`setup: ${setup.length} step(s), off camera`);
7152
+ const result = await runSetup(page, setup, { sleep: realSleep });
7153
+ for (const line of result.lines) log(` ${line}`);
7154
+ if (result.failed) {
7155
+ await context.close().catch(() => {
7156
+ });
7157
+ throw new SetupError(result.failed);
7158
+ }
7159
+ log(`goto ${url} (after setup)`);
7160
+ response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => null);
7161
+ await sleep(800);
7162
+ }
6800
7163
  let wall = null;
6801
7164
  if (opts.onArrival) {
6802
7165
  const seen = await page.evaluate(WALL_PROBE).catch(() => null);
@@ -6815,6 +7178,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6815
7178
  throw e;
6816
7179
  }
6817
7180
  }
7181
+ await scanExposures(-1);
6818
7182
  const cdp = await context.newCDPSession(page);
6819
7183
  const frames = [];
6820
7184
  let frameIdx = 0;
@@ -7074,6 +7438,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
7074
7438
  gestureMs,
7075
7439
  wallMs: now() - stepStart
7076
7440
  });
7441
+ await scanExposures(stepIdx);
7077
7442
  }
7078
7443
  if (!capped) await sleep(TRAILING_HOLD_MS);
7079
7444
  if (capped || capReached(now(), maxSeconds)) {
@@ -7104,7 +7469,9 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
7104
7469
  platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "windows" : "linux",
7105
7470
  producer: "cli",
7106
7471
  steps: stepSpans,
7107
- ...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {}
7472
+ ...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {},
7473
+ ...exposureLog.exposures().length ? { exposures: exposureLog.exposures() } : {},
7474
+ ...masks.length ? { masks: exposureLog.masks(masks) } : {}
7108
7475
  };
7109
7476
  const freezes = [];
7110
7477
  for (let i = 1; i < frames.length; i++) {
@@ -7140,6 +7507,8 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
7140
7507
  skipped,
7141
7508
  navTimeout,
7142
7509
  wall,
7510
+ exposures: exposureLog.exposures(),
7511
+ masks: exposureLog.masks(masks),
7143
7512
  freezes,
7144
7513
  freezePct,
7145
7514
  capped
@@ -10294,12 +10663,12 @@ var BOOLEAN_FLAGS5 = /* @__PURE__ */ new Set([
10294
10663
  "dry-run",
10295
10664
  "allow-wall"
10296
10665
  ]);
10297
- var MULTI_FLAGS2 = /* @__PURE__ */ new Set(["set", "override", "browser-arg"]);
10666
+ var MULTI_FLAGS2 = /* @__PURE__ */ new Set(["set", "override", "browser-arg", "header"]);
10298
10667
  var HELP = `vos \u2014 record a browser flow, plan effects, render a product video; sync with vos.so
10299
10668
 
10300
10669
  Take pipeline
10301
- vos create --actions actions.json [--url <url>] [--out take] [out.webm] [--strict] [--allow-wall] [--keep-frames] [--max-duration <s>] [--storage-state <file>] [--browser-arg=<switch>]... [--background <slug|url|none>] [render flags] [--json]
10302
- vos record --actions actions.json [--url <url>] [--out take] [--strict] [--dry-run] [--allow-wall] [--keep-frames] [--max-duration <s>] [--storage-state <file>] [--browser-arg=<switch>]... [--background <slug|url|none>] [--json]
10670
+ vos create --actions actions.json [--url <url>] [--out take] [out.webm] [--strict] [--allow-wall] [--keep-frames] [--max-duration <s>] [--storage-state <file>] [--header name=value]... [--browser-arg=<switch>]... [--background <slug|url|none>] [render flags] [--json]
10671
+ vos record --actions actions.json [--url <url>] [--out take] [--strict] [--dry-run] [--allow-wall] [--keep-frames] [--max-duration <s>] [--storage-state <file>] [--header name=value]... [--browser-arg=<switch>]... [--background <slug|url|none>] [--json]
10303
10672
  vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--with <doc.json|vosId>[@end|@start|@step:<id>|@<s>]]... [--background <slug|url|none>] [--motion] [--headline "\u2026"] [--kicker "\u2026"] [--launch LAUNCH.md] [--brand BRAND.md] [--music <slug|mood|none>] [--entrance tilt-in|pull-out|rise|fade|slide|none] [--transitions slide|fade|scale|none] [--end-card on|none|<doc.json|vosId>] [--captions none] [--clicks none] [--still <t>] [--release v2.1] [--json]
10304
10673
  vos render <take> [out.webm] [--width] [--height] [--fps] [--format webm|mp4] [--parallel N] [--range a..b] [--draft] [--frame <kind>] [--background <url|slug>] [--set <path=value>]... [--json]
10305
10674
  vos frames <take> [--times 0,25%,50%,75%,100%] [--frame <t>] [--at-zooms] [--at-moments] [--at-still] [--size WxH] [--out dir] [--background <url|slug>] [--set <path=value>]... [--json]
@@ -10575,8 +10944,30 @@ function strictReason(rec) {
10575
10944
  const parts = [`${rec.skipped.length} skipped step(s)`];
10576
10945
  if (rec.navTimeout) parts.push("networkidle timeout");
10577
10946
  if (rec.capped) parts.push("stopped at --max-duration");
10947
+ const missed = maskMisses(rec);
10948
+ if (missed.length)
10949
+ parts.push(
10950
+ `${missed.length} mask(s) that hid nothing (${missed.join(", ")}): whatever they were for may be showing`
10951
+ );
10578
10952
  return parts.join(" + ");
10579
10953
  }
10954
+ function maskMisses(rec) {
10955
+ return (rec.masks ?? []).filter((m) => m.hits === 0).map((m) => m.selector);
10956
+ }
10957
+ function exposureNote(rec) {
10958
+ if (!rec.exposures.length) return "";
10959
+ return `
10960
+ EXPOSED in the frame (${rec.exposures.length}):
10961
+ ${rec.exposures.map((e) => ` - ${exposureLine(e)}`).join("\n")}
10962
+ ${EXPOSURE_ADVICE}`;
10963
+ }
10964
+ function takeHeaders(multi) {
10965
+ try {
10966
+ return parseHeaders(multi.header);
10967
+ } catch (e) {
10968
+ throw new UsageError(e instanceof Error ? e.message : String(e));
10969
+ }
10970
+ }
10580
10971
  function takeBrowserArgs(multi) {
10581
10972
  const given = multi["browser-arg"];
10582
10973
  return (given ?? []).filter(Boolean);
@@ -10647,6 +11038,7 @@ async function cmdRecord(argv) {
10647
11038
  const maxDurationSeconds = await maxDuration(flags, r);
10648
11039
  const storageState = takeStorageState(flags);
10649
11040
  const browserArgs = takeBrowserArgs(multi);
11041
+ const headers = takeHeaders(multi);
10650
11042
  if (flags["dry-run"] === true) {
10651
11043
  void flags.strict;
10652
11044
  void flags["keep-frames"];
@@ -10663,6 +11055,7 @@ async function cmdRecord(argv) {
10663
11055
  r.log,
10664
11056
  {
10665
11057
  storageState,
11058
+ headers,
10666
11059
  dryRun: true,
10667
11060
  // A rehearsal is always strict, and it touches no directory.
10668
11061
  onArrival: wallGate(flags, r, { strict: true })
@@ -10674,10 +11067,11 @@ async function cmdRecord(argv) {
10674
11067
  const what = s.selector ? ` ${s.selector}` : "";
10675
11068
  return ` ${s.skipped ? "\u2717" : "\u2713"} #${s.step}${s.id ? ` (${s.id})` : ""} ${s.do}${what}${s.skipped ? " NOT FOUND" : rect}${s.navigated ? " \u2192 navigated" : ""}`;
10676
11069
  });
10677
- const failed2 = rec.skipped.length > 0 || rec.navTimeout;
11070
+ const failed2 = rec.skipped.length > 0 || rec.navTimeout || maskMisses(rec).length > 0;
10678
11071
  const carried = [
10679
11072
  ...strFlag(flags, "storage-state") ? [`--storage-state ${strFlag(flags, "storage-state")}`] : [],
10680
11073
  ...browserArgs.map((a) => `--browser-arg=${a}`),
11074
+ ...Object.entries(headers).map(([k, v]) => `--header ${k}=${v}`),
10681
11075
  ...flags["allow-wall"] === true ? ["--allow-wall"] : []
10682
11076
  ].join(" ");
10683
11077
  const nextOut = strFlag(flags, "out") ?? "take";
@@ -10688,11 +11082,13 @@ async function cmdRecord(argv) {
10688
11082
  seconds: +((Date.now() - started) / 1e3).toFixed(1),
10689
11083
  steps,
10690
11084
  skipped: rec.skipped,
10691
- navTimeout: rec.navTimeout
11085
+ navTimeout: rec.navTimeout,
11086
+ exposures: rec.exposures,
11087
+ masks: rec.masks
10692
11088
  },
10693
11089
  `${failed2 ? "REHEARSAL FAILED" : "Rehearsal passed"}: ${steps.length - rec.skipped.length}/${steps.length} steps resolved in ${((Date.now() - started) / 1e3).toFixed(1)}s${rec.navTimeout ? " (the first load never reached networkidle)" : ""}
10694
11090
  ${lines.join("\n")}
10695
- ${failed2 ? "Fix the script, rehearse again, then record." : `Rects are capture px (the step rects a pin or a callout reads). Next: vos record --actions ${actionsPath} --out ${nextOut}${carried ? ` ${carried}` : ""} --strict`}`
11091
+ ${maskMisses(rec).length ? `MASK hid nothing: ${maskMisses(rec).join(", ")} (the selector reached no element). ` : ""}${failed2 ? "Fix the script, rehearse again, then record." : `Rects are capture px (the step rects a pin or a callout reads). Next: vos record --actions ${actionsPath} --out ${nextOut}${carried ? ` ${carried}` : ""} --strict`}${exposureNote(rec)}`
10696
11092
  );
10697
11093
  return failed2 ? EXIT_USAGE : EXIT_OK;
10698
11094
  } finally {
@@ -10717,6 +11113,7 @@ ${lines.join("\n")}
10717
11113
  const rec = await recordTake(browser, url, actions, paths, r.log, {
10718
11114
  maxDurationSeconds,
10719
11115
  storageState,
11116
+ headers,
10720
11117
  onArrival: wallGate(flags, r, {
10721
11118
  strict: flags.strict === true,
10722
11119
  prepare
@@ -10734,7 +11131,7 @@ ${lines.join("\n")}
10734
11131
  const plan = await planTake(outDir, { backdrop });
10735
11132
  const clicks = rec.events.filter((e) => e.type === "down").length;
10736
11133
  const strict = flags.strict === true;
10737
- const strictFail = strict && (rec.skipped.length > 0 || rec.navTimeout || rec.capped);
11134
+ const strictFail = strict && (rec.skipped.length > 0 || rec.navTimeout || rec.capped || maskMisses(rec).length > 0);
10738
11135
  const skippedNote = rec.skipped.length ? `
10739
11136
  SKIPPED ${rec.skipped.length} step(s): ${rec.skipped.map((s) => `#${s.step} ${s.do} ${s.selector}`).join(", ")}` : "";
10740
11137
  r.done(
@@ -10753,11 +11150,13 @@ ${lines.join("\n")}
10753
11150
  capped: rec.capped,
10754
11151
  pace: rec.pace,
10755
11152
  ...rec.wall ? { wall: rec.wall } : {},
11153
+ exposures: rec.exposures,
11154
+ ...rec.masks.length ? { masks: rec.masks } : {},
10756
11155
  ...strictFail ? { strictFailed: true } : {}
10757
11156
  },
10758
- strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}` : `Take ready: ${outDir}
11157
+ strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}${exposureNote(rec)}` : `Take ready: ${outDir}
10759
11158
  ${(rec.meta.durationMs / 1e3).toFixed(1)}s \xB7 ${rec.frames.length} frames \xB7 ${rec.events.length} cursor events \xB7 ${clicks} clicks \xB7 ${plan.doc.zoom.length} zoom spans planned \xB7 ${rec.freezePct}% frozen${rec.freezePct >= 25 ? " \u26A0 keep motion in frame or trim" : ""}${skippedNote}
10760
- Next: edit ${join18(outDir, "doc.json")} (optional), then: vos render ${outDir}`
11159
+ Next: edit ${join18(outDir, "doc.json")} (optional), then: vos render ${outDir}${exposureNote(rec)}`
10761
11160
  );
10762
11161
  return strictFail ? EXIT_USAGE : EXIT_OK;
10763
11162
  } finally {
@@ -10795,6 +11194,7 @@ async function cmdCreate2(argv) {
10795
11194
  const maxDurationSeconds = await maxDuration(flags, r);
10796
11195
  const storageState = takeStorageState(flags);
10797
11196
  const browserArgs = takeBrowserArgs(multi);
11197
+ const headers = takeHeaders(multi);
10798
11198
  const paths = takePaths(outDir);
10799
11199
  const prepare = async () => {
10800
11200
  if (existsSync15(join18(outDir, "meta.json"))) {
@@ -10813,6 +11213,7 @@ async function cmdCreate2(argv) {
10813
11213
  const rec = await recordTake(browser, url, actions, paths, r.log, {
10814
11214
  maxDurationSeconds,
10815
11215
  storageState,
11216
+ headers,
10816
11217
  onArrival: wallGate(flags, r, {
10817
11218
  strict: flags.strict === true,
10818
11219
  prepare
@@ -10828,7 +11229,7 @@ async function cmdCreate2(argv) {
10828
11229
  await releaseFrames(paths, flags, r.log);
10829
11230
  r.event({ event: "phase", phase: "plan" });
10830
11231
  await planTake(outDir, { backdrop });
10831
- if (flags.strict === true && (rec.skipped.length > 0 || rec.navTimeout || rec.capped)) {
11232
+ if (flags.strict === true && (rec.skipped.length > 0 || rec.navTimeout || rec.capped || maskMisses(rec).length > 0)) {
10832
11233
  r.done(
10833
11234
  {
10834
11235
  take: outDir,
@@ -10872,10 +11273,12 @@ async function cmdCreate2(argv) {
10872
11273
  navTimeout: rec.navTimeout,
10873
11274
  freezes: rec.freezes,
10874
11275
  freezePct: rec.freezePct,
11276
+ exposures: rec.exposures,
11277
+ ...rec.masks.length ? { masks: rec.masks } : {},
10875
11278
  ...flags.draft === true ? { draft: true } : {}
10876
11279
  },
10877
11280
  `Created ${result.out} (${(result.bytes / 1024).toFixed(0)} KB, ${result.width}x${result.height}@${result.fps}, ${result.duration.toFixed(1)}s, ${result.zoomSpans} zoom spans, ${result.clicks} click effects${result.audio ? ", audio" : ""}${flags.draft === true ? ", DRAFT" : ""})
10878
- take: ${outDir} \u2014 inspect with: vos frames ${outDir} --at-zooms; edit doc.json and re-render with: vos render ${outDir}`
11281
+ take: ${outDir} \u2014 inspect with: vos frames ${outDir} --at-zooms; edit doc.json and re-render with: vos render ${outDir}${exposureNote(rec)}`
10879
11282
  );
10880
11283
  return EXIT_OK;
10881
11284
  } finally {
@@ -11440,12 +11843,14 @@ async function cmdDigest(argv) {
11440
11843
  outputDuration: d.take.outputDuration,
11441
11844
  hasCursor: d.take.hasCursor,
11442
11845
  ...d.take.wall ? { wall: d.take.wall } : {},
11846
+ ...d.take.exposures.length ? { exposures: d.take.exposures } : {},
11443
11847
  tokensEstimate: d.images.tokensEstimate,
11444
11848
  bytes: result.bytes
11445
11849
  },
11446
11850
  `Wrote ${result.file} \u2014 ${d.moments.length} moments (${Object.entries(
11447
11851
  kinds
11448
- ).map(([k, n]) => `${n} ${k}`).join(", ")}), ${d.images.full} frames + ${d.images.crop} crops` + (d.images.sheet ? `, sheet.png` : "") + ` \u2014 ~${d.images.tokensEstimate} image tokens to read them all` + (d.take.hasCursor ? "" : "\n no cursor track: moments are head/tail/scenes only \u2014 pace by activity, zoom only where the ask names a place") + (d.take.wall ? `
11852
+ ).map(([k, n]) => `${n} ${k}`).join(", ")}), ${d.images.full} frames + ${d.images.crop} crops` + (d.images.sheet ? `, sheet.png` : "") + ` \u2014 ~${d.images.tokensEstimate} image tokens to read them all` + (d.take.hasCursor ? "" : "\n no cursor track: moments are head/tail/scenes only \u2014 pace by activity, zoom only where the ask names a place") + (d.take.exposures.length ? `
11853
+ EXPOSED in the footage: ${d.take.exposures.map((e) => `${e.kind} in ${e.selector}`).join("; ")} \u2014 look at those frames before this take goes anywhere` : "") + (d.take.wall ? `
11449
11854
  WALL: asked for ${d.take.wall.asked}, recorded ${d.take.wall.landed} \u2014 this footage may be the wrong page; re-record with a session before cutting it` : "") + `
11450
11855
  Read digest.json, then the sheet, then a crop only where you must decide.`
11451
11856
  );
@@ -11709,6 +12114,18 @@ async function cmdValidate(argv) {
11709
12114
  );
11710
12115
  if (mismatch) warnings.push(mismatch);
11711
12116
  }
12117
+ for (const e of take.meta.exposures ?? [])
12118
+ warnings.push(`the recording shows ${exposureLine(e)}`);
12119
+ if (take.meta.exposures?.length) warnings.push(EXPOSURE_ADVICE);
12120
+ for (const m of take.meta.masks ?? [])
12121
+ if (m.hits === 0)
12122
+ warnings.push(
12123
+ `mask "${m.selector}" reached no element, so it hid nothing: whatever it was for may be showing`
12124
+ );
12125
+ if (take.meta.wall)
12126
+ warnings.push(
12127
+ `recorded past a wall: asked for ${take.meta.wall.asked}, landed on ${take.meta.wall.landed}. This footage may be the wrong page`
12128
+ );
11712
12129
  if (!take.doc) problems.push("missing doc.json (run plan)");
11713
12130
  if (take.doc) {
11714
12131
  const lint = lintDoc(take.doc);
@@ -11937,6 +12354,11 @@ async function run(argv) {
11937
12354
  } catch (e) {
11938
12355
  if (e instanceof UsageError) {
11939
12356
  process.stderr.write(`usage error: ${e.message}
12357
+ `);
12358
+ return EXIT_USAGE;
12359
+ }
12360
+ if (e instanceof SetupError || e instanceof SetupEnvError) {
12361
+ process.stderr.write(`${e.message}
11940
12362
  `);
11941
12363
  return EXIT_USAGE;
11942
12364
  }
@@ -11989,4 +12411,4 @@ export {
11989
12411
  verbHelp,
11990
12412
  run
11991
12413
  };
11992
- //# sourceMappingURL=chunk-4QVEV3HX.js.map
12414
+ //# sourceMappingURL=chunk-6PJPGBKH.js.map