@vosjs/cli 0.41.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/dist/{chunk-BD4FXBUO.js → chunk-7RMULTDM.js} +299 -12
- package/dist/chunk-7RMULTDM.js.map +1 -0
- package/dist/cli.js +17 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +43 -0
- package/dist/index.js +1 -1
- package/dist/{run-5PCYT25T.js → run-WN2IO3WL.js} +6 -4
- package/package.json +5 -5
- package/schema/actions.schema.json +22 -0
- package/schema/digest.schema.json +22 -0
- package/dist/chunk-BD4FXBUO.js.map +0 -1
- /package/dist/{run-5PCYT25T.js.map → run-WN2IO3WL.js.map} +0 -0
package/README.md
CHANGED
|
@@ -90,7 +90,20 @@ vos plan take --reuse # re-time that cut onto
|
|
|
90
90
|
|
|
91
91
|
**The wall check.** Once the first navigation settles, and before a frame is captured, `record`, `create` and `--dry-run` ask whether the recorder landed where it was sent. A take that met a sign-in instead is refused with exit 4 and a sentence (`asked for /dashboard, landed on /login: no session for app.acme.com`): the asked URL answered 401 or 403, the recorder was sent to an identity provider or a sign-in path, or the page is a sign-in form (one password field, or a one-time-code field) rendered in place. A redirect somewhere else with no sign-in in sight, which is what a site that shows strangers a public page looks like, is refused under `--strict` and in a rehearsal, and said as a warning otherwise. The check runs before a re-record clears anything, so a session that expired since the last take never costs the footage it failed to replace. The way past a wall is a session: `--storage-state <file>`, minted from the test auth the project already has wherever that exists. `--allow-wall` records the page anyway (a video OF a sign-in page is a legitimate take), and the take's `meta.wall` and its digest then say so.
|
|
92
92
|
|
|
93
|
-
**
|
|
93
|
+
**What the frame shows.** Getting past a login puts the account's own data in the picture, and asking for a demo account does not hold that line: a person asked to sign in signs in as themselves. So the recorder looks. After the page opens and after every step it reads the text visible in the viewport and reports the KIND of thing it saw and where, never the string: an email address (one on `example.com` or a `.test`, `.example`, `.invalid` or `.localhost` domain is demo data and is not reported), something shaped like an API key or a JWT, a card number that passes the Luhn check, a masked card's visible tail. They land in `meta.exposures[]` (`step`, `kind`, `selector`, `rect`, `seen`), in the `record` and `create` done events, at the end of a rehearsal (before anything is recorded), as warnings in `vos validate <take>`, and in the digest's `take.exposures`. They warn; they do not fail a take, because a product may legitimately show addresses. The `selector` reaches that element and no other, so it can be pasted into a mask.
|
|
94
|
+
|
|
95
|
+
**`mask`** in `actions.json` hides a selector BEFORE the first frame is captured and keeps it hidden across navigations and re-renders, so the real value is never in a frame, never in the recording, never pushed:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
"mask": [
|
|
99
|
+
{ "selector": "nav > span", "as": "text", "text": "jane@acme.test" },
|
|
100
|
+
{ "selector": ".card-number" }
|
|
101
|
+
]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`as: "blur"` (the default) blurs the element; `as: "text"` swaps its words, which reads as a product where a blur reads as a redaction. Use `text` for IDENTIFIERS (an email, a name, an account id), never for product copy or numbers: the video stays true to the product. A form control is always blurred, since writing into an input would change what the app submits. A mask whose selector reached no element hid nothing, so it fails `--strict` and a rehearsal (exit 2) and is named; `meta.masks[]` records each mask's `hits`.
|
|
105
|
+
|
|
106
|
+
**Rehearse before you record.** `vos record … --dry-run` runs every step against the real page, in order, because a later selector usually exists only after an earlier click. Nothing is captured and nothing is written: the pointer lands instead of travelling, every pause is cut to a beat, and the take directory beside it keeps its footage, its cut and its script exactly as they were (a real re-record moves `doc.json` aside; a rehearsal does not). Selector lookups keep their whole timeout, so a miss here is a miss in the take. It prints each step with the rect it resolved, in capture px, which are the rects a pin or `vos callout --step` reads, and exits 2 on any miss or a first load that never settled. Add `--dry-run` to the exact command you were about to run; `--storage-state` and `--browser-arg=` apply to it too, and the `Next:` line it prints carries them, so the command it hands you records what it rehearsed.
|
|
94
107
|
|
|
95
108
|
**Digest first.** `vos digest <take>` is how an agent sees a recording without reading the video. It writes `digest/digest.json`: one moment per thing the cursor track says mattered (click clusters, typing sessions, scroll runs, dwells, idle gaps, head, tail, and frame-diff scene changes), each with source and output extents, a normalized `focus` and `rect` you can copy into a zoom span, per-second `activity`, and the planners' `proposed` span ids; plus one footage frame and a crop around the target per moment, and `sheet.png`, the contact sheet. Read the JSON, then the sheet, then a crop only where you must decide. `vos validate` then warns when a zoom does not contain what was clicked under it, and `vos frames --at-moments` renders the composed output at every moment so a still and its footage crop share an id.
|
|
96
109
|
|
|
@@ -256,6 +269,7 @@ vos push /tmp/take --label "$TAG launch" --yes --json # VOS_API_KEY as a r
|
|
|
256
269
|
## For scripts and agents
|
|
257
270
|
|
|
258
271
|
- **Output.** Logs on stderr, results on stdout. `--json` turns every verb into NDJSON events ending with `{"event":"done",…}`.
|
|
272
|
+
- **`vos <verb> --help`** prints that verb's usage lines and exits 0 (it used to be a usage error).
|
|
259
273
|
- **Exit codes.** 0 ok, 1 error, 2 usage (including `--strict` failures), 3 no browser found, 4 the recorder met a wall (a sign-in, or under `--strict` a redirect away from the asked page) and nothing was recorded or cleared. `--json` carries the verdict as a `wall` event (`kind`, `level`, `asked`, `landed`, `refused`).
|
|
260
274
|
- **`--strict`** on `record` and `create` (agents: always): a skipped selector, a page that never reaches network idle, or a take that hit `--max-duration` exits 2 and lists `skipped[]` (and `capped`) in the done event. The default is lenient (exit 0, `skipped[]` still reported) for exploratory runs.
|
|
261
275
|
- **`--max-duration <s>`** on `record` and `create` defaults to the hosted recording cap, read live from `GET /api/limits` (2 s, fail-open to 30 min when the origin is unreachable): the capture stops there and the done event says so. Cut the flow rather than raising the cap; the platform refuses a longer take.
|
|
@@ -129,6 +129,24 @@ function validateActions(value) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
+
if (obj.mask !== void 0) {
|
|
133
|
+
if (!Array.isArray(obj.mask)) {
|
|
134
|
+
errors.push("mask must be an array of { selector, as?, text? }");
|
|
135
|
+
} else {
|
|
136
|
+
obj.mask.forEach((raw, i) => {
|
|
137
|
+
const at2 = `mask[${i}]`;
|
|
138
|
+
const m = raw ?? {};
|
|
139
|
+
if (typeof m.selector !== "string" || !m.selector.trim())
|
|
140
|
+
errors.push(`${at2}: needs a selector`);
|
|
141
|
+
if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
|
|
142
|
+
errors.push(`${at2}: as must be "blur" or "text"`);
|
|
143
|
+
if (m.as === "text" && (typeof m.text !== "string" || !m.text))
|
|
144
|
+
errors.push(`${at2}: as "text" needs the text to show instead`);
|
|
145
|
+
if (m.as !== "text" && m.text !== void 0)
|
|
146
|
+
errors.push(`${at2}: text is only read with as "text"`);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
132
150
|
if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
|
|
133
151
|
errors.push("steps must be a non-empty array");
|
|
134
152
|
return errors;
|
|
@@ -6687,7 +6705,7 @@ function redirectedAway(asked, landed) {
|
|
|
6687
6705
|
if (l.endsWith(a)) return false;
|
|
6688
6706
|
return true;
|
|
6689
6707
|
}
|
|
6690
|
-
var LADDER = '
|
|
6708
|
+
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.';
|
|
6691
6709
|
function wallVerdict(a) {
|
|
6692
6710
|
const asked = parse(a.askedUrl);
|
|
6693
6711
|
const landed = parse(a.landedUrl);
|
|
@@ -6758,6 +6776,199 @@ var WALL_PROBE = `(() => {
|
|
|
6758
6776
|
}
|
|
6759
6777
|
})()`;
|
|
6760
6778
|
|
|
6779
|
+
// src/plugin/exposure.ts
|
|
6780
|
+
var EXPOSURE_MATCHERS_SRC = `(() => {
|
|
6781
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,})/g
|
|
6782
|
+
const SAFE_HOST = /(^|\\.)example\\.(com|org|net)$|\\.(test|example|invalid|localhost)$/i
|
|
6783
|
+
const KEY = new RegExp([
|
|
6784
|
+
'\\\\b[srp]k_(?:live|test)_[A-Za-z0-9]{8,}',
|
|
6785
|
+
'\\\\bgh[pousr]_[A-Za-z0-9]{20,}',
|
|
6786
|
+
'\\\\bgithub_pat_[A-Za-z0-9_]{20,}',
|
|
6787
|
+
'\\\\bxox[abprs]-[A-Za-z0-9-]{10,}',
|
|
6788
|
+
'\\\\bAKIA[0-9A-Z]{16}\\\\b',
|
|
6789
|
+
'\\\\bAIza[0-9A-Za-z_-]{30,}',
|
|
6790
|
+
'\\\\bvos_(?:sk|rg|ho)_[A-Za-z0-9]{8,}',
|
|
6791
|
+
'\\\\beyJ[A-Za-z0-9_-]{8,}\\\\.eyJ[A-Za-z0-9_-]{8,}\\\\.[A-Za-z0-9_-]{8,}',
|
|
6792
|
+
].join('|'))
|
|
6793
|
+
const CARD = /(?:^|[^0-9])((?:[0-9][ -]?){12,18}[0-9])(?![0-9])/g
|
|
6794
|
+
const TAIL = /[\\u2022\\u00b7*xX]{2,}[ -]?[0-9]{4}(?![0-9])/
|
|
6795
|
+
const luhn = (digits) => {
|
|
6796
|
+
let sum = 0, alt = false
|
|
6797
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
6798
|
+
let d = digits.charCodeAt(i) - 48
|
|
6799
|
+
if (alt) { d *= 2; if (d > 9) d -= 9 }
|
|
6800
|
+
sum += d; alt = !alt
|
|
6801
|
+
}
|
|
6802
|
+
return sum % 10 === 0
|
|
6803
|
+
}
|
|
6804
|
+
const kindsIn = (text) => {
|
|
6805
|
+
const kinds = []
|
|
6806
|
+
if (!text || text.length < 6) return kinds
|
|
6807
|
+
EMAIL.lastIndex = 0
|
|
6808
|
+
for (let m; (m = EMAIL.exec(text)); ) {
|
|
6809
|
+
if (!SAFE_HOST.test(m[1])) { kinds.push('email'); break }
|
|
6810
|
+
}
|
|
6811
|
+
if (KEY.test(text)) kinds.push('key')
|
|
6812
|
+
CARD.lastIndex = 0
|
|
6813
|
+
for (let m; (m = CARD.exec(text)); ) {
|
|
6814
|
+
const digits = m[1].replace(/[ -]/g, '')
|
|
6815
|
+
if (digits.length >= 13 && digits.length <= 19 && !/^0+$/.test(digits) && luhn(digits)) { kinds.push('card'); break }
|
|
6816
|
+
}
|
|
6817
|
+
if (TAIL.test(text)) kinds.push('card-tail')
|
|
6818
|
+
return kinds
|
|
6819
|
+
}
|
|
6820
|
+
return { kindsIn }
|
|
6821
|
+
})()`;
|
|
6822
|
+
var EXPOSURE_PROBE = `(() => {
|
|
6823
|
+
const { kindsIn } = ${EXPOSURE_MATCHERS_SRC}
|
|
6824
|
+
const vw = innerWidth, vh = innerHeight
|
|
6825
|
+
// A selector that reaches THIS element and no other, so it can be pasted
|
|
6826
|
+
// into "mask" as it is: climb until the path is unique, and say which
|
|
6827
|
+
// sibling wherever a tag-and-class alone would match several.
|
|
6828
|
+
const segment = (el) => {
|
|
6829
|
+
const tid = el.getAttribute('data-testid')
|
|
6830
|
+
if (tid) return el.tagName.toLowerCase() + '[data-testid="' + tid + '"]'
|
|
6831
|
+
if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id)) return el.tagName.toLowerCase() + '#' + el.id
|
|
6832
|
+
const cls = [...el.classList].filter((c) => /^[A-Za-z_-][\\w-]*$/.test(c)).slice(0, 2)
|
|
6833
|
+
let seg = el.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '')
|
|
6834
|
+
const p = el.parentElement
|
|
6835
|
+
if (p && [...p.children].filter((c) => c.matches(seg)).length > 1)
|
|
6836
|
+
seg += ':nth-child(' + ([...p.children].indexOf(el) + 1) + ')'
|
|
6837
|
+
return seg
|
|
6838
|
+
}
|
|
6839
|
+
const name = (el) => {
|
|
6840
|
+
const parts = []
|
|
6841
|
+
for (let cur = el; cur && cur !== document.body && cur !== document.documentElement; cur = cur.parentElement) {
|
|
6842
|
+
parts.unshift(segment(cur))
|
|
6843
|
+
let n = 2
|
|
6844
|
+
try { n = document.querySelectorAll(parts.join(' > ')).length } catch {}
|
|
6845
|
+
// a bare tag is unique HERE and nowhere else: give it its parent too
|
|
6846
|
+
const bare = parts.length === 1 && !/[#.\\[]/.test(parts[0])
|
|
6847
|
+
if ((n === 1 && !bare) || parts.length >= 5) break
|
|
6848
|
+
}
|
|
6849
|
+
return parts.join(' > ')
|
|
6850
|
+
}
|
|
6851
|
+
const seen = (el) => {
|
|
6852
|
+
if (el.closest('[data-vos-masked]')) return null
|
|
6853
|
+
const r = el.getBoundingClientRect()
|
|
6854
|
+
if (r.width < 2 || r.height < 2) return null
|
|
6855
|
+
if (r.bottom <= 0 || r.right <= 0 || r.top >= vh || r.left >= vw) return null
|
|
6856
|
+
const s = getComputedStyle(el)
|
|
6857
|
+
if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity === 0) return null
|
|
6858
|
+
return r
|
|
6859
|
+
}
|
|
6860
|
+
const out = [], keys = new Set()
|
|
6861
|
+
const add = (el, text) => {
|
|
6862
|
+
const kinds = kindsIn(text)
|
|
6863
|
+
if (!kinds.length) return
|
|
6864
|
+
const r = seen(el)
|
|
6865
|
+
if (!r) return
|
|
6866
|
+
const selector = name(el)
|
|
6867
|
+
for (const kind of kinds) {
|
|
6868
|
+
const k = kind + '|' + selector
|
|
6869
|
+
if (keys.has(k) || out.length >= 40) continue
|
|
6870
|
+
keys.add(k)
|
|
6871
|
+
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) } })
|
|
6872
|
+
}
|
|
6873
|
+
}
|
|
6874
|
+
const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT)
|
|
6875
|
+
for (let n; (n = walker.nextNode()); ) {
|
|
6876
|
+
const el = n.parentElement
|
|
6877
|
+
if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(el.tagName)) continue
|
|
6878
|
+
add(el, n.nodeValue)
|
|
6879
|
+
}
|
|
6880
|
+
for (const el of document.querySelectorAll('input, textarea')) {
|
|
6881
|
+
if (/^(password|hidden|file|checkbox|radio)$/.test(el.type)) continue
|
|
6882
|
+
add(el, el.value)
|
|
6883
|
+
}
|
|
6884
|
+
const hits = {}
|
|
6885
|
+
for (const [sel, n] of Object.entries(window.__vosMaskHits || {})) hits[sel] = n
|
|
6886
|
+
return { exposures: out, maskHits: hits }
|
|
6887
|
+
})()`;
|
|
6888
|
+
function maskInitScript(masks) {
|
|
6889
|
+
const rules = masks.map((m) => ({
|
|
6890
|
+
selector: m.selector,
|
|
6891
|
+
as: m.as === "text" ? "text" : "blur",
|
|
6892
|
+
text: m.text ?? ""
|
|
6893
|
+
}));
|
|
6894
|
+
return `(() => {
|
|
6895
|
+
const RULES = ${JSON.stringify(rules)}
|
|
6896
|
+
const hits = (window.__vosMaskHits = window.__vosMaskHits || {})
|
|
6897
|
+
for (const r of RULES) hits[r.selector] = hits[r.selector] || 0
|
|
6898
|
+
const css = RULES.map((r) => r.selector + '[data-vos-masked="blur"]{filter:blur(7px) !important;user-select:none !important}').join('\\n')
|
|
6899
|
+
const paint = () => {
|
|
6900
|
+
const root = document.head || document.documentElement
|
|
6901
|
+
if (!root || document.getElementById('__vos-mask-style')) return
|
|
6902
|
+
const style = document.createElement('style')
|
|
6903
|
+
style.id = '__vos-mask-style'
|
|
6904
|
+
style.textContent = css
|
|
6905
|
+
root.appendChild(style)
|
|
6906
|
+
}
|
|
6907
|
+
const apply = () => {
|
|
6908
|
+
paint()
|
|
6909
|
+
for (const r of RULES) {
|
|
6910
|
+
let found
|
|
6911
|
+
try { found = document.querySelectorAll(r.selector) } catch { continue }
|
|
6912
|
+
if (found.length > hits[r.selector]) hits[r.selector] = found.length
|
|
6913
|
+
for (const el of found) {
|
|
6914
|
+
const form = /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)
|
|
6915
|
+
const mode = r.as === 'text' && !form ? 'text' : 'blur'
|
|
6916
|
+
if (el.getAttribute('data-vos-masked') !== mode) el.setAttribute('data-vos-masked', mode)
|
|
6917
|
+
if (mode !== 'text') continue
|
|
6918
|
+
const texts = []
|
|
6919
|
+
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
|
6920
|
+
for (let n; (n = w.nextNode()); ) texts.push(n)
|
|
6921
|
+
if (!texts.length) { if (el.textContent !== r.text) el.textContent = r.text; continue }
|
|
6922
|
+
texts.forEach((n, i) => { const want = i === 0 ? r.text : ''; if (n.nodeValue !== want) n.nodeValue = want })
|
|
6923
|
+
}
|
|
6924
|
+
}
|
|
6925
|
+
}
|
|
6926
|
+
new MutationObserver(apply).observe(document, { childList: true, subtree: true, characterData: true })
|
|
6927
|
+
document.addEventListener('DOMContentLoaded', apply)
|
|
6928
|
+
apply()
|
|
6929
|
+
})()`;
|
|
6930
|
+
}
|
|
6931
|
+
var ExposureLog = class {
|
|
6932
|
+
byKey = /* @__PURE__ */ new Map();
|
|
6933
|
+
maskHits = /* @__PURE__ */ new Map();
|
|
6934
|
+
constructor(masks = []) {
|
|
6935
|
+
for (const m of masks) this.maskHits.set(m.selector, 0);
|
|
6936
|
+
}
|
|
6937
|
+
add(step, scan) {
|
|
6938
|
+
if (!scan) return;
|
|
6939
|
+
for (const e of scan.exposures ?? []) {
|
|
6940
|
+
const key = `${e.kind}|${e.selector}`;
|
|
6941
|
+
const had = this.byKey.get(key);
|
|
6942
|
+
if (had) had.seen += 1;
|
|
6943
|
+
else this.byKey.set(key, { step, ...e, seen: 1 });
|
|
6944
|
+
}
|
|
6945
|
+
for (const [sel, n] of Object.entries(scan.maskHits ?? {})) {
|
|
6946
|
+
if (n > (this.maskHits.get(sel) ?? 0)) this.maskHits.set(sel, n);
|
|
6947
|
+
}
|
|
6948
|
+
}
|
|
6949
|
+
exposures() {
|
|
6950
|
+
return [...this.byKey.values()];
|
|
6951
|
+
}
|
|
6952
|
+
masks(rules) {
|
|
6953
|
+
return rules.map((m) => ({
|
|
6954
|
+
selector: m.selector,
|
|
6955
|
+
as: m.as === "text" ? "text" : "blur",
|
|
6956
|
+
hits: this.maskHits.get(m.selector) ?? 0
|
|
6957
|
+
}));
|
|
6958
|
+
}
|
|
6959
|
+
};
|
|
6960
|
+
var KIND_WORDS = {
|
|
6961
|
+
email: "an email address",
|
|
6962
|
+
key: "something shaped like an API key or token",
|
|
6963
|
+
card: "a card number",
|
|
6964
|
+
"card-tail": "a card's last digits"
|
|
6965
|
+
};
|
|
6966
|
+
function exposureLine(e) {
|
|
6967
|
+
const when = e.step < 0 ? "as the page opened" : `at step #${e.step}`;
|
|
6968
|
+
return `${KIND_WORDS[e.kind]} in ${e.selector} (${when}, ${e.rect.x},${e.rect.y} ${e.rect.w}\xD7${e.rect.h})`;
|
|
6969
|
+
}
|
|
6970
|
+
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.';
|
|
6971
|
+
|
|
6761
6972
|
// src/plugin/recorder.ts
|
|
6762
6973
|
var realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6763
6974
|
var DRY_PAUSE_MS = 120;
|
|
@@ -6785,7 +6996,14 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
|
|
|
6785
6996
|
deviceScaleFactor: 1,
|
|
6786
6997
|
...opts.storageState ? { storageState: opts.storageState } : {}
|
|
6787
6998
|
});
|
|
6999
|
+
const masks = actions.mask ?? [];
|
|
7000
|
+
if (masks.length) await context.addInitScript(maskInitScript(masks));
|
|
7001
|
+
const exposureLog = new ExposureLog(masks);
|
|
6788
7002
|
const page = await context.newPage();
|
|
7003
|
+
const scanExposures = async (step) => {
|
|
7004
|
+
const scan = await page.evaluate(EXPOSURE_PROBE).catch(() => null);
|
|
7005
|
+
exposureLog.add(step, scan);
|
|
7006
|
+
};
|
|
6789
7007
|
page.on("console", (m) => {
|
|
6790
7008
|
if (m.type() === "error") log(` [page error] ${m.text()}`);
|
|
6791
7009
|
});
|
|
@@ -6815,6 +7033,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
|
|
|
6815
7033
|
throw e;
|
|
6816
7034
|
}
|
|
6817
7035
|
}
|
|
7036
|
+
await scanExposures(-1);
|
|
6818
7037
|
const cdp = await context.newCDPSession(page);
|
|
6819
7038
|
const frames = [];
|
|
6820
7039
|
let frameIdx = 0;
|
|
@@ -7074,6 +7293,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
|
|
|
7074
7293
|
gestureMs,
|
|
7075
7294
|
wallMs: now() - stepStart
|
|
7076
7295
|
});
|
|
7296
|
+
await scanExposures(stepIdx);
|
|
7077
7297
|
}
|
|
7078
7298
|
if (!capped) await sleep(TRAILING_HOLD_MS);
|
|
7079
7299
|
if (capped || capReached(now(), maxSeconds)) {
|
|
@@ -7104,7 +7324,9 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
|
|
|
7104
7324
|
platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "windows" : "linux",
|
|
7105
7325
|
producer: "cli",
|
|
7106
7326
|
steps: stepSpans,
|
|
7107
|
-
...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {}
|
|
7327
|
+
...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {},
|
|
7328
|
+
...exposureLog.exposures().length ? { exposures: exposureLog.exposures() } : {},
|
|
7329
|
+
...masks.length ? { masks: exposureLog.masks(masks) } : {}
|
|
7108
7330
|
};
|
|
7109
7331
|
const freezes = [];
|
|
7110
7332
|
for (let i = 1; i < frames.length; i++) {
|
|
@@ -7140,6 +7362,8 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
|
|
|
7140
7362
|
skipped,
|
|
7141
7363
|
navTimeout,
|
|
7142
7364
|
wall,
|
|
7365
|
+
exposures: exposureLog.exposures(),
|
|
7366
|
+
masks: exposureLog.masks(masks),
|
|
7143
7367
|
freezes,
|
|
7144
7368
|
freezePct,
|
|
7145
7369
|
capped
|
|
@@ -10575,8 +10799,23 @@ function strictReason(rec) {
|
|
|
10575
10799
|
const parts = [`${rec.skipped.length} skipped step(s)`];
|
|
10576
10800
|
if (rec.navTimeout) parts.push("networkidle timeout");
|
|
10577
10801
|
if (rec.capped) parts.push("stopped at --max-duration");
|
|
10802
|
+
const missed = maskMisses(rec);
|
|
10803
|
+
if (missed.length)
|
|
10804
|
+
parts.push(
|
|
10805
|
+
`${missed.length} mask(s) that hid nothing (${missed.join(", ")}): whatever they were for may be showing`
|
|
10806
|
+
);
|
|
10578
10807
|
return parts.join(" + ");
|
|
10579
10808
|
}
|
|
10809
|
+
function maskMisses(rec) {
|
|
10810
|
+
return (rec.masks ?? []).filter((m) => m.hits === 0).map((m) => m.selector);
|
|
10811
|
+
}
|
|
10812
|
+
function exposureNote(rec) {
|
|
10813
|
+
if (!rec.exposures.length) return "";
|
|
10814
|
+
return `
|
|
10815
|
+
EXPOSED in the frame (${rec.exposures.length}):
|
|
10816
|
+
${rec.exposures.map((e) => ` - ${exposureLine(e)}`).join("\n")}
|
|
10817
|
+
${EXPOSURE_ADVICE}`;
|
|
10818
|
+
}
|
|
10580
10819
|
function takeBrowserArgs(multi) {
|
|
10581
10820
|
const given = multi["browser-arg"];
|
|
10582
10821
|
return (given ?? []).filter(Boolean);
|
|
@@ -10674,7 +10913,13 @@ async function cmdRecord(argv) {
|
|
|
10674
10913
|
const what = s.selector ? ` ${s.selector}` : "";
|
|
10675
10914
|
return ` ${s.skipped ? "\u2717" : "\u2713"} #${s.step}${s.id ? ` (${s.id})` : ""} ${s.do}${what}${s.skipped ? " NOT FOUND" : rect}${s.navigated ? " \u2192 navigated" : ""}`;
|
|
10676
10915
|
});
|
|
10677
|
-
const failed2 = rec.skipped.length > 0 || rec.navTimeout;
|
|
10916
|
+
const failed2 = rec.skipped.length > 0 || rec.navTimeout || maskMisses(rec).length > 0;
|
|
10917
|
+
const carried = [
|
|
10918
|
+
...strFlag(flags, "storage-state") ? [`--storage-state ${strFlag(flags, "storage-state")}`] : [],
|
|
10919
|
+
...browserArgs.map((a) => `--browser-arg=${a}`),
|
|
10920
|
+
...flags["allow-wall"] === true ? ["--allow-wall"] : []
|
|
10921
|
+
].join(" ");
|
|
10922
|
+
const nextOut = strFlag(flags, "out") ?? "take";
|
|
10678
10923
|
r.done(
|
|
10679
10924
|
{
|
|
10680
10925
|
dryRun: true,
|
|
@@ -10682,11 +10927,13 @@ async function cmdRecord(argv) {
|
|
|
10682
10927
|
seconds: +((Date.now() - started) / 1e3).toFixed(1),
|
|
10683
10928
|
steps,
|
|
10684
10929
|
skipped: rec.skipped,
|
|
10685
|
-
navTimeout: rec.navTimeout
|
|
10930
|
+
navTimeout: rec.navTimeout,
|
|
10931
|
+
exposures: rec.exposures,
|
|
10932
|
+
masks: rec.masks
|
|
10686
10933
|
},
|
|
10687
10934
|
`${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)" : ""}
|
|
10688
10935
|
${lines.join("\n")}
|
|
10689
|
-
${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 ${
|
|
10936
|
+
${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)}`
|
|
10690
10937
|
);
|
|
10691
10938
|
return failed2 ? EXIT_USAGE : EXIT_OK;
|
|
10692
10939
|
} finally {
|
|
@@ -10728,7 +10975,7 @@ ${lines.join("\n")}
|
|
|
10728
10975
|
const plan = await planTake(outDir, { backdrop });
|
|
10729
10976
|
const clicks = rec.events.filter((e) => e.type === "down").length;
|
|
10730
10977
|
const strict = flags.strict === true;
|
|
10731
|
-
const strictFail = strict && (rec.skipped.length > 0 || rec.navTimeout || rec.capped);
|
|
10978
|
+
const strictFail = strict && (rec.skipped.length > 0 || rec.navTimeout || rec.capped || maskMisses(rec).length > 0);
|
|
10732
10979
|
const skippedNote = rec.skipped.length ? `
|
|
10733
10980
|
SKIPPED ${rec.skipped.length} step(s): ${rec.skipped.map((s) => `#${s.step} ${s.do} ${s.selector}`).join(", ")}` : "";
|
|
10734
10981
|
r.done(
|
|
@@ -10747,11 +10994,13 @@ ${lines.join("\n")}
|
|
|
10747
10994
|
capped: rec.capped,
|
|
10748
10995
|
pace: rec.pace,
|
|
10749
10996
|
...rec.wall ? { wall: rec.wall } : {},
|
|
10997
|
+
exposures: rec.exposures,
|
|
10998
|
+
...rec.masks.length ? { masks: rec.masks } : {},
|
|
10750
10999
|
...strictFail ? { strictFailed: true } : {}
|
|
10751
11000
|
},
|
|
10752
|
-
strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}` : `Take ready: ${outDir}
|
|
11001
|
+
strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}${exposureNote(rec)}` : `Take ready: ${outDir}
|
|
10753
11002
|
${(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}
|
|
10754
|
-
Next: edit ${join18(outDir, "doc.json")} (optional), then: vos render ${outDir}`
|
|
11003
|
+
Next: edit ${join18(outDir, "doc.json")} (optional), then: vos render ${outDir}${exposureNote(rec)}`
|
|
10755
11004
|
);
|
|
10756
11005
|
return strictFail ? EXIT_USAGE : EXIT_OK;
|
|
10757
11006
|
} finally {
|
|
@@ -10822,7 +11071,7 @@ async function cmdCreate2(argv) {
|
|
|
10822
11071
|
await releaseFrames(paths, flags, r.log);
|
|
10823
11072
|
r.event({ event: "phase", phase: "plan" });
|
|
10824
11073
|
await planTake(outDir, { backdrop });
|
|
10825
|
-
if (flags.strict === true && (rec.skipped.length > 0 || rec.navTimeout || rec.capped)) {
|
|
11074
|
+
if (flags.strict === true && (rec.skipped.length > 0 || rec.navTimeout || rec.capped || maskMisses(rec).length > 0)) {
|
|
10826
11075
|
r.done(
|
|
10827
11076
|
{
|
|
10828
11077
|
take: outDir,
|
|
@@ -10866,10 +11115,12 @@ async function cmdCreate2(argv) {
|
|
|
10866
11115
|
navTimeout: rec.navTimeout,
|
|
10867
11116
|
freezes: rec.freezes,
|
|
10868
11117
|
freezePct: rec.freezePct,
|
|
11118
|
+
exposures: rec.exposures,
|
|
11119
|
+
...rec.masks.length ? { masks: rec.masks } : {},
|
|
10869
11120
|
...flags.draft === true ? { draft: true } : {}
|
|
10870
11121
|
},
|
|
10871
11122
|
`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" : ""})
|
|
10872
|
-
take: ${outDir} \u2014 inspect with: vos frames ${outDir} --at-zooms; edit doc.json and re-render with: vos render ${outDir}`
|
|
11123
|
+
take: ${outDir} \u2014 inspect with: vos frames ${outDir} --at-zooms; edit doc.json and re-render with: vos render ${outDir}${exposureNote(rec)}`
|
|
10873
11124
|
);
|
|
10874
11125
|
return EXIT_OK;
|
|
10875
11126
|
} finally {
|
|
@@ -11434,12 +11685,14 @@ async function cmdDigest(argv) {
|
|
|
11434
11685
|
outputDuration: d.take.outputDuration,
|
|
11435
11686
|
hasCursor: d.take.hasCursor,
|
|
11436
11687
|
...d.take.wall ? { wall: d.take.wall } : {},
|
|
11688
|
+
...d.take.exposures.length ? { exposures: d.take.exposures } : {},
|
|
11437
11689
|
tokensEstimate: d.images.tokensEstimate,
|
|
11438
11690
|
bytes: result.bytes
|
|
11439
11691
|
},
|
|
11440
11692
|
`Wrote ${result.file} \u2014 ${d.moments.length} moments (${Object.entries(
|
|
11441
11693
|
kinds
|
|
11442
|
-
).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.
|
|
11694
|
+
).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 ? `
|
|
11695
|
+
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 ? `
|
|
11443
11696
|
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` : "") + `
|
|
11444
11697
|
Read digest.json, then the sheet, then a crop only where you must decide.`
|
|
11445
11698
|
);
|
|
@@ -11703,6 +11956,18 @@ async function cmdValidate(argv) {
|
|
|
11703
11956
|
);
|
|
11704
11957
|
if (mismatch) warnings.push(mismatch);
|
|
11705
11958
|
}
|
|
11959
|
+
for (const e of take.meta.exposures ?? [])
|
|
11960
|
+
warnings.push(`the recording shows ${exposureLine(e)}`);
|
|
11961
|
+
if (take.meta.exposures?.length) warnings.push(EXPOSURE_ADVICE);
|
|
11962
|
+
for (const m of take.meta.masks ?? [])
|
|
11963
|
+
if (m.hits === 0)
|
|
11964
|
+
warnings.push(
|
|
11965
|
+
`mask "${m.selector}" reached no element, so it hid nothing: whatever it was for may be showing`
|
|
11966
|
+
);
|
|
11967
|
+
if (take.meta.wall)
|
|
11968
|
+
warnings.push(
|
|
11969
|
+
`recorded past a wall: asked for ${take.meta.wall.asked}, landed on ${take.meta.wall.landed}. This footage may be the wrong page`
|
|
11970
|
+
);
|
|
11706
11971
|
if (!take.doc) problems.push("missing doc.json (run plan)");
|
|
11707
11972
|
if (take.doc) {
|
|
11708
11973
|
const lint = lintDoc(take.doc);
|
|
@@ -11842,6 +12107,23 @@ async function cmdPull2(argv) {
|
|
|
11842
12107
|
);
|
|
11843
12108
|
return EXIT_OK;
|
|
11844
12109
|
}
|
|
12110
|
+
function verbHelp(verb) {
|
|
12111
|
+
const lines = HELP.split("\n");
|
|
12112
|
+
const out = [];
|
|
12113
|
+
let taking = false;
|
|
12114
|
+
for (const line of lines) {
|
|
12115
|
+
if (/^ {2}vos /.test(line)) taking = line.startsWith(` vos ${verb} `);
|
|
12116
|
+
else if (taking && !/^ {3,}\S/.test(line)) taking = false;
|
|
12117
|
+
if (taking) out.push(line);
|
|
12118
|
+
}
|
|
12119
|
+
if (out.length === 0) return `vos ${verb}: no such verb here. Run: vos help
|
|
12120
|
+
`;
|
|
12121
|
+
return `${out.join("\n")}
|
|
12122
|
+
|
|
12123
|
+
The whole reference, with every flag explained: https://vos.so/llms-full.txt
|
|
12124
|
+
Exit codes: 0 ok, 1 error, 2 usage or --strict failure, 3 no browser, 4 the recorder met a sign-in instead of the page.
|
|
12125
|
+
`;
|
|
12126
|
+
}
|
|
11845
12127
|
async function run(argv) {
|
|
11846
12128
|
const [cmd, ...rest] = argv;
|
|
11847
12129
|
try {
|
|
@@ -11849,6 +12131,10 @@ async function run(argv) {
|
|
|
11849
12131
|
process.stdout.write(HELP);
|
|
11850
12132
|
return cmd ? EXIT_OK : EXIT_USAGE;
|
|
11851
12133
|
}
|
|
12134
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
12135
|
+
process.stdout.write(verbHelp(cmd));
|
|
12136
|
+
return EXIT_OK;
|
|
12137
|
+
}
|
|
11852
12138
|
switch (cmd) {
|
|
11853
12139
|
case "create":
|
|
11854
12140
|
return await cmdCreate2(rest);
|
|
@@ -11959,6 +12245,7 @@ export {
|
|
|
11959
12245
|
MULTI_FLAGS2 as MULTI_FLAGS,
|
|
11960
12246
|
HELP,
|
|
11961
12247
|
takeBrowserArgs,
|
|
12248
|
+
verbHelp,
|
|
11962
12249
|
run
|
|
11963
12250
|
};
|
|
11964
|
-
//# sourceMappingURL=chunk-
|
|
12251
|
+
//# sourceMappingURL=chunk-7RMULTDM.js.map
|