@vosjs/cli 0.48.1 → 0.49.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.
@@ -7,23 +7,9 @@ import {
7
7
  trackFlags
8
8
  } from "./chunk-FACYY7VV.js";
9
9
  import {
10
- PRESS_HOLD_MS,
11
- PRESS_LEAD_MS,
12
- SCROLL_SETTLE_MS,
13
- TRAILING_HOLD_MS,
14
- askedMs,
15
- clockMotion,
16
- clockTyping,
17
10
  deadLine,
18
- deadTime,
19
- deadWarns,
20
- holdLeftMs,
21
- paceLine,
22
- paceReport,
23
- pointerTravelMs,
24
- settleMs,
25
- settleVerdict
26
- } from "./chunk-AUPGJJTL.js";
11
+ deadWarns
12
+ } from "./chunk-72ADTMWW.js";
27
13
 
28
14
  // src/plugin/run.ts
29
15
  import { mkdir as mkdir10, readFile as readFile15, rm as rm5 } from "fs/promises";
@@ -119,238 +105,8 @@ function createReporter(json) {
119
105
  };
120
106
  }
121
107
 
122
- // src/plugin/setup.ts
123
- var VERBS = /* @__PURE__ */ new Set(["wait", "click", "type", "press", "goto"]);
124
- function validateSetup(value) {
125
- const errors = [];
126
- if (!Array.isArray(value)) return ["setup must be an array of steps"];
127
- value.forEach((raw, i) => {
128
- const at2 = `setup[${i}]`;
129
- if (typeof raw !== "object" || raw === null) {
130
- errors.push(`${at2}: must be an object`);
131
- return;
132
- }
133
- const s = raw;
134
- if (typeof s.do !== "string" || !VERBS.has(s.do)) {
135
- errors.push(`${at2}: "do" must be one of ${[...VERBS].join(", ")}`);
136
- return;
137
- }
138
- if ((s.do === "click" || s.do === "type") && typeof s.selector !== "string")
139
- errors.push(`${at2}: ${s.do} needs a selector`);
140
- if (s.do === "wait" && typeof s.ms !== "number")
141
- errors.push(`${at2}: wait needs ms`);
142
- if (s.do === "press" && typeof s.key !== "string")
143
- errors.push(`${at2}: press needs a key`);
144
- if (s.do === "goto" && typeof s.url !== "string")
145
- errors.push(`${at2}: goto needs a url`);
146
- if (s.do === "type") {
147
- const t = s.text;
148
- const isEnv = typeof t === "object" && t !== null && typeof t.env === "string" && Object.keys(t).length === 1;
149
- if (typeof t !== "string" && !isEnv)
150
- errors.push(
151
- `${at2}: type needs text, a string or { "env": "NAME" } read at run time`
152
- );
153
- if (typeof t === "string" && /passw|secret|token/i.test(String(s.selector)) && t.length > 0)
154
- errors.push(
155
- `${at2}: a literal value typed into ${String(s.selector)} would live in actions.json, which is committed and pushed. Use { "env": "NAME" }`
156
- );
157
- }
158
- });
159
- return errors;
160
- }
161
- function resolveSetupText(t, env = process.env) {
162
- if (typeof t === "string") return { value: t, secret: false };
163
- const v = env[t.env];
164
- if (v === void 0 || v === "")
165
- throw new SetupEnvError(
166
- `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`
167
- );
168
- return { value: v, secret: true };
169
- }
170
- async function runSetup(page, steps, opts) {
171
- const lines = [];
172
- for (const [i, step] of steps.entries()) {
173
- const settle = "ms" in step && typeof step.ms === "number" ? step.ms : 150;
174
- try {
175
- switch (step.do) {
176
- case "wait":
177
- await opts.sleep(step.ms);
178
- lines.push(`setup #${i} wait ${step.ms}ms`);
179
- break;
180
- case "goto":
181
- await page.goto(step.url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => {
182
- });
183
- lines.push(`setup #${i} goto ${step.url}`);
184
- break;
185
- case "press":
186
- await page.keyboard.press(step.key);
187
- await opts.sleep(settle);
188
- lines.push(`setup #${i} press ${step.key}`);
189
- break;
190
- case "click": {
191
- const loc = page.locator(step.selector).first();
192
- await loc.waitFor({ state: "visible", timeout: 8e3 });
193
- await loc.click();
194
- await opts.sleep(settle);
195
- lines.push(`setup #${i} click ${step.selector}`);
196
- break;
197
- }
198
- case "type": {
199
- const loc = page.locator(step.selector).first();
200
- await loc.waitFor({ state: "visible", timeout: 8e3 });
201
- const { value, secret } = resolveSetupText(step.text, opts.env);
202
- await loc.fill(value);
203
- await opts.sleep(settle);
204
- lines.push(
205
- `setup #${i} type ${secret ? `\${${step.text.env}}` : `${value.length} chars`} into ${step.selector}`
206
- );
207
- break;
208
- }
209
- }
210
- } catch (e) {
211
- if (e instanceof SetupEnvError) throw e;
212
- return {
213
- ran: i,
214
- lines,
215
- failed: {
216
- step: i,
217
- do: step.do,
218
- ..."selector" in step ? { selector: step.selector } : {}
219
- }
220
- };
221
- }
222
- }
223
- return { ran: steps.length, lines };
224
- }
225
- var SetupEnvError = class extends Error {
226
- constructor(message) {
227
- super(message);
228
- this.name = "SetupEnvError";
229
- }
230
- };
231
- var SetupError = class extends Error {
232
- constructor(failed2) {
233
- super(
234
- `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.`
235
- );
236
- this.failed = failed2;
237
- this.name = "SetupError";
238
- }
239
- failed;
240
- };
241
- function parseHeaders(given) {
242
- const out = {};
243
- for (const h of given ?? []) {
244
- const i = h.indexOf("=");
245
- if (i <= 0) throw new Error(`--header wants name=value, got "${h}"`);
246
- out[h.slice(0, i).trim()] = h.slice(i + 1);
247
- }
248
- return out;
249
- }
250
-
251
108
  // src/plugin/actions.ts
