@mrkt_frwd/reel 0.1.0 → 0.2.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 +63 -9
- package/package.json +13 -4
- package/src/cli.mjs +21 -2
- package/src/test-reel.mjs +74 -0
package/README.md
CHANGED
|
@@ -1,17 +1,71 @@
|
|
|
1
1
|
# @mrkt_frwd/reel
|
|
2
2
|
|
|
3
|
-
Drive a real browser from a declarative script and record it.
|
|
3
|
+
**Drive a real browser from a declarative script and record it. Run it twice and
|
|
4
|
+
get the same frames.**
|
|
4
5
|
|
|
5
6
|
```bash
|
|
6
|
-
npx reel
|
|
7
|
-
npx reel
|
|
7
|
+
npx @mrkt_frwd/reel script.json --dry-run
|
|
8
|
+
npx @mrkt_frwd/reel script.json --deterministic
|
|
8
9
|
```
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
selector in seconds, so a capture never dies four minutes in on a renamed one.
|
|
12
|
-
`--deterministic` runs a fixed clock, so the frames do not depend on how long a
|
|
13
|
-
frame cost to draw.
|
|
11
|
+
## What it is for
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
Repeatable video of a web page — product demos, regression captures, marketing
|
|
14
|
+
clips — without a human driving the mouse and without the output drifting between
|
|
15
|
+
runs.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
## The claim, and the evidence for it
|
|
18
|
+
|
|
19
|
+
The script is **data, not code**:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"name": "studio-tour",
|
|
24
|
+
"viewport": { "width": 1440, "height": 900 },
|
|
25
|
+
"fps": 24,
|
|
26
|
+
"shots": [
|
|
27
|
+
{ "id": "land", "intent": "Open on the landing and let the hero settle.",
|
|
28
|
+
"actions": [
|
|
29
|
+
{ "type": "goto", "url": "/" },
|
|
30
|
+
{ "type": "hold", "ms": 1100 },
|
|
31
|
+
{ "type": "scroll", "to": 620, "ms": 1500 }
|
|
32
|
+
] }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Because it is data, an agent can write one, diff one, and review one before
|
|
38
|
+
anything runs. An imperative recording script has to be executed to be
|
|
39
|
+
understood — and by then you have already paid for the capture.
|
|
40
|
+
|
|
41
|
+
**`--dry-run` on a real 4-shot, 18-action script: 4.06 s.** It loads the page,
|
|
42
|
+
resolves all six selectors, and validates the whole script without recording a
|
|
43
|
+
frame. A capture that dies four minutes in on a renamed selector is the expensive
|
|
44
|
+
failure in this pipeline; this is the cheap thing that catches it.
|
|
45
|
+
|
|
46
|
+
**`--deterministic` runs a fixed clock**, so frame N is frame N regardless of how
|
|
47
|
+
long a frame cost to draw on your machine.
|
|
48
|
+
|
|
49
|
+
## What it refuses to do
|
|
50
|
+
|
|
51
|
+
- A shot without an `intent` is refused — the critic has nothing to judge against.
|
|
52
|
+
- A selector that does not resolve fails the dry run rather than the capture.
|
|
53
|
+
|
|
54
|
+
## Flags
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
--dry-run validate and resolve every selector; record nothing
|
|
58
|
+
--deterministic fixed clock — the same frames every run
|
|
59
|
+
--formats mp4, gif, vertical, from one capture pass
|
|
60
|
+
--live <origin> record against a running server
|
|
61
|
+
--gate fail the build when the recording does not hold
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Requirements
|
|
65
|
+
|
|
66
|
+
Node 18+, Chromium and ffmpeg — both come in as dependencies. This is the one
|
|
67
|
+
engine in the set that is not zero-dependency, because it drives a real browser
|
|
68
|
+
and encodes real video.
|
|
69
|
+
|
|
70
|
+
MIT © Joe Asare. Built at [Joe Asare Studio](https://joeasare.com), where it
|
|
71
|
+
records the work the other engines make.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrkt_frwd/reel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Reel
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Reel \u2014 drive a real browser from a declarative script and record it. Deterministic mode gives the same frames every run.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.mjs"
|
|
@@ -15,10 +15,19 @@
|
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@playwright/browser-chromium": "^1.62.1",
|
|
18
|
-
"ffmpeg-static": "^5.3.0"
|
|
18
|
+
"ffmpeg-static": "^5.3.0",
|
|
19
|
+
"playwright-core": "^1.62.1"
|
|
19
20
|
},
|
|
20
21
|
"engines": {
|
|
21
22
|
"node": ">=20"
|
|
22
23
|
},
|
|
23
|
-
"license": "MIT"
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/mrktfrwd/reel.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://joeasare.com/engines/reel",
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node src/test-reel.mjs"
|
|
32
|
+
}
|
|
24
33
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -36,10 +36,29 @@ const flag = (name, fallback = null) => {
|
|
|
36
36
|
};
|
|
37
37
|
const has = (name) => argv.includes(`--${name}`);
|
|
38
38
|
|
|
39
|
+
const USAGE = `reel — drive a real browser from a declarative script and record it.
|
|
40
|
+
|
|
41
|
+
npx @mrkt_frwd/reel <script.json> [flags]
|
|
42
|
+
|
|
43
|
+
--dry-run validate the script and resolve every selector; record nothing
|
|
44
|
+
--deterministic fixed clock, so frame N is frame N on any machine
|
|
45
|
+
--formats <list> mp4, gif, vertical — from one capture pass
|
|
46
|
+
--live <origin> record against an already-running server
|
|
47
|
+
--keep-frames leave the raw frames on disk
|
|
48
|
+
--gate exit non-zero when the recording does not hold
|
|
49
|
+
|
|
50
|
+
The script is data, not code: viewport, fps, and a list of shots, each with an
|
|
51
|
+
intent and a list of actions (goto, hold, scroll, click, type, drag, waitFor, eval).
|
|
52
|
+
|
|
53
|
+
Needs Chromium and ffmpeg, both installed as dependencies.
|
|
54
|
+
Docs: https://joeasare.com/engines/reel`;
|
|
55
|
+
|
|
39
56
|
const scriptPath = argv.find((a) => !a.startsWith('--') && a.endsWith('.json'));
|
|
40
57
|
if (!scriptPath) {
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
// Every engine in this set prints its own manual on a bare invocation and exits 0.
|
|
59
|
+
// Asking for help is not an error, and the pages promise one instruction is enough.
|
|
60
|
+
console.log(USAGE);
|
|
61
|
+
process.exit(0);
|
|
43
62
|
}
|
|
44
63
|
if (!fs.existsSync(scriptPath)) {
|
|
45
64
|
console.error(`no such script: ${scriptPath}`);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Reel's promise is that a script is data you can check before you pay for a capture,
|
|
4
|
+
* and that two runs produce the same frames. These tests exercise the checking half —
|
|
5
|
+
* no browser, no ffmpeg, so they run anywhere.
|
|
6
|
+
*
|
|
7
|
+
* node src/test-reel.mjs
|
|
8
|
+
*/
|
|
9
|
+
import assert from 'assert';
|
|
10
|
+
import { validate, selectorsFor, parseViewport } from './schema.mjs';
|
|
11
|
+
import { ACTION_TYPES } from './actions.mjs';
|
|
12
|
+
|
|
13
|
+
const fails = [];
|
|
14
|
+
const it = (n, f) => { try { f(); console.log(` ok ${n}`); } catch (e) { fails.push(n); console.log(` FAIL ${n}\n ${e.message}`); } };
|
|
15
|
+
const script = (over = {}) => ({
|
|
16
|
+
name: 'probe', viewport: { width: 1440, height: 900 }, fps: 24,
|
|
17
|
+
shots: [{ id: 'one', intent: 'Load the page and let it settle.',
|
|
18
|
+
actions: [{ type: 'goto', url: '/' }, { type: 'hold', ms: 200 }] }],
|
|
19
|
+
...over,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
console.log('\n REEL\n ----------------------------------');
|
|
23
|
+
|
|
24
|
+
it('a well-formed script validates', () => {
|
|
25
|
+
assert.deepEqual(validate(script()), [], 'a good script must report no errors');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('a shot without an intent is refused — the critic needs something to judge', () => {
|
|
29
|
+
const s = script();
|
|
30
|
+
delete s.shots[0].intent;
|
|
31
|
+
const errs = validate(s);
|
|
32
|
+
assert.ok(errs.length > 0, 'a shot with no intent must be an error');
|
|
33
|
+
assert.ok(errs.join(' ').includes('intent'), `the error must name the intent, got ${errs.join('; ')}`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('an unknown action type is refused rather than skipped', () => {
|
|
37
|
+
const s = script();
|
|
38
|
+
s.shots[0].actions.push({ type: 'teleport', to: 'moon' });
|
|
39
|
+
assert.ok(validate(s).length > 0, 'an unknown action must not pass silently');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('every documented action type is recognised, and missing fields are named', () => {
|
|
43
|
+
for (const t of ACTION_TYPES) {
|
|
44
|
+
const s = script();
|
|
45
|
+
s.shots[0].actions.push({ type: t });
|
|
46
|
+
const errs = validate(s).join(' ');
|
|
47
|
+
// The type itself must never come back as unknown. Complaining that a required
|
|
48
|
+
// field is missing is the validator working, and it must say which field.
|
|
49
|
+
assert.ok(!errs.includes(`unknown`) || !errs.includes(t),
|
|
50
|
+
`${t} is a documented action type but validate calls it unknown`);
|
|
51
|
+
if (errs.includes(`"${t}" requires`)) {
|
|
52
|
+
assert.ok(/requires "\w+"/.test(errs), `${t}: a missing field must be named, got: ${errs}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('selectorsFor lists what a dry run has to resolve, and skips waitFor', () => {
|
|
58
|
+
const s = script();
|
|
59
|
+
s.shots[0].actions.push({ type: 'click', selector: '#buy' }, { type: 'waitFor', selector: '.ready' });
|
|
60
|
+
const sel = selectorsFor(s.shots[0]);
|
|
61
|
+
const found = sel.map((x) => x.selector);
|
|
62
|
+
assert.ok(found.includes('#buy'), `expected #buy, got ${JSON.stringify(found)}`);
|
|
63
|
+
// waitFor is a condition that may legitimately not exist yet — resolving it up front
|
|
64
|
+
// would fail a script that is correct.
|
|
65
|
+
assert.ok(!found.includes('.ready'), 'waitFor selectors must not be pre-resolved');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('parseViewport reads a size and refuses nonsense', () => {
|
|
69
|
+
const v = parseViewport('800x600');
|
|
70
|
+
assert.equal(v.width, 800); assert.equal(v.height, 600);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (fails.length) { console.error(`\n ${fails.length} failure(s)\n`); process.exit(1); }
|
|
74
|
+
console.log('\n all reel checks passed\n');
|