versioncam 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/LICENSE.md +105 -0
- package/README.md +463 -0
- package/bin/versioncam.js +29 -0
- package/dist/.types-render/render-page/draw.d.ts +28 -0
- package/dist/.types-render/render-page/main.d.ts +24 -0
- package/dist/.types-render/render-page/theme.d.ts +37 -0
- package/dist/app-server.d.ts +59 -0
- package/dist/app-server.js +328 -0
- package/dist/app-server.js.map +1 -0
- package/dist/cli/app.d.ts +13 -0
- package/dist/cli/app.js +21 -0
- package/dist/cli/app.js.map +1 -0
- package/dist/cli/commands/check.d.ts +8 -0
- package/dist/cli/commands/check.js +51 -0
- package/dist/cli/commands/check.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +8 -0
- package/dist/cli/commands/doctor.js +130 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/dsl.d.ts +16 -0
- package/dist/cli/commands/dsl.js +22 -0
- package/dist/cli/commands/dsl.js.map +1 -0
- package/dist/cli/commands/frame.d.ts +8 -0
- package/dist/cli/commands/frame.js +72 -0
- package/dist/cli/commands/frame.js.map +1 -0
- package/dist/cli/commands/init.d.ts +23 -0
- package/dist/cli/commands/init.js +109 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/inspect.d.ts +1 -0
- package/dist/cli/commands/inspect.js +32 -0
- package/dist/cli/commands/inspect.js.map +1 -0
- package/dist/cli/commands/install.d.ts +33 -0
- package/dist/cli/commands/install.js +66 -0
- package/dist/cli/commands/install.js.map +1 -0
- package/dist/cli/commands/login.d.ts +10 -0
- package/dist/cli/commands/login.js +49 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/commands/measure.d.ts +1 -0
- package/dist/cli/commands/measure.js +36 -0
- package/dist/cli/commands/measure.js.map +1 -0
- package/dist/cli/commands/open-app.d.ts +14 -0
- package/dist/cli/commands/open-app.js +45 -0
- package/dist/cli/commands/open-app.js.map +1 -0
- package/dist/cli/commands/preview.d.ts +8 -0
- package/dist/cli/commands/preview.js +55 -0
- package/dist/cli/commands/preview.js.map +1 -0
- package/dist/cli/commands/record.d.ts +10 -0
- package/dist/cli/commands/record.js +86 -0
- package/dist/cli/commands/record.js.map +1 -0
- package/dist/cli/commands/render.d.ts +1 -0
- package/dist/cli/commands/render.js +93 -0
- package/dist/cli/commands/render.js.map +1 -0
- package/dist/cli/commands/review.d.ts +6 -0
- package/dist/cli/commands/review.js +89 -0
- package/dist/cli/commands/review.js.map +1 -0
- package/dist/cli/commands/sheet.d.ts +1 -0
- package/dist/cli/commands/sheet.js +48 -0
- package/dist/cli/commands/sheet.js.map +1 -0
- package/dist/cli/commands/stability.d.ts +14 -0
- package/dist/cli/commands/stability.js +110 -0
- package/dist/cli/commands/stability.js.map +1 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +69 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/usage.d.ts +10 -0
- package/dist/cli/usage.js +46 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/config.d.ts +250 -0
- package/dist/config.js +154 -0
- package/dist/config.js.map +1 -0
- package/dist/core/camera.d.ts +30 -0
- package/dist/core/camera.js +94 -0
- package/dist/core/camera.js.map +1 -0
- package/dist/core/compose.d.ts +38 -0
- package/dist/core/compose.js +81 -0
- package/dist/core/compose.js.map +1 -0
- package/dist/core/cursor.d.ts +38 -0
- package/dist/core/cursor.js +103 -0
- package/dist/core/cursor.js.map +1 -0
- package/dist/core/easing.d.ts +15 -0
- package/dist/core/easing.js +33 -0
- package/dist/core/easing.js.map +1 -0
- package/dist/core/loop.d.ts +30 -0
- package/dist/core/loop.js +96 -0
- package/dist/core/loop.js.map +1 -0
- package/dist/core/motion-defaults.d.ts +61 -0
- package/dist/core/motion-defaults.js +62 -0
- package/dist/core/motion-defaults.js.map +1 -0
- package/dist/core/rng.d.ts +13 -0
- package/dist/core/rng.js +27 -0
- package/dist/core/rng.js.map +1 -0
- package/dist/core/sse.d.ts +15 -0
- package/dist/core/sse.js +16 -0
- package/dist/core/sse.js.map +1 -0
- package/dist/core/timeline.d.ts +146 -0
- package/dist/core/timeline.js +81 -0
- package/dist/core/timeline.js.map +1 -0
- package/dist/core/timing.d.ts +31 -0
- package/dist/core/timing.js +29 -0
- package/dist/core/timing.js.map +1 -0
- package/dist/core/typing.d.ts +12 -0
- package/dist/core/typing.js +35 -0
- package/dist/core/typing.js.map +1 -0
- package/dist/driver/clip.d.ts +71 -0
- package/dist/driver/clip.js +120 -0
- package/dist/driver/clip.js.map +1 -0
- package/dist/driver/compare.d.ts +34 -0
- package/dist/driver/compare.js +40 -0
- package/dist/driver/compare.js.map +1 -0
- package/dist/driver/gate.d.ts +36 -0
- package/dist/driver/gate.js +27 -0
- package/dist/driver/gate.js.map +1 -0
- package/dist/driver/launch.d.ts +43 -0
- package/dist/driver/launch.js +47 -0
- package/dist/driver/launch.js.map +1 -0
- package/dist/driver/page-hooks.d.ts +72 -0
- package/dist/driver/page-hooks.js +129 -0
- package/dist/driver/page-hooks.js.map +1 -0
- package/dist/driver/reports.d.ts +34 -0
- package/dist/driver/reports.js +42 -0
- package/dist/driver/reports.js.map +1 -0
- package/dist/driver/session.d.ts +285 -0
- package/dist/driver/session.js +773 -0
- package/dist/driver/session.js.map +1 -0
- package/dist/driver/settle.d.ts +41 -0
- package/dist/driver/settle.js +82 -0
- package/dist/driver/settle.js.map +1 -0
- package/dist/env.d.ts +11 -0
- package/dist/env.js +41 -0
- package/dist/env.js.map +1 -0
- package/dist/fixtures.d.ts +13 -0
- package/dist/fixtures.js +13 -0
- package/dist/fixtures.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect/inspect.d.ts +70 -0
- package/dist/inspect/inspect.js +176 -0
- package/dist/inspect/inspect.js.map +1 -0
- package/dist/inspect/measure.d.ts +40 -0
- package/dist/inspect/measure.js +107 -0
- package/dist/inspect/measure.js.map +1 -0
- package/dist/loader.d.ts +28 -0
- package/dist/loader.js +143 -0
- package/dist/loader.js.map +1 -0
- package/dist/page/assets/index-DUom5amc.js +1 -0
- package/dist/page/index.html +18 -0
- package/dist/render/encode.d.ts +40 -0
- package/dist/render/encode.js +183 -0
- package/dist/render/encode.js.map +1 -0
- package/dist/render/ffmpeg.d.ts +13 -0
- package/dist/render/ffmpeg.js +72 -0
- package/dist/render/ffmpeg.js.map +1 -0
- package/dist/render/presentation.d.ts +19 -0
- package/dist/render/presentation.js +27 -0
- package/dist/render/presentation.js.map +1 -0
- package/dist/render/render.d.ts +59 -0
- package/dist/render/render.js +144 -0
- package/dist/render/render.js.map +1 -0
- package/dist/render/sampling.d.ts +47 -0
- package/dist/render/sampling.js +129 -0
- package/dist/render/sampling.js.map +1 -0
- package/dist/render/sequence.d.ts +24 -0
- package/dist/render/sequence.js +105 -0
- package/dist/render/sequence.js.map +1 -0
- package/dist/render/serve.d.ts +35 -0
- package/dist/render/serve.js +124 -0
- package/dist/render/serve.js.map +1 -0
- package/dist/review/review.d.ts +54 -0
- package/dist/review/review.js +229 -0
- package/dist/review/review.js.map +1 -0
- package/dist/scene.d.ts +23 -0
- package/dist/scene.js +2 -0
- package/dist/scene.js.map +1 -0
- package/dsl.md +119 -0
- package/package.json +76 -0
- package/plugin/.claude-plugin/plugin.json +9 -0
- package/plugin/README.md +105 -0
- package/plugin/agents/versioncam-reviewer.md +63 -0
- package/plugin/skills/versioncam/SKILL.md +235 -0
- package/plugin/skills/versioncam/authoring.md +226 -0
- package/plugin/skills/versioncam/onboarding.md +199 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { FREEZE_CSS_SCRIPT, MUTATION_COUNTER_SCRIPT } from "./page-hooks.js";
|
|
4
|
+
import { writeFailure, writeSettles } from "./reports.js";
|
|
5
|
+
import { RecordingSession } from "./session.js";
|
|
6
|
+
/**
|
|
7
|
+
* Declare a clip.
|
|
8
|
+
*
|
|
9
|
+
* This only describes one — running it is `runClip`, which the CLI does. The
|
|
10
|
+
* prototype registered a Playwright test here instead, which worked but meant
|
|
11
|
+
* every way of recording had to go through a test runner.
|
|
12
|
+
*/
|
|
13
|
+
export function clip(id, options, body) {
|
|
14
|
+
return { id, options, body };
|
|
15
|
+
}
|
|
16
|
+
/** The app's commit, for the recording's provenance. Best effort. */
|
|
17
|
+
function appCommit(cwd) {
|
|
18
|
+
try {
|
|
19
|
+
return execFileSync("git", ["rev-parse", "--short", "HEAD"], {
|
|
20
|
+
cwd,
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
23
|
+
}).trim();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "unknown";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function runClip(browser, config, definition, runOptions = {}) {
|
|
30
|
+
const { id, options, body } = definition;
|
|
31
|
+
const draft = runOptions.draft ?? false;
|
|
32
|
+
const viewport = options.viewport ?? config.viewport;
|
|
33
|
+
const dpr = options.dpr ?? config.dpr;
|
|
34
|
+
// A draft ignores a clip's own frame rate: the point is to be cheap, and a
|
|
35
|
+
// clip asking for 60 would quietly cost four times what the flag promises.
|
|
36
|
+
const fps = draft ? 15 : (options.fps ?? config.fps);
|
|
37
|
+
const capture = { ...config.capture, ...options.capture };
|
|
38
|
+
const context = await browser.newContext({
|
|
39
|
+
viewport,
|
|
40
|
+
deviceScaleFactor: dpr,
|
|
41
|
+
// The app should render its normal motion design; the freeze script is
|
|
42
|
+
// narrower than reduced-motion and does not change layout.
|
|
43
|
+
reducedMotion: "no-preference",
|
|
44
|
+
// A fixed timezone and locale keep any rendered date identical run to run.
|
|
45
|
+
timezoneId: config.clock.timezoneId,
|
|
46
|
+
locale: config.clock.locale,
|
|
47
|
+
storageState: config.auth?.storageState,
|
|
48
|
+
});
|
|
49
|
+
const page = await context.newPage();
|
|
50
|
+
await page.addInitScript(MUTATION_COUNTER_SCRIPT);
|
|
51
|
+
await page.addInitScript(FREEZE_CSS_SCRIPT);
|
|
52
|
+
// Paused, not just installed. `clock.install()` leaves Playwright's fake
|
|
53
|
+
// clock running on real time between the driver's own `runFor` calls, so
|
|
54
|
+
// every screenshot, every probe and every stall of the machine reached the
|
|
55
|
+
// page as time: on the Linux runner one slow frame moved the page's clock
|
|
56
|
+
// 840 ms instead of 67, and the example app's 800 ms timer fired seven
|
|
57
|
+
// frames early. Paused, it moves only when the driver moves it — 1/fps a
|
|
58
|
+
// frame, a tick at a time while it waits — and it starts at exactly
|
|
59
|
+
// `clock.time` instead of that plus however long the navigation took.
|
|
60
|
+
await page.clock.pauseAt(config.clock.time);
|
|
61
|
+
// A dev server broadcasts a full page reload to every connected client when
|
|
62
|
+
// it cannot hot-update a changed module, including a page being recorded.
|
|
63
|
+
// Editing any file mid-run therefore replaces the document, which otherwise
|
|
64
|
+
// surfaces as an inscrutable "Execution context was destroyed" from
|
|
65
|
+
// whichever evaluate lost the race.
|
|
66
|
+
//
|
|
67
|
+
// Counted from "load" rather than "framenavigated": the latter also fires
|
|
68
|
+
// for History API pushes, and an app that routes client-side would report
|
|
69
|
+
// navigations that never replaced anything.
|
|
70
|
+
let documentLoads = 0;
|
|
71
|
+
page.on("load", () => {
|
|
72
|
+
documentLoads += 1;
|
|
73
|
+
});
|
|
74
|
+
const dir = join(config.recordingsDir, id);
|
|
75
|
+
const session = new RecordingSession(page, {
|
|
76
|
+
clipId: id,
|
|
77
|
+
title: options.title,
|
|
78
|
+
fps,
|
|
79
|
+
viewport,
|
|
80
|
+
dpr,
|
|
81
|
+
seed: options.seed ?? 1,
|
|
82
|
+
outputDir: dir,
|
|
83
|
+
appCommit: appCommit(config.root),
|
|
84
|
+
capture,
|
|
85
|
+
draft,
|
|
86
|
+
config,
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
await body(session);
|
|
90
|
+
if (documentLoads > 1) {
|
|
91
|
+
throw new Error(`The document reloaded ${documentLoads - 1} time(s) during "${id}". ` +
|
|
92
|
+
"The usual cause is editing a file while recording: a dev server " +
|
|
93
|
+
"pushes a full reload to every open client. Re-record without " +
|
|
94
|
+
"touching the working tree.");
|
|
95
|
+
}
|
|
96
|
+
const timeline = session.finish();
|
|
97
|
+
writeSettles(dir, session.settles);
|
|
98
|
+
return { timeline, dir, cost: session.cost, settles: session.settles };
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
// Here rather than in the caller, because the context closes below and the
|
|
102
|
+
// page is the one piece of evidence that cannot be recovered afterwards:
|
|
103
|
+
// by the time an error reaches `record`, the app it failed on is gone.
|
|
104
|
+
//
|
|
105
|
+
// Nothing in this block may throw, or it would replace the failure it
|
|
106
|
+
// exists to report — a disk error standing in for the locator that broke.
|
|
107
|
+
try {
|
|
108
|
+
writeSettles(dir, session.settles);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Said by the error on its way up, which is the more useful of the two.
|
|
112
|
+
}
|
|
113
|
+
await writeFailure(page, dir, { message: error.message, ...session.at }, { draft });
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await context.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=clip.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clip.js","sourceRoot":"","sources":["../../src/driver/clip.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Timeline } from "../core/timeline.js";
|
|
2
|
+
/**
|
|
3
|
+
* Are two recordings of the same clip the same video?
|
|
4
|
+
*
|
|
5
|
+
* The README's rule — when a clip will not reproduce, record it twice the
|
|
6
|
+
* same way first — as a function, because two places need the answer: the
|
|
7
|
+
* `stability` command, which asks it of an app before anything is authored
|
|
8
|
+
* for it, and the example app's gate test, which asks it of the recorder.
|
|
9
|
+
*
|
|
10
|
+
* Frame by frame, by what each frame shows: the state image behind it,
|
|
11
|
+
* hashed. Not by state index or state count, which differ between a gated and
|
|
12
|
+
* an ungated recording of an identical video; and not by the timeline, whose
|
|
13
|
+
* cursor, camera and captions are the driver's own decisions and identical by
|
|
14
|
+
* construction. The pixels are what the app drew, so they are what can vary.
|
|
15
|
+
*/
|
|
16
|
+
/** A recording on disk: the directory and the timeline it holds. */
|
|
17
|
+
export type RecordingRef = {
|
|
18
|
+
dir: string;
|
|
19
|
+
timeline: Timeline;
|
|
20
|
+
};
|
|
21
|
+
export type Comparison = {
|
|
22
|
+
/** The longer of the two, so a recording that stops early differs. */
|
|
23
|
+
frames: number;
|
|
24
|
+
identical: number;
|
|
25
|
+
firstDifference: number | null;
|
|
26
|
+
differing: number[];
|
|
27
|
+
};
|
|
28
|
+
/** Read the recording `runClip` wrote into `dir`. */
|
|
29
|
+
export declare function readRecording(dir: string): RecordingRef;
|
|
30
|
+
/** What each frame shows, as a hash of the state image behind it. */
|
|
31
|
+
export declare function frameHashes(recording: RecordingRef): string[];
|
|
32
|
+
/** The state image a frame shows, or null past the recording's end. */
|
|
33
|
+
export declare function stateFileAt(recording: RecordingRef, frame: number): string | null;
|
|
34
|
+
export declare function compareRecordings(a: RecordingRef, b: RecordingRef): Comparison;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** Read the recording `runClip` wrote into `dir`. */
|
|
5
|
+
export function readRecording(dir) {
|
|
6
|
+
const timeline = JSON.parse(readFileSync(join(dir, "timeline.json"), "utf8"));
|
|
7
|
+
return { dir, timeline };
|
|
8
|
+
}
|
|
9
|
+
/** What each frame shows, as a hash of the state image behind it. */
|
|
10
|
+
export function frameHashes(recording) {
|
|
11
|
+
const states = recording.timeline.states.map((name) => createHash("sha1")
|
|
12
|
+
.update(readFileSync(join(recording.dir, "states", name)))
|
|
13
|
+
.digest("hex"));
|
|
14
|
+
return recording.timeline.frames.map((index) => states[index]);
|
|
15
|
+
}
|
|
16
|
+
/** The state image a frame shows, or null past the recording's end. */
|
|
17
|
+
export function stateFileAt(recording, frame) {
|
|
18
|
+
const index = recording.timeline.frames[frame];
|
|
19
|
+
if (index === undefined)
|
|
20
|
+
return null;
|
|
21
|
+
return join(recording.dir, "states", recording.timeline.states[index]);
|
|
22
|
+
}
|
|
23
|
+
export function compareRecordings(a, b) {
|
|
24
|
+
const left = frameHashes(a);
|
|
25
|
+
const right = frameHashes(b);
|
|
26
|
+
const frames = Math.max(left.length, right.length);
|
|
27
|
+
const differing = [];
|
|
28
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
29
|
+
if (left[frame] === undefined || left[frame] !== right[frame]) {
|
|
30
|
+
differing.push(frame);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
frames,
|
|
35
|
+
identical: frames - differing.length,
|
|
36
|
+
firstDifference: differing[0] ?? null,
|
|
37
|
+
differing,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=compare.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compare.js","sourceRoot":"","sources":["../../src/driver/compare.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AA6BjC,qDAAqD;AACrD,MAAM,UAAU,aAAa,CAAC,GAAW;IACvC,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CACzB,YAAY,CAAC,IAAI,CAAC,GAAG,EAAE,eAAe,CAAC,EAAE,MAAM,CAAC,CACrC,CAAC;IACd,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC;AAC3B,CAAC;AAED,qEAAqE;AACrE,MAAM,UAAU,WAAW,CAAC,SAAuB;IACjD,MAAM,MAAM,GAAG,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CACpD,UAAU,CAAC,MAAM,CAAC;SACf,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC;SACzD,MAAM,CAAC,KAAK,CAAC,CACjB,CAAC;IACF,OAAO,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;AACjE,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,WAAW,CACzB,SAAuB,EACvB,KAAa;IAEb,MAAM,KAAK,GAAG,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC/C,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACrC,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,QAAQ,EAAE,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;AACzE,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,CAAe,EACf,CAAe;IAEf,MAAM,IAAI,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC;IAC5B,MAAM,KAAK,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC;IAC7B,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACnD,MAAM,SAAS,GAAa,EAAE,CAAC;IAC/B,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAC/C,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,SAAS,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC;YAC9D,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACxB,CAAC;IACH,CAAC;IACD,OAAO;QACL,MAAM;QACN,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC,MAAM;QACpC,eAAe,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI;QACrC,SAAS;KACV,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A latch the clip opens when it is ready for something to arrive.
|
|
3
|
+
*
|
|
4
|
+
* Apps that stream a response — an assistant answering, a job reporting
|
|
5
|
+
* progress — look wrong in a clip if the reply lands in the frame after the
|
|
6
|
+
* request: it reads as a lookup rather than as work. The clip wants to hold on
|
|
7
|
+
* the app's own pending state for a beat first.
|
|
8
|
+
*
|
|
9
|
+
* That beat has to be measured in *clip* time, not real time. A sleep inside
|
|
10
|
+
* the route handler would resolve after however many frames the machine
|
|
11
|
+
* happened to capture in that interval, which differs per machine and defeats
|
|
12
|
+
* the whole design. So a mocked route waits on the gate, and the clip holds
|
|
13
|
+
* for an authored duration — capturing the pending state while it does — and
|
|
14
|
+
* then opens it.
|
|
15
|
+
*
|
|
16
|
+
* const gate = createGate();
|
|
17
|
+
* await page.route(url, async (route) => {
|
|
18
|
+
* await gate.wait();
|
|
19
|
+
* await route.fulfill({ ... });
|
|
20
|
+
* });
|
|
21
|
+
* // in the clip:
|
|
22
|
+
* await s.press("Enter");
|
|
23
|
+
* await s.hold(1500); // the app's spinner, on screen, for 1.5s of clip
|
|
24
|
+
* gate.open();
|
|
25
|
+
*/
|
|
26
|
+
export type Gate = {
|
|
27
|
+
/** Resolves once the gate is open. Resolves immediately if it already is. */
|
|
28
|
+
wait(): Promise<void>;
|
|
29
|
+
/** Let whatever is waiting through. */
|
|
30
|
+
open(): void;
|
|
31
|
+
/** Close it again, for a clip with more than one turn. */
|
|
32
|
+
close(): void;
|
|
33
|
+
/** Whether something is currently waiting. */
|
|
34
|
+
isHolding(): boolean;
|
|
35
|
+
};
|
|
36
|
+
export declare function createGate(startOpen?: boolean): Gate;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function createGate(startOpen = false) {
|
|
2
|
+
let open = startOpen;
|
|
3
|
+
let waiting = [];
|
|
4
|
+
return {
|
|
5
|
+
wait() {
|
|
6
|
+
if (open)
|
|
7
|
+
return Promise.resolve();
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
waiting.push(resolve);
|
|
10
|
+
});
|
|
11
|
+
},
|
|
12
|
+
open() {
|
|
13
|
+
open = true;
|
|
14
|
+
const pending = waiting;
|
|
15
|
+
waiting = [];
|
|
16
|
+
for (const resolve of pending)
|
|
17
|
+
resolve();
|
|
18
|
+
},
|
|
19
|
+
close() {
|
|
20
|
+
open = false;
|
|
21
|
+
},
|
|
22
|
+
isHolding() {
|
|
23
|
+
return waiting.length > 0;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gate.js","sourceRoot":"","sources":["../../src/driver/gate.ts"],"names":[],"mappings":"AAoCA,MAAM,UAAU,UAAU,CAAC,SAAS,GAAG,KAAK;IAC1C,IAAI,IAAI,GAAG,SAAS,CAAC;IACrB,IAAI,OAAO,GAAmB,EAAE,CAAC;IAEjC,OAAO;QACL,IAAI;YACF,IAAI,IAAI;gBAAE,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;YACnC,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;gBACnC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACxB,CAAC,CAAC,CAAC;QACL,CAAC;QACD,IAAI;YACF,IAAI,GAAG,IAAI,CAAC;YACZ,MAAM,OAAO,GAAG,OAAO,CAAC;YACxB,OAAO,GAAG,EAAE,CAAC;YACb,KAAK,MAAM,OAAO,IAAI,OAAO;gBAAE,OAAO,EAAE,CAAC;QAC3C,CAAC;QACD,KAAK;YACH,IAAI,GAAG,KAAK,CAAC;QACf,CAAC;QACD,SAAS;YACP,OAAO,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;QAC5B,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Browser } from "playwright";
|
|
2
|
+
/**
|
|
3
|
+
* How the recorder launches Chromium, everywhere.
|
|
4
|
+
*
|
|
5
|
+
* One flag, and it is here because of a measurement.
|
|
6
|
+
*
|
|
7
|
+
* `--disable-partial-raster` makes Chromium repaint the whole tile when part
|
|
8
|
+
* of it changes, instead of only the rectangle the page reported as changed.
|
|
9
|
+
* A full recording does not need it: every frame of the example app's clips
|
|
10
|
+
* is byte-identical with and without it, on macOS and on Linux. A draft does.
|
|
11
|
+
* Its CSS-scale screenshot of a page at device pixel ratio 2 has Chromium
|
|
12
|
+
* paint the page a second time, at half the scale, for every shot, and when it
|
|
13
|
+
* can, repaint only what changed since the last one — from rectangles measured
|
|
14
|
+
* at the page's own scale. At half the scale a glyph's antialiased edge can
|
|
15
|
+
* land a pixel outside its rectangle, and whether that pixel was repainted
|
|
16
|
+
* then depends on which tiles Chromium happened to keep, which depends on
|
|
17
|
+
* timing. On the Linux runner, where text has LCD antialiasing, the example
|
|
18
|
+
* app's draft differed at frame 23 in 19 of 32 pairs of runs, by one pixel:
|
|
19
|
+
* the red fringe at the right edge of the "r" just typed into "Mir", drawn or
|
|
20
|
+
* still white. With this flag, 28 pairs of 28 matched on the same runner.
|
|
21
|
+
*
|
|
22
|
+
* The list was empty before that, and the emptiness was a finding too. Chasing
|
|
23
|
+
* a six-pixel difference between two runs of one clip, the obvious suspect was
|
|
24
|
+
* font rasterisation, so these three went in:
|
|
25
|
+
*
|
|
26
|
+
* --disable-lcd-text --disable-font-subpixel-positioning
|
|
27
|
+
* --force-color-profile=srgb
|
|
28
|
+
*
|
|
29
|
+
* They made no difference, because the cause was elsewhere: the screenshot was
|
|
30
|
+
* being taken with Playwright's `animations: "disabled"`, which cancelled the
|
|
31
|
+
* spinner to rotation zero and raced the recorder's own scrub. Once that was
|
|
32
|
+
* fixed, six consecutive runs came out byte-identical with no flags at all.
|
|
33
|
+
*
|
|
34
|
+
* A flag that changes how a correct page looks would change every captured
|
|
35
|
+
* pixel, so the bar for adding one is a measurement showing it fixes
|
|
36
|
+
* something. Keeping this function as the single launch site is the point:
|
|
37
|
+
* there is one place to put such a flag, and one place to explain it.
|
|
38
|
+
*/
|
|
39
|
+
export declare const DETERMINISTIC_ARGS: string[];
|
|
40
|
+
/** Launch Chromium the way every recorder command should. */
|
|
41
|
+
export declare function launchBrowser(options?: {
|
|
42
|
+
headless?: boolean;
|
|
43
|
+
}): Promise<Browser>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
/**
|
|
3
|
+
* How the recorder launches Chromium, everywhere.
|
|
4
|
+
*
|
|
5
|
+
* One flag, and it is here because of a measurement.
|
|
6
|
+
*
|
|
7
|
+
* `--disable-partial-raster` makes Chromium repaint the whole tile when part
|
|
8
|
+
* of it changes, instead of only the rectangle the page reported as changed.
|
|
9
|
+
* A full recording does not need it: every frame of the example app's clips
|
|
10
|
+
* is byte-identical with and without it, on macOS and on Linux. A draft does.
|
|
11
|
+
* Its CSS-scale screenshot of a page at device pixel ratio 2 has Chromium
|
|
12
|
+
* paint the page a second time, at half the scale, for every shot, and when it
|
|
13
|
+
* can, repaint only what changed since the last one — from rectangles measured
|
|
14
|
+
* at the page's own scale. At half the scale a glyph's antialiased edge can
|
|
15
|
+
* land a pixel outside its rectangle, and whether that pixel was repainted
|
|
16
|
+
* then depends on which tiles Chromium happened to keep, which depends on
|
|
17
|
+
* timing. On the Linux runner, where text has LCD antialiasing, the example
|
|
18
|
+
* app's draft differed at frame 23 in 19 of 32 pairs of runs, by one pixel:
|
|
19
|
+
* the red fringe at the right edge of the "r" just typed into "Mir", drawn or
|
|
20
|
+
* still white. With this flag, 28 pairs of 28 matched on the same runner.
|
|
21
|
+
*
|
|
22
|
+
* The list was empty before that, and the emptiness was a finding too. Chasing
|
|
23
|
+
* a six-pixel difference between two runs of one clip, the obvious suspect was
|
|
24
|
+
* font rasterisation, so these three went in:
|
|
25
|
+
*
|
|
26
|
+
* --disable-lcd-text --disable-font-subpixel-positioning
|
|
27
|
+
* --force-color-profile=srgb
|
|
28
|
+
*
|
|
29
|
+
* They made no difference, because the cause was elsewhere: the screenshot was
|
|
30
|
+
* being taken with Playwright's `animations: "disabled"`, which cancelled the
|
|
31
|
+
* spinner to rotation zero and raced the recorder's own scrub. Once that was
|
|
32
|
+
* fixed, six consecutive runs came out byte-identical with no flags at all.
|
|
33
|
+
*
|
|
34
|
+
* A flag that changes how a correct page looks would change every captured
|
|
35
|
+
* pixel, so the bar for adding one is a measurement showing it fixes
|
|
36
|
+
* something. Keeping this function as the single launch site is the point:
|
|
37
|
+
* there is one place to put such a flag, and one place to explain it.
|
|
38
|
+
*/
|
|
39
|
+
export const DETERMINISTIC_ARGS = ["--disable-partial-raster"];
|
|
40
|
+
/** Launch Chromium the way every recorder command should. */
|
|
41
|
+
export function launchBrowser(options = {}) {
|
|
42
|
+
return chromium.launch({
|
|
43
|
+
headless: options.headless ?? true,
|
|
44
|
+
args: DETERMINISTIC_ARGS,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=launch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"launch.js","sourceRoot":"","sources":["../../src/driver/launch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAGtC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAa,CAAC,0BAA0B,CAAC,CAAC;AAEzE,6DAA6D;AAC7D,MAAM,UAAU,aAAa,CAC3B,UAAkC,EAAE;IAEpC,OAAO,QAAQ,CAAC,MAAM,CAAC;QACrB,QAAQ,EAAE,OAAO,CAAC,QAAQ,IAAI,IAAI;QAClC,IAAI,EAAE,kBAAkB;KACzB,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scripts injected into every document the recorder drives.
|
|
3
|
+
*
|
|
4
|
+
* These run at document start, before any app code, and must be
|
|
5
|
+
* self-contained strings — they are evaluated in the page, not here.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Counts DOM mutations so `settle()` can tell "the page is still working"
|
|
9
|
+
* from "the page has finished". Counting is far cheaper than diffing and
|
|
10
|
+
* needs no cooperation from the app.
|
|
11
|
+
*/
|
|
12
|
+
export declare const MUTATION_COUNTER_SCRIPT = "(() => {\n const state = {\n mutations: 0,\n ids: new WeakMap(),\n nextId: 1,\n idOf(el) {\n if (!el) return 0;\n let id = state.ids.get(el);\n if (!id) { id = state.nextId++; state.ids.set(el, id); }\n return id;\n },\n probe(t, x, y) {\n document.documentElement.style.setProperty(\"--recorder-t\", t);\n return {\n mutations: state.mutations,\n hovered: state.idOf(document.elementFromPoint(x, y)),\n animations: document.getAnimations().length,\n };\n },\n };\n window.__recorder = state;\n\n // The driver writes --recorder-t to <html style> on every single frame. Left\n // alone that write is a mutation like any other, so the counter would move\n // every frame and anything built on it \u2014 a settle that waits for quiet, a\n // gate that skips an unchanged frame \u2014 would be reading its own footprint.\n // Anything else that touches <html style>, a scroll lock or a theme switch,\n // still counts.\n const withoutScrub = (value) =>\n (value || \"\").replace(/--recorder-t\\s*:[^;]*;?/g, \"\").trim();\n\n const start = () => {\n const root = document.documentElement;\n const isOurScrub = (record) =>\n record.type === \"attributes\" &&\n record.target === root &&\n record.attributeName === \"style\" &&\n withoutScrub(record.oldValue) === withoutScrub(root.getAttribute(\"style\"));\n\n new MutationObserver((records) => {\n for (const record of records) {\n if (!isOurScrub(record)) state.mutations += 1;\n }\n }).observe(root || document, {\n childList: true,\n attributes: true,\n attributeOldValue: true,\n characterData: true,\n subtree: true,\n });\n };\n if (document.documentElement) start();\n else document.addEventListener(\"DOMContentLoaded\", start, { once: true });\n})();";
|
|
13
|
+
/**
|
|
14
|
+
* Puts the page's CSS motion under the recorder's control.
|
|
15
|
+
*
|
|
16
|
+
* `page.clock` drives timers and requestAnimationFrame, but CSS transitions
|
|
17
|
+
* and animations run on the compositor's own clock, which the fake clock does
|
|
18
|
+
* not touch. Left alone they produce half-finished frames whose content
|
|
19
|
+
* depends on how busy the machine was — the exact non-determinism this
|
|
20
|
+
* recorder exists to remove.
|
|
21
|
+
*
|
|
22
|
+
* Transitions are simply zeroed: a state change should land in the frame that
|
|
23
|
+
* caused it, and the renderer puts a uniform crossfade back in post.
|
|
24
|
+
*
|
|
25
|
+
* Animations are handled differently, because zeroing them is wrong. A
|
|
26
|
+
* loading spinner with `animation-duration: 0s` is a dead spinner, and a clip
|
|
27
|
+
* that pauses on a dead spinner looks broken rather than thoughtful. So every
|
|
28
|
+
* animation is *paused* and then scrubbed to a chosen offset by a negative
|
|
29
|
+
* delay: a paused animation renders the state it would have been in at
|
|
30
|
+
* `-animation-delay`. The driver sets that offset from authored time, once per
|
|
31
|
+
* frame, which makes the page's own animations advance with the clip —
|
|
32
|
+
* deterministically, at exactly the rate the timeline says.
|
|
33
|
+
*
|
|
34
|
+
* `scroll-behavior: auto` is here for the same reason: a smooth-scrolling
|
|
35
|
+
* container animates outside our control.
|
|
36
|
+
*/
|
|
37
|
+
export declare const FREEZE_CSS = "*, *::before, *::after {\n transition-duration: 0s !important;\n transition-delay: 0s !important;\n animation-play-state: paused !important;\n animation-delay: calc(-1s * var(--recorder-t, 0)) !important;\n scroll-behavior: auto !important;\n caret-color: transparent !important;\n}";
|
|
38
|
+
export declare const FREEZE_CSS_SCRIPT: string;
|
|
39
|
+
/**
|
|
40
|
+
* What the driver learns from the page once per frame.
|
|
41
|
+
*
|
|
42
|
+
* One round trip does three jobs: it advances the page's CSS animations to
|
|
43
|
+
* this frame's authored time, and reports the two things that decide whether
|
|
44
|
+
* this frame needs a screenshot.
|
|
45
|
+
*
|
|
46
|
+
* `hovered` is the identity of the element under the cursor, as a number. The
|
|
47
|
+
* cursor glyph is drawn in post, so the pointer moving changes no pixel by
|
|
48
|
+
* itself — what changes pixels is the pointer moving *onto something else*.
|
|
49
|
+
* `elementFromPoint` runs the same hit test a real pointer event does, so when
|
|
50
|
+
* it returns the same element as last frame, the `:hover` chain is the same
|
|
51
|
+
* chain and no hover styling can have changed.
|
|
52
|
+
*
|
|
53
|
+
* `animations` is how many animations are running. A scrubbed animation
|
|
54
|
+
* repaints on every frame without touching the DOM, so while any is on screen
|
|
55
|
+
* — a spinner, a progress bar — every frame is genuinely different and none
|
|
56
|
+
* may be skipped.
|
|
57
|
+
*/
|
|
58
|
+
export declare const FRAME_PROBE: (seconds: number, x: number, y: number) => string;
|
|
59
|
+
export type FrameProbe = {
|
|
60
|
+
mutations: number;
|
|
61
|
+
/** Identity of the element under the cursor; 0 when there is none. */
|
|
62
|
+
hovered: number;
|
|
63
|
+
animations: number;
|
|
64
|
+
};
|
|
65
|
+
declare global {
|
|
66
|
+
interface Window {
|
|
67
|
+
__recorder?: {
|
|
68
|
+
mutations: number;
|
|
69
|
+
probe(t: string, x: number, y: number): FrameProbe;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scripts injected into every document the recorder drives.
|
|
3
|
+
*
|
|
4
|
+
* These run at document start, before any app code, and must be
|
|
5
|
+
* self-contained strings — they are evaluated in the page, not here.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Counts DOM mutations so `settle()` can tell "the page is still working"
|
|
9
|
+
* from "the page has finished". Counting is far cheaper than diffing and
|
|
10
|
+
* needs no cooperation from the app.
|
|
11
|
+
*/
|
|
12
|
+
export const MUTATION_COUNTER_SCRIPT = `(() => {
|
|
13
|
+
const state = {
|
|
14
|
+
mutations: 0,
|
|
15
|
+
ids: new WeakMap(),
|
|
16
|
+
nextId: 1,
|
|
17
|
+
idOf(el) {
|
|
18
|
+
if (!el) return 0;
|
|
19
|
+
let id = state.ids.get(el);
|
|
20
|
+
if (!id) { id = state.nextId++; state.ids.set(el, id); }
|
|
21
|
+
return id;
|
|
22
|
+
},
|
|
23
|
+
probe(t, x, y) {
|
|
24
|
+
document.documentElement.style.setProperty("--recorder-t", t);
|
|
25
|
+
return {
|
|
26
|
+
mutations: state.mutations,
|
|
27
|
+
hovered: state.idOf(document.elementFromPoint(x, y)),
|
|
28
|
+
animations: document.getAnimations().length,
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
window.__recorder = state;
|
|
33
|
+
|
|
34
|
+
// The driver writes --recorder-t to <html style> on every single frame. Left
|
|
35
|
+
// alone that write is a mutation like any other, so the counter would move
|
|
36
|
+
// every frame and anything built on it — a settle that waits for quiet, a
|
|
37
|
+
// gate that skips an unchanged frame — would be reading its own footprint.
|
|
38
|
+
// Anything else that touches <html style>, a scroll lock or a theme switch,
|
|
39
|
+
// still counts.
|
|
40
|
+
const withoutScrub = (value) =>
|
|
41
|
+
(value || "").replace(/--recorder-t\\s*:[^;]*;?/g, "").trim();
|
|
42
|
+
|
|
43
|
+
const start = () => {
|
|
44
|
+
const root = document.documentElement;
|
|
45
|
+
const isOurScrub = (record) =>
|
|
46
|
+
record.type === "attributes" &&
|
|
47
|
+
record.target === root &&
|
|
48
|
+
record.attributeName === "style" &&
|
|
49
|
+
withoutScrub(record.oldValue) === withoutScrub(root.getAttribute("style"));
|
|
50
|
+
|
|
51
|
+
new MutationObserver((records) => {
|
|
52
|
+
for (const record of records) {
|
|
53
|
+
if (!isOurScrub(record)) state.mutations += 1;
|
|
54
|
+
}
|
|
55
|
+
}).observe(root || document, {
|
|
56
|
+
childList: true,
|
|
57
|
+
attributes: true,
|
|
58
|
+
attributeOldValue: true,
|
|
59
|
+
characterData: true,
|
|
60
|
+
subtree: true,
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
if (document.documentElement) start();
|
|
64
|
+
else document.addEventListener("DOMContentLoaded", start, { once: true });
|
|
65
|
+
})();`;
|
|
66
|
+
/**
|
|
67
|
+
* Puts the page's CSS motion under the recorder's control.
|
|
68
|
+
*
|
|
69
|
+
* `page.clock` drives timers and requestAnimationFrame, but CSS transitions
|
|
70
|
+
* and animations run on the compositor's own clock, which the fake clock does
|
|
71
|
+
* not touch. Left alone they produce half-finished frames whose content
|
|
72
|
+
* depends on how busy the machine was — the exact non-determinism this
|
|
73
|
+
* recorder exists to remove.
|
|
74
|
+
*
|
|
75
|
+
* Transitions are simply zeroed: a state change should land in the frame that
|
|
76
|
+
* caused it, and the renderer puts a uniform crossfade back in post.
|
|
77
|
+
*
|
|
78
|
+
* Animations are handled differently, because zeroing them is wrong. A
|
|
79
|
+
* loading spinner with `animation-duration: 0s` is a dead spinner, and a clip
|
|
80
|
+
* that pauses on a dead spinner looks broken rather than thoughtful. So every
|
|
81
|
+
* animation is *paused* and then scrubbed to a chosen offset by a negative
|
|
82
|
+
* delay: a paused animation renders the state it would have been in at
|
|
83
|
+
* `-animation-delay`. The driver sets that offset from authored time, once per
|
|
84
|
+
* frame, which makes the page's own animations advance with the clip —
|
|
85
|
+
* deterministically, at exactly the rate the timeline says.
|
|
86
|
+
*
|
|
87
|
+
* `scroll-behavior: auto` is here for the same reason: a smooth-scrolling
|
|
88
|
+
* container animates outside our control.
|
|
89
|
+
*/
|
|
90
|
+
export const FREEZE_CSS = `*, *::before, *::after {
|
|
91
|
+
transition-duration: 0s !important;
|
|
92
|
+
transition-delay: 0s !important;
|
|
93
|
+
animation-play-state: paused !important;
|
|
94
|
+
animation-delay: calc(-1s * var(--recorder-t, 0)) !important;
|
|
95
|
+
scroll-behavior: auto !important;
|
|
96
|
+
caret-color: transparent !important;
|
|
97
|
+
}`;
|
|
98
|
+
export const FREEZE_CSS_SCRIPT = `(() => {
|
|
99
|
+
const css = ${JSON.stringify(FREEZE_CSS)};
|
|
100
|
+
const inject = () => {
|
|
101
|
+
const style = document.createElement("style");
|
|
102
|
+
style.id = "__recorder_freeze";
|
|
103
|
+
style.textContent = css;
|
|
104
|
+
(document.head || document.documentElement).appendChild(style);
|
|
105
|
+
};
|
|
106
|
+
if (document.head || document.documentElement) inject();
|
|
107
|
+
else document.addEventListener("DOMContentLoaded", inject, { once: true });
|
|
108
|
+
})();`;
|
|
109
|
+
/**
|
|
110
|
+
* What the driver learns from the page once per frame.
|
|
111
|
+
*
|
|
112
|
+
* One round trip does three jobs: it advances the page's CSS animations to
|
|
113
|
+
* this frame's authored time, and reports the two things that decide whether
|
|
114
|
+
* this frame needs a screenshot.
|
|
115
|
+
*
|
|
116
|
+
* `hovered` is the identity of the element under the cursor, as a number. The
|
|
117
|
+
* cursor glyph is drawn in post, so the pointer moving changes no pixel by
|
|
118
|
+
* itself — what changes pixels is the pointer moving *onto something else*.
|
|
119
|
+
* `elementFromPoint` runs the same hit test a real pointer event does, so when
|
|
120
|
+
* it returns the same element as last frame, the `:hover` chain is the same
|
|
121
|
+
* chain and no hover styling can have changed.
|
|
122
|
+
*
|
|
123
|
+
* `animations` is how many animations are running. A scrubbed animation
|
|
124
|
+
* repaints on every frame without touching the DOM, so while any is on screen
|
|
125
|
+
* — a spinner, a progress bar — every frame is genuinely different and none
|
|
126
|
+
* may be skipped.
|
|
127
|
+
*/
|
|
128
|
+
export const FRAME_PROBE = (seconds, x, y) => `window.__recorder.probe("${seconds.toFixed(4)}", ${x.toFixed(2)}, ${y.toFixed(2)})`;
|
|
129
|
+
//# sourceMappingURL=page-hooks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page-hooks.js","sourceRoot":"","sources":["../../src/driver/page-hooks.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MAqDjC,CAAC;AAEP;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;EAOxB,CAAC;AAEH,MAAM,CAAC,MAAM,iBAAiB,GAAG;gBACjB,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC;;;;;;;;;MASpC,CAAC;AAEP;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,OAAe,EAAE,CAAS,EAAE,CAAS,EAAU,EAAE,CAC3E,4BAA4B,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { SettleReport } from "./settle.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a recording leaves behind besides its frames.
|
|
5
|
+
*
|
|
6
|
+
* A clip is written by someone who was not watching it run — increasingly,
|
|
7
|
+
* by something that cannot watch at all — so everything that would have been
|
|
8
|
+
* on screen has to be on disk. Two files: what the waits waited for, and, if
|
|
9
|
+
* it ended badly, the page at the moment it gave up.
|
|
10
|
+
*/
|
|
11
|
+
/** The name a reader looks for, in one place. */
|
|
12
|
+
export declare const SETTLES_FILE = "settles.json";
|
|
13
|
+
export declare const FAILURE_JSON = "failure.json";
|
|
14
|
+
export declare const FAILURE_PNG = "failure.png";
|
|
15
|
+
export declare function writeSettles(dir: string, reports: SettleReport[]): void;
|
|
16
|
+
export type Failure = {
|
|
17
|
+
message: string;
|
|
18
|
+
/** The last frame captured before it gave up; -1 if none was. */
|
|
19
|
+
frame: number;
|
|
20
|
+
/** Authored seconds, which is where in the script to look. */
|
|
21
|
+
t: number;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The failure, then the picture of it.
|
|
25
|
+
*
|
|
26
|
+
* In that order deliberately: the message, the frame and the time can be
|
|
27
|
+
* reconstructed from nothing else, while a screenshot needs a live page and
|
|
28
|
+
* may not be available — a crashed browser, a context already gone. Losing the
|
|
29
|
+
* picture must not lose the rest, and neither may mask the error that caused
|
|
30
|
+
* this to be called in the first place.
|
|
31
|
+
*/
|
|
32
|
+
export declare function writeFailure(page: Page, dir: string, failure: Failure, options?: {
|
|
33
|
+
draft?: boolean;
|
|
34
|
+
}): Promise<void>;
|