252
- var VERBS2 = /* @__PURE__ */ new Set([
253
- "wait",
254
- "hover",
255
- "click",
256
- "type",
257
- "scroll",
258
- "move",
259
- "drag"
260
- ]);
261
- function validateActions(value) {
262
- const errors = [];
263
- if (typeof value !== "object" || value === null)
264
- return ["actions file must be a JSON object"];
265
- const obj = value;
266
- if (obj.url !== void 0 && typeof obj.url !== "string")
267
- errors.push("url must be a string");
268
- if (obj.viewport !== void 0) {
269
- const v = obj.viewport;
270
- if (typeof v !== "object" || v === null) {
271
- errors.push("viewport must be { width, height }");
272
- } else {
273
- const vp = v;
274
- if (typeof vp.width !== "number" || typeof vp.height !== "number") {
275
- errors.push("viewport must be { width, height }");
276
- }
277
- }
278
- }
279
- if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
280
- if (obj.mask !== void 0) {
281
- if (!Array.isArray(obj.mask)) {
282
- errors.push("mask must be an array of { selector, as?, text? }");
283
- } else {
284
- obj.mask.forEach((raw, i) => {
285
- const at2 = `mask[${i}]`;
286
- const m = raw ?? {};
287
- if (typeof m.selector !== "string" || !m.selector.trim())
288
- errors.push(`${at2}: needs a selector`);
289
- if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
290
- errors.push(`${at2}: as must be "blur" or "text"`);
291
- if (m.as === "text" && (typeof m.text !== "string" || !m.text))
292
- errors.push(`${at2}: as "text" needs the text to show instead`);
293
- if (m.as !== "text" && m.text !== void 0)
294
- errors.push(`${at2}: text is only read with as "text"`);
295
- });
296
- }
297
- }
298
- if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
299
- errors.push("steps must be a non-empty array");
300
- return errors;
301
- }
302
- const seenIds = /* @__PURE__ */ new Set();
303
- obj.steps.forEach((raw, i) => {
304
- const at2 = `steps[${i}]`;
305
- if (typeof raw !== "object" || raw === null) {
306
- errors.push(`${at2}: must be an object`);
307
- return;
308
- }
309
- const s = raw;
310
- if (typeof s.do !== "string" || !VERBS2.has(s.do)) {
311
- errors.push(`${at2}: "do" must be one of ${[...VERBS2].join(", ")}`);
312
- return;
313
- }
314
- if (s.id !== void 0) {
315
- if (typeof s.id !== "string" || !s.id.trim()) {
316
- errors.push(`${at2}: id must be a non-empty string`);
317
- } else if (seenIds.has(s.id)) {
318
- errors.push(
319
- `${at2}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
320
- );
321
- } else {
322
- seenIds.add(s.id);
323
- }
324
- }
325
- const needSelector = s.do === "hover" || s.do === "click" || s.do === "type";
326
- if (needSelector && typeof s.selector !== "string")
327
- errors.push(`${at2}: ${s.do} needs a selector`);
328
- if (s.do === "wait" && typeof s.ms !== "number")
329
- errors.push(`${at2}: wait needs ms`);
330
- if (s.ms !== void 0 && (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 0))
331
- errors.push(`${at2}: ms must be a number of milliseconds \u2265 0`);
332
- if (s.do === "type" && typeof s.text !== "string")
333
- errors.push(`${at2}: type needs text`);
334
- if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
335
- errors.push(`${at2}: type focus must be true or false`);
336
- if (s.do === "scroll" && typeof s.dy !== "number")
337
- errors.push(`${at2}: scroll needs dy`);
338
- if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
339
- errors.push(`${at2}: move needs x and y (CSS px)`);
340
- }
341
- if (s.do === "drag") {
342
- if (typeof s.tx !== "number" || typeof s.ty !== "number") {
343
- errors.push(`${at2}: drag needs tx and ty (CSS px)`);
344
- }
345
- const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
346
- if (!hasStart)
347
- errors.push(
348
- `${at2}: drag needs a selector OR x and y as the start point`
349
- );
350
- }
351
- });
352
- return errors;
353
- }
109
+ import { validateActions } from "@vosjs/render-core/record";
354
110
 
355
111
  // src/plugin/validateDoc.ts
356
112
  import {
@@ -6674,805 +6430,67 @@ async function validateKit(kitPath, opts = {}) {
6674
6430
  }
6675
6431
 
6676
6432
  // src/plugin/recorder.ts
6677
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
6433
+ import { writeFileSync as writeFileSync2 } from "fs";
6678
6434
  import { join as join13 } from "path";
6679
-
6680
- // src/plugin/recordingCap.ts
6681
- var HOSTED_RECORDING_CAP_SECONDS = 30 * 60;
6682
- function parseHostedCap(body) {
6683
- const limits = body && typeof body === "object" ? body.limits : void 0;
6684
- const cap = limits?.recordingMaxSeconds;
6685
- return typeof cap === "number" && Number.isInteger(cap) && cap > 0 ? cap : null;
6686
- }
6687
- async function hostedRecordingCap(origin, key, fetchImpl = fetch) {
6688
- const ctl = new AbortController();
6689
- const timer = setTimeout(() => ctl.abort(), 2e3);
6690
- try {
6691
- const res = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/limits`, {
6692
- signal: ctl.signal,
6693
- ...key ? { headers: { authorization: `Bearer ${key}` } } : {}
6694
- });
6695
- if (!res.ok) return null;
6696
- return parseHostedCap(await res.json());
6697
- } catch {
6698
- return null;
6699
- } finally {
6700
- clearTimeout(timer);
6701
- }
6702
- }
6703
- function defaultMaxDurationSeconds() {
6704
- return HOSTED_RECORDING_CAP_SECONDS;
6705
- }
6706
- function capReached(elapsedMs, capSeconds) {
6707
- return elapsedMs >= capSeconds * 1e3;
6708
- }
6709
- function clampWait(waitMs, elapsedMs, capSeconds) {
6710
- return Math.max(0, Math.min(waitMs, capSeconds * 1e3 - elapsedMs));
6711
- }
6712
- function formatDurationCap(seconds) {
6713
- if (seconds < 60) return `${seconds} s`;
6714
- const m = Math.round(seconds / 60);
6715
- if (m < 60) return `${m} min`;
6716
- const h = Math.floor(m / 60);
6717
- const rest = m % 60;
6718
- return rest ? `${h} h ${rest} min` : `${h} h`;
6719
- }
6720
- function cappedLine(capSeconds) {
6721
- return `stopped at ${formatDurationCap(capSeconds)} (--max-duration ${capSeconds}); the remaining steps did not run`;
6722
- }
6723
-
6724
- // src/plugin/wall.ts
6725
- var IDP_HOSTS = [
6726
- "accounts.google.com",
6727
- "login.microsoftonline.com",
6728
- "login.live.com",
6729
- "appleid.apple.com",
6730
- "auth0.com",
6731
- "okta.com",
6732
- "onelogin.com",
6733
- "clerk.accounts.dev",
6734
- "accounts.dev",
6735
- "b2clogin.com",
6736
- "amazoncognito.com",
6737
- "workos.com",
6738
- "stytch.com"
6739
- ];
6740
- var SIGNIN_SEGMENTS = /* @__PURE__ */ new Set([
6741
- "login",
6742
- "log-in",
6743
- "signin",
6744
- "sign-in",
6745
- "sign_in",
6746
- "sso",
6747
- "auth",
6748
- "authenticate",
6749
- "authorize",
6750
- "oauth",
6751
- "oauth2",
6752
- "saml"
6753
- ]);
6754
- function parse(url) {
6755
- try {
6756
- return new URL(url);
6757
- } catch {
6758
- return null;
6759
- }
6760
- }
6761
- var bareHost = (h) => h.replace(/^www\./, "").toLowerCase();
6762
- var trimSlash = (p) => p.length > 1 ? p.replace(/\/+$/, "") : p;
6763
- var place = (u) => `${u.origin}${trimSlash(u.pathname)}`;
6764
- function isIdpHost(host) {
6765
- const h = host.toLowerCase();
6766
- if (h.startsWith("clerk.")) return true;
6767
- return IDP_HOSTS.some((idp) => h === idp || h.endsWith(`.${idp}`));
6768
- }
6769
- function isSignInPath(pathname) {
6770
- const segs = pathname.toLowerCase().split("/").filter(Boolean);
6771
- return segs.some((s) => SIGNIN_SEGMENTS.has(s));
6772
- }
6773
- function redirectedAway(asked, landed) {
6774
- if (bareHost(asked.hostname) !== bareHost(landed.hostname)) return true;
6775
- const a = trimSlash(asked.pathname).toLowerCase();
6776
- const l = trimSlash(landed.pathname).toLowerCase();
6777
- if (a === l || a === "/") return false;
6778
- if (l.startsWith(`${a}/`)) return false;
6779
- if (l.endsWith(a)) return false;
6780
- return true;
6781
- }
6782
- var LADDER = 'This is a missing or expired session, not a script bug. Ways past, cheapest first: mint a session from the test auth the project already has; script the sign-in off camera and save the storage state; or have a person sign in once (npx playwright open --channel chrome --save-storage=<file> <url>). Then pass --storage-state <file>. The ladder: https://vos.so/llms-full.txt, "Sessions". A take OF this page is --allow-wall.';
6783
- function wallVerdict(a) {
6784
- const asked = parse(a.askedUrl);
6785
- const landed = parse(a.landedUrl);
6786
- if (!asked || !landed) return null;
6787
- if (!/^https?:$/.test(asked.protocol)) return null;
6788
- const away = redirectedAway(asked, landed);
6789
- const where = { asked: place(asked), landed: place(landed) };
6790
- const host = bareHost(asked.hostname);
6791
- if (a.status === 401 || a.status === 403) {
6792
- return {
6793
- kind: "status",
6794
- level: "hard",
6795
- ...where,
6796
- message: `asked for ${asked.pathname}, answered ${a.status}: no session for ${host}. ${LADDER}`
6797
- };
6798
- }
6799
- const askedForSignIn = isSignInPath(asked.pathname) || isIdpHost(asked.hostname);
6800
- if (askedForSignIn && !away) return null;
6801
- if (away && isIdpHost(landed.hostname) && !isIdpHost(asked.hostname)) {
6802
- return {
6803
- kind: "idp",
6804
- level: "hard",
6805
- ...where,
6806
- message: `asked for ${asked.pathname}, landed on ${landed.hostname}: no session for ${host}. ${LADDER}`
6807
- };
6808
- }
6809
- const signInForm = a.passwordFields === 1 && !a.newPasswordField || a.oneTimeCodeField;
6810
- if (away && isSignInPath(landed.pathname) || signInForm && !askedForSignIn) {
6811
- const seen = away ? `landed on ${landed.pathname}` : "the page is a sign-in form";
6812
- return {
6813
- kind: "signin",
6814
- level: "hard",
6815
- ...where,
6816
- message: `asked for ${asked.pathname}, ${seen}: no session for ${host}. ${LADDER}`
6817
- };
6818
- }
6819
- if (away) {
6820
- return {
6821
- kind: "redirect",
6822
- level: "soft",
6823
- ...where,
6824
- message: `asked for ${asked.pathname}, landed on ${landed.pathname}: the site sent the recorder elsewhere, which is what a page behind a login does to a stranger. If that is the page you meant, record it by its own URL. ${LADDER}`
6825
- };
6826
- }
6827
- return null;
6828
- }
6829
- var WallError = class extends Error {
6830
- constructor(verdict) {
6831
- super(verdict.message);
6832
- this.verdict = verdict;
6833
- this.name = "WallError";
6834
- }
6835
- verdict;
6836
- };
6837
- var WALL_PROBE = `(() => {
6838
- const shown = (el) => {
6839
- const r = el.getBoundingClientRect()
6840
- if (r.width < 2 || r.height < 2) return false
6841
- const s = getComputedStyle(el)
6842
- return s.visibility !== 'hidden' && s.display !== 'none'
6843
- }
6844
- const pw = [...document.querySelectorAll('input[type="password"]')].filter(shown)
6845
- const otp = [...document.querySelectorAll('input[autocomplete="one-time-code"]')].filter(shown)
6846
- return {
6847
- passwordFields: pw.length,
6848
- newPasswordField: pw.some((el) => (el.getAttribute('autocomplete') || '').includes('new-password')),
6849
- oneTimeCodeField: otp.length > 0,
6850
- }
6851
- })()`;
6852
-
6853
- // src/plugin/exposure.ts
6854
- var EXPOSURE_MATCHERS_SRC = `(() => {
6855
- const EMAIL = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,})/g
6856
- const SAFE_HOST = /(^|\\.)example\\.(com|org|net)$|\\.(test|example|invalid|localhost)$/i
6857
- const KEY = new RegExp([
6858
- '\\\\b[srp]k_(?:live|test)_[A-Za-z0-9]{8,}',
6859
- '\\\\bgh[pousr]_[A-Za-z0-9]{20,}',
6860
- '\\\\bgithub_pat_[A-Za-z0-9_]{20,}',
6861
- '\\\\bxox[abprs]-[A-Za-z0-9-]{10,}',
6862
- '\\\\bAKIA[0-9A-Z]{16}\\\\b',
6863
- '\\\\bAIza[0-9A-Za-z_-]{30,}',
6864
- '\\\\bvos_(?:sk|rg|ho)_[A-Za-z0-9]{8,}',
6865
- '\\\\beyJ[A-Za-z0-9_-]{8,}\\\\.eyJ[A-Za-z0-9_-]{8,}\\\\.[A-Za-z0-9_-]{8,}',
6866
- ].join('|'))
6867
- const CARD = /(?:^|[^0-9])((?:[0-9][ -]?){12,18}[0-9])(?![0-9])/g
6868
- const TAIL = /[\\u2022\\u00b7*xX]{2,}[ -]?[0-9]{4}(?![0-9])/
6869
- const luhn = (digits) => {
6870
- let sum = 0, alt = false
6871
- for (let i = digits.length - 1; i >= 0; i--) {
6872
- let d = digits.charCodeAt(i) - 48
6873
- if (alt) { d *= 2; if (d > 9) d -= 9 }
6874
- sum += d; alt = !alt
6875
- }
6876
- return sum % 10 === 0
6877
- }
6878
- const kindsIn = (text) => {
6879
- const kinds = []
6880
- if (!text || text.length < 6) return kinds
6881
- EMAIL.lastIndex = 0
6882
- for (let m; (m = EMAIL.exec(text)); ) {
6883
- if (!SAFE_HOST.test(m[1])) { kinds.push('email'); break }
6884
- }
6885
- if (KEY.test(text)) kinds.push('key')
6886
- CARD.lastIndex = 0
6887
- for (let m; (m = CARD.exec(text)); ) {
6888
- const digits = m[1].replace(/[ -]/g, '')
6889
- if (digits.length >= 13 && digits.length <= 19 && !/^0+$/.test(digits) && luhn(digits)) { kinds.push('card'); break }
6890
- }
6891
- if (TAIL.test(text)) kinds.push('card-tail')
6892
- return kinds
6893
- }
6894
- return { kindsIn }
6895
- })()`;
6896
- var EXPOSURE_PROBE = `(() => {
6897
- const { kindsIn } = ${EXPOSURE_MATCHERS_SRC}
6898
- const vw = innerWidth, vh = innerHeight
6899
- // A selector that reaches THIS element and no other, so it can be pasted
6900
- // into "mask" as it is: climb until the path is unique, and say which
6901
- // sibling wherever a tag-and-class alone would match several.
6902
- const segment = (el) => {
6903
- const tid = el.getAttribute('data-testid')
6904
- if (tid) return el.tagName.toLowerCase() + '[data-testid="' + tid + '"]'
6905
- if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id)) return el.tagName.toLowerCase() + '#' + el.id
6906
- const cls = [...el.classList].filter((c) => /^[A-Za-z_-][\\w-]*$/.test(c)).slice(0, 2)
6907
- let seg = el.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '')
6908
- const p = el.parentElement
6909
- if (p && [...p.children].filter((c) => c.matches(seg)).length > 1)
6910
- seg += ':nth-child(' + ([...p.children].indexOf(el) + 1) + ')'
6911
- return seg
6912
- }
6913
- const name = (el) => {
6914
- const parts = []
6915
- for (let cur = el; cur && cur !== document.body && cur !== document.documentElement; cur = cur.parentElement) {
6916
- parts.unshift(segment(cur))
6917
- let n = 2
6918
- try { n = document.querySelectorAll(parts.join(' > ')).length } catch {}
6919
- // a bare tag is unique HERE and nowhere else: give it its parent too
6920
- const bare = parts.length === 1 && !/[#.\\[]/.test(parts[0])
6921
- if ((n === 1 && !bare) || parts.length >= 5) break
6922
- }
6923
- return parts.join(' > ')
6924
- }
6925
- const seen = (el) => {
6926
- if (el.closest('[data-vos-masked]')) return null
6927
- const r = el.getBoundingClientRect()
6928
- if (r.width < 2 || r.height < 2) return null
6929
- if (r.bottom <= 0 || r.right <= 0 || r.top >= vh || r.left >= vw) return null
6930
- const s = getComputedStyle(el)
6931
- if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity === 0) return null
6932
- return r
6933
- }
6934
- const out = [], keys = new Set()
6935
- const add = (el, text) => {
6936
- const kinds = kindsIn(text)
6937
- if (!kinds.length) return
6938
- const r = seen(el)
6939
- if (!r) return
6940
- const selector = name(el)
6941
- for (const kind of kinds) {
6942
- const k = kind + '|' + selector
6943
- if (keys.has(k) || out.length >= 40) continue
6944
- keys.add(k)
6945
- 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) } })
6946
- }
6947
- }
6948
- const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT)
6949
- for (let n; (n = walker.nextNode()); ) {
6950
- const el = n.parentElement
6951
- if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(el.tagName)) continue
6952
- add(el, n.nodeValue)
6953
- }
6954
- for (const el of document.querySelectorAll('input, textarea')) {
6955
- if (/^(password|hidden|file|checkbox|radio)$/.test(el.type)) continue
6956
- add(el, el.value)
6957
- }
6958
- const hits = {}
6959
- for (const [sel, n] of Object.entries(window.__vosMaskHits || {})) hits[sel] = n
6960
- return { exposures: out, maskHits: hits }
6961
- })()`;
6962
- function maskInitScript(masks) {
6963
- const rules = masks.map((m) => ({
6964
- selector: m.selector,
6965
- as: m.as === "text" ? "text" : "blur",
6966
- text: m.text ?? ""
6967
- }));
6968
- return `(() => {
6969
- const RULES = ${JSON.stringify(rules)}
6970
- const hits = (window.__vosMaskHits = window.__vosMaskHits || {})
6971
- for (const r of RULES) hits[r.selector] = hits[r.selector] || 0
6972
- const css = RULES.map((r) => r.selector + '[data-vos-masked="blur"]{filter:blur(7px) !important;user-select:none !important}').join('\\n')
6973
- const paint = () => {
6974
- const root = document.head || document.documentElement
6975
- if (!root || document.getElementById('__vos-mask-style')) return
6976
- const style = document.createElement('style')
6977
- style.id = '__vos-mask-style'
6978
- style.textContent = css
6979
- root.appendChild(style)
6980
- }
6981
- const apply = () => {
6982
- paint()
6983
- for (const r of RULES) {
6984
- let found
6985
- try { found = document.querySelectorAll(r.selector) } catch { continue }
6986
- if (found.length > hits[r.selector]) hits[r.selector] = found.length
6987
- for (const el of found) {
6988
- const form = /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)
6989
- const mode = r.as === 'text' && !form ? 'text' : 'blur'
6990
- if (el.getAttribute('data-vos-masked') !== mode) el.setAttribute('data-vos-masked', mode)
6991
- if (mode !== 'text') continue
6992
- const texts = []
6993
- const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
6994
- for (let n; (n = w.nextNode()); ) texts.push(n)
6995
- if (!texts.length) { if (el.textContent !== r.text) el.textContent = r.text; continue }
6996
- texts.forEach((n, i) => { const want = i === 0 ? r.text : ''; if (n.nodeValue !== want) n.nodeValue = want })
6997
- }
6998
- }
6999
- }
7000
- new MutationObserver(apply).observe(document, { childList: true, subtree: true, characterData: true })
7001
- document.addEventListener('DOMContentLoaded', apply)
7002
- apply()
7003
- })()`;
7004
- }
7005
- var ExposureLog = class {
7006
- byKey = /* @__PURE__ */ new Map();
7007
- maskHits = /* @__PURE__ */ new Map();
7008
- constructor(masks = []) {
7009
- for (const m of masks) this.maskHits.set(m.selector, 0);
7010
- }
7011
- add(step, scan) {
7012
- if (!scan) return;
7013
- for (const e of scan.exposures ?? []) {
7014
- const key = `${e.kind}|${e.selector}`;
7015
- const had = this.byKey.get(key);
7016
- if (had) had.seen += 1;
7017
- else this.byKey.set(key, { step, ...e, seen: 1 });
7018
- }
7019
- for (const [sel, n] of Object.entries(scan.maskHits ?? {})) {
7020
- if (n > (this.maskHits.get(sel) ?? 0)) this.maskHits.set(sel, n);
7021
- }
7022
- }
7023
- exposures() {
7024
- return [...this.byKey.values()];
7025
- }
7026
- masks(rules) {
7027
- return rules.map((m) => ({
7028
- selector: m.selector,
7029
- as: m.as === "text" ? "text" : "blur",
7030
- hits: this.maskHits.get(m.selector) ?? 0
7031
- }));
7032
- }
7033
- };
7034
- var KIND_WORDS = {
7035
- email: "an email address",
7036
- key: "something shaped like an API key or token",
7037
- card: "a card number",
7038
- "card-tail": "a card's last digits"
7039
- };
7040
- function exposureLine(e) {
7041
- const when = e.step < 0 ? "as the page opened" : `at step #${e.step}`;
7042
- return `${KIND_WORDS[e.kind]} in ${e.selector} (${when}, ${e.rect.x},${e.rect.y} ${e.rect.w}\xD7${e.rect.h})`;
7043
- }
7044
- 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.';
7045
-
7046
- // src/plugin/recorder.ts
7047
- var realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
7048
- var DRY_PAUSE_MS = 120;
7049
- function jpegDims(buf) {
7050
- let i = 2;
7051
- while (i < buf.length - 9) {
7052
- if (buf[i] !== 255) return null;
7053
- const marker = buf[i + 1];
7054
- const len = buf.readUInt16BE(i + 2);
7055
- if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
7056
- return { h: buf.readUInt16BE(i + 5), w: buf.readUInt16BE(i + 7) };
7057
- }
7058
- i += 2 + len;
7059
- }
7060
- return null;
7061
- }
6435
+ import { recordTake as recordTakeCore } from "@vosjs/render-core/record";
7062
6436
  async function recordTake(browser, url, actions, paths, log, opts = {}) {
7063
6437
  const dry = opts.dryRun === true;
7064
- const sleep = dry ? (ms) => realSleep(Math.min(ms, DRY_PAUSE_MS)) : realSleep;
7065
- const maxSeconds = dry ? Infinity : opts.maxDurationSeconds ?? Infinity;
7066
- const vw = actions.viewport?.width ?? 1280;
7067
- const vh = actions.viewport?.height ?? 720;
7068
- const context = opts.context ?? await browser.newContext({
7069
- viewport: { width: vw, height: vh },
7070
- deviceScaleFactor: 1,
7071
- ...opts.storageState ? { storageState: opts.storageState } : {},
7072
- ...opts.headers && Object.keys(opts.headers).length ? { extraHTTPHeaders: opts.headers } : {}
7073
- });
7074
- if (opts.context && opts.headers && Object.keys(opts.headers).length)
7075
- await context.setExtraHTTPHeaders(opts.headers);
7076
- const masks = actions.mask ?? [];
7077
- if (masks.length) await context.addInitScript(maskInitScript(masks));
7078
- const exposureLog = new ExposureLog(masks);
7079
- const page = await context.newPage();
7080
- const scanExposures = async (step) => {
7081
- const scan = await page.evaluate(EXPOSURE_PROBE).catch(() => null);
7082
- exposureLog.add(step, scan);
7083
- };
7084
- page.on("console", (m) => {
7085
- if (m.type() === "error") log(` [page error] ${m.text()}`);
7086
- });
7087
- log(`goto ${url}`);
7088
- let navTimeout = false;
7089
- let response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => {
7090
- navTimeout = true;
7091
- log(" (networkidle timeout \u2014 continuing)");
7092
- return null;
7093
- });
7094
- await sleep(800);
7095
- const setup = actions.setup ?? [];
7096
- if (setup.length) {
7097
- log(`setup: ${setup.length} step(s), off camera`);
7098
- const result = await runSetup(page, setup, { sleep: realSleep });
7099
- for (const line of result.lines) log(` ${line}`);
7100
- if (result.failed) {
7101
- await context.close().catch(() => {
7102
- });
7103
- throw new SetupError(result.failed);
7104
- }
7105
- log(`goto ${url} (after setup)`);
7106
- response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => null);
7107
- await sleep(800);
7108
- }
7109
- let wall = null;
7110
- if (opts.onArrival) {
7111
- const seen = await page.evaluate(WALL_PROBE).catch(() => null);
7112
- try {
7113
- wall = await opts.onArrival({
7114
- askedUrl: url,
7115
- landedUrl: page.url(),
7116
- ...response ? { status: response.status() } : {},
7117
- passwordFields: seen?.passwordFields ?? 0,
7118
- newPasswordField: seen?.newPasswordField ?? false,
7119
- oneTimeCodeField: seen?.oneTimeCodeField ?? false
7120
- }) ?? null;
7121
- } catch (e) {
7122
- await context.close().catch(() => {
7123
- });
7124
- throw e;
7125
- }
7126
- }
7127
- await scanExposures(-1);
7128
- const cdp = await context.newCDPSession(page);
7129
- const frames = [];
7130
- let frameIdx = 0;
7131
- let t0 = 0;
7132
- if (!dry)
7133
- cdp.on("Page.screencastFrame", (ev) => {
7134
- const tsMs = ev.metadata.timestamp ? ev.metadata.timestamp * 1e3 : Date.now();
7135
- const file = `frame-${String(frameIdx++).padStart(5, "0")}.jpg`;
7136
- writeFileSync2(join13(paths.framesDir, file), Buffer.from(ev.data, "base64"));
7137
- frames.push({ file, tMs: Math.max(0, Math.round(tsMs - t0)) });
7138
- cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
7139
- });
7140
- });
7141
- t0 = Date.now();
7142
- if (!dry)
7143
- await cdp.send("Page.startScreencast", {
7144
- format: "jpeg",
7145
- quality: 90,
7146
- maxWidth: vw,
7147
- maxHeight: vh,
7148
- everyNthFrame: 1
7149
- });
7150
- const events = [];
7151
- const cur = { x: 48, y: 48 };
7152
- const now = () => Date.now() - t0;
7153
- const emit = (e) => events.push({ t: now(), ...e });
7154
- await page.mouse.move(cur.x, cur.y);
7155
- emit({ x: cur.x, y: cur.y, type: "move" });
7156
- const clock = { now: () => Date.now(), sleep };
7157
- const moveTo = async (tx, ty) => {
7158
- const dist = Math.hypot(tx - cur.x, ty - cur.y);
7159
- const from = { ...cur };
7160
- await clockMotion(
7161
- dry ? 0 : pointerTravelMs(dist),
7162
- async (u) => {
7163
- cur.x = from.x + (tx - from.x) * u;
7164
- cur.y = from.y + (ty - from.y) * u;
7165
- await page.mouse.move(cur.x, cur.y);
7166
- emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "move" });
7167
- },
7168
- clock
7169
- );
7170
- };
7171
- const boxOf = async (selector) => {
7172
- const loc = page.locator(selector).first();
7173
- try {
7174
- await loc.waitFor({ state: "visible", timeout: 8e3 });
7175
- const before = await page.evaluate(() => [window.scrollX, window.scrollY]).catch(() => [0, 0]);
7176
- await loc.scrollIntoViewIfNeeded();
7177
- const after = await page.evaluate(() => [window.scrollX, window.scrollY]).catch(() => before);
7178
- if (before[0] !== after[0] || before[1] !== after[1])
7179
- await sleep(SCROLL_SETTLE_MS);
7180
- const bb = await loc.boundingBox();
7181
- if (!bb) return null;
7182
- return { x: bb.x, y: bb.y, w: bb.width, h: bb.height };
7183
- } catch {
7184
- log(` (selector not found, skipping: ${selector})`);
7185
- return null;
7186
- }
7187
- };
7188
- const clickAt = async (rect) => {
7189
- await moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2);
7190
- await sleep(PRESS_LEAD_MS);
7191
- emit({
7192
- x: Math.round(cur.x),
7193
- y: Math.round(cur.y),
7194
- type: "down",
7195
- button: 0,
7196
- rect
7197
- });
7198
- await page.mouse.down();
7199
- await sleep(PRESS_HOLD_MS);
7200
- await page.mouse.up();
7201
- emit({
7202
- x: Math.round(cur.x),
7203
- y: Math.round(cur.y),
7204
- type: "up",
7205
- button: 0,
7206
- rect
7207
- });
7208
- };
7209
- const waitSettled = async () => {
7210
- if (dry) return null;
7211
- const pressAt = now();
7212
- const before = frames.length;
7213
- for (; ; ) {
7214
- const t = now();
7215
- const last = frames.length > before ? frames[frames.length - 1] : null;
7216
- const sinceChange = last ? t - last.tMs : null;
7217
- if (settleVerdict({ sincePress: t - pressAt, sinceChange }) === "settled")
7218
- return sinceChange;
7219
- await sleep(40);
7220
- }
7221
- };
7222
- const skipped = [];
7223
- const stepSpans = [];
7224
- const paces = [];
7225
- let capped = false;
7226
- for (const [stepIdx, step] of actions.steps.entries()) {
7227
- if (capReached(now(), maxSeconds)) {
7228
- capped = true;
7229
- break;
7230
- }
7231
- const stepStart = now();
7232
- let gestureMs = 0;
7233
- const gesture = async (fn) => {
7234
- const t = now();
7235
- const out = await fn();
7236
- gestureMs += now() - t;
7237
- return out;
7238
- };
7239
- const skippedBefore = skipped.length;
7240
- const urlBefore = page.url();
7241
- let stepRect = null;
7242
- switch (step.do) {
7243
- case "wait":
7244
- await sleep(clampWait(step.ms, now(), maxSeconds));
7245
- break;
7246
- case "move":
7247
- await moveTo(step.x, step.y);
7248
- break;
7249
- case "hover": {
7250
- const rect = await boxOf(step.selector);
7251
- stepRect = rect;
7252
- if (rect) {
7253
- await gesture(() => moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2));
7254
- log(`hover ${step.selector}`);
7255
- await sleep(step.ms ?? 700);
7256
- } else
7257
- skipped.push({ step: stepIdx, do: step.do, selector: step.selector });
7258
- break;
7259
- }
7260
- case "click": {
7261
- const rect = await boxOf(step.selector);
7262
- stepRect = rect;
7263
- if (rect) {
7264
- await gesture(() => clickAt(rect));
7265
- log(`click ${step.selector}`);
7266
- const sinceChange = await gesture(() => waitSettled());
7267
- await sleep(holdLeftMs(settleMs(step), sinceChange));
7268
- } else
7269
- skipped.push({ step: stepIdx, do: step.do, selector: step.selector });
7270
- break;
7271
- }
7272
- case "type": {
7273
- const rect = await boxOf(step.selector);
7274
- stepRect = rect;
7275
- if (rect) {
7276
- if (step.focus !== false) await gesture(() => clickAt(rect));
7277
- const delay = step.delayMs ?? 40;
7278
- const ping = () => emit({
7279
- x: Math.round(rect.x + rect.w / 2),
7280
- y: Math.round(rect.y + rect.h / 2),
7281
- type: "key",
7282
- rect
7283
- });
7284
- let lastPing = -Infinity;
7285
- await clockTyping(
7286
- [...step.text],
7287
- delay,
7288
- async (ch) => {
7289
- if (now() - lastPing >= 350) {
7290
- lastPing = now();
7291
- ping();
7292
- }
7293
- await page.keyboard.type(ch);
7294
- },
7295
- clock
7296
- );
7297
- ping();
7298
- log(`type "${step.text}" into ${step.selector}`);
7299
- await gesture(() => sleep(settleMs(step)));
7300
- } else
7301
- skipped.push({ step: stepIdx, do: step.do, selector: step.selector });
7302
- break;
7303
- }
7304
- case "scroll": {
7305
- const total = Math.abs(step.dy);
7306
- const dir = Math.sign(step.dy);
7307
- for (let done = 0; done < total; done += 120) {
7308
- await page.mouse.wheel(0, dir * Math.min(120, total - done));
7309
- emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "scroll" });
7310
- await sleep(40);
7311
- }
7312
- await gesture(() => sleep(settleMs(step)));
7313
- break;
7314
- }
7315
- case "drag": {
7316
- let start = null;
7317
- let rect;
7318
- if (step.selector) {
7319
- const box = await boxOf(step.selector);
7320
- stepRect = box;
7321
- if (box) {
7322
- rect = box;
7323
- start = { x: box.x + box.w / 2, y: box.y + box.h / 2 };
7324
- } else {
7325
- skipped.push({
7326
- step: stepIdx,
7327
- do: step.do,
7328
- selector: step.selector
7329
- });
7330
- break;
7331
- }
7332
- } else if (typeof step.x === "number" && typeof step.y === "number") {
7333
- start = { x: step.x, y: step.y };
7334
- }
7335
- if (!start) break;
7336
- await gesture(() => moveTo(start.x, start.y));
7337
- await gesture(() => sleep(PRESS_LEAD_MS));
7338
- emit({
7339
- x: Math.round(cur.x),
7340
- y: Math.round(cur.y),
7341
- type: "down",
7342
- button: 0,
7343
- rect
7344
- });
7345
- await page.mouse.down();
7346
- await gesture(() => sleep(PRESS_HOLD_MS));
7347
- const dur = step.ms ?? 700;
7348
- const from = { ...cur };
7349
- await clockMotion(
7350
- dur,
7351
- async (u) => {
7352
- cur.x = from.x + (step.tx - from.x) * u;
7353
- cur.y = from.y + (step.ty - from.y) * u;
7354
- await page.mouse.move(cur.x, cur.y);
7355
- emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "move" });
7356
- },
7357
- clock
7358
- );
7359
- await gesture(() => sleep(settleMs({ do: "drag" })));
7360
- await page.mouse.up();
7361
- emit({
7362
- x: Math.round(cur.x),
7363
- y: Math.round(cur.y),
7364
- type: "up",
7365
- button: 0,
7366
- rect
7367
- });
7368
- log(
7369
- `drag ${step.selector ?? `${start.x},${start.y}`} \u2192 ${step.tx},${step.ty}`
7370
- );
7371
- await sleep(400);
7372
- break;
6438
+ const result = await recordTakeCore(
6439
+ browser,
6440
+ url,
6441
+ actions,
6442
+ {
6443
+ frame: (file, bytes) => {
6444
+ writeFileSync2(join13(paths.framesDir, file), bytes);
7373
6445
  }
6446
+ },
6447
+ log,
6448
+ {
6449
+ ...opts,
6450
+ env: process.env,
6451
+ platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "windows" : "linux"
7374
6452
  }
7375
- const stepInfo = step;
7376
- stepSpans.push({
7377
- step: stepIdx,
7378
- ...stepInfo.id ? { id: stepInfo.id } : {},
7379
- do: step.do,
7380
- ...stepInfo.selector ? { selector: stepInfo.selector } : {},
7381
- tStart: +(stepStart / 1e3).toFixed(3),
7382
- tEnd: +(now() / 1e3).toFixed(3),
7383
- ...skipped.length > skippedBefore ? { skipped: true } : {},
7384
- ...page.url() !== urlBefore ? { navigated: true } : {},
7385
- ...stepRect ? {
7386
- rect: {
7387
- x: +stepRect.x.toFixed(1),
7388
- y: +stepRect.y.toFixed(1),
7389
- w: +stepRect.w.toFixed(1),
7390
- h: +stepRect.h.toFixed(1)
7391
- }
7392
- } : {}
7393
- });
7394
- paces.push({
7395
- step: stepIdx,
7396
- do: step.do,
7397
- askedMs: askedMs(step),
7398
- gestureMs,
7399
- wallMs: now() - stepStart
7400
- });
7401
- await scanExposures(stepIdx);
7402
- }
7403
- if (!capped) await sleep(TRAILING_HOLD_MS);
7404
- if (capped || capReached(now(), maxSeconds)) {
7405
- capped = true;
7406
- log(` ${cappedLine(maxSeconds)}`);
7407
- }
7408
- const durationMs = capped ? Math.min(now(), maxSeconds * 1e3) : now();
7409
- await cdp.send("Page.stopScreencast").catch(() => {
7410
- });
7411
- await sleep(200);
7412
- const pageTitle = await page.title().catch(() => "");
7413
- await context.close();
7414
- const firstFrame = frames[0] ? jpegDims(readFileSync3(join13(paths.framesDir, frames[0].file))) : null;
7415
- const meta = {
7416
- dpr: 1,
7417
- zoom: 1,
7418
- t0,
7419
- durationMs,
7420
- width: vw,
7421
- height: vh,
7422
- fps: 30,
7423
- hasAudio: false,
7424
- captureWidth: firstFrame?.w ?? vw,
7425
- captureHeight: firstFrame?.h ?? vh,
7426
- captureSurface: "tab",
7427
- pageUrl: url,
7428
- pageTitle,
7429
- platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "windows" : "linux",
7430
- producer: "cli",
7431
- steps: stepSpans,
7432
- ...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {},
7433
- ...exposureLog.exposures().length ? { exposures: exposureLog.exposures() } : {},
7434
- ...masks.length ? { masks: exposureLog.masks(masks) } : {}
7435
- };
7436
- const freezes = [];
7437
- for (let i = 1; i < frames.length; i++) {
7438
- const gap = frames[i].tMs - frames[i - 1].tMs;
7439
- if (gap >= 400) {
7440
- freezes.push({
7441
- from: +(frames[i - 1].tMs / 1e3).toFixed(2),
7442
- to: +(frames[i].tMs / 1e3).toFixed(2),
7443
- ms: gap
7444
- });
7445
- }
7446
- }
7447
- const frozenMs = freezes.reduce((sum, f) => sum + f.ms, 0);
7448
- const freezePct = durationMs > 0 ? Math.round(frozenMs / durationMs * 100) : 0;
7449
- const dead = deadTime(stepSpans, frames, events, durationMs);
7450
- if (deadWarns(dead)) log(` WARNING: ${deadLine(dead)}`);
7451
- const pace = paceReport(paces);
6453
+ );
7452
6454
  if (!dry) {
7453
- await writeJson(paths.cursor, events);
7454
- await writeJson(paths.meta, meta, true);
7455
- await writeJson(paths.framesIndex, frames);
7456
- await writeJson(paths.actions, { ...actions, url }, true);
7457
- log(` ${paceLine(pace)}`);
6455
+ await writeJson(paths.cursor, result.events);
6456
+ await writeJson(paths.meta, result.meta, true);
6457
+ await writeJson(paths.framesIndex, result.frames);
6458
+ await writeJson(paths.actions, result.actions, true);
7458
6459
  }
