@openruntime/cli 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/assets/auth-connector/icon-128.png +0 -0
- package/assets/auth-connector/icon-16.png +0 -0
- package/assets/auth-connector/icon-32.png +0 -0
- package/assets/auth-connector/icon-48.png +0 -0
- package/dist/args.d.ts +9 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +52 -0
- package/dist/args.js.map +1 -0
- package/dist/auth-connector.d.ts +66 -0
- package/dist/auth-connector.d.ts.map +1 -0
- package/dist/auth-connector.js +1031 -0
- package/dist/auth-connector.js.map +1 -0
- package/dist/bridge-process.d.ts +64 -0
- package/dist/bridge-process.d.ts.map +1 -0
- package/dist/bridge-process.js +228 -0
- package/dist/bridge-process.js.map +1 -0
- package/dist/browser.d.ts +25 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +180 -0
- package/dist/browser.js.map +1 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +132 -0
- package/dist/client.js.map +1 -0
- package/dist/command-definition.d.ts +19 -0
- package/dist/command-definition.d.ts.map +1 -0
- package/dist/command-definition.js +42 -0
- package/dist/command-definition.js.map +1 -0
- package/dist/command-skill.d.ts +5 -0
- package/dist/command-skill.d.ts.map +1 -0
- package/dist/command-skill.js +25 -0
- package/dist/command-skill.js.map +1 -0
- package/dist/entry.d.ts +2 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +16 -0
- package/dist/entry.js.map +1 -0
- package/dist/extension-api.d.ts +85 -0
- package/dist/extension-api.d.ts.map +1 -0
- package/dist/extension-api.js +313 -0
- package/dist/extension-api.js.map +1 -0
- package/dist/extensions/types.d.ts +3 -0
- package/dist/extensions/types.d.ts.map +1 -0
- package/dist/extensions/types.js +2 -0
- package/dist/extensions/types.js.map +1 -0
- package/dist/external-extensions.d.ts +19 -0
- package/dist/external-extensions.d.ts.map +1 -0
- package/dist/external-extensions.js +168 -0
- package/dist/external-extensions.js.map +1 -0
- package/dist/help.d.ts +17 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +214 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1579 -0
- package/dist/index.js.map +1 -0
- package/dist/next-browser-profile-preload.d.ts +2 -0
- package/dist/next-browser-profile-preload.d.ts.map +1 -0
- package/dist/next-browser-profile-preload.js +391 -0
- package/dist/next-browser-profile-preload.js.map +1 -0
- package/dist/operation-log.d.ts +23 -0
- package/dist/operation-log.d.ts.map +1 -0
- package/dist/operation-log.js +76 -0
- package/dist/operation-log.js.map +1 -0
- package/dist/output.d.ts +42 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +111 -0
- package/dist/output.js.map +1 -0
- package/dist/profile.d.ts +56 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +414 -0
- package/dist/profile.js.map +1 -0
- package/dist/record.d.ts +17 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +1494 -0
- package/dist/record.js.map +1 -0
- package/package.json +46 -0
package/dist/record.js
ADDED
|
@@ -0,0 +1,1494 @@
|
|
|
1
|
+
import { appendFile, chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { OPEN_RUNTIME_SESSION_QUERY_PARAM } from "@openruntime/core";
|
|
4
|
+
import { getNumberOption, getOptionValue } from "./args.js";
|
|
5
|
+
import { parseBrowserJsonOutput, resolveBrowserProfileDirectory } from "./browser.js";
|
|
6
|
+
import { ensureBridge } from "./bridge-process.js";
|
|
7
|
+
import { fetchRuntimeResource, fetchRuntimes, selectRuntime } from "./client.js";
|
|
8
|
+
const DEFAULT_RECORD_DURATION_MS = 10_000;
|
|
9
|
+
const DEFAULT_RECORD_INTERVAL_MS = 1_000;
|
|
10
|
+
const DEFAULT_RECORD_START_URL = "about:blank";
|
|
11
|
+
const RECORDING_FORMAT = "openruntime-recording";
|
|
12
|
+
const RECORDING_VERSION = 1;
|
|
13
|
+
const RECORD_EVENT_CONSOLE_MARKER = "__OPENRUNTIME_RECORD_EVENT__";
|
|
14
|
+
const RECORD_AUDIO_CONSOLE_MARKER = "__OPENRUNTIME_RECORD_AUDIO__";
|
|
15
|
+
const OPENRUNTIME_RECORDING_CONTROL_FILE = "recording-session.json";
|
|
16
|
+
const DEFAULT_TRANSCRIPTION_MODEL = "whisper-1";
|
|
17
|
+
export async function runRecordCommand(options) {
|
|
18
|
+
const subcommand = options.args.command[1];
|
|
19
|
+
if (subcommand === "start") {
|
|
20
|
+
return await runRecordStartCommand(options);
|
|
21
|
+
}
|
|
22
|
+
if (subcommand === "stop") {
|
|
23
|
+
return await runRecordStopCommand(options);
|
|
24
|
+
}
|
|
25
|
+
if (subcommand === "generate-script") {
|
|
26
|
+
return await runRecordGenerateScriptCommand(options);
|
|
27
|
+
}
|
|
28
|
+
if (subcommand === "transcribe") {
|
|
29
|
+
return await runRecordTranscribeCommand(options);
|
|
30
|
+
}
|
|
31
|
+
return await runRecordFixedDurationCommand(options);
|
|
32
|
+
}
|
|
33
|
+
async function runRecordStartCommand(options) {
|
|
34
|
+
const requestedUrl = getRecordUrl(options.args, "start");
|
|
35
|
+
const url = requestedUrl ?? DEFAULT_RECORD_START_URL;
|
|
36
|
+
const startedAt = new Date();
|
|
37
|
+
const outputDirectory = resolveRecordStartOutputDirectory(options.args, startedAt);
|
|
38
|
+
const intervalMs = getPositiveNumberOption(options.args, "interval") ?? DEFAULT_RECORD_INTERVAL_MS;
|
|
39
|
+
const sessionId = getOptionValue(options.args, "session");
|
|
40
|
+
const runtimeSelector = createRecordRuntimeSelector(requestedUrl, sessionId, getOptionValue(options.args, "runtime"));
|
|
41
|
+
const openedUrl = withOpenRuntimeSession(url, sessionId);
|
|
42
|
+
const files = createRecordingFiles();
|
|
43
|
+
const operations = [
|
|
44
|
+
{
|
|
45
|
+
type: "record.start",
|
|
46
|
+
startedAt: startedAt.toISOString()
|
|
47
|
+
}
|
|
48
|
+
];
|
|
49
|
+
const runtimeSamples = [];
|
|
50
|
+
const pageSnapshots = [];
|
|
51
|
+
const domSnapshots = [];
|
|
52
|
+
const interactions = [];
|
|
53
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
54
|
+
operations.push(await writeRecordingControlFile(outputDirectory, files, startedAt, hasOption(options.args, "mic")));
|
|
55
|
+
operations.push(await resetRecordingBrowser(options.browserRunner));
|
|
56
|
+
await ensureRecordBridge(options, options.bridgeUrl);
|
|
57
|
+
if (!hasOption(options.args, "no-open")) {
|
|
58
|
+
operations.push(await openRecordingBrowser(options, openedUrl));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
operations.push(createSkippedBrowserOpenOperation(openedUrl));
|
|
62
|
+
}
|
|
63
|
+
operations.push(await setRecordingInstrumentation(options.browserRunner, outputDirectory, startedAt));
|
|
64
|
+
const sampledAt = new Date();
|
|
65
|
+
const [runtimeSample, pageSnapshot, domSnapshot] = await Promise.all([
|
|
66
|
+
sampleRuntime(options.fetcher, options.bridgeUrl, runtimeSelector, sampledAt),
|
|
67
|
+
samplePageSnapshot(options.browserRunner, sampledAt),
|
|
68
|
+
sampleDomSnapshot(options.browserRunner, sampledAt)
|
|
69
|
+
]);
|
|
70
|
+
runtimeSamples.push(runtimeSample);
|
|
71
|
+
pageSnapshots.push(pageSnapshot);
|
|
72
|
+
domSnapshots.push(domSnapshot);
|
|
73
|
+
const manifest = createRecordingManifest({
|
|
74
|
+
args: options.args,
|
|
75
|
+
url,
|
|
76
|
+
openedUrl,
|
|
77
|
+
bridgeUrl: options.bridgeUrl,
|
|
78
|
+
sessionId,
|
|
79
|
+
startedAt,
|
|
80
|
+
intervalMs,
|
|
81
|
+
status: "recording",
|
|
82
|
+
files,
|
|
83
|
+
counts: {
|
|
84
|
+
runtimeSamples: runtimeSamples.length,
|
|
85
|
+
pageSnapshots: pageSnapshots.length,
|
|
86
|
+
domSnapshots: domSnapshots.length,
|
|
87
|
+
interactions: interactions.length,
|
|
88
|
+
audioChunks: 0,
|
|
89
|
+
transcriptSegments: 0,
|
|
90
|
+
operations: operations.length
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
await writeRecordingFiles(outputDirectory, manifest, runtimeSamples, pageSnapshots, domSnapshots, interactions, operations);
|
|
94
|
+
writeJson(options.stdout, {
|
|
95
|
+
ok: true,
|
|
96
|
+
status: "recording",
|
|
97
|
+
output: outputDirectory,
|
|
98
|
+
manifest: join(outputDirectory, files.manifest),
|
|
99
|
+
next: `open-runtime record stop --out ${outputDirectory}`
|
|
100
|
+
});
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
async function runRecordStopCommand(options) {
|
|
104
|
+
const outputDirectory = resolve(requireOption(options.args, "out"));
|
|
105
|
+
const recording = await readRecordingData(outputDirectory);
|
|
106
|
+
const bridgeUrl = resolveRecordingBridgeUrl(options.args, options.bridgeUrl, recording.manifest);
|
|
107
|
+
const sessionId = getOptionValue(options.args, "session") ?? recording.manifest.sessionId;
|
|
108
|
+
const runtimeSelector = createRecordRuntimeSelector(recording.manifest.url, sessionId, getOptionValue(options.args, "runtime"));
|
|
109
|
+
const stopStartedAt = new Date();
|
|
110
|
+
const bridge = await tryEnsureRecordBridge(options, bridgeUrl);
|
|
111
|
+
const sampledAt = new Date();
|
|
112
|
+
const [runtimeSample, pageSnapshot, domSnapshot] = await Promise.all([
|
|
113
|
+
sampleRuntime(options.fetcher, bridgeUrl, runtimeSelector, sampledAt),
|
|
114
|
+
samplePageSnapshot(options.browserRunner, sampledAt),
|
|
115
|
+
sampleDomSnapshot(options.browserRunner, sampledAt)
|
|
116
|
+
]);
|
|
117
|
+
await appendJsonLine(join(outputDirectory, recording.manifest.files.runtime), runtimeSample);
|
|
118
|
+
await appendJsonLine(join(outputDirectory, recording.manifest.files.pageSnapshots), pageSnapshot);
|
|
119
|
+
await appendJsonLine(join(outputDirectory, recording.manifest.files.domSnapshots), domSnapshot);
|
|
120
|
+
const interactionCollection = await collectInteractionEvents(outputDirectory, options.browserRunner);
|
|
121
|
+
await writeJsonLines(join(outputDirectory, recording.manifest.files.interactions), interactionCollection.interactions);
|
|
122
|
+
await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), interactionCollection.operation);
|
|
123
|
+
const audioCollection = await collectAudioCapture(outputDirectory, recording.manifest.files, recording.manifest.capture.audio.requested);
|
|
124
|
+
await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), audioCollection.operation);
|
|
125
|
+
const stopOperation = {
|
|
126
|
+
type: "record.stop",
|
|
127
|
+
startedAt: stopStartedAt.toISOString(),
|
|
128
|
+
endedAt: new Date().toISOString()
|
|
129
|
+
};
|
|
130
|
+
await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), stopOperation);
|
|
131
|
+
let closeOperation;
|
|
132
|
+
if (!hasOption(options.args, "no-close")) {
|
|
133
|
+
closeOperation = await closeRecordingBrowser(options.browserRunner);
|
|
134
|
+
await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), closeOperation);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
closeOperation = {
|
|
138
|
+
type: "browser.close",
|
|
139
|
+
startedAt: new Date().toISOString(),
|
|
140
|
+
skipped: true,
|
|
141
|
+
reason: "--no-close was set"
|
|
142
|
+
};
|
|
143
|
+
await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), closeOperation);
|
|
144
|
+
}
|
|
145
|
+
await clearRecordingControlFile();
|
|
146
|
+
const refreshedRecording = await readRecordingData(outputDirectory);
|
|
147
|
+
let generatedScript;
|
|
148
|
+
if (!hasOption(options.args, "no-script")) {
|
|
149
|
+
generatedScript = await writeGeneratedScript(outputDirectory, refreshedRecording, getOptionValue(options.args, "script-out"));
|
|
150
|
+
await appendJsonLine(join(outputDirectory, recording.manifest.files.operations), {
|
|
151
|
+
type: "script.generated",
|
|
152
|
+
startedAt: new Date().toISOString(),
|
|
153
|
+
path: generatedScript.path
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const completedAt = new Date();
|
|
157
|
+
const counts = await readRecordingCounts(outputDirectory, recording.manifest.files);
|
|
158
|
+
const manifest = {
|
|
159
|
+
...recording.manifest,
|
|
160
|
+
bridgeUrl,
|
|
161
|
+
status: "completed",
|
|
162
|
+
endedAt: completedAt.toISOString(),
|
|
163
|
+
durationMs: completedAt.getTime() - Date.parse(recording.manifest.startedAt),
|
|
164
|
+
capture: {
|
|
165
|
+
...recording.manifest.capture,
|
|
166
|
+
audio: createCompletedAudioCapture(recording.manifest.files, audioCollection.summary)
|
|
167
|
+
},
|
|
168
|
+
counts,
|
|
169
|
+
...createOptionalGeneratedProperty(generatedScript)
|
|
170
|
+
};
|
|
171
|
+
await writeJsonFile(join(outputDirectory, recording.manifest.files.manifest), manifest);
|
|
172
|
+
writeJson(options.stdout, {
|
|
173
|
+
ok: true,
|
|
174
|
+
status: "completed",
|
|
175
|
+
output: outputDirectory,
|
|
176
|
+
manifest: join(outputDirectory, recording.manifest.files.manifest),
|
|
177
|
+
script: generatedScript?.path,
|
|
178
|
+
bridge,
|
|
179
|
+
close: closeOperation,
|
|
180
|
+
counts: manifest.counts
|
|
181
|
+
});
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
async function runRecordGenerateScriptCommand(options) {
|
|
185
|
+
const inputDirectory = resolve(requireOption(options.args, "input"));
|
|
186
|
+
const recording = await readRecordingData(inputDirectory);
|
|
187
|
+
const generatedScript = await writeGeneratedScript(inputDirectory, recording, getOptionValue(options.args, "out"));
|
|
188
|
+
const generatedAt = new Date().toISOString();
|
|
189
|
+
const manifest = {
|
|
190
|
+
...recording.manifest,
|
|
191
|
+
generated: {
|
|
192
|
+
...(recording.manifest.generated ?? {}),
|
|
193
|
+
script: generatedScript.relativePath,
|
|
194
|
+
generatedAt
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
await appendJsonLine(join(inputDirectory, recording.manifest.files.operations), {
|
|
198
|
+
type: "script.generated",
|
|
199
|
+
startedAt: generatedAt,
|
|
200
|
+
path: generatedScript.path
|
|
201
|
+
});
|
|
202
|
+
const counts = await readRecordingCounts(inputDirectory, recording.manifest.files);
|
|
203
|
+
await writeJsonFile(join(inputDirectory, recording.manifest.files.manifest), {
|
|
204
|
+
...manifest,
|
|
205
|
+
counts
|
|
206
|
+
});
|
|
207
|
+
writeJson(options.stdout, {
|
|
208
|
+
ok: true,
|
|
209
|
+
input: inputDirectory,
|
|
210
|
+
script: generatedScript.path
|
|
211
|
+
});
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
async function runRecordTranscribeCommand(options) {
|
|
215
|
+
const inputDirectory = resolve(requireOption(options.args, "input"));
|
|
216
|
+
const recording = await readRecordingData(inputDirectory);
|
|
217
|
+
const model = getOptionValue(options.args, "model") ?? DEFAULT_TRANSCRIPTION_MODEL;
|
|
218
|
+
const apiKey = getOptionValue(options.args, "api-key") ?? process.env.OPENAI_API_KEY;
|
|
219
|
+
if (apiKey === undefined || apiKey.length === 0) {
|
|
220
|
+
throw new Error("Missing OpenAI API key. Set OPENAI_API_KEY or pass --api-key.");
|
|
221
|
+
}
|
|
222
|
+
const audioPath = resolve(inputDirectory, getOptionValue(options.args, "audio") ?? recording.manifest.files.audio);
|
|
223
|
+
const startedAt = new Date();
|
|
224
|
+
const transcript = await transcribeAudioFile(options.fetcher, audioPath, apiKey, model);
|
|
225
|
+
const transcribedAt = new Date().toISOString();
|
|
226
|
+
const transcriptData = {
|
|
227
|
+
status: "completed",
|
|
228
|
+
audio: createManifestPath(inputDirectory, audioPath),
|
|
229
|
+
model,
|
|
230
|
+
transcribedAt,
|
|
231
|
+
text: transcript.text,
|
|
232
|
+
segments: transcript.segments,
|
|
233
|
+
...(transcript.words.length > 0 ? { words: transcript.words } : {})
|
|
234
|
+
};
|
|
235
|
+
await writeJsonFile(join(inputDirectory, recording.manifest.files.transcript), transcriptData);
|
|
236
|
+
await appendJsonLine(join(inputDirectory, recording.manifest.files.operations), {
|
|
237
|
+
type: "audio.transcribe",
|
|
238
|
+
startedAt: startedAt.toISOString(),
|
|
239
|
+
endedAt: transcribedAt,
|
|
240
|
+
model,
|
|
241
|
+
audio: audioPath,
|
|
242
|
+
segmentCount: transcript.segments.length,
|
|
243
|
+
wordCount: transcript.words.length
|
|
244
|
+
});
|
|
245
|
+
const counts = await readRecordingCounts(inputDirectory, recording.manifest.files);
|
|
246
|
+
const manifest = {
|
|
247
|
+
...recording.manifest,
|
|
248
|
+
capture: {
|
|
249
|
+
...recording.manifest.capture,
|
|
250
|
+
audio: {
|
|
251
|
+
...recording.manifest.capture.audio,
|
|
252
|
+
requested: true,
|
|
253
|
+
status: "transcribed",
|
|
254
|
+
file: recording.manifest.files.audio,
|
|
255
|
+
chunks: recording.manifest.files.audioChunks,
|
|
256
|
+
transcript: recording.manifest.files.transcript,
|
|
257
|
+
segmentCount: transcript.segments.length,
|
|
258
|
+
chunkCount: counts.audioChunks,
|
|
259
|
+
reason: "Microphone audio was transcribed with timestamps."
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
counts
|
|
263
|
+
};
|
|
264
|
+
await writeJsonFile(join(inputDirectory, recording.manifest.files.manifest), manifest);
|
|
265
|
+
writeJson(options.stdout, {
|
|
266
|
+
ok: true,
|
|
267
|
+
input: inputDirectory,
|
|
268
|
+
transcript: join(inputDirectory, recording.manifest.files.transcript),
|
|
269
|
+
model,
|
|
270
|
+
segmentCount: transcript.segments.length,
|
|
271
|
+
wordCount: transcript.words.length
|
|
272
|
+
});
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
async function transcribeAudioFile(fetcher, audioPath, apiKey, model) {
|
|
276
|
+
const audio = await readFile(audioPath);
|
|
277
|
+
const form = new FormData();
|
|
278
|
+
form.set("file", new Blob([audio], { type: "audio/webm" }), "audio.webm");
|
|
279
|
+
form.set("model", model);
|
|
280
|
+
if (model === DEFAULT_TRANSCRIPTION_MODEL) {
|
|
281
|
+
form.set("response_format", "verbose_json");
|
|
282
|
+
form.append("timestamp_granularities[]", "segment");
|
|
283
|
+
form.append("timestamp_granularities[]", "word");
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
form.set("response_format", "json");
|
|
287
|
+
}
|
|
288
|
+
const response = await fetcher("https://api.openai.com/v1/audio/transcriptions", {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: {
|
|
291
|
+
authorization: `Bearer ${apiKey}`
|
|
292
|
+
},
|
|
293
|
+
body: form
|
|
294
|
+
});
|
|
295
|
+
const text = await response.text();
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
throw new Error(text.length === 0 ? `Audio transcription failed with status ${response.status}.` : text);
|
|
298
|
+
}
|
|
299
|
+
const parsed = JSON.parse(text);
|
|
300
|
+
return {
|
|
301
|
+
text: typeof parsed.text === "string" ? parsed.text : "",
|
|
302
|
+
segments: normalizeTranscriptSegments(parsed.segments, parsed.text),
|
|
303
|
+
words: normalizeTranscriptWords(parsed.words)
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
async function runRecordFixedDurationCommand(options) {
|
|
307
|
+
const url = requireRecordUrl(options.args);
|
|
308
|
+
const outputDirectory = resolve(requireOption(options.args, "out"));
|
|
309
|
+
const durationMs = getPositiveNumberOption(options.args, "duration") ?? DEFAULT_RECORD_DURATION_MS;
|
|
310
|
+
const intervalMs = getPositiveNumberOption(options.args, "interval") ?? DEFAULT_RECORD_INTERVAL_MS;
|
|
311
|
+
const sessionId = getOptionValue(options.args, "session");
|
|
312
|
+
const runtimeSelector = createRecordRuntimeSelector(url, sessionId, getOptionValue(options.args, "runtime"));
|
|
313
|
+
const openedUrl = withOpenRuntimeSession(url, sessionId);
|
|
314
|
+
const startedAt = new Date();
|
|
315
|
+
const files = createRecordingFiles();
|
|
316
|
+
const operations = [];
|
|
317
|
+
const runtimeSamples = [];
|
|
318
|
+
const pageSnapshots = [];
|
|
319
|
+
const domSnapshots = [];
|
|
320
|
+
const interactions = [];
|
|
321
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
322
|
+
await ensureRecordBridge(options, options.bridgeUrl);
|
|
323
|
+
if (!hasOption(options.args, "no-open")) {
|
|
324
|
+
operations.push(await openRecordingBrowser(options, openedUrl));
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
operations.push(createSkippedBrowserOpenOperation(openedUrl));
|
|
328
|
+
}
|
|
329
|
+
const deadline = Date.now() + durationMs;
|
|
330
|
+
do {
|
|
331
|
+
const sampledAt = new Date();
|
|
332
|
+
const [runtimeSample, pageSnapshot, domSnapshot] = await Promise.all([
|
|
333
|
+
sampleRuntime(options.fetcher, options.bridgeUrl, runtimeSelector, sampledAt),
|
|
334
|
+
samplePageSnapshot(options.browserRunner, sampledAt),
|
|
335
|
+
sampleDomSnapshot(options.browserRunner, sampledAt)
|
|
336
|
+
]);
|
|
337
|
+
runtimeSamples.push(runtimeSample);
|
|
338
|
+
pageSnapshots.push(pageSnapshot);
|
|
339
|
+
domSnapshots.push(domSnapshot);
|
|
340
|
+
const remainingMs = deadline - Date.now();
|
|
341
|
+
if (remainingMs <= 0)
|
|
342
|
+
break;
|
|
343
|
+
await sleep(Math.min(intervalMs, remainingMs));
|
|
344
|
+
} while (Date.now() <= deadline);
|
|
345
|
+
const endedAt = new Date();
|
|
346
|
+
const manifest = createRecordingManifest({
|
|
347
|
+
args: options.args,
|
|
348
|
+
url,
|
|
349
|
+
openedUrl,
|
|
350
|
+
bridgeUrl: options.bridgeUrl,
|
|
351
|
+
sessionId,
|
|
352
|
+
startedAt,
|
|
353
|
+
intervalMs,
|
|
354
|
+
status: "completed",
|
|
355
|
+
files,
|
|
356
|
+
requestedDurationMs: durationMs,
|
|
357
|
+
endedAt,
|
|
358
|
+
counts: {
|
|
359
|
+
runtimeSamples: runtimeSamples.length,
|
|
360
|
+
pageSnapshots: pageSnapshots.length,
|
|
361
|
+
domSnapshots: domSnapshots.length,
|
|
362
|
+
interactions: interactions.length,
|
|
363
|
+
audioChunks: 0,
|
|
364
|
+
transcriptSegments: 0,
|
|
365
|
+
operations: operations.length
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
await writeRecordingFiles(outputDirectory, manifest, runtimeSamples, pageSnapshots, domSnapshots, interactions, operations);
|
|
369
|
+
writeJson(options.stdout, {
|
|
370
|
+
ok: true,
|
|
371
|
+
output: outputDirectory,
|
|
372
|
+
manifest: join(outputDirectory, files.manifest),
|
|
373
|
+
counts: manifest.counts,
|
|
374
|
+
media: manifest.capture
|
|
375
|
+
});
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
async function ensureRecordBridge(options, bridgeUrl) {
|
|
379
|
+
await ensureBridge({
|
|
380
|
+
fetcher: options.fetcher,
|
|
381
|
+
bridgeUrl,
|
|
382
|
+
starter: options.bridgeStarter,
|
|
383
|
+
stateStore: options.bridgeStateStore,
|
|
384
|
+
...createOptionalNumberProperty("port", getNumberOption(options.args, "port"))
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
async function tryEnsureRecordBridge(options, bridgeUrl) {
|
|
388
|
+
try {
|
|
389
|
+
await ensureRecordBridge(options, bridgeUrl);
|
|
390
|
+
return { ok: true };
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
return {
|
|
394
|
+
ok: false,
|
|
395
|
+
error: error instanceof Error ? error.message : String(error)
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function openRecordingBrowser(options, openedUrl) {
|
|
400
|
+
const openStartedAt = new Date();
|
|
401
|
+
const openResult = await options.browserRunner.run(["open", openedUrl], {
|
|
402
|
+
ui: !hasOption(options.args, "headless")
|
|
403
|
+
});
|
|
404
|
+
const operation = {
|
|
405
|
+
type: "browser.open",
|
|
406
|
+
url: openedUrl,
|
|
407
|
+
startedAt: openStartedAt.toISOString(),
|
|
408
|
+
endedAt: new Date().toISOString(),
|
|
409
|
+
exitCode: openResult.exitCode,
|
|
410
|
+
stdout: openResult.stdout.trim(),
|
|
411
|
+
stderr: openResult.stderr.trim()
|
|
412
|
+
};
|
|
413
|
+
if (openResult.exitCode !== 0) {
|
|
414
|
+
throw new Error(openResult.stderr.trim() || openResult.stdout.trim() || "Could not open browser for recording.");
|
|
415
|
+
}
|
|
416
|
+
return operation;
|
|
417
|
+
}
|
|
418
|
+
function createSkippedBrowserOpenOperation(openedUrl) {
|
|
419
|
+
return {
|
|
420
|
+
type: "browser.open",
|
|
421
|
+
url: openedUrl,
|
|
422
|
+
startedAt: new Date().toISOString(),
|
|
423
|
+
skipped: true,
|
|
424
|
+
reason: "--no-open was set"
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
async function closeRecordingBrowser(browserRunner) {
|
|
428
|
+
const closeStartedAt = new Date();
|
|
429
|
+
const closeResult = await browserRunner.run(["close"]);
|
|
430
|
+
return {
|
|
431
|
+
type: "browser.close",
|
|
432
|
+
startedAt: closeStartedAt.toISOString(),
|
|
433
|
+
endedAt: new Date().toISOString(),
|
|
434
|
+
exitCode: closeResult.exitCode,
|
|
435
|
+
stdout: closeResult.stdout.trim(),
|
|
436
|
+
stderr: closeResult.stderr.trim()
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
async function writeRecordingControlFile(outputDirectory, files, startedAt, audioRequested) {
|
|
440
|
+
const operationStartedAt = new Date();
|
|
441
|
+
const profileDirectory = resolveBrowserProfileDirectory();
|
|
442
|
+
const controlFile = join(profileDirectory, OPENRUNTIME_RECORDING_CONTROL_FILE);
|
|
443
|
+
const eventsFile = join(outputDirectory, "interaction-events.raw.jsonl");
|
|
444
|
+
const audioFile = join(outputDirectory, files.audio);
|
|
445
|
+
const audioChunksFile = join(outputDirectory, files.audioChunks);
|
|
446
|
+
const audioEventsFile = join(outputDirectory, files.audioEvents);
|
|
447
|
+
const audioChunksDirectory = join(outputDirectory, "audio-chunks");
|
|
448
|
+
await mkdir(profileDirectory, { recursive: true });
|
|
449
|
+
await mkdir(audioChunksDirectory, { recursive: true });
|
|
450
|
+
await writeJsonFile(controlFile, {
|
|
451
|
+
marker: RECORD_EVENT_CONSOLE_MARKER,
|
|
452
|
+
eventsFile,
|
|
453
|
+
startedAt: startedAt.toISOString(),
|
|
454
|
+
audio: audioRequested
|
|
455
|
+
? {
|
|
456
|
+
marker: RECORD_AUDIO_CONSOLE_MARKER,
|
|
457
|
+
audioFile,
|
|
458
|
+
chunksFile: audioChunksFile,
|
|
459
|
+
eventsFile: audioEventsFile,
|
|
460
|
+
chunksDirectory: audioChunksDirectory,
|
|
461
|
+
recorderUrl: "https://openruntime-recorder.localhost/recorder",
|
|
462
|
+
startedAt: startedAt.toISOString(),
|
|
463
|
+
chunkMs: 1000
|
|
464
|
+
}
|
|
465
|
+
: undefined
|
|
466
|
+
});
|
|
467
|
+
await writeFile(eventsFile, "", "utf8");
|
|
468
|
+
await ensureJsonLinesFile(audioChunksFile);
|
|
469
|
+
await ensureJsonLinesFile(audioEventsFile);
|
|
470
|
+
if (audioRequested) {
|
|
471
|
+
await writeFile(audioFile, "");
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
type: "recording.control.write",
|
|
475
|
+
startedAt: operationStartedAt.toISOString(),
|
|
476
|
+
endedAt: new Date().toISOString(),
|
|
477
|
+
profileDirectory,
|
|
478
|
+
controlFile,
|
|
479
|
+
eventsFile,
|
|
480
|
+
audioRequested,
|
|
481
|
+
...(audioRequested
|
|
482
|
+
? {
|
|
483
|
+
audioFile,
|
|
484
|
+
audioChunksFile,
|
|
485
|
+
audioEventsFile
|
|
486
|
+
}
|
|
487
|
+
: {})
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
async function clearRecordingControlFile() {
|
|
491
|
+
await rm(join(resolveBrowserProfileDirectory(), OPENRUNTIME_RECORDING_CONTROL_FILE), {
|
|
492
|
+
force: true
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
async function resetRecordingBrowser(browserRunner) {
|
|
496
|
+
const started = new Date();
|
|
497
|
+
const result = await browserRunner.run(["close"]);
|
|
498
|
+
return {
|
|
499
|
+
type: "browser.reset",
|
|
500
|
+
startedAt: started.toISOString(),
|
|
501
|
+
endedAt: new Date().toISOString(),
|
|
502
|
+
exitCode: result.exitCode,
|
|
503
|
+
stdout: result.stdout.trim(),
|
|
504
|
+
stderr: result.stderr.trim()
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
async function setRecordingInstrumentation(browserRunner, outputDirectory, startedAt) {
|
|
508
|
+
const started = new Date();
|
|
509
|
+
const scriptPath = join(outputDirectory, "recording-instrumentation.js");
|
|
510
|
+
await writeFile(scriptPath, createInteractionRecorderScript(startedAt.getTime()), "utf8");
|
|
511
|
+
const result = await browserRunner.run(["instrumentation", "set", scriptPath]);
|
|
512
|
+
return {
|
|
513
|
+
type: "browser.instrumentation.set",
|
|
514
|
+
path: scriptPath,
|
|
515
|
+
startedAt: started.toISOString(),
|
|
516
|
+
endedAt: new Date().toISOString(),
|
|
517
|
+
exitCode: result.exitCode,
|
|
518
|
+
stdout: result.stdout.trim(),
|
|
519
|
+
stderr: result.stderr.trim()
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
async function collectInteractionEvents(outputDirectory, browserRunner) {
|
|
523
|
+
const started = new Date();
|
|
524
|
+
const result = await browserRunner.run(["browser-logs"]);
|
|
525
|
+
const persistedInteractions = await readJsonLinesIfExists(join(outputDirectory, "interaction-events.raw.jsonl"));
|
|
526
|
+
const browserLogInteractions = result.exitCode === 0 ? parseInteractionEventsFromBrowserLogs(result.stdout) : [];
|
|
527
|
+
const interactions = mergeInteractionEvents(persistedInteractions, browserLogInteractions);
|
|
528
|
+
return {
|
|
529
|
+
operation: {
|
|
530
|
+
type: "interactions.collect",
|
|
531
|
+
startedAt: started.toISOString(),
|
|
532
|
+
endedAt: new Date().toISOString(),
|
|
533
|
+
exitCode: result.exitCode,
|
|
534
|
+
count: interactions.length,
|
|
535
|
+
persistedCount: persistedInteractions.length,
|
|
536
|
+
browserLogCount: browserLogInteractions.length,
|
|
537
|
+
stdout: result.stdout.trim(),
|
|
538
|
+
stderr: result.stderr.trim()
|
|
539
|
+
},
|
|
540
|
+
interactions
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
async function collectAudioCapture(outputDirectory, files, requested) {
|
|
544
|
+
const started = new Date();
|
|
545
|
+
const audioFile = join(outputDirectory, files.audio);
|
|
546
|
+
const chunksFile = join(outputDirectory, files.audioChunks);
|
|
547
|
+
const eventsFile = join(outputDirectory, files.audioEvents);
|
|
548
|
+
const chunks = await readJsonLinesIfExists(chunksFile);
|
|
549
|
+
const events = await readJsonLinesIfExists(eventsFile);
|
|
550
|
+
const liveTranscript = createLiveTranscriptFromAudioEvents(files, events);
|
|
551
|
+
if (liveTranscript.segments.length > 0) {
|
|
552
|
+
await writeJsonFile(join(outputDirectory, files.transcript), liveTranscript);
|
|
553
|
+
}
|
|
554
|
+
const summary = {
|
|
555
|
+
requested,
|
|
556
|
+
chunkCount: chunks.length,
|
|
557
|
+
audioFile,
|
|
558
|
+
chunksFile,
|
|
559
|
+
eventsFile,
|
|
560
|
+
status: requested && chunks.length > 0 ? "captured" : requested ? "not-captured" : "not-requested",
|
|
561
|
+
...createOptionalStringProperty("reason", createAudioCaptureReason(requested, chunks, events))
|
|
562
|
+
};
|
|
563
|
+
return {
|
|
564
|
+
operation: {
|
|
565
|
+
type: "audio.collect",
|
|
566
|
+
startedAt: started.toISOString(),
|
|
567
|
+
endedAt: new Date().toISOString(),
|
|
568
|
+
requested,
|
|
569
|
+
chunkCount: chunks.length,
|
|
570
|
+
audioFile,
|
|
571
|
+
chunksFile,
|
|
572
|
+
eventsFile,
|
|
573
|
+
status: summary.status,
|
|
574
|
+
transcriptSegmentCount: liveTranscript.segments.length,
|
|
575
|
+
...createOptionalStringProperty("reason", summary.reason)
|
|
576
|
+
},
|
|
577
|
+
summary
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function createLiveTranscriptFromAudioEvents(files, events) {
|
|
581
|
+
const speechEvents = events
|
|
582
|
+
.map((event) => normalizeLiveTranscriptEvent(event))
|
|
583
|
+
.filter((event) => event !== undefined);
|
|
584
|
+
if (speechEvents.length === 0) {
|
|
585
|
+
return {
|
|
586
|
+
status: "pending",
|
|
587
|
+
audio: files.audio,
|
|
588
|
+
segments: []
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
const segments = speechEvents.map((event) => {
|
|
592
|
+
const endMs = event.endMs ?? event.timeMs ?? 0;
|
|
593
|
+
const startMs = event.startMs ?? Math.max(0, endMs - estimateSpeechDurationMs(event.text ?? ""));
|
|
594
|
+
return {
|
|
595
|
+
startMs,
|
|
596
|
+
endMs,
|
|
597
|
+
text: event.text ?? ""
|
|
598
|
+
};
|
|
599
|
+
});
|
|
600
|
+
return {
|
|
601
|
+
status: "completed",
|
|
602
|
+
audio: files.audio,
|
|
603
|
+
model: "browser-speech-recognition",
|
|
604
|
+
transcribedAt: new Date().toISOString(),
|
|
605
|
+
text: segments.map((segment) => segment.text).join(" ").trim(),
|
|
606
|
+
segments
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function normalizeLiveTranscriptEvent(event) {
|
|
610
|
+
if (event.type !== "speech-result")
|
|
611
|
+
return undefined;
|
|
612
|
+
const text = typeof event.text === "string" ? event.text.trim() : "";
|
|
613
|
+
if (text.length === 0)
|
|
614
|
+
return undefined;
|
|
615
|
+
return {
|
|
616
|
+
type: "speech-result",
|
|
617
|
+
...createOptionalNumberProperty("timeMs", getNumberProperty(event, "timeMs")),
|
|
618
|
+
...createOptionalNumberProperty("startMs", getNumberProperty(event, "startMs")),
|
|
619
|
+
...createOptionalNumberProperty("endMs", getNumberProperty(event, "endMs")),
|
|
620
|
+
text,
|
|
621
|
+
...createOptionalNumberProperty("confidence", getNumberProperty(event, "confidence"))
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function estimateSpeechDurationMs(text) {
|
|
625
|
+
const normalized = text.trim();
|
|
626
|
+
if (normalized.length === 0)
|
|
627
|
+
return 0;
|
|
628
|
+
return Math.min(12_000, Math.max(1200, normalized.length * 180));
|
|
629
|
+
}
|
|
630
|
+
function createAudioCaptureReason(requested, chunks, events) {
|
|
631
|
+
if (!requested)
|
|
632
|
+
return "Microphone capture was not requested.";
|
|
633
|
+
if (chunks.length > 0)
|
|
634
|
+
return undefined;
|
|
635
|
+
const errorEvent = [...events].reverse().find((event) => event.type === "audio-error");
|
|
636
|
+
const message = typeof errorEvent?.message === "string" ? errorEvent.message : undefined;
|
|
637
|
+
return message === undefined ? "No microphone audio chunks were captured." : message;
|
|
638
|
+
}
|
|
639
|
+
function createRecordingManifest(input) {
|
|
640
|
+
return {
|
|
641
|
+
format: RECORDING_FORMAT,
|
|
642
|
+
version: RECORDING_VERSION,
|
|
643
|
+
status: input.status,
|
|
644
|
+
url: input.url,
|
|
645
|
+
openedUrl: input.openedUrl,
|
|
646
|
+
bridgeUrl: input.bridgeUrl,
|
|
647
|
+
...createOptionalStringProperty("sessionId", input.sessionId),
|
|
648
|
+
startedAt: input.startedAt.toISOString(),
|
|
649
|
+
...createOptionalEndedAtProperties(input.startedAt, input.endedAt),
|
|
650
|
+
...createOptionalNumberProperty("requestedDurationMs", input.requestedDurationMs),
|
|
651
|
+
intervalMs: input.intervalMs,
|
|
652
|
+
capture: {
|
|
653
|
+
runtime: true,
|
|
654
|
+
browserSnapshots: true,
|
|
655
|
+
operations: true,
|
|
656
|
+
video: {
|
|
657
|
+
requested: !hasOption(input.args, "headless"),
|
|
658
|
+
status: "not-captured",
|
|
659
|
+
reason: "This prototype records browser snapshots and OpenRuntime state; continuous video capture is reserved for the next stage."
|
|
660
|
+
},
|
|
661
|
+
audio: createInitialAudioCapture(input.args, input.files, input.status)
|
|
662
|
+
},
|
|
663
|
+
counts: input.counts,
|
|
664
|
+
files: input.files
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function createOptionalEndedAtProperties(startedAt, endedAt) {
|
|
668
|
+
if (endedAt === undefined)
|
|
669
|
+
return {};
|
|
670
|
+
return {
|
|
671
|
+
endedAt: endedAt.toISOString(),
|
|
672
|
+
durationMs: endedAt.getTime() - startedAt.getTime()
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function createOptionalGeneratedProperty(generatedScript) {
|
|
676
|
+
if (generatedScript === undefined)
|
|
677
|
+
return {};
|
|
678
|
+
return {
|
|
679
|
+
generated: {
|
|
680
|
+
script: generatedScript.relativePath,
|
|
681
|
+
generatedAt: new Date().toISOString()
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
function createInitialAudioCapture(args, files, status) {
|
|
686
|
+
if (!hasOption(args, "mic")) {
|
|
687
|
+
return {
|
|
688
|
+
requested: false,
|
|
689
|
+
status: "not-requested",
|
|
690
|
+
reason: "Microphone capture was not requested."
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
if (status === "completed") {
|
|
694
|
+
return {
|
|
695
|
+
requested: true,
|
|
696
|
+
status: "not-captured",
|
|
697
|
+
file: files.audio,
|
|
698
|
+
chunks: files.audioChunks,
|
|
699
|
+
transcript: files.transcript,
|
|
700
|
+
reason: "Microphone capture is available for manual record start/stop sessions."
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
requested: true,
|
|
705
|
+
status: "recording",
|
|
706
|
+
file: files.audio,
|
|
707
|
+
chunks: files.audioChunks,
|
|
708
|
+
transcript: files.transcript,
|
|
709
|
+
reason: "Microphone capture was requested; audio chunks are written while the browser recording is active."
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
function createCompletedAudioCapture(files, summary) {
|
|
713
|
+
if (!summary.requested) {
|
|
714
|
+
return {
|
|
715
|
+
requested: false,
|
|
716
|
+
status: "not-requested",
|
|
717
|
+
reason: "Microphone capture was not requested."
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
if (summary.chunkCount === 0) {
|
|
721
|
+
return {
|
|
722
|
+
requested: true,
|
|
723
|
+
status: "not-captured",
|
|
724
|
+
file: files.audio,
|
|
725
|
+
chunks: files.audioChunks,
|
|
726
|
+
transcript: files.transcript,
|
|
727
|
+
reason: summary.reason ?? "No microphone audio chunks were captured."
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
requested: true,
|
|
732
|
+
status: "captured",
|
|
733
|
+
file: files.audio,
|
|
734
|
+
chunks: files.audioChunks,
|
|
735
|
+
transcript: files.transcript,
|
|
736
|
+
chunkCount: summary.chunkCount,
|
|
737
|
+
reason: "Microphone audio was captured. Run record transcribe to extract text with timestamps."
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
function createRecordingFiles() {
|
|
741
|
+
return {
|
|
742
|
+
manifest: "manifest.json",
|
|
743
|
+
runtime: "runtime.jsonl",
|
|
744
|
+
pageSnapshots: "page-snapshots.jsonl",
|
|
745
|
+
domSnapshots: "dom-snapshots.jsonl",
|
|
746
|
+
interactions: "interactions.jsonl",
|
|
747
|
+
audio: "audio.webm",
|
|
748
|
+
audioChunks: "audio-chunks.jsonl",
|
|
749
|
+
audioEvents: "audio-events.jsonl",
|
|
750
|
+
operations: "operations.jsonl",
|
|
751
|
+
transcript: "transcript.json"
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
async function writeRecordingFiles(outputDirectory, manifest, runtimeSamples, pageSnapshots, domSnapshots, interactions, operations) {
|
|
755
|
+
await writeJsonFile(join(outputDirectory, manifest.files.manifest), manifest);
|
|
756
|
+
await writeJsonLines(join(outputDirectory, manifest.files.runtime), runtimeSamples);
|
|
757
|
+
await writeJsonLines(join(outputDirectory, manifest.files.pageSnapshots), pageSnapshots);
|
|
758
|
+
await writeJsonLines(join(outputDirectory, manifest.files.domSnapshots), domSnapshots);
|
|
759
|
+
await writeJsonLines(join(outputDirectory, manifest.files.interactions), interactions);
|
|
760
|
+
await ensureJsonLinesFile(join(outputDirectory, manifest.files.audioChunks));
|
|
761
|
+
await ensureJsonLinesFile(join(outputDirectory, manifest.files.audioEvents));
|
|
762
|
+
await writeJsonLines(join(outputDirectory, manifest.files.operations), operations);
|
|
763
|
+
await ensureJsonFile(join(outputDirectory, manifest.files.transcript), {
|
|
764
|
+
status: manifest.capture.audio.requested ? "pending" : "not-requested",
|
|
765
|
+
...(manifest.capture.audio.requested ? { audio: manifest.files.audio } : {}),
|
|
766
|
+
segments: []
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
async function readRecordingData(outputDirectory) {
|
|
770
|
+
const manifest = await readRecordingManifest(outputDirectory);
|
|
771
|
+
return {
|
|
772
|
+
manifest,
|
|
773
|
+
runtimeSamples: await readJsonLines(join(outputDirectory, manifest.files.runtime)),
|
|
774
|
+
pageSnapshots: await readJsonLines(join(outputDirectory, manifest.files.pageSnapshots)),
|
|
775
|
+
domSnapshots: await readJsonLinesIfExists(join(outputDirectory, manifest.files.domSnapshots)),
|
|
776
|
+
interactions: await readJsonLinesIfExists(join(outputDirectory, manifest.files.interactions)),
|
|
777
|
+
transcript: await readTranscriptData(outputDirectory, manifest.files),
|
|
778
|
+
operations: await readJsonLines(join(outputDirectory, manifest.files.operations))
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
async function readRecordingManifest(outputDirectory) {
|
|
782
|
+
const manifest = await readJsonFile(join(outputDirectory, createRecordingFiles().manifest));
|
|
783
|
+
if (manifest.format !== RECORDING_FORMAT) {
|
|
784
|
+
throw new Error(`Unsupported recording format in ${outputDirectory}.`);
|
|
785
|
+
}
|
|
786
|
+
if (manifest.version !== RECORDING_VERSION) {
|
|
787
|
+
throw new Error(`Unsupported recording version "${manifest.version}".`);
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
...manifest,
|
|
791
|
+
files: {
|
|
792
|
+
...createRecordingFiles(),
|
|
793
|
+
...(manifest.files ?? {})
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
async function readRecordingCounts(outputDirectory, files) {
|
|
798
|
+
const [runtimeSamples, pageSnapshots, domSnapshots, interactions, audioChunks, transcriptSegments, operations] = await Promise.all([
|
|
799
|
+
countJsonLines(join(outputDirectory, files.runtime)),
|
|
800
|
+
countJsonLines(join(outputDirectory, files.pageSnapshots)),
|
|
801
|
+
countJsonLinesIfExists(join(outputDirectory, files.domSnapshots)),
|
|
802
|
+
countJsonLinesIfExists(join(outputDirectory, files.interactions)),
|
|
803
|
+
countJsonLinesIfExists(join(outputDirectory, files.audioChunks)),
|
|
804
|
+
countTranscriptSegments(outputDirectory, files),
|
|
805
|
+
countJsonLines(join(outputDirectory, files.operations))
|
|
806
|
+
]);
|
|
807
|
+
return {
|
|
808
|
+
runtimeSamples,
|
|
809
|
+
pageSnapshots,
|
|
810
|
+
domSnapshots,
|
|
811
|
+
interactions,
|
|
812
|
+
audioChunks,
|
|
813
|
+
transcriptSegments,
|
|
814
|
+
operations
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
async function writeGeneratedScript(recordingDirectory, recording, outputPath) {
|
|
818
|
+
const scriptPath = resolve(outputPath ?? join(recordingDirectory, "generated-script.mjs"));
|
|
819
|
+
const content = createGeneratedScriptContent(recording);
|
|
820
|
+
await mkdir(dirname(scriptPath), { recursive: true });
|
|
821
|
+
await writeFile(scriptPath, content, "utf8");
|
|
822
|
+
await chmod(scriptPath, 0o755);
|
|
823
|
+
return {
|
|
824
|
+
path: scriptPath,
|
|
825
|
+
relativePath: createManifestPath(recordingDirectory, scriptPath)
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function createGeneratedScriptContent(recording) {
|
|
829
|
+
const manifest = recording.manifest;
|
|
830
|
+
const waitTarget = findWaitTarget(recording.runtimeSamples);
|
|
831
|
+
const actionNames = findActionNames(recording.runtimeSamples);
|
|
832
|
+
const pageTitle = findLatestPageTitle(recording.pageSnapshots);
|
|
833
|
+
const recordedUrl = findLatestRuntimeUrl(recording.runtimeSamples);
|
|
834
|
+
const scriptUrl = manifest.url === DEFAULT_RECORD_START_URL
|
|
835
|
+
? recordedUrl ?? (manifest.openedUrl || manifest.url)
|
|
836
|
+
: manifest.openedUrl || manifest.url;
|
|
837
|
+
const waitTargetLiteral = waitTarget === undefined ? "undefined" : JSON.stringify(waitTarget, null, 2);
|
|
838
|
+
const actionsComment = actionNames.length === 0
|
|
839
|
+
? "No runtime actions were discovered in the recording."
|
|
840
|
+
: `Discovered runtime actions: ${actionNames.join(", ")}`;
|
|
841
|
+
const pageComment = pageTitle === undefined ? "No page title was captured." : `Captured page title: ${pageTitle}`;
|
|
842
|
+
const interactionSteps = createInteractionScriptSteps(recording.interactions);
|
|
843
|
+
const interactionComment = interactionSteps.length === 0
|
|
844
|
+
? "No browser interaction events were captured."
|
|
845
|
+
: `Captured browser interaction events: ${recording.interactions.length}`;
|
|
846
|
+
const transcriptComment = createTranscriptScriptComment(recording.transcript);
|
|
847
|
+
return `#!/usr/bin/env node
|
|
848
|
+
import { execFile } from "node:child_process";
|
|
849
|
+
import { promisify } from "node:util";
|
|
850
|
+
|
|
851
|
+
const execFileAsync = promisify(execFile);
|
|
852
|
+
const cli = process.env.OPENRUNTIME_CLI ?? "openruntime";
|
|
853
|
+
const bridgeUrl = ${JSON.stringify(manifest.bridgeUrl)};
|
|
854
|
+
const url = ${JSON.stringify(scriptUrl)};
|
|
855
|
+
const waitTarget = ${waitTargetLiteral};
|
|
856
|
+
|
|
857
|
+
// Generated from an OpenRuntime recording.
|
|
858
|
+
// ${pageComment}
|
|
859
|
+
// ${actionsComment}
|
|
860
|
+
// ${interactionComment}
|
|
861
|
+
// ${transcriptComment}
|
|
862
|
+
|
|
863
|
+
async function run(args) {
|
|
864
|
+
const { stdout, stderr } = await execFileAsync(cli, args, {
|
|
865
|
+
env: process.env
|
|
866
|
+
});
|
|
867
|
+
if (stdout.trim().length > 0) process.stdout.write(stdout);
|
|
868
|
+
if (stderr.trim().length > 0) process.stderr.write(stderr);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function main() {
|
|
872
|
+
await run(["open", url, "--bridge", bridgeUrl, "--ui"]);
|
|
873
|
+
|
|
874
|
+
${interactionSteps.length === 0 ? " // TODO: no click/input events were captured for this recording." : interactionSteps.map((step) => ` ${step}`).join("\n")}
|
|
875
|
+
|
|
876
|
+
if (waitTarget !== undefined) {
|
|
877
|
+
await run([
|
|
878
|
+
"wait-for",
|
|
879
|
+
"--bridge",
|
|
880
|
+
bridgeUrl,
|
|
881
|
+
"--url",
|
|
882
|
+
url,
|
|
883
|
+
waitTarget.targetId,
|
|
884
|
+
waitTarget.status,
|
|
885
|
+
"--timeout",
|
|
886
|
+
"10000"
|
|
887
|
+
]);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
await run(["snapshot", "--bridge", bridgeUrl, "--url", url]);
|
|
891
|
+
await run(["events", "--bridge", bridgeUrl, "--url", url, "--limit", "50"]);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
main().catch((error) => {
|
|
895
|
+
console.error(error);
|
|
896
|
+
process.exitCode = 1;
|
|
897
|
+
});
|
|
898
|
+
`;
|
|
899
|
+
}
|
|
900
|
+
function createTranscriptScriptComment(transcript) {
|
|
901
|
+
if (transcript.segments.length === 0) {
|
|
902
|
+
return transcript.audio === undefined
|
|
903
|
+
? "No voice transcript was captured."
|
|
904
|
+
: `Voice audio was saved to ${transcript.audio}, but no transcript text is available.`;
|
|
905
|
+
}
|
|
906
|
+
const summary = transcript.segments
|
|
907
|
+
.slice(0, 4)
|
|
908
|
+
.map((segment) => `[${segment.startMs}-${segment.endMs}ms] ${segment.text}`)
|
|
909
|
+
.join(" | ");
|
|
910
|
+
return `Voice transcript: ${summary}`;
|
|
911
|
+
}
|
|
912
|
+
function createInteractionScriptSteps(interactions) {
|
|
913
|
+
const steps = [];
|
|
914
|
+
for (const interaction of compactInteractionEvents(interactions)) {
|
|
915
|
+
const selector = interaction.target?.selector;
|
|
916
|
+
if (selector === undefined || selector.length === 0)
|
|
917
|
+
continue;
|
|
918
|
+
if ((interaction.type === "input" || interaction.type === "change") && typeof interaction.target?.value === "string") {
|
|
919
|
+
if (interaction.target.value === "[redacted]") {
|
|
920
|
+
steps.push(`// ${formatInteractionTime(interaction)} skipped redacted input for ${JSON.stringify(selector)}.`);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
steps.push(`await run(["fill", ${JSON.stringify(selector)}, ${JSON.stringify(interaction.target.value)}]); // ${formatInteractionTime(interaction)}`);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (interaction.type === "click") {
|
|
927
|
+
steps.push(`await run(["click", ${JSON.stringify(selector)}]); // ${formatInteractionTime(interaction)}`);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (interaction.type === "keydown" && interaction.key === "Enter") {
|
|
931
|
+
steps.push(`await run(["eval", ${JSON.stringify(createKeyboardEventScript(selector, "Enter"))}]); // ${formatInteractionTime(interaction)}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return steps;
|
|
935
|
+
}
|
|
936
|
+
function normalizeTranscriptSegments(value, fallbackText) {
|
|
937
|
+
if (!Array.isArray(value)) {
|
|
938
|
+
return typeof fallbackText === "string" && fallbackText.length > 0
|
|
939
|
+
? [{ startMs: 0, endMs: 0, text: fallbackText }]
|
|
940
|
+
: [];
|
|
941
|
+
}
|
|
942
|
+
return value
|
|
943
|
+
.map((item) => {
|
|
944
|
+
const record = asRecord(item);
|
|
945
|
+
if (record === undefined)
|
|
946
|
+
return undefined;
|
|
947
|
+
const text = getStringProperty(record, "text");
|
|
948
|
+
if (text === undefined)
|
|
949
|
+
return undefined;
|
|
950
|
+
const start = getNumberProperty(record, "start");
|
|
951
|
+
const end = getNumberProperty(record, "end");
|
|
952
|
+
return {
|
|
953
|
+
startMs: secondsToMs(start),
|
|
954
|
+
endMs: secondsToMs(end),
|
|
955
|
+
text
|
|
956
|
+
};
|
|
957
|
+
})
|
|
958
|
+
.filter((item) => item !== undefined);
|
|
959
|
+
}
|
|
960
|
+
function normalizeTranscriptWords(value) {
|
|
961
|
+
if (!Array.isArray(value))
|
|
962
|
+
return [];
|
|
963
|
+
return value
|
|
964
|
+
.map((item) => {
|
|
965
|
+
const record = asRecord(item);
|
|
966
|
+
if (record === undefined)
|
|
967
|
+
return undefined;
|
|
968
|
+
const text = getStringProperty(record, "word") ?? getStringProperty(record, "text");
|
|
969
|
+
if (text === undefined)
|
|
970
|
+
return undefined;
|
|
971
|
+
return {
|
|
972
|
+
startMs: secondsToMs(getNumberProperty(record, "start")),
|
|
973
|
+
endMs: secondsToMs(getNumberProperty(record, "end")),
|
|
974
|
+
text
|
|
975
|
+
};
|
|
976
|
+
})
|
|
977
|
+
.filter((item) => item !== undefined);
|
|
978
|
+
}
|
|
979
|
+
function secondsToMs(value) {
|
|
980
|
+
return value === undefined ? 0 : Math.max(0, Math.round(value * 1000));
|
|
981
|
+
}
|
|
982
|
+
function compactInteractionEvents(interactions) {
|
|
983
|
+
const output = [];
|
|
984
|
+
for (const interaction of interactions) {
|
|
985
|
+
if (interaction.type === "recorder-ready" || interaction.type === "recorder-error")
|
|
986
|
+
continue;
|
|
987
|
+
const previous = output.at(-1);
|
|
988
|
+
if (previous !== undefined &&
|
|
989
|
+
(interaction.type === "input" || interaction.type === "change") &&
|
|
990
|
+
(previous.type === "input" || previous.type === "change") &&
|
|
991
|
+
previous.target?.selector === interaction.target?.selector &&
|
|
992
|
+
interaction.timeMs - previous.timeMs < 1200) {
|
|
993
|
+
output[output.length - 1] = interaction;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
output.push(interaction);
|
|
997
|
+
}
|
|
998
|
+
return output;
|
|
999
|
+
}
|
|
1000
|
+
function createKeyboardEventScript(selector, key) {
|
|
1001
|
+
return [
|
|
1002
|
+
"(() => {",
|
|
1003
|
+
` const element = document.querySelector(${JSON.stringify(selector)});`,
|
|
1004
|
+
" if (!element) throw new Error('Recorded target was not found.');",
|
|
1005
|
+
" element.focus?.();",
|
|
1006
|
+
` element.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, code: ${JSON.stringify(key)}, bubbles: true, cancelable: true }));`,
|
|
1007
|
+
"})()"
|
|
1008
|
+
].join("\n");
|
|
1009
|
+
}
|
|
1010
|
+
function formatInteractionTime(interaction) {
|
|
1011
|
+
return `t=${Math.round(interaction.timeMs)}ms ${interaction.type}`;
|
|
1012
|
+
}
|
|
1013
|
+
function findWaitTarget(runtimeSamples) {
|
|
1014
|
+
for (const sample of [...runtimeSamples].reverse()) {
|
|
1015
|
+
const snapshot = asRecord(sample.resources?.snapshot);
|
|
1016
|
+
const targets = asRecord(snapshot?.targets);
|
|
1017
|
+
if (targets === undefined)
|
|
1018
|
+
continue;
|
|
1019
|
+
const candidates = [];
|
|
1020
|
+
for (const [targetId, targetValue] of Object.entries(targets)) {
|
|
1021
|
+
const target = asRecord(targetValue);
|
|
1022
|
+
const status = getStringProperty(target, "status");
|
|
1023
|
+
if (status === undefined)
|
|
1024
|
+
continue;
|
|
1025
|
+
candidates.push({ targetId, status });
|
|
1026
|
+
}
|
|
1027
|
+
const readyTarget = candidates.find((candidate) => candidate.status === "ready");
|
|
1028
|
+
if (readyTarget !== undefined)
|
|
1029
|
+
return readyTarget;
|
|
1030
|
+
if (candidates[0] !== undefined)
|
|
1031
|
+
return candidates[0];
|
|
1032
|
+
}
|
|
1033
|
+
return undefined;
|
|
1034
|
+
}
|
|
1035
|
+
function findActionNames(runtimeSamples) {
|
|
1036
|
+
const names = new Set();
|
|
1037
|
+
for (const sample of runtimeSamples) {
|
|
1038
|
+
collectActionNames(sample.resources?.actions, names);
|
|
1039
|
+
}
|
|
1040
|
+
return [...names].slice(0, 8);
|
|
1041
|
+
}
|
|
1042
|
+
function collectActionNames(value, names) {
|
|
1043
|
+
if (Array.isArray(value)) {
|
|
1044
|
+
for (const item of value)
|
|
1045
|
+
collectActionNames(item, names);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
const record = asRecord(value);
|
|
1049
|
+
if (record === undefined)
|
|
1050
|
+
return;
|
|
1051
|
+
const name = getStringProperty(record, "name");
|
|
1052
|
+
if (name !== undefined)
|
|
1053
|
+
names.add(name);
|
|
1054
|
+
collectActionNames(record.actions, names);
|
|
1055
|
+
collectActionNames(record.items, names);
|
|
1056
|
+
}
|
|
1057
|
+
function findLatestPageTitle(pageSnapshots) {
|
|
1058
|
+
for (const sample of [...pageSnapshots].reverse()) {
|
|
1059
|
+
const result = asRecord(sample.result);
|
|
1060
|
+
const title = getStringProperty(result, "title");
|
|
1061
|
+
if (title !== undefined)
|
|
1062
|
+
return title;
|
|
1063
|
+
}
|
|
1064
|
+
return undefined;
|
|
1065
|
+
}
|
|
1066
|
+
function findLatestRuntimeUrl(runtimeSamples) {
|
|
1067
|
+
for (const sample of [...runtimeSamples].reverse()) {
|
|
1068
|
+
const url = sample.runtime?.url;
|
|
1069
|
+
if (url !== undefined && url.length > 0)
|
|
1070
|
+
return url;
|
|
1071
|
+
}
|
|
1072
|
+
return undefined;
|
|
1073
|
+
}
|
|
1074
|
+
function createManifestPath(recordingDirectory, scriptPath) {
|
|
1075
|
+
const relativePath = relative(recordingDirectory, scriptPath);
|
|
1076
|
+
if (relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath)) {
|
|
1077
|
+
return relativePath;
|
|
1078
|
+
}
|
|
1079
|
+
return scriptPath;
|
|
1080
|
+
}
|
|
1081
|
+
function resolveRecordingBridgeUrl(args, defaultBridgeUrl, manifest) {
|
|
1082
|
+
return getOptionValue(args, "bridge") === undefined ? manifest.bridgeUrl : defaultBridgeUrl;
|
|
1083
|
+
}
|
|
1084
|
+
function getRecordUrl(args, subcommand) {
|
|
1085
|
+
const optionUrl = getOptionValue(args, "url");
|
|
1086
|
+
const commandUrl = args.command[subcommand === "start" ? 2 : 1];
|
|
1087
|
+
return optionUrl ?? commandUrl;
|
|
1088
|
+
}
|
|
1089
|
+
function requireRecordUrl(args) {
|
|
1090
|
+
const url = getRecordUrl(args);
|
|
1091
|
+
if (url === undefined || url.length === 0) {
|
|
1092
|
+
throw new Error("Missing required URL. Use open-runtime record --url <url> --out <path>.");
|
|
1093
|
+
}
|
|
1094
|
+
return url;
|
|
1095
|
+
}
|
|
1096
|
+
function resolveRecordStartOutputDirectory(args, startedAt) {
|
|
1097
|
+
const output = getOptionValue(args, "out");
|
|
1098
|
+
if (output !== undefined && output.length > 0)
|
|
1099
|
+
return resolve(output);
|
|
1100
|
+
return resolve("recordings", `openruntime-${formatTimestampForPath(startedAt)}.orrec`);
|
|
1101
|
+
}
|
|
1102
|
+
function formatTimestampForPath(date) {
|
|
1103
|
+
return date.toISOString().replace(/[:.]/gu, "-");
|
|
1104
|
+
}
|
|
1105
|
+
function requireOption(args, name) {
|
|
1106
|
+
const value = getOptionValue(args, name);
|
|
1107
|
+
if (value === undefined || value.length === 0) {
|
|
1108
|
+
throw new Error(`Missing required option "--${name}".`);
|
|
1109
|
+
}
|
|
1110
|
+
return value;
|
|
1111
|
+
}
|
|
1112
|
+
function getPositiveNumberOption(args, name) {
|
|
1113
|
+
const rawValue = getOptionValue(args, name);
|
|
1114
|
+
if (rawValue === undefined)
|
|
1115
|
+
return undefined;
|
|
1116
|
+
const value = Number(rawValue);
|
|
1117
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1118
|
+
throw new Error(`--${name} must be a positive number.`);
|
|
1119
|
+
}
|
|
1120
|
+
return value;
|
|
1121
|
+
}
|
|
1122
|
+
function createRecordRuntimeSelector(url, sessionId, runtimeId) {
|
|
1123
|
+
return {
|
|
1124
|
+
...createOptionalStringProperty("runtimeId", runtimeId),
|
|
1125
|
+
...createOptionalStringProperty("sessionId", sessionId),
|
|
1126
|
+
...createOptionalStringProperty("url", url === undefined || url === DEFAULT_RECORD_START_URL
|
|
1127
|
+
? undefined
|
|
1128
|
+
: withOpenRuntimeSession(url, sessionId))
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
async function sampleRuntime(fetcher, bridgeUrl, selector, sampledAt) {
|
|
1132
|
+
try {
|
|
1133
|
+
const runtimes = await fetchRuntimes(fetcher, bridgeUrl);
|
|
1134
|
+
const runtime = selectRuntime(runtimes, selector);
|
|
1135
|
+
const [targets, snapshot, actions, events] = await Promise.all([
|
|
1136
|
+
fetchRuntimeResource(fetcher, bridgeUrl, runtime, "targets", new URLSearchParams()),
|
|
1137
|
+
fetchRuntimeResource(fetcher, bridgeUrl, runtime, "snapshot", new URLSearchParams()),
|
|
1138
|
+
fetchRuntimeResource(fetcher, bridgeUrl, runtime, "actions", new URLSearchParams()),
|
|
1139
|
+
fetchRuntimeResource(fetcher, bridgeUrl, runtime, "events", createEventsQuery())
|
|
1140
|
+
]);
|
|
1141
|
+
return {
|
|
1142
|
+
sampledAt: sampledAt.toISOString(),
|
|
1143
|
+
ok: true,
|
|
1144
|
+
runtimes,
|
|
1145
|
+
runtime,
|
|
1146
|
+
resources: {
|
|
1147
|
+
targets: targets.result,
|
|
1148
|
+
snapshot: snapshot.result,
|
|
1149
|
+
actions: actions.result,
|
|
1150
|
+
events: events.result
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
catch (error) {
|
|
1155
|
+
return {
|
|
1156
|
+
sampledAt: sampledAt.toISOString(),
|
|
1157
|
+
ok: false,
|
|
1158
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
async function samplePageSnapshot(browserRunner, sampledAt) {
|
|
1163
|
+
const result = await browserRunner.run(["snapshot"]);
|
|
1164
|
+
if (result.exitCode !== 0) {
|
|
1165
|
+
return {
|
|
1166
|
+
sampledAt: sampledAt.toISOString(),
|
|
1167
|
+
ok: false,
|
|
1168
|
+
exitCode: result.exitCode,
|
|
1169
|
+
stdout: result.stdout.trim(),
|
|
1170
|
+
stderr: result.stderr.trim()
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
try {
|
|
1174
|
+
return {
|
|
1175
|
+
sampledAt: sampledAt.toISOString(),
|
|
1176
|
+
ok: true,
|
|
1177
|
+
exitCode: result.exitCode,
|
|
1178
|
+
result: parseBrowserJsonOutput(result.stdout)
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
return {
|
|
1183
|
+
sampledAt: sampledAt.toISOString(),
|
|
1184
|
+
ok: true,
|
|
1185
|
+
exitCode: result.exitCode,
|
|
1186
|
+
stdout: result.stdout.trim(),
|
|
1187
|
+
stderr: result.stderr.trim()
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
async function sampleDomSnapshot(browserRunner, sampledAt) {
|
|
1192
|
+
const result = await browserRunner.run(["eval", createDomSnapshotScript()]);
|
|
1193
|
+
if (result.exitCode !== 0) {
|
|
1194
|
+
return {
|
|
1195
|
+
sampledAt: sampledAt.toISOString(),
|
|
1196
|
+
ok: false,
|
|
1197
|
+
exitCode: result.exitCode,
|
|
1198
|
+
stdout: result.stdout.trim(),
|
|
1199
|
+
stderr: result.stderr.trim()
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
try {
|
|
1203
|
+
return {
|
|
1204
|
+
sampledAt: sampledAt.toISOString(),
|
|
1205
|
+
ok: true,
|
|
1206
|
+
exitCode: result.exitCode,
|
|
1207
|
+
result: parseBrowserJsonOutput(result.stdout)
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
return {
|
|
1212
|
+
sampledAt: sampledAt.toISOString(),
|
|
1213
|
+
ok: true,
|
|
1214
|
+
exitCode: result.exitCode,
|
|
1215
|
+
stdout: result.stdout.trim(),
|
|
1216
|
+
stderr: result.stderr.trim()
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function createDomSnapshotScript() {
|
|
1221
|
+
return [
|
|
1222
|
+
"(() => {",
|
|
1223
|
+
" const html = document.documentElement?.outerHTML ?? '';",
|
|
1224
|
+
" return {",
|
|
1225
|
+
" url: location.href,",
|
|
1226
|
+
" title: document.title,",
|
|
1227
|
+
" capturedAt: Date.now(),",
|
|
1228
|
+
" htmlLength: html.length,",
|
|
1229
|
+
" html: html.slice(0, 200000)",
|
|
1230
|
+
" };",
|
|
1231
|
+
"})()"
|
|
1232
|
+
].join("\n");
|
|
1233
|
+
}
|
|
1234
|
+
function createInteractionRecorderScript(recordingStartedAtMs) {
|
|
1235
|
+
return [
|
|
1236
|
+
"(() => {",
|
|
1237
|
+
" const marker = " + JSON.stringify(RECORD_EVENT_CONSOLE_MARKER) + ";",
|
|
1238
|
+
" const startedAt = " + JSON.stringify(recordingStartedAtMs) + ";",
|
|
1239
|
+
" if (window.__OPENRUNTIME_INTERACTION_RECORDER_INSTALLED__) return;",
|
|
1240
|
+
" window.__OPENRUNTIME_INTERACTION_RECORDER_INSTALLED__ = true;",
|
|
1241
|
+
" const textOf = (value, max = 160) => String(value ?? '').replace(/\\s+/g, ' ').trim().slice(0, max);",
|
|
1242
|
+
" const cssEscape = (value) => {",
|
|
1243
|
+
" if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);",
|
|
1244
|
+
" return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\\\$&');",
|
|
1245
|
+
" };",
|
|
1246
|
+
" const selectorFor = (element) => {",
|
|
1247
|
+
" if (!(element instanceof Element)) return undefined;",
|
|
1248
|
+
" const test = (selector) => {",
|
|
1249
|
+
" try { return document.querySelectorAll(selector).length === 1; } catch { return false; }",
|
|
1250
|
+
" };",
|
|
1251
|
+
" const id = element.getAttribute('id');",
|
|
1252
|
+
" if (id) {",
|
|
1253
|
+
" const selector = `#${cssEscape(id)}`;",
|
|
1254
|
+
" if (test(selector)) return selector;",
|
|
1255
|
+
" }",
|
|
1256
|
+
" const attrs = ['data-testid', 'data-test-id', 'aria-label', 'name', 'placeholder', 'title'];",
|
|
1257
|
+
" for (const attr of attrs) {",
|
|
1258
|
+
" const value = element.getAttribute(attr);",
|
|
1259
|
+
" if (!value) continue;",
|
|
1260
|
+
" const selector = `${element.tagName.toLowerCase()}[${attr}=${JSON.stringify(value)}]`;",
|
|
1261
|
+
" if (test(selector)) return selector;",
|
|
1262
|
+
" }",
|
|
1263
|
+
" const parts = [];",
|
|
1264
|
+
" let current = element;",
|
|
1265
|
+
" while (current && current.nodeType === 1 && current !== document.documentElement) {",
|
|
1266
|
+
" let part = current.tagName.toLowerCase();",
|
|
1267
|
+
" const parent = current.parentElement;",
|
|
1268
|
+
" if (!parent) break;",
|
|
1269
|
+
" const siblings = Array.from(parent.children).filter((item) => item.tagName === current.tagName);",
|
|
1270
|
+
" if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(current) + 1})`;",
|
|
1271
|
+
" parts.unshift(part);",
|
|
1272
|
+
" const selector = parts.join(' > ');",
|
|
1273
|
+
" if (test(selector)) return selector;",
|
|
1274
|
+
" current = parent;",
|
|
1275
|
+
" }",
|
|
1276
|
+
" return parts.join(' > ') || undefined;",
|
|
1277
|
+
" };",
|
|
1278
|
+
" const targetOf = (event) => {",
|
|
1279
|
+
" const element = event.target instanceof Element ? event.target : undefined;",
|
|
1280
|
+
" if (!element) return undefined;",
|
|
1281
|
+
" const input = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element : undefined;",
|
|
1282
|
+
" const value = input ? (input.type === 'password' ? '[redacted]' : input.value) : undefined;",
|
|
1283
|
+
" return {",
|
|
1284
|
+
" selector: selectorFor(element),",
|
|
1285
|
+
" tagName: element.tagName.toLowerCase(),",
|
|
1286
|
+
" role: element.getAttribute('role') ?? undefined,",
|
|
1287
|
+
" name: element.getAttribute('name') ?? undefined,",
|
|
1288
|
+
" inputType: input instanceof HTMLInputElement ? input.type : undefined,",
|
|
1289
|
+
" text: textOf(element.textContent),",
|
|
1290
|
+
" value",
|
|
1291
|
+
" };",
|
|
1292
|
+
" };",
|
|
1293
|
+
" const emit = (type, event, extra = {}) => {",
|
|
1294
|
+
" try {",
|
|
1295
|
+
" const entry = {",
|
|
1296
|
+
" type,",
|
|
1297
|
+
" timeMs: Math.max(0, Date.now() - startedAt),",
|
|
1298
|
+
" url: location.href,",
|
|
1299
|
+
" title: document.title,",
|
|
1300
|
+
" target: targetOf(event),",
|
|
1301
|
+
" ...extra",
|
|
1302
|
+
" };",
|
|
1303
|
+
" console.info(marker + JSON.stringify(entry));",
|
|
1304
|
+
" } catch (error) {",
|
|
1305
|
+
" console.info(marker + JSON.stringify({ type: 'recorder-error', timeMs: Math.max(0, Date.now() - startedAt), message: String(error) }));",
|
|
1306
|
+
" }",
|
|
1307
|
+
" };",
|
|
1308
|
+
" document.addEventListener('click', (event) => emit('click', event, { pointer: { x: event.clientX, y: event.clientY, button: event.button } }), true);",
|
|
1309
|
+
" document.addEventListener('input', (event) => emit('input', event), true);",
|
|
1310
|
+
" document.addEventListener('change', (event) => emit('change', event), true);",
|
|
1311
|
+
" document.addEventListener('keydown', (event) => emit('keydown', event, { key: event.key, code: event.code, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey }), true);",
|
|
1312
|
+
" document.addEventListener('submit', (event) => emit('submit', event), true);",
|
|
1313
|
+
" console.info(marker + JSON.stringify({ type: 'recorder-ready', timeMs: Math.max(0, Date.now() - startedAt), url: location.href, title: document.title }));",
|
|
1314
|
+
"})()"
|
|
1315
|
+
].join("\n");
|
|
1316
|
+
}
|
|
1317
|
+
function parseInteractionEventsFromBrowserLogs(stdout) {
|
|
1318
|
+
const events = [];
|
|
1319
|
+
const seen = new Set();
|
|
1320
|
+
for (const line of stdout.split(/\r?\n/u)) {
|
|
1321
|
+
const markerIndex = line.indexOf(RECORD_EVENT_CONSOLE_MARKER);
|
|
1322
|
+
if (markerIndex < 0)
|
|
1323
|
+
continue;
|
|
1324
|
+
const payload = line.slice(markerIndex + RECORD_EVENT_CONSOLE_MARKER.length).trim();
|
|
1325
|
+
if (payload.length === 0 || seen.has(payload))
|
|
1326
|
+
continue;
|
|
1327
|
+
try {
|
|
1328
|
+
const parsed = JSON.parse(payload);
|
|
1329
|
+
if (typeof parsed.type === "string" && typeof parsed.timeMs === "number") {
|
|
1330
|
+
seen.add(payload);
|
|
1331
|
+
events.push(parsed);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
// Ignore unrelated console lines that happen to contain the marker.
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return events.sort((left, right) => left.timeMs - right.timeMs);
|
|
1339
|
+
}
|
|
1340
|
+
function mergeInteractionEvents(...sources) {
|
|
1341
|
+
const merged = [];
|
|
1342
|
+
const seen = new Set();
|
|
1343
|
+
for (const source of sources) {
|
|
1344
|
+
for (const event of source) {
|
|
1345
|
+
const key = JSON.stringify(event);
|
|
1346
|
+
if (seen.has(key))
|
|
1347
|
+
continue;
|
|
1348
|
+
seen.add(key);
|
|
1349
|
+
merged.push(event);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return merged.sort((left, right) => left.timeMs - right.timeMs);
|
|
1353
|
+
}
|
|
1354
|
+
function createEventsQuery() {
|
|
1355
|
+
const params = new URLSearchParams();
|
|
1356
|
+
params.set("limit", "50");
|
|
1357
|
+
return params;
|
|
1358
|
+
}
|
|
1359
|
+
function hasOption(args, name) {
|
|
1360
|
+
return args.options.has(name);
|
|
1361
|
+
}
|
|
1362
|
+
function withOpenRuntimeSession(input, sessionId) {
|
|
1363
|
+
if (sessionId === undefined || sessionId.length === 0)
|
|
1364
|
+
return input;
|
|
1365
|
+
try {
|
|
1366
|
+
const url = new URL(input);
|
|
1367
|
+
url.searchParams.set(OPEN_RUNTIME_SESSION_QUERY_PARAM, sessionId);
|
|
1368
|
+
return url.toString();
|
|
1369
|
+
}
|
|
1370
|
+
catch {
|
|
1371
|
+
return input;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
async function writeJsonFile(path, value) {
|
|
1375
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
1376
|
+
}
|
|
1377
|
+
async function readJsonFile(path) {
|
|
1378
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
1379
|
+
}
|
|
1380
|
+
async function writeJsonLines(path, values) {
|
|
1381
|
+
await writeFile(path, values.map((value) => JSON.stringify(value)).join("\n") + (values.length === 0 ? "" : "\n"), "utf8");
|
|
1382
|
+
}
|
|
1383
|
+
async function ensureJsonLinesFile(path) {
|
|
1384
|
+
try {
|
|
1385
|
+
await writeFile(path, "", {
|
|
1386
|
+
encoding: "utf8",
|
|
1387
|
+
flag: "wx"
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
catch (error) {
|
|
1391
|
+
if (isNodeError(error) && error.code === "EEXIST")
|
|
1392
|
+
return;
|
|
1393
|
+
throw error;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
async function ensureJsonFile(path, value) {
|
|
1397
|
+
try {
|
|
1398
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, {
|
|
1399
|
+
encoding: "utf8",
|
|
1400
|
+
flag: "wx"
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
catch (error) {
|
|
1404
|
+
if (isNodeError(error) && error.code === "EEXIST")
|
|
1405
|
+
return;
|
|
1406
|
+
throw error;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
async function appendJsonLine(path, value) {
|
|
1410
|
+
await appendFile(path, `${JSON.stringify(value)}\n`, "utf8");
|
|
1411
|
+
}
|
|
1412
|
+
async function readJsonLines(path) {
|
|
1413
|
+
const text = await readFile(path, "utf8");
|
|
1414
|
+
if (text.trim().length === 0)
|
|
1415
|
+
return [];
|
|
1416
|
+
return text
|
|
1417
|
+
.split(/\r?\n/u)
|
|
1418
|
+
.filter((line) => line.trim().length > 0)
|
|
1419
|
+
.map((line) => JSON.parse(line));
|
|
1420
|
+
}
|
|
1421
|
+
async function readJsonLinesIfExists(path) {
|
|
1422
|
+
try {
|
|
1423
|
+
return await readJsonLines(path);
|
|
1424
|
+
}
|
|
1425
|
+
catch (error) {
|
|
1426
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
1427
|
+
return [];
|
|
1428
|
+
throw error;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
async function countJsonLines(path) {
|
|
1432
|
+
return (await readJsonLines(path)).length;
|
|
1433
|
+
}
|
|
1434
|
+
async function countJsonLinesIfExists(path) {
|
|
1435
|
+
return (await readJsonLinesIfExists(path)).length;
|
|
1436
|
+
}
|
|
1437
|
+
async function readTranscriptData(outputDirectory, files) {
|
|
1438
|
+
try {
|
|
1439
|
+
const data = await readJsonFile(join(outputDirectory, files.transcript));
|
|
1440
|
+
return {
|
|
1441
|
+
status: data.status,
|
|
1442
|
+
...createOptionalStringProperty("audio", data.audio),
|
|
1443
|
+
...createOptionalStringProperty("model", data.model),
|
|
1444
|
+
...createOptionalStringProperty("transcribedAt", data.transcribedAt),
|
|
1445
|
+
...createOptionalStringProperty("text", data.text),
|
|
1446
|
+
segments: Array.isArray(data.segments) ? data.segments : [],
|
|
1447
|
+
...(Array.isArray(data.words) ? { words: data.words } : {}),
|
|
1448
|
+
...createOptionalStringProperty("error", data.error)
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
catch (error) {
|
|
1452
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
1453
|
+
return {
|
|
1454
|
+
status: "not-requested",
|
|
1455
|
+
segments: []
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
throw error;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
async function countTranscriptSegments(outputDirectory, files) {
|
|
1462
|
+
return (await readTranscriptData(outputDirectory, files)).segments.length;
|
|
1463
|
+
}
|
|
1464
|
+
function writeJson(stdout, value) {
|
|
1465
|
+
stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
1466
|
+
}
|
|
1467
|
+
function createOptionalNumberProperty(name, value) {
|
|
1468
|
+
return value === undefined ? {} : { [name]: value };
|
|
1469
|
+
}
|
|
1470
|
+
function createOptionalStringProperty(name, value) {
|
|
1471
|
+
return value === undefined ? {} : { [name]: value };
|
|
1472
|
+
}
|
|
1473
|
+
function asRecord(value) {
|
|
1474
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1475
|
+
? value
|
|
1476
|
+
: undefined;
|
|
1477
|
+
}
|
|
1478
|
+
function getStringProperty(record, name) {
|
|
1479
|
+
const value = record?.[name];
|
|
1480
|
+
return typeof value === "string" ? value : undefined;
|
|
1481
|
+
}
|
|
1482
|
+
function getNumberProperty(record, name) {
|
|
1483
|
+
const value = record?.[name];
|
|
1484
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
1485
|
+
}
|
|
1486
|
+
function isNodeError(error) {
|
|
1487
|
+
return error instanceof Error && "code" in error;
|
|
1488
|
+
}
|
|
1489
|
+
function sleep(milliseconds) {
|
|
1490
|
+
return new Promise((resolve) => {
|
|
1491
|
+
setTimeout(resolve, milliseconds);
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
//# sourceMappingURL=record.js.map
|