@a1knowhow/demo-maker 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 +201 -0
- package/README.md +104 -0
- package/SECURITY.md +33 -0
- package/bin/demo-maker.mjs +14 -0
- package/docs/README.md +35 -0
- package/docs/actions.md +160 -0
- package/docs/cli.md +73 -0
- package/docs/getting-started.md +220 -0
- package/docs/imports-and-fragments.md +78 -0
- package/docs/library-api.md +64 -0
- package/docs/locators.md +119 -0
- package/docs/recording.md +59 -0
- package/docs/repairing-scenarios.md +73 -0
- package/docs/scenario-schema.md +80 -0
- package/docs/url-capture.md +75 -0
- package/docs/variables.md +48 -0
- package/docs/video-and-pacing.md +109 -0
- package/examples/demo1-introduction.yaml +166 -0
- package/examples/demo2-github-actions.yaml +109 -0
- package/examples/files/demo1-introduction-run.log +50 -0
- package/examples/fragments/open-repository.yaml +15 -0
- package/examples/smoke.yaml +18 -0
- package/llms.txt +32 -0
- package/package.json +66 -0
- package/src/cli.ts +226 -0
- package/src/env.ts +112 -0
- package/src/export-chapters.ts +99 -0
- package/src/ffmpeg.ts +168 -0
- package/src/index.ts +54 -0
- package/src/load.ts +143 -0
- package/src/logger.ts +126 -0
- package/src/main.ts +68 -0
- package/src/parse-errors.ts +99 -0
- package/src/parse.ts +622 -0
- package/src/playwright-selector-to-locator.ts +328 -0
- package/src/record-cli.ts +179 -0
- package/src/recorder.ts +442 -0
- package/src/recording-to-yaml.ts +435 -0
- package/src/runner.ts +1582 -0
- package/src/selector-to-locator.ts +69 -0
- package/src/templates.ts +48 -0
- package/src/types.ts +319 -0
- package/src/url-capture.ts +105 -0
- package/src/variables.ts +64 -0
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live recorder: use Playwright's official recording API (context._enableRecorder
|
|
3
|
+
* with mode "recording" and recorderMode "api") to capture actions, then save as YAML/JSON.
|
|
4
|
+
* See: https://playwright.dev/docs/codegen and playwright-core server/recorder.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { chromium } from "playwright";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
9
|
+
import { basename, dirname, resolve } from "path";
|
|
10
|
+
import type { DemoStep, Locator } from "./types";
|
|
11
|
+
import type { OurRecordedScenario } from "./recording-to-yaml";
|
|
12
|
+
import {
|
|
13
|
+
composeRecordedOnlyScenario,
|
|
14
|
+
parsedScenarioToYaml,
|
|
15
|
+
recordingJsonToScenario,
|
|
16
|
+
relativeImportPath,
|
|
17
|
+
scenarioToYaml,
|
|
18
|
+
} from "./recording-to-yaml";
|
|
19
|
+
import {
|
|
20
|
+
playwrightActionToDemoStep,
|
|
21
|
+
type PlaywrightRecordedAction,
|
|
22
|
+
} from "./playwright-selector-to-locator";
|
|
23
|
+
import {
|
|
24
|
+
applyBaseUrlOverride,
|
|
25
|
+
buildRunVariables,
|
|
26
|
+
collectFlatPlaceholderKeys,
|
|
27
|
+
DEMO_BASE_URL_ENV_KEY,
|
|
28
|
+
loadEnvFile,
|
|
29
|
+
resolveBaseUrlFromEnv,
|
|
30
|
+
} from "./env";
|
|
31
|
+
import { loadScenario } from "./load";
|
|
32
|
+
import { parseScenario } from "./parse";
|
|
33
|
+
import type { ParsedScenario } from "./types";
|
|
34
|
+
import type { RunContext } from "./types";
|
|
35
|
+
import { runScenarioOnExistingPage } from "./runner";
|
|
36
|
+
|
|
37
|
+
/** True if two locators refer to the same element (for merging type steps). */
|
|
38
|
+
function locatorEquals(a: Locator | undefined, b: Locator | undefined): boolean {
|
|
39
|
+
if (!a || !b || a.kind !== b.kind) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
switch (a.kind) {
|
|
43
|
+
case "role":
|
|
44
|
+
return b.kind === "role" && a.role === b.role && nameEquals(a.name, b.name);
|
|
45
|
+
case "label":
|
|
46
|
+
return b.kind === "label" && nameEquals(a.label, b.label);
|
|
47
|
+
case "text":
|
|
48
|
+
return b.kind === "text" && a.text === b.text;
|
|
49
|
+
case "placeholder":
|
|
50
|
+
return b.kind === "placeholder" && a.placeholder === b.placeholder;
|
|
51
|
+
case "title":
|
|
52
|
+
return b.kind === "title" && a.title === b.title;
|
|
53
|
+
default:
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function nameEquals(x: string | RegExp, y: string | RegExp): boolean {
|
|
58
|
+
if (typeof x === "string" && typeof y === "string") {
|
|
59
|
+
return x === y;
|
|
60
|
+
}
|
|
61
|
+
if (x instanceof RegExp && y instanceof RegExp) {
|
|
62
|
+
return x.source === y.source && x.flags === y.flags;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Action payload from Playwright recorder (actionAdded event): { frame, action }. */
|
|
68
|
+
interface ActionInContext {
|
|
69
|
+
frame?: { pageGuid?: string; framePath?: string[]; pageAlias?: string };
|
|
70
|
+
action: PlaywrightRecordedAction & { name: string; selector?: string; text?: string; options?: string[] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Signal payload from Playwright recorder (signalAdded event): { frame, signal }. */
|
|
74
|
+
interface SignalInContext {
|
|
75
|
+
frame?: { pageGuid?: string };
|
|
76
|
+
signal?: { name: string; url?: string };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RecordOptions {
|
|
80
|
+
saveTo: string;
|
|
81
|
+
saveJson?: string;
|
|
82
|
+
url?: string;
|
|
83
|
+
cwd?: string;
|
|
84
|
+
/** When set, load this scenario YAML, replay its steps, then start recording additional steps (merge on save). */
|
|
85
|
+
continueFrom?: string;
|
|
86
|
+
/** When set with continueFrom, replay with minimal delays (no video pacing). Ignored when not using continueFrom. */
|
|
87
|
+
fast?: boolean;
|
|
88
|
+
/** When set with continueFrom, path to .env-style file for auth and {{ variables }} during replay. */
|
|
89
|
+
envFile?: string;
|
|
90
|
+
/** When set with continueFrom, replay only steps 1..N (1-based inclusive). */
|
|
91
|
+
endStep?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Minimal delay overrides for replay when --fast is used with continue-from. */
|
|
95
|
+
const FAST_REPLAY_DELAYS = {
|
|
96
|
+
step_delay_ms: 0,
|
|
97
|
+
slow_mo_ms: 0,
|
|
98
|
+
typing_delay_ms: 0,
|
|
99
|
+
post_click_delay_ms: 0,
|
|
100
|
+
navigate_hold_ms: 100,
|
|
101
|
+
pre_action_delay_ms: 0,
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
104
|
+
const END_WAIT_MS = 1000;
|
|
105
|
+
|
|
106
|
+
function buildAndSave(
|
|
107
|
+
steps: DemoStep[],
|
|
108
|
+
baseUrl: string,
|
|
109
|
+
saveToPath: string,
|
|
110
|
+
saveJsonPath: string | undefined,
|
|
111
|
+
baseParsed?: ParsedScenario,
|
|
112
|
+
continueFromPath?: string,
|
|
113
|
+
): void {
|
|
114
|
+
const stepsWithEndWait: DemoStep[] = [
|
|
115
|
+
...steps,
|
|
116
|
+
{ action: "wait", timeout_ms: END_WAIT_MS },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
if (baseParsed && continueFromPath) {
|
|
120
|
+
const name = basename(saveToPath, ".yaml").replace(/\.yml$/i, "") || "recorded-scenario";
|
|
121
|
+
const importPath = relativeImportPath(saveToPath, continueFromPath);
|
|
122
|
+
const composed = composeRecordedOnlyScenario(baseParsed, stepsWithEndWait, { importPath, name });
|
|
123
|
+
const yamlContent = parsedScenarioToYaml(composed);
|
|
124
|
+
mkdirSync(dirname(saveToPath), { recursive: true });
|
|
125
|
+
writeFileSync(saveToPath, yamlContent, "utf-8");
|
|
126
|
+
console.log("Saved YAML (imports base + recorded steps only):", saveToPath);
|
|
127
|
+
if (saveJsonPath) {
|
|
128
|
+
const scenario: OurRecordedScenario = {
|
|
129
|
+
title: name,
|
|
130
|
+
base_url: baseParsed.base_url,
|
|
131
|
+
steps: stepsWithEndWait.map((s) => {
|
|
132
|
+
const step: OurRecordedScenario["steps"][number] = { action: s.action };
|
|
133
|
+
if (s.url !== undefined) step.url = s.url;
|
|
134
|
+
if (s.locator !== undefined) step.locator = s.locator;
|
|
135
|
+
if (s.value !== undefined) step.value = s.value;
|
|
136
|
+
if (s.timeout_ms !== undefined) step.timeout_ms = s.timeout_ms;
|
|
137
|
+
if (s.until !== undefined) step.until = s.until;
|
|
138
|
+
if (s.url_pattern !== undefined) step.url_pattern = s.url_pattern;
|
|
139
|
+
if (s.caption !== undefined) step.caption = s.caption;
|
|
140
|
+
if (s.note !== undefined) step.note = s.note;
|
|
141
|
+
return step;
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
mkdirSync(dirname(saveJsonPath), { recursive: true });
|
|
145
|
+
writeFileSync(saveJsonPath, JSON.stringify(scenario, null, 2), "utf-8");
|
|
146
|
+
console.log("Saved JSON:", saveJsonPath);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const title = "recorded-scenario";
|
|
152
|
+
const scenario: OurRecordedScenario = {
|
|
153
|
+
title,
|
|
154
|
+
base_url: baseUrl.startsWith("http") ? baseUrl : "http://localhost:2000",
|
|
155
|
+
steps: stepsWithEndWait.map((s) => {
|
|
156
|
+
const step: OurRecordedScenario["steps"][number] = { action: s.action };
|
|
157
|
+
if (s.url !== undefined) step.url = s.url;
|
|
158
|
+
if (s.locator !== undefined) step.locator = s.locator;
|
|
159
|
+
if (s.value !== undefined) step.value = s.value;
|
|
160
|
+
if (s.timeout_ms !== undefined) step.timeout_ms = s.timeout_ms;
|
|
161
|
+
if (s.until !== undefined) step.until = s.until;
|
|
162
|
+
if (s.url_pattern !== undefined) step.url_pattern = s.url_pattern;
|
|
163
|
+
if (s.caption !== undefined) step.caption = s.caption;
|
|
164
|
+
if (s.note !== undefined) step.note = s.note;
|
|
165
|
+
return step;
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const demoScenario = recordingJsonToScenario(JSON.stringify(scenario), {
|
|
170
|
+
...(scenario.base_url !== undefined ? { base_url: scenario.base_url } : {}),
|
|
171
|
+
});
|
|
172
|
+
demoScenario.video = { enabled: false, dir: "output/videos" };
|
|
173
|
+
demoScenario.step_delay_ms = 500;
|
|
174
|
+
demoScenario.slow_mo_ms = 100;
|
|
175
|
+
demoScenario.typing_delay_ms = 50;
|
|
176
|
+
demoScenario.post_click_delay_ms = 250;
|
|
177
|
+
demoScenario.navigate_hold_ms = 2000;
|
|
178
|
+
demoScenario.pre_action_delay_ms = 300;
|
|
179
|
+
const yamlContent = scenarioToYaml(demoScenario);
|
|
180
|
+
|
|
181
|
+
mkdirSync(dirname(saveToPath), { recursive: true });
|
|
182
|
+
writeFileSync(saveToPath, yamlContent, "utf-8");
|
|
183
|
+
console.log("Saved YAML:", saveToPath);
|
|
184
|
+
|
|
185
|
+
if (saveJsonPath) {
|
|
186
|
+
mkdirSync(dirname(saveJsonPath), { recursive: true });
|
|
187
|
+
writeFileSync(saveJsonPath, JSON.stringify(scenario, null, 2), "utf-8");
|
|
188
|
+
console.log("Saved JSON:", saveJsonPath);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function runLiveRecord(options: RecordOptions): Promise<void> {
|
|
193
|
+
const cwd = options.cwd ?? process.cwd();
|
|
194
|
+
const saveToPath = resolve(cwd, options.saveTo);
|
|
195
|
+
const saveJsonPath = options.saveJson ? resolve(cwd, options.saveJson) : undefined;
|
|
196
|
+
const initialUrl = options.url ?? "http://localhost:2000";
|
|
197
|
+
const continueFrom = options.continueFrom
|
|
198
|
+
? resolve(cwd, options.continueFrom)
|
|
199
|
+
: undefined;
|
|
200
|
+
|
|
201
|
+
console.log("Output paths (cwd:", cwd, "):");
|
|
202
|
+
console.log(" YAML:", saveToPath);
|
|
203
|
+
if (saveJsonPath) {
|
|
204
|
+
console.log(" JSON:", saveJsonPath);
|
|
205
|
+
}
|
|
206
|
+
if (continueFrom) {
|
|
207
|
+
console.log(" Continue from:", continueFrom);
|
|
208
|
+
} else {
|
|
209
|
+
console.log(" Initial URL:", initialUrl);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const steps: DemoStep[] = [];
|
|
213
|
+
let baseUrl = initialUrl.startsWith("http") ? initialUrl : "http://localhost:2000";
|
|
214
|
+
let isRecordingActive = !continueFrom;
|
|
215
|
+
let baseParsed: ParsedScenario | undefined;
|
|
216
|
+
|
|
217
|
+
if (continueFrom) {
|
|
218
|
+
let content: string;
|
|
219
|
+
try {
|
|
220
|
+
content = readFileSync(continueFrom, "utf-8");
|
|
221
|
+
} catch (e) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Failed to read --continue-from file: ${e instanceof Error ? e.message : String(e)}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
baseParsed = parseScenario(content);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Failed to parse --continue-from YAML: ${e instanceof Error ? e.message : String(e)}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let envFromFile: Record<string, string> = {};
|
|
236
|
+
if (options.envFile) {
|
|
237
|
+
let envPath = resolve(cwd, options.envFile);
|
|
238
|
+
if (!existsSync(envPath) && continueFrom) {
|
|
239
|
+
const scenarioDir = dirname(continueFrom);
|
|
240
|
+
const altPath = resolve(scenarioDir, options.envFile);
|
|
241
|
+
if (existsSync(altPath)) {
|
|
242
|
+
envPath = altPath;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
envFromFile = loadEnvFile(envPath);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (baseParsed) {
|
|
249
|
+
applyBaseUrlOverride(baseParsed, { envFromFile });
|
|
250
|
+
baseUrl = baseParsed.base_url;
|
|
251
|
+
} else {
|
|
252
|
+
const resolved = resolveBaseUrlFromEnv(envFromFile);
|
|
253
|
+
if (resolved) {
|
|
254
|
+
baseUrl = resolved;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const browser = await chromium.launch({ headless: false });
|
|
259
|
+
try {
|
|
260
|
+
const viewport = { width: 1280, height: 720 };
|
|
261
|
+
const context = await browser.newContext({
|
|
262
|
+
viewport,
|
|
263
|
+
...(continueFrom && baseParsed ? { baseURL: baseParsed.base_url } : {}),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Use Playwright's official recording API: mode "recording" + recorderMode "api"
|
|
267
|
+
// (no Inspector UI; actions are sent to our eventSink).
|
|
268
|
+
const contextWithRecorder = context as typeof context & {
|
|
269
|
+
_enableRecorder: (
|
|
270
|
+
params: { mode: string; recorderMode: string },
|
|
271
|
+
eventSink: {
|
|
272
|
+
actionAdded?: (page: unknown, data: ActionInContext, code: string) => void;
|
|
273
|
+
actionUpdated?: (page: unknown, data: ActionInContext, code: string) => void;
|
|
274
|
+
signalAdded?: (page: unknown, data: SignalInContext) => void;
|
|
275
|
+
}
|
|
276
|
+
) => Promise<void>;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
await contextWithRecorder._enableRecorder(
|
|
280
|
+
{ mode: "recording", recorderMode: "api" },
|
|
281
|
+
{
|
|
282
|
+
actionAdded(_page, data: ActionInContext, code: string) {
|
|
283
|
+
if (!isRecordingActive) return;
|
|
284
|
+
const act = data?.action;
|
|
285
|
+
if (!act?.name) return;
|
|
286
|
+
const step = playwrightActionToDemoStep(act, code);
|
|
287
|
+
if (step) {
|
|
288
|
+
steps.push(step as DemoStep);
|
|
289
|
+
console.log("[record]", act.name, "steps:", steps.length);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
actionUpdated(_page, data: ActionInContext, code: string) {
|
|
293
|
+
if (!isRecordingActive) return;
|
|
294
|
+
const act = data?.action;
|
|
295
|
+
if (!act?.name || act.name !== "fill") return;
|
|
296
|
+
const step = playwrightActionToDemoStep(act, code);
|
|
297
|
+
if (!step || step.action !== "type" || step.value === undefined) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const last = steps[steps.length - 1];
|
|
301
|
+
if (last?.action === "type" && last.locator && step.locator && locatorEquals(last.locator, step.locator)) {
|
|
302
|
+
last.value = step.value;
|
|
303
|
+
console.log("[record] fill updated, value length:", step.value.length);
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
signalAdded(_page, data: SignalInContext) {
|
|
307
|
+
if (!isRecordingActive) return;
|
|
308
|
+
const sig = data?.signal;
|
|
309
|
+
if (sig?.name === "navigation" && sig.url) {
|
|
310
|
+
try {
|
|
311
|
+
const u = new URL(sig.url);
|
|
312
|
+
const base = new URL(baseUrl);
|
|
313
|
+
const url = u.origin === base.origin ? u.pathname + u.search : sig.url;
|
|
314
|
+
steps.push({ action: "navigate", url });
|
|
315
|
+
console.log("[record] navigate, steps:", steps.length);
|
|
316
|
+
} catch {
|
|
317
|
+
steps.push({ action: "navigate", url: sig.url });
|
|
318
|
+
console.log("[record] navigate, steps:", steps.length);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const page = await context.newPage();
|
|
326
|
+
|
|
327
|
+
if (continueFrom && baseParsed) {
|
|
328
|
+
const scenarioToRun = loadScenario(continueFrom, { cwd });
|
|
329
|
+
applyBaseUrlOverride(scenarioToRun, { envFromFile });
|
|
330
|
+
const n = scenarioToRun.steps.length;
|
|
331
|
+
|
|
332
|
+
let runContext: RunContext = { auth: { email: "", password: "" }, variables: {} };
|
|
333
|
+
if (options.envFile) {
|
|
334
|
+
const emailEnv = scenarioToRun.auth?.email_env ?? "E2E_TEST_USER_EMAIL";
|
|
335
|
+
const passwordEnv = scenarioToRun.auth?.password_env ?? "E2E_TEST_USER_PASSWORD";
|
|
336
|
+
const email = envFromFile[emailEnv] ?? process.env[emailEnv];
|
|
337
|
+
const password = envFromFile[passwordEnv] ?? process.env[passwordEnv];
|
|
338
|
+
if (!email || !password) {
|
|
339
|
+
let envPath = resolve(cwd, options.envFile);
|
|
340
|
+
if (!existsSync(envPath) && continueFrom) {
|
|
341
|
+
const scenarioDir = dirname(continueFrom);
|
|
342
|
+
const altPath = resolve(scenarioDir, options.envFile);
|
|
343
|
+
if (existsSync(altPath)) {
|
|
344
|
+
envPath = altPath;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const hint =
|
|
348
|
+
Object.keys(envFromFile).length === 0
|
|
349
|
+
? `Env file resolved to: ${envPath} (file empty or not found).`
|
|
350
|
+
: `Env file loaded from: ${envPath} but missing ${emailEnv} and/or ${passwordEnv}.`;
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Missing credentials for replay. Set ${emailEnv} and ${passwordEnv} in env or --env-file. ${hint}`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
runContext = {
|
|
356
|
+
auth: { email, password },
|
|
357
|
+
variables: buildRunVariables({
|
|
358
|
+
envFromFile,
|
|
359
|
+
baseUrl: scenarioToRun.base_url,
|
|
360
|
+
allowFromProcess: [
|
|
361
|
+
emailEnv,
|
|
362
|
+
passwordEnv,
|
|
363
|
+
DEMO_BASE_URL_ENV_KEY,
|
|
364
|
+
...collectFlatPlaceholderKeys(JSON.stringify(scenarioToRun)),
|
|
365
|
+
],
|
|
366
|
+
}),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (n === 0) {
|
|
371
|
+
console.log("Base scenario has 0 steps. Recording is active from the start.");
|
|
372
|
+
isRecordingActive = true;
|
|
373
|
+
} else {
|
|
374
|
+
let scenarioForReplay = options.fast
|
|
375
|
+
? { ...scenarioToRun, ...FAST_REPLAY_DELAYS }
|
|
376
|
+
: scenarioToRun;
|
|
377
|
+
const replayCount =
|
|
378
|
+
options.endStep !== undefined
|
|
379
|
+
? Math.min(options.endStep, scenarioForReplay.steps.length)
|
|
380
|
+
: scenarioForReplay.steps.length;
|
|
381
|
+
if (options.endStep !== undefined) {
|
|
382
|
+
console.log(`Replaying steps 1..${replayCount} (--end-step).`);
|
|
383
|
+
}
|
|
384
|
+
if (options.fast) {
|
|
385
|
+
console.log("Replay in fast mode (minimal delays).");
|
|
386
|
+
}
|
|
387
|
+
console.log(`Replaying ${replayCount} steps from base scenario...`);
|
|
388
|
+
const runResult = await runScenarioOnExistingPage(
|
|
389
|
+
page,
|
|
390
|
+
scenarioForReplay,
|
|
391
|
+
runContext,
|
|
392
|
+
{ cwd, endStep: options.endStep }
|
|
393
|
+
);
|
|
394
|
+
if (!runResult.success) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`Replay failed: ${runResult.error ?? "unknown"}`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const u = new URL(page.url());
|
|
401
|
+
baseUrl = u.origin + u.pathname;
|
|
402
|
+
} catch {
|
|
403
|
+
baseUrl = baseParsed.base_url;
|
|
404
|
+
}
|
|
405
|
+
isRecordingActive = true;
|
|
406
|
+
console.log(`Replayed ${replayCount} steps. Recording is now active; perform additional actions, then press Ctrl+C to save.`);
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
await page.goto(initialUrl).catch(() => {});
|
|
410
|
+
try {
|
|
411
|
+
const u = new URL(page.url());
|
|
412
|
+
baseUrl = u.origin + u.pathname;
|
|
413
|
+
steps.push({ action: "navigate", url: u.pathname + u.search || "/" });
|
|
414
|
+
} catch {
|
|
415
|
+
baseUrl = initialUrl;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
console.log("Recording (Playwright recording mode). Interact with the page, then press Ctrl+C to stop and save.");
|
|
420
|
+
const logSteps = () => console.log("Recorded steps so far:", steps.length);
|
|
421
|
+
logSteps();
|
|
422
|
+
const interval = setInterval(logSteps, 3000);
|
|
423
|
+
await new Promise<void>((resolvePromise) => {
|
|
424
|
+
const onExit = () => {
|
|
425
|
+
clearInterval(interval);
|
|
426
|
+
process.off("SIGINT", onExit);
|
|
427
|
+
process.off("SIGTERM", onExit);
|
|
428
|
+
console.log("Stopping... recorded", steps.length, "steps. Saving to", saveToPath);
|
|
429
|
+
try {
|
|
430
|
+
buildAndSave(steps, baseUrl, saveToPath, saveJsonPath, baseParsed, continueFrom);
|
|
431
|
+
} catch (e) {
|
|
432
|
+
console.error("Save failed:", e instanceof Error ? e.message : e);
|
|
433
|
+
}
|
|
434
|
+
resolvePromise();
|
|
435
|
+
};
|
|
436
|
+
process.on("SIGINT", onExit);
|
|
437
|
+
process.on("SIGTERM", onExit);
|
|
438
|
+
});
|
|
439
|
+
} finally {
|
|
440
|
+
await browser.close();
|
|
441
|
+
}
|
|
442
|
+
}
|