7459
- return {
7460
- events,
7461
- frames,
7462
- meta,
7463
- pace,
7464
- skipped,
7465
- navTimeout,
7466
- wall,
7467
- exposures: exposureLog.exposures(),
7468
- masks: exposureLog.masks(masks),
7469
- freezes,
7470
- freezePct,
7471
- dead,
7472
- capped
7473
- };
6460
+ return result;
7474
6461
  }
7475
6462
 
6463
+ // src/plugin/wall.ts
6464
+ import {
6465
+ WALL_PROBE,
6466
+ WallError,
6467
+ isIdpHost,
6468
+ isSignInPath,
6469
+ redirectedAway,
6470
+ wallVerdict
6471
+ } from "@vosjs/render-core/record";
6472
+
6473
+ // src/plugin/exposure.ts
6474
+ import {
6475
+ EXPOSURE_ADVICE,
6476
+ EXPOSURE_MATCHERS_SRC,
6477
+ EXPOSURE_PROBE,
6478
+ ExposureLog,
6479
+ exposureLine,
6480
+ maskInitScript
6481
+ } from "@vosjs/render-core/record";
6482
+
6483
+ // src/plugin/setup.ts
6484
+ import {
6485
+ SetupEnvError,
6486
+ SetupError,
6487
+ parseHeaders,
6488
+ resolveSetupText,
6489
+ runSetup,
6490
+ setupEnvNames,
6491
+ validateSetup
6492
+ } from "@vosjs/render-core/record";
6493
+
7476
6494
  // src/plugin/session.ts
