agent-screenplay 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +141 -2
- package/assets/editor/index.html +536 -0
- package/assets/fonts/Inter-Bold.ttf +0 -0
- package/assets/fonts/Inter-LICENSE.txt +92 -0
- package/assets/fonts/Inter-Medium.ttf +0 -0
- package/assets/fonts/Inter-Regular.ttf +0 -0
- package/assets/fonts/Inter-SemiBold.ttf +0 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +163 -0
- package/dist/cli.js.map +1 -0
- package/dist/core.d.ts +16 -0
- package/dist/core.js +73 -0
- package/dist/core.js.map +1 -0
- package/dist/direct/plan.d.ts +100 -0
- package/dist/direct/plan.js +274 -0
- package/dist/direct/plan.js.map +1 -0
- package/dist/drivers/browser.d.ts +9 -0
- package/dist/drivers/browser.js +44 -0
- package/dist/drivers/browser.js.map +1 -0
- package/dist/drivers/desktop.d.ts +53 -0
- package/dist/drivers/desktop.js +650 -0
- package/dist/drivers/desktop.js.map +1 -0
- package/dist/drivers/pace.d.ts +11 -0
- package/dist/drivers/pace.js +44 -0
- package/dist/drivers/pace.js.map +1 -0
- package/dist/drivers/web.d.ts +37 -0
- package/dist/drivers/web.js +570 -0
- package/dist/drivers/web.js.map +1 -0
- package/dist/editor/server.d.ts +96 -0
- package/dist/editor/server.js +303 -0
- package/dist/editor/server.js.map +1 -0
- package/dist/ffmpeg.d.ts +5 -0
- package/dist/ffmpeg.js +44 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/guide.d.ts +6 -0
- package/dist/guide.js +25 -0
- package/dist/guide.js.map +1 -0
- package/dist/init.d.ts +19 -0
- package/dist/init.js +147 -0
- package/dist/init.js.map +1 -0
- package/dist/login.d.ts +2 -0
- package/dist/login.js +50 -0
- package/dist/login.js.map +1 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +132 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/render/compose.d.ts +68 -0
- package/dist/render/compose.js +361 -0
- package/dist/render/compose.js.map +1 -0
- package/dist/render/presets.d.ts +42 -0
- package/dist/render/presets.js +111 -0
- package/dist/render/presets.js.map +1 -0
- package/dist/render/render.d.ts +25 -0
- package/dist/render/render.js +257 -0
- package/dist/render/render.js.map +1 -0
- package/dist/render/sources.d.ts +15 -0
- package/dist/render/sources.js +146 -0
- package/dist/render/sources.js.map +1 -0
- package/dist/render/style.d.ts +37 -0
- package/dist/render/style.js +118 -0
- package/dist/render/style.js.map +1 -0
- package/dist/scenario/config.d.ts +43 -0
- package/dist/scenario/config.js +69 -0
- package/dist/scenario/config.js.map +1 -0
- package/dist/scenario/fingerprint.d.ts +10 -0
- package/dist/scenario/fingerprint.js +58 -0
- package/dist/scenario/fingerprint.js.map +1 -0
- package/dist/scenario/load.d.ts +36 -0
- package/dist/scenario/load.js +101 -0
- package/dist/scenario/load.js.map +1 -0
- package/dist/scenario/schema.d.ts +553 -0
- package/dist/scenario/schema.js +262 -0
- package/dist/scenario/schema.js.map +1 -0
- package/dist/scenario/target.d.ts +26 -0
- package/dist/scenario/target.js +40 -0
- package/dist/scenario/target.js.map +1 -0
- package/dist/take.d.ts +100 -0
- package/dist/take.js +15 -0
- package/dist/take.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/dist/voice/cartesia.d.ts +12 -0
- package/dist/voice/cartesia.js +103 -0
- package/dist/voice/cartesia.js.map +1 -0
- package/package.json +65 -4
- package/schema/scenario.schema.json +1098 -0
- package/skills/screenplay/SKILL.md +127 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { describeStep, stepKind } from "../scenario/schema.js";
|
|
7
|
+
import { parseTarget } from "../scenario/target.js";
|
|
8
|
+
import { captureFfmpegPath } from "../ffmpeg.js";
|
|
9
|
+
import { cardDuration, travelDuration, typingDelays } from "./pace.js";
|
|
10
|
+
import { StepError, sleep } from "./web.js";
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
export class AgentDesktop {
|
|
14
|
+
bin;
|
|
15
|
+
session;
|
|
16
|
+
constructor() {
|
|
17
|
+
this.bin = AgentDesktop.locate();
|
|
18
|
+
}
|
|
19
|
+
static locate() {
|
|
20
|
+
if (process.env.SCREENPLAY_AGENT_DESKTOP)
|
|
21
|
+
return process.env.SCREENPLAY_AGENT_DESKTOP;
|
|
22
|
+
try {
|
|
23
|
+
const pkg = require.resolve("agent-desktop/package.json");
|
|
24
|
+
const dir = path.dirname(pkg);
|
|
25
|
+
const meta = JSON.parse(fs.readFileSync(pkg, "utf8"));
|
|
26
|
+
const rel = typeof meta.bin === "string" ? meta.bin : meta.bin?.["agent-desktop"];
|
|
27
|
+
if (rel)
|
|
28
|
+
return path.join(dir, rel);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* not installed as a dependency */
|
|
32
|
+
}
|
|
33
|
+
const probe = spawnSync(process.platform === "win32" ? "where" : "which", ["agent-desktop"], { encoding: "utf8" });
|
|
34
|
+
if (probe.status === 0)
|
|
35
|
+
return probe.stdout.split(/\r?\n/)[0].trim();
|
|
36
|
+
throw new Error("Desktop recording needs agent-desktop: run `npm install -g agent-desktop` (https://github.com/vercel-labs/agent-desktop).");
|
|
37
|
+
}
|
|
38
|
+
async call(args, opts = {}) {
|
|
39
|
+
const env = { ...process.env, ...(this.session ? { AGENT_DESKTOP_SESSION: this.session } : {}) };
|
|
40
|
+
const isJs = this.bin.endsWith(".js");
|
|
41
|
+
const cmd = isJs ? process.execPath : this.bin;
|
|
42
|
+
const argv = isJs ? [this.bin, ...args] : args;
|
|
43
|
+
let stdout = "";
|
|
44
|
+
try {
|
|
45
|
+
const r = await run(cmd, argv, { env, maxBuffer: 32 * 1024 * 1024 });
|
|
46
|
+
stdout = r.stdout;
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
stdout = e.stdout ?? "";
|
|
50
|
+
if (!stdout)
|
|
51
|
+
throw new Error(`agent-desktop ${args[0]} failed: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
const env2 = JSON.parse(stdout.trim().split("\n").pop() || "{}");
|
|
54
|
+
if (!env2.ok && !opts.allowFail) {
|
|
55
|
+
throw new Error(`agent-desktop ${args[0]}: ${env2.error?.code ?? "ERROR"} ${env2.error?.message ?? ""}`.trim());
|
|
56
|
+
}
|
|
57
|
+
return env2;
|
|
58
|
+
}
|
|
59
|
+
async startSession() {
|
|
60
|
+
const r = await this.call(["session", "start", "--name", "screenplay", "--no-trace"]);
|
|
61
|
+
this.session = r.data.session_id;
|
|
62
|
+
}
|
|
63
|
+
async endSession() {
|
|
64
|
+
if (this.session)
|
|
65
|
+
await this.call(["session", "end", this.session], { allowFail: true }).catch(() => { });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The role/name part of an `agent-desktop find` query for a scenario target. */
|
|
69
|
+
export function findQuery(target) {
|
|
70
|
+
const t = parseTarget(target);
|
|
71
|
+
switch (t.kind) {
|
|
72
|
+
case "role":
|
|
73
|
+
return ["--role", t.role, ...(t.name ? ["--name", t.name, "--exact"] : []), "--first"];
|
|
74
|
+
case "text":
|
|
75
|
+
return ["--name", t.text, "--first"];
|
|
76
|
+
case "label":
|
|
77
|
+
case "placeholder":
|
|
78
|
+
return ["--name", t.text, "--first"];
|
|
79
|
+
case "testid":
|
|
80
|
+
return ["--name", t.id, "--first"];
|
|
81
|
+
case "css":
|
|
82
|
+
throw new Error(`css= targets work on websites only, not in desktop apps ("${target}").`);
|
|
83
|
+
case "at":
|
|
84
|
+
throw new Error("Coordinates have no element.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Translate a scenario target into `agent-desktop find` arguments. With a window id, the search
|
|
89
|
+
* stays in that window, so another window of the same app is never clicked or typed into.
|
|
90
|
+
*/
|
|
91
|
+
export function findArgs(app, target, windowId) {
|
|
92
|
+
return ["find", "--app", app, ...(windowId ? ["--window-id", windowId] : []), ...findQuery(target)];
|
|
93
|
+
}
|
|
94
|
+
class DesktopSession {
|
|
95
|
+
screen;
|
|
96
|
+
ad = new AgentDesktop();
|
|
97
|
+
/** The app steps act in when they name none. With `screen`, `open` and a step's `app` change it. */
|
|
98
|
+
app;
|
|
99
|
+
/** The filmed area in screen coordinates: the app's window, or the whole main display. */
|
|
100
|
+
frame;
|
|
101
|
+
/** Window recordings: the filmed window. */
|
|
102
|
+
win;
|
|
103
|
+
scale = 1;
|
|
104
|
+
constructor(app, screen) {
|
|
105
|
+
this.screen = screen;
|
|
106
|
+
this.app = app;
|
|
107
|
+
}
|
|
108
|
+
async open(log, opts = {}) {
|
|
109
|
+
if (process.platform === "linux" && !process.env.DISPLAY) {
|
|
110
|
+
throw new Error(process.env.WAYLAND_DISPLAY
|
|
111
|
+
? "Desktop recording on Linux needs an X11 session (Wayland blocks screen capture and input by design). Log in with an X11 session, or record a website instead."
|
|
112
|
+
: "No display: desktop recording needs a graphical session (DISPLAY is not set).");
|
|
113
|
+
}
|
|
114
|
+
await this.ad.startSession();
|
|
115
|
+
if (this.app)
|
|
116
|
+
await this.activate(this.app, log);
|
|
117
|
+
const displays = (await this.ad.call(["list-displays"])).data ?? [];
|
|
118
|
+
if (this.screen) {
|
|
119
|
+
const disp = displays.find((d) => d.is_primary) ?? displays[0];
|
|
120
|
+
if (!disp)
|
|
121
|
+
throw new Error("No display found to film.");
|
|
122
|
+
this.frame = disp.bounds;
|
|
123
|
+
this.scale = disp.scale ?? 1;
|
|
124
|
+
if (opts.hideOthers) {
|
|
125
|
+
if (process.platform !== "darwin")
|
|
126
|
+
log("hideOthers works on macOS only: other windows stay visible.");
|
|
127
|
+
else if (!this.app)
|
|
128
|
+
throw new Error('hideOthers needs `app`: the app to keep (Hide Others is run from it).');
|
|
129
|
+
else {
|
|
130
|
+
await this.ad.call(["press", "--app", this.app, "cmd+alt+h"]);
|
|
131
|
+
await sleep(500);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
this.win = await this.frontWindow(this.app);
|
|
137
|
+
this.frame = this.win.bounds;
|
|
138
|
+
const b = this.frame;
|
|
139
|
+
const disp = displays.find((d) => b.x >= d.bounds.x && b.x < d.bounds.x + d.bounds.width) ?? displays[0];
|
|
140
|
+
this.scale = disp?.scale ?? 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Launch the app, or bring it to the front; it becomes the app the next steps act in. */
|
|
144
|
+
async activate(app, log) {
|
|
145
|
+
const apps = await this.ad.call(["list-apps"]);
|
|
146
|
+
const match = apps.data.apps.find((a) => a.name.toLowerCase() === app.toLowerCase());
|
|
147
|
+
if (!match) {
|
|
148
|
+
log(`Launching ${app}`);
|
|
149
|
+
await this.ad.call(["launch", app, "--activate"]);
|
|
150
|
+
await sleep(1500);
|
|
151
|
+
this.app = app;
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
this.app = match.name;
|
|
155
|
+
const win = await this.frontWindow(this.app).catch(() => undefined);
|
|
156
|
+
if (win)
|
|
157
|
+
await this.ad.call(["--headed", "focus-window", "--window-id", win.id], { allowFail: true });
|
|
158
|
+
else if (match)
|
|
159
|
+
await this.ad.call(["launch", this.app, "--activate"], { allowFail: true });
|
|
160
|
+
}
|
|
161
|
+
async frontWindow(app) {
|
|
162
|
+
const wins = await this.ad.call(["list-windows", "--app", app]);
|
|
163
|
+
const list = (wins.data ?? []).filter((w) => {
|
|
164
|
+
const f = w;
|
|
165
|
+
return f.visible !== false && !f.minimized;
|
|
166
|
+
});
|
|
167
|
+
if (!list.length)
|
|
168
|
+
throw new Error(`${app} has no visible window.`);
|
|
169
|
+
return list.find((w) => w.is_focused) ?? list[0];
|
|
170
|
+
}
|
|
171
|
+
/** The app a step acts in. */
|
|
172
|
+
appFor(where) {
|
|
173
|
+
const app = where.app ?? this.app;
|
|
174
|
+
if (!app)
|
|
175
|
+
throw new Error('Name the app this step acts in: add "app" to the step, or "open" it first.');
|
|
176
|
+
if (!this.screen && app.toLowerCase() !== this.app.toLowerCase()) {
|
|
177
|
+
throw new Error(`This recording films ${this.app}'s window only. To act in ${app} too, film the whole screen with "screen": true.`);
|
|
178
|
+
}
|
|
179
|
+
return app;
|
|
180
|
+
}
|
|
181
|
+
/** `find` arguments that limit the search to where the step acts. */
|
|
182
|
+
async scope(where) {
|
|
183
|
+
const app = this.appFor(where);
|
|
184
|
+
const surface = where.surface ?? "window";
|
|
185
|
+
if (surface !== "window")
|
|
186
|
+
return ["--app", app, "--surface", surface];
|
|
187
|
+
const id = this.screen ? (await this.frontWindow(app)).id : this.win.id;
|
|
188
|
+
return ["--app", app, "--window-id", id];
|
|
189
|
+
}
|
|
190
|
+
async resolve(target, where = {}, timeoutMs = 15_000) {
|
|
191
|
+
const t = parseTarget(target);
|
|
192
|
+
if (t.kind === "at")
|
|
193
|
+
return { box: { x: t.x - 12, y: t.y - 12, width: 24, height: 24 } };
|
|
194
|
+
const query = findQuery(target);
|
|
195
|
+
const deadline = Date.now() + timeoutMs;
|
|
196
|
+
let last = "";
|
|
197
|
+
while (true) {
|
|
198
|
+
let r;
|
|
199
|
+
try {
|
|
200
|
+
r = await this.ad.call(["find", ...(await this.scope(where)), ...query], { allowFail: true });
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
// The app may still be opening its window.
|
|
204
|
+
if (!/has no visible window/.test(e.message) || Date.now() > deadline)
|
|
205
|
+
throw e;
|
|
206
|
+
await sleep(250);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const m = r.ok ? r.data?.match : undefined;
|
|
210
|
+
if (m) {
|
|
211
|
+
let b = m.bounds;
|
|
212
|
+
if (m.ref_id) {
|
|
213
|
+
const g = await this.ad.call(["get", m.ref_id, "--property", "bounds"], { allowFail: true });
|
|
214
|
+
b = g.data?.value ?? b;
|
|
215
|
+
}
|
|
216
|
+
if (b) {
|
|
217
|
+
return {
|
|
218
|
+
ref: m.ref_id ?? undefined,
|
|
219
|
+
box: { x: b.x - this.frame.x, y: b.y - this.frame.y, width: b.width, height: b.height },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
last = r.error?.message ?? "not found";
|
|
224
|
+
if (Date.now() > deadline)
|
|
225
|
+
break;
|
|
226
|
+
await sleep(250);
|
|
227
|
+
}
|
|
228
|
+
const app = this.appFor(where);
|
|
229
|
+
const place = where.surface && where.surface !== "window" ? `${app}'s ${where.surface}` : app;
|
|
230
|
+
throw new StepError(-1, target, `nothing in ${place} matches "${target}" (${last}).`, await this.candidates(where));
|
|
231
|
+
}
|
|
232
|
+
async candidates(where = {}) {
|
|
233
|
+
const out = [];
|
|
234
|
+
const scope = await this.scope(where).catch(() => undefined);
|
|
235
|
+
if (!scope)
|
|
236
|
+
return out;
|
|
237
|
+
for (const role of ["button", "textfield", "link", "checkbox", "tab", "menuitem"]) {
|
|
238
|
+
const r = await this.ad.call(["find", ...scope, "--role", role, "--limit", "15"], {
|
|
239
|
+
allowFail: true,
|
|
240
|
+
});
|
|
241
|
+
for (const m of r.data?.matches ?? [])
|
|
242
|
+
if (m.name)
|
|
243
|
+
out.push(`role=${m.role}[name='${m.name.replace(/'/g, "\\'")}']`);
|
|
244
|
+
}
|
|
245
|
+
return out.slice(0, 40);
|
|
246
|
+
}
|
|
247
|
+
async clickAt(res, opts = {}) {
|
|
248
|
+
if (res.ref) {
|
|
249
|
+
const cmd = opts.right ? "right-click" : opts.double ? "double-click" : "click";
|
|
250
|
+
const headed = cmd === "double-click" || cmd === "right-click" ? ["--headed"] : [];
|
|
251
|
+
const r = await this.ad.call([...headed, cmd, res.ref], { allowFail: !headed.length });
|
|
252
|
+
// Some elements (a text area on macOS) have no click action: click them with the real pointer.
|
|
253
|
+
// The system cursor is not filmed, so this does not show in the video.
|
|
254
|
+
if (!r.ok) {
|
|
255
|
+
if (r.error?.code !== "POLICY_DENIED")
|
|
256
|
+
throw new Error(`agent-desktop ${cmd}: ${r.error?.code ?? "ERROR"} ${r.error?.message ?? ""}`.trim());
|
|
257
|
+
await this.ad.call(["--headed", cmd, res.ref]);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const x = Math.round(this.frame.x + res.box.x + res.box.width / 2);
|
|
262
|
+
const y = Math.round(this.frame.y + res.box.y + res.box.height / 2);
|
|
263
|
+
await this.ad.call(["--headed", "mouse-click", "--xy", `${x},${y}`]);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async press(combo, where = {}) {
|
|
267
|
+
await this.ad.call(["press", "--app", this.appFor(where), normalizeCombo(combo)]);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export function normalizeCombo(combo) {
|
|
271
|
+
const mac = process.platform === "darwin";
|
|
272
|
+
return combo
|
|
273
|
+
.split("+")
|
|
274
|
+
.map((k) => {
|
|
275
|
+
const l = k.trim().toLowerCase();
|
|
276
|
+
if (l === "meta" || l === "command" || l === "cmd")
|
|
277
|
+
return mac ? "cmd" : "super";
|
|
278
|
+
if (l === "control")
|
|
279
|
+
return "ctrl";
|
|
280
|
+
if (l === "enter")
|
|
281
|
+
return "return";
|
|
282
|
+
if (l === "esc")
|
|
283
|
+
return "escape";
|
|
284
|
+
if (l === " ")
|
|
285
|
+
return "space";
|
|
286
|
+
return l;
|
|
287
|
+
})
|
|
288
|
+
.join("+");
|
|
289
|
+
}
|
|
290
|
+
/** Films a screen region with ffmpeg, without the system cursor. */
|
|
291
|
+
class Recorder {
|
|
292
|
+
file;
|
|
293
|
+
proc;
|
|
294
|
+
startedAt = 0;
|
|
295
|
+
fps = 30;
|
|
296
|
+
crop = { x: 0, y: 0, width: 0, height: 0 };
|
|
297
|
+
constructor(file) {
|
|
298
|
+
this.file = file;
|
|
299
|
+
}
|
|
300
|
+
async start(area, scale, t0) {
|
|
301
|
+
const bin = captureFfmpegPath();
|
|
302
|
+
const W = Math.round(area.width * scale) & ~1;
|
|
303
|
+
const H = Math.round(area.height * scale) & ~1;
|
|
304
|
+
let input;
|
|
305
|
+
if (process.platform === "linux") {
|
|
306
|
+
const x = Math.round(area.x * scale);
|
|
307
|
+
const y = Math.round(area.y * scale);
|
|
308
|
+
input = ["-f", "x11grab", "-draw_mouse", "0", "-framerate", String(this.fps), "-video_size", `${W}x${H}`, "-i", `${process.env.DISPLAY}+${x},${y}`];
|
|
309
|
+
this.crop = { x: 0, y: 0, width: W, height: H };
|
|
310
|
+
}
|
|
311
|
+
else if (process.platform === "win32") {
|
|
312
|
+
input = [
|
|
313
|
+
"-f",
|
|
314
|
+
"gdigrab",
|
|
315
|
+
"-draw_mouse",
|
|
316
|
+
"0",
|
|
317
|
+
"-framerate",
|
|
318
|
+
String(this.fps),
|
|
319
|
+
"-offset_x",
|
|
320
|
+
String(Math.round(area.x * scale)),
|
|
321
|
+
"-offset_y",
|
|
322
|
+
String(Math.round(area.y * scale)),
|
|
323
|
+
"-video_size",
|
|
324
|
+
`${W}x${H}`,
|
|
325
|
+
"-i",
|
|
326
|
+
"desktop",
|
|
327
|
+
];
|
|
328
|
+
this.crop = { x: 0, y: 0, width: W, height: H };
|
|
329
|
+
}
|
|
330
|
+
else if (process.platform === "darwin") {
|
|
331
|
+
const screen = macScreenIndex(bin);
|
|
332
|
+
input = ["-f", "avfoundation", "-capture_cursor", "0", "-framerate", String(this.fps), "-i", `${screen}:none`];
|
|
333
|
+
this.crop = { x: Math.round(area.x * scale), y: Math.round(area.y * scale), width: W, height: H };
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
throw new Error(`Desktop recording is not supported on ${process.platform}.`);
|
|
337
|
+
}
|
|
338
|
+
this.proc = spawn(bin, ["-hide_banner", "-y", ...input, "-c:v", "libx264", "-preset", "ultrafast", "-crf", "12", "-pix_fmt", "yuv444p", this.file]);
|
|
339
|
+
let err = "";
|
|
340
|
+
await new Promise((resolve, reject) => {
|
|
341
|
+
const timer = setTimeout(() => reject(new Error(`Screen capture did not start: ${err.slice(-800)}`)), 10_000);
|
|
342
|
+
this.proc.stderr.on("data", (d) => {
|
|
343
|
+
err += d.toString();
|
|
344
|
+
if (!this.startedAt && /frame=\s*\d+/.test(err)) {
|
|
345
|
+
// ffmpeg prints its first progress line about half a second after the first frame.
|
|
346
|
+
this.startedAt = Date.now() / 1000 - t0 - 0.5;
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
resolve();
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
this.proc.on("close", (code) => {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
if (!this.startedAt)
|
|
354
|
+
reject(new Error(`Screen capture failed (${code}): ${err.slice(-800)}`));
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
if (process.platform === "darwin" && /permission|not authorized|denied/i.test(err)) {
|
|
358
|
+
throw new Error("macOS refused screen capture: allow your terminal in System Settings → Privacy & Security → Screen Recording.");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async stop() {
|
|
362
|
+
if (!this.proc)
|
|
363
|
+
return;
|
|
364
|
+
const p = this.proc;
|
|
365
|
+
await new Promise((resolve) => {
|
|
366
|
+
p.on("close", () => resolve());
|
|
367
|
+
p.stdin.write("q");
|
|
368
|
+
setTimeout(() => p.kill("SIGINT"), 5000);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function macScreenIndex(bin) {
|
|
373
|
+
const r = spawnSync(bin, ["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""], { encoding: "utf8" });
|
|
374
|
+
const m = /\[(\d+)\] Capture screen 0/.exec(r.stderr ?? "");
|
|
375
|
+
return m ? m[1] : "1";
|
|
376
|
+
}
|
|
377
|
+
export async function runDesktop(loaded, opts) {
|
|
378
|
+
const res = await play(loaded, { record: true, minDurations: opts.minDurations, log: opts.log ?? (() => { }) });
|
|
379
|
+
return res.take;
|
|
380
|
+
}
|
|
381
|
+
export async function checkDesktop(loaded, opts) {
|
|
382
|
+
const res = await play(loaded, { record: false, log: opts.log ?? (() => { }) });
|
|
383
|
+
return res.check;
|
|
384
|
+
}
|
|
385
|
+
async function play(loaded, opts) {
|
|
386
|
+
const { scenario } = loaded;
|
|
387
|
+
const record = opts.record;
|
|
388
|
+
const ds = new DesktopSession(scenario.app, Boolean(scenario.screen));
|
|
389
|
+
const takeDir = loaded.takeDir;
|
|
390
|
+
if (record) {
|
|
391
|
+
fs.rmSync(takeDir, { recursive: true, force: true });
|
|
392
|
+
fs.mkdirSync(takeDir, { recursive: true });
|
|
393
|
+
}
|
|
394
|
+
const t0 = Date.now() / 1000;
|
|
395
|
+
const now = () => Date.now() / 1000 - t0;
|
|
396
|
+
const sleepIf = (s) => (record && s > 0 ? sleep(s * 1000) : Promise.resolve());
|
|
397
|
+
let recorder;
|
|
398
|
+
const checks = [];
|
|
399
|
+
try {
|
|
400
|
+
await ds.open(opts.log, { hideOthers: scenario.hideOthers });
|
|
401
|
+
if (ds.screen && record)
|
|
402
|
+
opts.log("Filming the whole screen: notifications and every visible window will be in the video.");
|
|
403
|
+
const W = ds.frame.width;
|
|
404
|
+
const H = ds.frame.height;
|
|
405
|
+
if (record) {
|
|
406
|
+
recorder = new Recorder(path.join(takeDir, "capture.mp4"));
|
|
407
|
+
await recorder.start(ds.frame, ds.scale, t0);
|
|
408
|
+
await sleep(600);
|
|
409
|
+
}
|
|
410
|
+
let cursor = { x: W * 0.62, y: H * 0.7 };
|
|
411
|
+
const cursorStart = { ...cursor };
|
|
412
|
+
let lastField;
|
|
413
|
+
const steps = [];
|
|
414
|
+
for (let i = 0; i < scenario.steps.length; i++) {
|
|
415
|
+
const step = scenario.steps[i];
|
|
416
|
+
const kind = stepKind(step);
|
|
417
|
+
const label = describeStep(step);
|
|
418
|
+
opts.log(`${record ? "▶" : "·"} ${i + 1}/${scenario.steps.length} ${label}`);
|
|
419
|
+
const ts = { index: i, kind, label, start: now(), end: 0, waits: [] };
|
|
420
|
+
const where = { app: step.app, surface: step.surface };
|
|
421
|
+
const minDuration = opts.minDurations?.[i] ?? 0;
|
|
422
|
+
const travelTo = async (box) => {
|
|
423
|
+
const p = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
424
|
+
ts.travelStart = now();
|
|
425
|
+
await sleepIf(travelDuration(cursor, p));
|
|
426
|
+
ts.point = p;
|
|
427
|
+
ts.box = box;
|
|
428
|
+
cursor = p;
|
|
429
|
+
};
|
|
430
|
+
const resolve = async (target) => {
|
|
431
|
+
const w0 = now();
|
|
432
|
+
try {
|
|
433
|
+
const r = await ds.resolve(target, where);
|
|
434
|
+
if (now() - w0 > 0.5)
|
|
435
|
+
ts.waits.push([w0, now()]);
|
|
436
|
+
return r;
|
|
437
|
+
}
|
|
438
|
+
catch (e) {
|
|
439
|
+
if (e instanceof StepError)
|
|
440
|
+
throw new StepError(i, label, e.message.replace(/^Step 0 \(.*?\) failed: /, ""), e.candidates);
|
|
441
|
+
throw e;
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
try {
|
|
445
|
+
// On a whole-screen recording, naming another app brings it to the front (an element of a
|
|
446
|
+
// background app would be acted on out of sight) and makes it the one the next steps act in.
|
|
447
|
+
if (ds.screen && step.app && step.app.toLowerCase() !== ds.app?.toLowerCase()) {
|
|
448
|
+
const w0 = now();
|
|
449
|
+
await ds.activate(step.app, opts.log);
|
|
450
|
+
await sleepIf(0.6);
|
|
451
|
+
ts.waits.push([w0, now()]);
|
|
452
|
+
lastField = undefined; // the focused field belonged to the previous app
|
|
453
|
+
}
|
|
454
|
+
switch (kind) {
|
|
455
|
+
case "title":
|
|
456
|
+
case "end":
|
|
457
|
+
ts.card = {
|
|
458
|
+
kind,
|
|
459
|
+
title: step[kind],
|
|
460
|
+
subtitle: step.subtitle,
|
|
461
|
+
duration: Math.max(step.duration ?? cardDuration(step[kind], step.subtitle), minDuration),
|
|
462
|
+
};
|
|
463
|
+
break;
|
|
464
|
+
case "goto":
|
|
465
|
+
throw new StepError(i, label, "goto works on websites only; desktop scenarios start with the app already open.");
|
|
466
|
+
case "open": {
|
|
467
|
+
if (!ds.screen)
|
|
468
|
+
throw new StepError(i, label, 'open needs "screen": true: a window recording films one app.');
|
|
469
|
+
const w0 = now();
|
|
470
|
+
await ds.activate(step.open, opts.log);
|
|
471
|
+
lastField = undefined; // the focused field belonged to the previous app
|
|
472
|
+
await sleepIf(0.6); // let the window come to the front
|
|
473
|
+
ts.waits.push([w0, now()]);
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
case "click":
|
|
477
|
+
case "select": {
|
|
478
|
+
const r = await resolve(step[kind]);
|
|
479
|
+
await travelTo(r.box);
|
|
480
|
+
ts.actionAt = now();
|
|
481
|
+
if (kind === "select") {
|
|
482
|
+
if (!r.ref)
|
|
483
|
+
throw new StepError(i, label, "select needs an element.");
|
|
484
|
+
await ds.ad.call(["select", r.ref, step.option]);
|
|
485
|
+
}
|
|
486
|
+
else
|
|
487
|
+
await ds.clickAt(r, { double: step.double, right: step.button === "right" });
|
|
488
|
+
if (r.ref && parseTarget(step[kind]).kind !== "at")
|
|
489
|
+
lastField = r.ref;
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
case "hover": {
|
|
493
|
+
// Hover moves the real pointer (menus and tooltips need it); it is not filmed, the drawn cursor is.
|
|
494
|
+
const r = await resolve(step.hover);
|
|
495
|
+
await travelTo(r.box);
|
|
496
|
+
ts.actionAt = now();
|
|
497
|
+
if (r.ref)
|
|
498
|
+
await ds.ad.call(["--headed", "hover", r.ref]);
|
|
499
|
+
else {
|
|
500
|
+
const x = Math.round(ds.frame.x + r.box.x + r.box.width / 2);
|
|
501
|
+
const y = Math.round(ds.frame.y + r.box.y + r.box.height / 2);
|
|
502
|
+
await ds.ad.call(["--headed", "mouse-move", "--xy", `${x},${y}`]);
|
|
503
|
+
}
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
case "type": {
|
|
507
|
+
let ref = lastField;
|
|
508
|
+
if (step.into) {
|
|
509
|
+
const r = await resolve(step.into);
|
|
510
|
+
await travelTo(r.box);
|
|
511
|
+
ts.actionAt = now();
|
|
512
|
+
await ds.clickAt(r);
|
|
513
|
+
ref = r.ref;
|
|
514
|
+
lastField = r.ref;
|
|
515
|
+
if (step.clear && ref)
|
|
516
|
+
await ds.ad.call(["clear", ref], { allowFail: true });
|
|
517
|
+
await sleepIf(0.15);
|
|
518
|
+
}
|
|
519
|
+
else
|
|
520
|
+
ts.actionAt = now();
|
|
521
|
+
const text = step.type;
|
|
522
|
+
ts.typed = step.secret ? "•".repeat(text.length) : text;
|
|
523
|
+
const delays = typingDelays(text, scenario.typingSpeed ?? 1, i + 1);
|
|
524
|
+
if (!record) {
|
|
525
|
+
if (ref)
|
|
526
|
+
await ds.ad.call(["type", ref, text]);
|
|
527
|
+
else
|
|
528
|
+
for (const ch of text)
|
|
529
|
+
await ds.press(ch === " " ? "space" : ch, where);
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
// Type in small chunks so the text appears progressively, like a person typing.
|
|
533
|
+
for (let k = 0; k < text.length; k++) {
|
|
534
|
+
const ch = text[k];
|
|
535
|
+
const t1 = Date.now();
|
|
536
|
+
if (ref)
|
|
537
|
+
await ds.ad.call(["type", ref, ch]);
|
|
538
|
+
else
|
|
539
|
+
await ds.press(ch === " " ? "space" : ch, where);
|
|
540
|
+
const spent = (Date.now() - t1) / 1000;
|
|
541
|
+
await sleep(Math.max(0, delays[k] - spent) * 1000);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
ts.actionEnd = now();
|
|
545
|
+
if (step.submit) {
|
|
546
|
+
await sleepIf(0.25);
|
|
547
|
+
await ds.press("return", where);
|
|
548
|
+
}
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
case "press":
|
|
552
|
+
ts.actionAt = now();
|
|
553
|
+
await ds.press(step.press, where);
|
|
554
|
+
break;
|
|
555
|
+
case "scroll": {
|
|
556
|
+
const v = step.scroll;
|
|
557
|
+
const s0 = now();
|
|
558
|
+
if (["down", "up"].includes(v)) {
|
|
559
|
+
const r = await ds.ad.call(["find", ...(await ds.scope(where)), "--role", "scrollarea", "--first"], { allowFail: true });
|
|
560
|
+
const ref = r.data?.match?.ref_id;
|
|
561
|
+
if (!ref)
|
|
562
|
+
throw new StepError(i, label, "no scrollable area found in the app.");
|
|
563
|
+
await ds.ad.call(["scroll", ref, "--direction", v, "--amount", "5"]);
|
|
564
|
+
}
|
|
565
|
+
else if (["top", "bottom"].includes(v)) {
|
|
566
|
+
await ds.press(v === "top" ? "home" : "end", where);
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
const r = await resolve(v);
|
|
570
|
+
if (r.ref)
|
|
571
|
+
await ds.ad.call(["scroll-to", r.ref], { allowFail: true });
|
|
572
|
+
}
|
|
573
|
+
await sleepIf(0.5);
|
|
574
|
+
(ts.scrolls ??= []).push([s0, now()]);
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
case "wait": {
|
|
578
|
+
const w0 = now();
|
|
579
|
+
if (typeof step.wait === "number")
|
|
580
|
+
await sleepIf(step.wait);
|
|
581
|
+
else
|
|
582
|
+
await ds.resolve(step.wait, where, (step.timeout ?? 30) * 1000);
|
|
583
|
+
ts.waits.push([w0, now()]);
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case "pause":
|
|
587
|
+
await sleepIf(step.pause);
|
|
588
|
+
break;
|
|
589
|
+
case "callout":
|
|
590
|
+
case "highlight":
|
|
591
|
+
case "zoomTo": {
|
|
592
|
+
const r = await resolve(step[kind]);
|
|
593
|
+
ts.box = r.box;
|
|
594
|
+
ts.actionAt = now();
|
|
595
|
+
const d = Math.max(step.duration ?? (kind === "zoomTo" ? 2 : 3), minDuration);
|
|
596
|
+
await sleepIf(d + (kind === "zoomTo" ? 0 : 0.55));
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
catch (e) {
|
|
602
|
+
const err = e instanceof StepError ? e : new StepError(i, label, e.message);
|
|
603
|
+
if (!record) {
|
|
604
|
+
checks.push({ index: i, step: label, ok: false, error: err.message, candidates: err.candidates });
|
|
605
|
+
for (let j = i + 1; j < scenario.steps.length; j++)
|
|
606
|
+
checks.push({ index: j, step: describeStep(scenario.steps[j]), ok: false, error: "not reached" });
|
|
607
|
+
return { check: { ok: false, steps: checks } };
|
|
608
|
+
}
|
|
609
|
+
throw err;
|
|
610
|
+
}
|
|
611
|
+
if (!ts.card && record) {
|
|
612
|
+
const isAction = ["click", "type", "press", "hover", "select", "scroll"].includes(kind);
|
|
613
|
+
await sleepIf((step.hold ?? 0) + (isAction ? 0.7 : 0.2));
|
|
614
|
+
const elapsed = now() - ts.start;
|
|
615
|
+
if (elapsed < minDuration)
|
|
616
|
+
await sleepIf(minDuration - elapsed);
|
|
617
|
+
}
|
|
618
|
+
ts.end = ts.card ? ts.start : now();
|
|
619
|
+
steps.push(ts);
|
|
620
|
+
checks.push({ index: i, step: label, ok: true });
|
|
621
|
+
}
|
|
622
|
+
if (!record)
|
|
623
|
+
return { check: { ok: true, steps: checks } };
|
|
624
|
+
await sleep(1000);
|
|
625
|
+
const duration = now();
|
|
626
|
+
const rec = recorder;
|
|
627
|
+
await rec.stop();
|
|
628
|
+
recorder = undefined;
|
|
629
|
+
const take = {
|
|
630
|
+
version: 1,
|
|
631
|
+
name: loaded.name,
|
|
632
|
+
recordedAt: new Date().toISOString(),
|
|
633
|
+
platform: process.platform,
|
|
634
|
+
target: { kind: "desktop", app: scenario.app ?? "", ...(ds.screen ? { screen: true } : {}) },
|
|
635
|
+
width: W,
|
|
636
|
+
height: H,
|
|
637
|
+
scale: ds.scale,
|
|
638
|
+
duration,
|
|
639
|
+
cursorStart,
|
|
640
|
+
source: { kind: "video", file: "capture.mp4", fps: rec.fps, offset: rec.startedAt, crop: rec.crop },
|
|
641
|
+
steps,
|
|
642
|
+
};
|
|
643
|
+
return { take };
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
await recorder?.stop().catch(() => { });
|
|
647
|
+
await ds.ad.endSession();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
//# sourceMappingURL=desktop.js.map
|