7477
6495
  import { spawn } from "child_process";
7478
6496
  import { existsSync as existsSync12, mkdirSync as mkdirSync2, readdirSync as readdirSync2, rmSync, statSync as statSync3 } from "fs";
@@ -7616,47 +6634,47 @@ function looksLikeStorageState(value) {
7616
6634
  return Array.isArray(v.cookies) && Array.isArray(v.origins);
7617
6635
  }
7618
6636
 
6637
+ // src/plugin/recordingCap.ts
6638
+ import {
6639
+ capReached,
6640
+ cappedLine,
6641
+ clampWait,
6642
+ formatDurationCap
6643
+ } from "@vosjs/render-core/record";
6644
+ var HOSTED_RECORDING_CAP_SECONDS = 30 * 60;
6645
+ function parseHostedCap(body) {
6646
+ const limits = body && typeof body === "object" ? body.limits : void 0;
6647
+ const cap = limits?.recordingMaxSeconds;
6648
+ return typeof cap === "number" && Number.isInteger(cap) && cap > 0 ? cap : null;
6649
+ }
6650
+ async function hostedRecordingCap(origin, key, fetchImpl = fetch) {
6651
+ const ctl = new AbortController();
6652
+ const timer = setTimeout(() => ctl.abort(), 2e3);
6653
+ try {
6654
+ const res = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/limits`, {
6655
+ signal: ctl.signal,
6656
+ ...key ? { headers: { authorization: `Bearer ${key}` } } : {}
6657
+ });
6658
+ if (!res.ok) return null;
6659
+ return parseHostedCap(await res.json());
6660
+ } catch {
6661
+ return null;
6662
+ } finally {
6663
+ clearTimeout(timer);
6664
+ }
6665
+ }
6666
+ function defaultMaxDurationSeconds() {
6667
+ return HOSTED_RECORDING_CAP_SECONDS;
6668
+ }
6669
+
7619
6670
  // src/plugin/encode.ts
7620
- var MEDIABUNNY_URL2 = "https://esm.sh/mediabunny@1.55.7?target=es2022";
7621
- var ENCODE_HTML = `<!doctype html><html><head><meta charset="utf-8"></head><body>
7622
- <script type="module">
7623
- try {
7624
- const MB = await import('${MEDIABUNNY_URL2}')
7625
- const frames = await (await fetch('/frames.json')).json()
7626
- const meta = await (await fetch('/meta.json')).json()
7627
- const W = meta.captureWidth ?? meta.width, H = meta.captureHeight ?? meta.height
7628
- const FPS = 30
7629
- const durSec = meta.durationMs / 1000
7630
- const cv = document.createElement('canvas')
7631
- cv.width = W; cv.height = H
7632
- const c2 = cv.getContext('2d')
7633
- const output = new MB.Output({ format: new MB.WebMOutputFormat(), target: new MB.BufferTarget() })
7634
- const src = new MB.CanvasSource(cv, { codec: 'vp9', bitrate: 8_000_000 })
7635
- output.addVideoTrack(src, { frameRate: FPS })
7636
- await output.start()
7637
- let fi = -1
7638
- const total = Math.ceil(durSec * FPS)
7639
- for (let i = 0; i < total; i++) {
7640
- const t = i / FPS
7641
- let next = fi
7642
- while (next + 1 < frames.length && frames[next + 1].tMs <= t * 1000) next++
7643
- if (next < 0) next = 0
7644
- if (next !== fi) {
7645
- fi = next
7646
- const blob = await (await fetch('/frames/' + frames[fi].file)).blob()
7647
- const bmp = await createImageBitmap(blob)
7648
- c2.drawImage(bmp, 0, 0, W, H)
7649
- bmp.close()
7650
- }
7651
- await src.add(t, 1 / FPS)
7652
- window.__progress = i / total
7653
- }
7654
- await output.finalize()
7655
- const buf = output.target.buffer
7656
- await fetch('/save?name=${RECORDING_NAME}', { method: 'POST', body: buf })
7657
- window.__done = { ok: true, bytes: buf.byteLength }
7658
- } catch (e) { window.__error = String(e && e.stack || e) }
7659
- </script></body></html>`;
6671
+ import { buildEncodePage } from "@vosjs/render-core/record";
6672
+ var ENCODE_HTML = buildEncodePage({
6673
+ framesIndexUrl: "/frames.json",
6674
+ metaUrl: "/meta.json",
6675
+ framesBaseUrl: "/frames/",
6676
+ save: { method: "POST", url: `/save?name=${RECORDING_NAME}` }
6677
+ });
7660
6678
  async function encodeRecording(browser, takeDir, onProgress) {
7661
6679
  const server = await startTakeServer(takeDir, { "/encode.html": ENCODE_HTML });
7662
6680
  const page = await browser.newPage();
@@ -8112,7 +7130,7 @@ async function planTake(dir, opts = {}) {
8112
7130
 
8113
7131
  // src/plugin/sync.ts
8114
7132
  import { createHash } from "crypto";
8115
- import { existsSync as existsSync14, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
7133
+ import { existsSync as existsSync14, readFileSync as readFileSync3, readdirSync as readdirSync3 } from "fs";
8116
7134
  import { readFile as readFile13 } from "fs/promises";
8117
7135
  import { createInterface } from "readline/promises";
8118
7136
  import { basename as basename3, join as join17 } from "path";
@@ -8616,7 +7634,7 @@ function storageStateInDir(dir) {
8616
7634
  if (!f.endsWith(".json") || f === "doc.json" || f === "meta.json" || f === "actions.json" || f === "cursor.json" || f === "vos.json" || f === "doc.prev.json")
8617
7635
  continue;
8618
7636
  try {
8619
- const v = JSON.parse(readFileSync4(join17(dir, f), "utf8"));
7637
+ const v = JSON.parse(readFileSync3(join17(dir, f), "utf8"));
8620
7638
  if (looksLikeStorageState(v)) return join17(dir, f);
8621
7639
  } catch {
8622
7640
  }
@@ -9723,7 +8741,7 @@ async function writeProgramDoc(dir, hosted) {
9723
8741
  }
9724
8742
 
9725
8743
  // src/plugin/asset.ts
9726
- import { readFileSync as readFileSync5 } from "fs";
8744
+ import { readFileSync as readFileSync4 } from "fs";
9727
8745
  import { basename as basename5, extname as extname2 } from "path";
9728
8746
  var BOOLEAN_FLAGS3 = /* @__PURE__ */ new Set(["json", "help"]);
9729
8747
  var MIME_BY_EXT = {
@@ -9777,7 +8795,7 @@ async function cmdPush(argv) {
9777
8795
  for (const file of positionals) {
9778
8796
  const name = basename5(file);
9779
8797
  const mime = MIME_BY_EXT[extname2(name).toLowerCase()] ?? "application/octet-stream";
9780
- const body = readFileSync5(file);
8798
+ const body = readFileSync4(file);
9781
8799
  const form = new FormData();
9782
8800
  form.set("file", new Blob([body], { type: mime }), name);
9783
8801
  if (folder) form.set("folderId", folder.id);
@@ -9831,7 +8849,7 @@ async function cmdAsset(argv) {
9831
8849
  }
9832
8850
 
9833
8851
  // src/plugin/recipe.ts
9834
- import { readFileSync as readFileSync6 } from "fs";
8852
+ import { readFileSync as readFileSync5 } from "fs";
9835
8853
  import { basename as basename6 } from "path";
9836
8854
  var BOOLEAN_FLAGS4 = /* @__PURE__ */ new Set(["json", "help"]);
9837
8855
  var USAGE = "vos recipe push <file.md> (--folder <folderId|slug> | --asset <assetId>)";
@@ -9851,7 +8869,7 @@ async function cmdPush2(argv) {
9851
8869
  throw new UsageError(USAGE);
9852
8870
  }
9853
8871
  const filename = recipeFilename(file);
9854
- const body = readFileSync6(file);
8872
+ const body = readFileSync5(file);
9855
8873
  const r = createReporter(flags.json === true);
9856
8874
  const origin = platformOrigin({
9857
8875
  origin: strFlag(flags, "origin"),
@@ -10294,6 +9312,103 @@ async function cmdBrand(argv) {
10294
9312
  return 0;
10295
9313
  }
10296
9314
 
9315
+ // src/plugin/hostedRecord.ts
9316
+ import { setupEnvNames as setupEnvNames2 } from "@vosjs/render-core/record";
9317
+ var POLL_MS = 3e3;
9318
+ var DEFAULT_TIMEOUT_MS = 20 * 60 * 1e3;
9319
+ function hostedEnv(actions, shell) {
9320
+ const env = {};
9321
+ const missing = [];
9322
+ for (const name of setupEnvNames2(actions.setup)) {
9323
+ const v = shell[name];
9324
+ if (v === void 0 || v === "") missing.push(name);
9325
+ else env[name] = v;
9326
+ }
9327
+ return { env, missing };
9328
+ }
9329
+ function isWallError(error) {
9330
+ return !!error && /^(\[\w+\] )?The page is behind a sign-in/.test(error);
9331
+ }
9332
+ async function recordHosted(input) {
9333
+ const sleep = input.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
9334
+ const posted = await apiJson(input.origin, "/api/record/jobs", {
9335
+ method: "POST",
9336
+ key: input.key,
9337
+ body: {
9338
+ url: input.url,
9339
+ actions: input.actions,
9340
+ viewport: input.actions.viewport,
9341
+ ...Object.keys(input.headers).length ? { headers: input.headers } : {},
9342
+ ...Object.keys(input.env).length ? { env: input.env } : {},
9343
+ ...input.title ? { title: input.title } : {},
9344
+ ...input.folderId ? { folderId: input.folderId } : {}
9345
+ }
9346
+ });
9347
+ if (posted.status !== 202) {
9348
+ return {
9349
+ ok: false,
9350
+ wall: false,
9351
+ error: apiError("record on vos.so", posted),
9352
+ vosId: null,
9353
+ jobId: null
9354
+ };
9355
+ }
9356
+ const job = posted.body.job;
9357
+ const vos = posted.body.vos;
9358
+ const cap = posted.body.cap;
9359
+ input.log(
9360
+ `queued on vos.so as job ${job.id.slice(0, 8)} (vos ${job.vosId}${cap?.seconds ? `, cap ${cap.seconds}s` : ""})`
9361
+ );
9362
+ const deadline = Date.now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
9363
+ let lastStatus = "queued";
9364
+ while (Date.now() < deadline) {
9365
+ await sleep(POLL_MS);
9366
+ const polled = await apiJson(input.origin, `/api/record/jobs/${job.id}`, {
9367
+ key: input.key
9368
+ });
9369
+ if (polled.status !== 200) {
9370
+ return {
9371
+ ok: false,
9372
+ wall: false,
9373
+ error: apiError(`poll record job ${job.id}`, polled),
9374
+ vosId: job.vosId,
9375
+ jobId: job.id
9376
+ };
9377
+ }
9378
+ const state = polled.body.job;
9379
+ if (state.status !== lastStatus) {
9380
+ input.log(`${state.status}\u2026`);
9381
+ lastStatus = state.status;
9382
+ }
9383
+ if (state.status === "completed") {
9384
+ return {
9385
+ ok: true,
9386
+ vosId: state.vosId,
9387
+ jobId: state.id,
9388
+ job: state,
9389
+ studio: vos?.studio ?? `${input.origin}/studio?vos=${state.vosId}`
9390
+ };
9391
+ }
9392
+ if (state.status === "failed" || state.status === "canceled") {
9393
+ const error = state.error ?? (state.status === "canceled" ? "the record job was canceled" : "the record job failed");
9394
+ return {
9395
+ ok: false,
9396
+ wall: isWallError(error),
9397
+ error,
9398
+ vosId: state.vosId,
9399
+ jobId: state.id
9400
+ };
9401
+ }
9402
+ }
9403
+ return {
9404
+ ok: false,
9405
+ wall: false,
9406
+ error: `the record job ${job.id} did not finish within ${Math.round((input.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 6e4)} min; poll ${input.origin}/api/record/jobs/${job.id}`,
9407
+ vosId: job.vosId,
9408
+ jobId: job.id
9409
+ };
9410
+ }
9411
+
10297
9412
  // src/plugin/agentBrowser.ts
10298
9413
  import { writeFile as writeFile13 } from "fs/promises";
10299
9414
  import { resolve as resolve8 } from "path";
@@ -10814,7 +9929,7 @@ function summary(result) {
10814
9929
  async function cmdActionsScript(input, flags) {
10815
9930
  const r = createReporter(flags.json === true);
10816
9931
  const { readFile: readFile16 } = await import("fs/promises");
10817
- const { shotList, formatShotList } = await import("./shotList-7F2FE3RU.js");
9932
+ const { shotList, formatShotList } = await import("./shotList-IYFDHSIK.js");
10818
9933
  const raw = JSON.parse(await readFile16(resolve8(input), "utf8"));
10819
9934
  const errors = validateActions(raw);
10820
9935
  if (errors.length) throw new UsageError(`${input}:
@@ -10830,7 +9945,7 @@ import {
10830
9945
  cpSync,
10831
9946
  existsSync as existsSync17,
10832
9947
  mkdirSync as mkdirSync3,
10833
- readFileSync as readFileSync8,
9948
+ readFileSync as readFileSync7,
10834
9949
  rmSync as rmSync2,
10835
9950
  writeFileSync as writeFileSync3
10836
9951
  } from "fs";
@@ -10839,7 +9954,7 @@ import { join as join20 } from "path";
10839
9954
  import { chromium as chromium2 } from "playwright";
10840
9955
 
10841
9956
  // src/plugin/machine.ts
10842
- import { existsSync as existsSync16, readFileSync as readFileSync7, readdirSync as readdirSync4, statSync as statSync4 } from "fs";
9957
+ import { existsSync as existsSync16, readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync4 } from "fs";
10843
9958
  import { dirname as dirname5, join as join19 } from "path";
10844
9959
  var AGENT_NAMES = [
10845
9960
  "claude",
@@ -11056,7 +10171,7 @@ function bundledSkillsDir(from) {
11056
10171
  const pkg = join19(dir, "package.json");
11057
10172
  if (existsSync16(pkg)) {
11058
10173
  try {
11059
- const name = JSON.parse(readFileSync7(pkg, "utf8")).name;
10174
+ const name = JSON.parse(readFileSync6(pkg, "utf8")).name;
11060
10175
  if (name === "@vosjs/cli") {
11061
10176
  const skills = join19(dir, "skills");
11062
10177
  return existsSync16(skills) && statSync4(skills).isDirectory() ? skills : null;
@@ -11238,7 +10353,7 @@ async function cmdSetup(argv) {
11238
10353
  let rules = null;
11239
10354
  if (!noRules) {
11240
10355
  const file = rulesFileFor(root);
11241
- const existing = existsSync17(file) ? readFileSync8(file, "utf8") : null;
10356
+ const existing = existsSync17(file) ? readFileSync7(file, "utf8") : null;
11242
10357
  const { text, changed } = applyRulesBlock(existing);
11243
10358
  if (changed) writeFileSync3(file, text);
11244
10359
  rules = { file, changed };
@@ -11389,7 +10504,8 @@ var BOOLEAN_FLAGS6 = /* @__PURE__ */ new Set([
11389
10504
  "leader",
11390
10505
  "keep-frames",
11391
10506
  "dry-run",
11392
- "allow-wall"
10507
+ "allow-wall",
10508
+ "hosted"
11393
10509
  ]);
11394
10510
  var MULTI_FLAGS2 = /* @__PURE__ */ new Set(["set", "override", "browser-arg", "header"]);
11395
10511
  var HELP = `vos \u2014 record a browser flow, plan effects, render a product video; sync with vos.so
@@ -11397,6 +10513,8 @@ var HELP = `vos \u2014 record a browser flow, plan effects, render a product vid
11397
10513
  Take pipeline
11398
10514
  vos create --actions actions.json [--url <url>] [--out take] [out.webm] [--strict] [--allow-wall] [--keep-frames] [--max-duration <s>] [--storage-state <file>|--session <name>] [--header name=value]... [--browser-arg=<switch>]... [--background <slug|url|none>] [render flags] [--json]
11399
10515
  vos record --actions actions.json [--url <url>] [--out take] [--strict] [--dry-run] [--allow-wall] [--keep-frames] [--max-duration <s>] [--storage-state <file>|--session <name>] [--header name=value]... [--browser-arg=<switch>]... [--background <slug|url|none>] [--json]
10516
+ vos record --hosted --actions actions.json [--url <url>] [--out take] [--title "\u2026"] [--folder <id>] [--header name=value]... [--json]
10517
+ the take recorded on vos.so's fleet (no browser here): lands as a private vos with its doc and digest, then fetched into --out with its media
11400
10518
  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]
11401
10519
  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]
11402
10520
  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]
@@ -11811,12 +10929,102 @@ async function cmdRecord(argv) {
11811
10929
  if (!url)
11812
10930
  throw new UsageError('no URL \u2014 set "url" in the actions file or pass --url');
11813
10931
  const outDir = resolve9(strFlag(flags, "out") ?? "take");
11814
- const backdrop = await takeBackdrop(flags, r);
11815
- const maxDurationSeconds = await maxDuration(flags, r);
11816
10932
  const storageState = takeStorageState(flags);
11817
10933
  const sessionName = takeSessionName(flags, storageState);
11818
10934
  const browserArgs = takeBrowserArgs(multi);
11819
10935
  const headers = takeHeaders(multi);
10936
+ if (flags.hosted === true) {
10937
+ if (flags["dry-run"] === true)
10938
+ throw new UsageError(
10939
+ "--dry-run rehearses on this machine; drop --hosted to rehearse, or --dry-run to record on vos.so"
10940
+ );
10941
+ if (storageState || sessionName)
10942
+ throw new UsageError(
10943
+ 'a session never reaches vos.so: --hosted takes no --session or --storage-state. Put the sign-in in the setup steps with { "env": "NAME" }, or record here with vos record.'
10944
+ );
10945
+ const origin = platformOrigin({
10946
+ origin: strFlag(flags, "origin"),
10947
+ api: strFlag(flags, "api")
10948
+ });
10949
+ const key = resolveCredential(strFlag(flags, "key"));
10950
+ if (!key)
10951
+ throw new UsageError(
10952
+ "recording on vos.so needs a credential: vos login, or VOS_API_KEY (mint a key at https://vos.so/app/api)"
10953
+ );
10954
+ const { env, missing } = hostedEnv(actions, process.env);
10955
+ if (missing.length)
10956
+ throw new UsageError(
10957
+ `the setup reads ${missing.map((n) => `{ "env": "${n}" }`).join(", ")} and this shell has no ${missing.length === 1 ? "value for it" : "values for them"}; export ${missing.join(", ")} and run again (the value rides the job, and is never stored)`
10958
+ );
10959
+ r.log("recording on vos.so\u2026");
10960
+ r.event({ event: "phase", phase: "record" });
10961
+ const started = Date.now();
10962
+ const outcome = await recordHosted({
10963
+ origin,
10964
+ key,
10965
+ url,
10966
+ actions: { ...actions, url },
10967
+ headers,
10968
+ title: strFlag(flags, "title"),
10969
+ folderId: strFlag(flags, "folder"),
10970
+ env,
10971
+ log: r.log
10972
+ });
10973
+ if (!outcome.ok) {
10974
+ if (outcome.vosId) {
10975
+ const gone = await apiJson(origin, `/api/vos/${outcome.vosId}`, {
10976
+ method: "DELETE",
10977
+ key
10978
+ });
10979
+ if (gone.status === 200)
10980
+ r.log("the empty vos it would have landed on was removed");
10981
+ }
10982
+ r.done(
10983
+ {
10984
+ hosted: true,
10985
+ ok: false,
10986
+ wall: outcome.wall,
10987
+ error: outcome.error,
10988
+ vosId: outcome.vosId,
10989
+ jobId: outcome.jobId
10990
+ },
10991
+ `${outcome.wall ? "REFUSED" : "FAILED"} on vos.so: ${outcome.error}`
10992
+ );
10993
+ return outcome.wall ? EXIT_WALL : EXIT_ERROR;
10994
+ }
10995
+ r.event({ event: "phase", phase: "fetch" });
10996
+ r.log(`landed as ${outcome.studio}; fetching the take into ${outDir}\u2026`);
10997
+ const fetched = await cmdFetch([
10998
+ outcome.vosId,
10999
+ "--out",
11000
+ outDir,
11001
+ "--media",
11002
+ ...strFlag(flags, "origin") ? ["--origin", strFlag(flags, "origin")] : [],
11003
+ ...strFlag(flags, "key") ? ["--key", strFlag(flags, "key")] : [],
11004
+ ...flags.json === true ? ["--json"] : []
11005
+ ]);
11006
+ if (fetched !== EXIT_OK) return fetched;
11007
+ const t = outcome.job.telemetry ?? {};
11008
+ r.done(
11009
+ {
11010
+ hosted: true,
11011
+ ok: true,
11012
+ vosId: outcome.vosId,
11013
+ jobId: outcome.jobId,
11014
+ out: outDir,
11015
+ seconds: +((Date.now() - started) / 1e3).toFixed(1),
11016
+ telemetry: t
11017
+ },
11018
+ `Recorded on vos.so in ${((Date.now() - started) / 1e3).toFixed(1)}s: ${outcome.studio}
11019
+ ${outDir}/ holds doc.json, the recording and vos.json${typeof t.durationMs === "number" ? ` (${(t.durationMs / 1e3).toFixed(1)}s of footage)` : ""}${Array.isArray(t.dead) && t.dead.length ? `
11020
+ DEAD TIME: ${t.dead.length} stretch(es) under a parked cursor; give those steps their ms` : ""}${Array.isArray(t.exposures) && t.exposures.length ? `
11021
+ EXPOSURES: ${t.exposures.length} sensitive-looking text(s) in frame; mask them in actions.json` : ""}
11022
+ Next: vos digest ${outDir} \u2014 look before you cut; edit doc.json; vos push ${outDir}`
11023
+ );
11024
+ return EXIT_OK;
11025
+ }
11026
+ const backdrop = await takeBackdrop(flags, r);
11027
+ const maxDurationSeconds = await maxDuration(flags, r);
11820
11028
  if (flags["dry-run"] === true) {
11821
11029
  void flags.strict;
11822
11030
  void flags["keep-frames"];
@@ -13326,4 +12534,4 @@ export {
13326
12534
  verbHelp,
13327
12535
  run
13328
12536
  };
13329
- //# sourceMappingURL=chunk-RHPRZQRM.js.map
12537
+ //# sourceMappingURL=chunk-TAWRT7UM.js.map