@originator/playflow 1.0.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.
Files changed (3) hide show
  1. package/README.md +106 -0
  2. package/dist/playflow.js +723 -0
  3. package/package.json +37 -0
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # Playflow
2
+
3
+ Run Playwright browser flows from a JSON file.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install
9
+ ```
10
+
11
+ During install, Playwright will download the Chromium browser automatically.
12
+
13
+ If you prefer to skip browser downloads (CI or custom caching), set:
14
+
15
+ ```bash
16
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install
17
+ ```
18
+
19
+ Then install Chromium separately:
20
+
21
+ ```bash
22
+ npx playwright install chromium
23
+ ```
24
+
25
+ ## Build
26
+
27
+ ```bash
28
+ npm run build
29
+ ```
30
+
31
+ ## Run
32
+
33
+ ```bash
34
+ node dist/playflow.js flow_send_direct_message.json --headed
35
+ node dist/playflow.js https://example.com/flow.json --headless
36
+ ```
37
+
38
+ Or after publishing:
39
+
40
+ ```bash
41
+ npx playflow flow_send_direct_message.json --headed
42
+ npx playflow https://example.com/flow.json --headless
43
+
44
+ If you run with no arguments, Playflow will prompt for a file path or URL:
45
+
46
+ ```bash
47
+ npx playflow
48
+ ```
49
+ ```
50
+
51
+ ## CLI options
52
+
53
+ - `--headed` / `--headless`
54
+ - `--slowmo=ms`
55
+ - `--timeout=ms`
56
+ - `--debug`
57
+
58
+ Chromium is the only supported browser.
59
+
60
+ ## Flow file format
61
+
62
+ Example:
63
+
64
+ ```json
65
+ {
66
+ "headless": false,
67
+ "steps": [
68
+ { "type": "goto", "url": "https://example.com" },
69
+ { "type": "click", "selector": "text=Sign in" },
70
+ { "type": "fill", "selector": "#email", "value": "user@example.com" }
71
+ ]
72
+ }
73
+ ```
74
+
75
+ ### Supported steps
76
+
77
+ - `goto`: `{ "type": "goto", "url": "https://..." }`
78
+ - `click`: `{ "type": "click", "selector": "..." }`
79
+ - `hover`: `{ "type": "hover", "selector": "..." }`
80
+ - `choose_file`: `{ "type": "choose_file", "selector": "...", "file_name": "file.txt" }`
81
+ - `fill`: `{ "type": "fill", "selector": "...", "value": "..." }`
82
+ - `waitForSelector`: `{ "type": "waitForSelector", "selector": "...", "timeoutMs": 10000 }`
83
+ - `focus`: `{ "type": "focus", "selector": "..." }`
84
+ - `screenshot`: `{ "type": "screenshot", "path": "optional-name.png" }`
85
+ - `keyboard_press`: `{ "type": "keyboard_press", "keys": "Enter" }`
86
+ - `keyboard_type`: `{ "type": "keyboard_type", "text": "hello" }`
87
+ - `keyboard_down`: `{ "type": "keyboard_down", "keys": "Shift" }`
88
+ - `waitForTimeout`: `{ "type": "waitForTimeout", "ms": 1000 }`
89
+ - `waitForLoadState`: `{ "type": "waitForLoadState", "state": "networkidle" }`
90
+
91
+ ### Role-based selectors (optional)
92
+
93
+ Some steps accept a `role` field:
94
+
95
+ ```json
96
+ {
97
+ "type": "click",
98
+ "selector": "button",
99
+ "role": { "type": "button", "opts": { "name": "Save" } }
100
+ }
101
+ ```
102
+
103
+ ## Debug output
104
+
105
+ Screenshots are saved to `debug-output/` with timestamps. On step failures, a
106
+ DOM snapshot and error screenshot are captured.
@@ -0,0 +1,723 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const os_1 = require("os");
10
+ const http_1 = __importDefault(require("http"));
11
+ const https_1 = __importDefault(require("https"));
12
+ const readline_1 = __importDefault(require("readline"));
13
+ const child_process_1 = require("child_process");
14
+ const playwright_1 = require("playwright");
15
+ function parseArgs(argv) {
16
+ const args = { _: [] };
17
+ for (let i = 0; i < argv.length; i += 1) {
18
+ const arg = argv[i];
19
+ if (arg === "--headless") {
20
+ args.headless = true;
21
+ }
22
+ else if (arg === "--headed") {
23
+ args.headless = false;
24
+ }
25
+ else if (arg.startsWith("--slowmo=")) {
26
+ args.slowMo = Number(arg.split("=")[1]);
27
+ }
28
+ else if (arg === "--slowmo") {
29
+ args.slowMo = Number(argv[i + 1]);
30
+ i += 1;
31
+ }
32
+ else if (arg.startsWith("--timeout=")) {
33
+ args.timeout = Number(arg.split("=")[1]);
34
+ }
35
+ else if (arg === "--timeout") {
36
+ args.timeout = Number(argv[i + 1]);
37
+ i += 1;
38
+ }
39
+ else if (arg === "--debug") {
40
+ args.debug = true;
41
+ }
42
+ else if (arg === "-h" || arg === "--help") {
43
+ args.help = true;
44
+ }
45
+ else if (arg.startsWith("-")) {
46
+ args.unknown = args.unknown || [];
47
+ args.unknown.push(arg);
48
+ }
49
+ else {
50
+ args._.push(arg);
51
+ }
52
+ }
53
+ return args;
54
+ }
55
+ function printUsage() {
56
+ console.log([
57
+ "Usage:",
58
+ " playflow <flow.json|flow-url> [--headed|--headless]",
59
+ " [--slowmo=ms] [--timeout=ms] [--debug]",
60
+ "",
61
+ "Example:",
62
+ " playflow flow_send_direct_message.json --headed --slowmo=100 --debug",
63
+ " playflow https://example.com/flow.json --headless",
64
+ ].join("\n"));
65
+ }
66
+ const ui = {
67
+ reset: "\u001b[0m",
68
+ bold: "\u001b[1m",
69
+ dim: "\u001b[2m",
70
+ cyan: "\u001b[36m",
71
+ green: "\u001b[32m",
72
+ yellow: "\u001b[33m",
73
+ };
74
+ function formatBox(lines) {
75
+ const contentWidth = Math.max(...lines.map((line) => line.length), 0);
76
+ const top = `+${"-".repeat(contentWidth + 2)}+`;
77
+ const bottom = top;
78
+ return [
79
+ top,
80
+ ...lines.map((line) => `| ${line.padEnd(contentWidth, " ")} |`),
81
+ bottom,
82
+ ];
83
+ }
84
+ function clearScreen() {
85
+ if (process.stdout.isTTY) {
86
+ process.stdout.write("\u001b[2J\u001b[H");
87
+ }
88
+ }
89
+ async function selectSimpleMenu(title, options, fallback) {
90
+ if (!process.stdin.isTTY ||
91
+ !process.stdout.isTTY ||
92
+ typeof process.stdin.setRawMode !== "function") {
93
+ return fallback;
94
+ }
95
+ process.stdin.resume();
96
+ readline_1.default.emitKeypressEvents(process.stdin);
97
+ process.stdin.setRawMode(true);
98
+ let selected = 0;
99
+ const render = () => {
100
+ clearScreen();
101
+ console.log(`${ui.cyan}${ui.bold}Playflow${ui.reset} ${ui.dim}- ${title}${ui.reset}`);
102
+ console.log("");
103
+ options.forEach((option, index) => {
104
+ const isActive = index === selected;
105
+ const cursor = isActive ? `${ui.green}›${ui.reset}` : " ";
106
+ const label = isActive ? `${ui.bold}${option.label}${ui.reset}` : option.label;
107
+ const hint = option.hint ? ` ${ui.dim}${option.hint}${ui.reset}` : "";
108
+ console.log(` ${cursor} ${label}${hint}`);
109
+ });
110
+ console.log("");
111
+ console.log(`${ui.dim}Use ↑/↓ to move, Enter to select, Q to quit.${ui.reset}`);
112
+ };
113
+ return new Promise((resolve, reject) => {
114
+ const cleanup = () => {
115
+ process.stdin.setRawMode(false);
116
+ process.stdin.removeListener("keypress", onKeypress);
117
+ process.stdin.pause();
118
+ };
119
+ const onKeypress = (_, key) => {
120
+ if (key.ctrl && key.name === "c") {
121
+ cleanup();
122
+ process.exit(1);
123
+ }
124
+ if (key.name === "up") {
125
+ selected = (selected - 1 + options.length) % options.length;
126
+ render();
127
+ return;
128
+ }
129
+ if (key.name === "down") {
130
+ selected = (selected + 1) % options.length;
131
+ render();
132
+ return;
133
+ }
134
+ if (key.name === "return") {
135
+ const choice = options[selected];
136
+ cleanup();
137
+ clearScreen();
138
+ resolve(choice.value);
139
+ return;
140
+ }
141
+ if (key.name === "q") {
142
+ cleanup();
143
+ clearScreen();
144
+ console.log("Bye!");
145
+ process.exit(0);
146
+ }
147
+ };
148
+ process.stdin.on("keypress", onKeypress);
149
+ render();
150
+ });
151
+ }
152
+ async function selectMenu(options) {
153
+ if (!process.stdin.isTTY ||
154
+ !process.stdout.isTTY ||
155
+ typeof process.stdin.setRawMode !== "function") {
156
+ return "file";
157
+ }
158
+ process.stdin.resume();
159
+ readline_1.default.emitKeypressEvents(process.stdin);
160
+ process.stdin.setRawMode(true);
161
+ let selected = 0;
162
+ const render = () => {
163
+ clearScreen();
164
+ console.log(`${ui.cyan}${ui.bold}Playflow${ui.reset} ${ui.dim}- choose input type${ui.reset}`);
165
+ console.log("");
166
+ options.forEach((option, index) => {
167
+ const isActive = index === selected;
168
+ const cursor = isActive ? `${ui.green}›${ui.reset}` : " ";
169
+ const label = isActive ? `${ui.bold}${option.label}${ui.reset}` : option.label;
170
+ const hint = option.hint ? ` ${ui.dim}${option.hint}${ui.reset}` : "";
171
+ console.log(` ${cursor} ${label}${hint}`);
172
+ });
173
+ console.log("");
174
+ console.log(`${ui.dim}Use ↑/↓ to move, Enter to select, Q to quit.${ui.reset}`);
175
+ };
176
+ return new Promise((resolve, reject) => {
177
+ const cleanup = () => {
178
+ process.stdin.setRawMode(false);
179
+ process.stdin.removeListener("keypress", onKeypress);
180
+ process.stdin.pause();
181
+ };
182
+ const onKeypress = (_, key) => {
183
+ if (key.ctrl && key.name === "c") {
184
+ cleanup();
185
+ process.exit(1);
186
+ }
187
+ if (key.name === "up") {
188
+ selected = (selected - 1 + options.length) % options.length;
189
+ render();
190
+ return;
191
+ }
192
+ if (key.name === "down") {
193
+ selected = (selected + 1) % options.length;
194
+ render();
195
+ return;
196
+ }
197
+ if (key.name === "return") {
198
+ const choice = options[selected];
199
+ cleanup();
200
+ clearScreen();
201
+ resolve(choice.value);
202
+ return;
203
+ }
204
+ if (key.name === "q") {
205
+ cleanup();
206
+ clearScreen();
207
+ console.log("Bye!");
208
+ process.exit(0);
209
+ }
210
+ };
211
+ process.stdin.on("keypress", onKeypress);
212
+ render();
213
+ });
214
+ }
215
+ async function promptRunAgain() {
216
+ const rl = readline_1.default.createInterface({
217
+ input: process.stdin,
218
+ output: process.stdout,
219
+ });
220
+ try {
221
+ const answer = (await askQuestion(rl, `${ui.bold}Run another flow?${ui.reset} (y/N): `))
222
+ .trim()
223
+ .toLowerCase();
224
+ return answer === "y" || answer === "yes";
225
+ }
226
+ finally {
227
+ rl.close();
228
+ }
229
+ }
230
+ function tryMacFolderPicker() {
231
+ if (process.platform !== "darwin")
232
+ return null;
233
+ try {
234
+ const result = (0, child_process_1.execSync)('osascript -e \'POSIX path of (choose folder with prompt "Select a folder to save the video")\'', { stdio: ["ignore", "pipe", "ignore"] })
235
+ .toString("utf8")
236
+ .trim();
237
+ return result || null;
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ }
243
+ async function promptForFolderPath(prompt) {
244
+ const picked = tryMacFolderPicker();
245
+ if (picked)
246
+ return picked;
247
+ const rl = readline_1.default.createInterface({
248
+ input: process.stdin,
249
+ output: process.stdout,
250
+ });
251
+ try {
252
+ const answer = (await askQuestion(rl, prompt)).trim();
253
+ return answer || null;
254
+ }
255
+ finally {
256
+ rl.close();
257
+ }
258
+ }
259
+ async function promptForVideoChoice() {
260
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
261
+ return { enabled: false };
262
+ }
263
+ const choice = await selectSimpleMenu("Record a video?", [
264
+ { label: "No", value: "no", hint: "(default)" },
265
+ { label: "Yes", value: "yes", hint: "(save after run)" },
266
+ ], "no");
267
+ if (choice === "yes") {
268
+ const dir = fs_1.default.mkdtempSync(path_1.default.join((0, os_1.tmpdir)(), "playflow-video-"));
269
+ return { enabled: true, dir };
270
+ }
271
+ return { enabled: false };
272
+ }
273
+ function printWelcome() {
274
+ clearScreen();
275
+ const header = `${ui.cyan}${ui.bold}Playflow${ui.reset}`;
276
+ const subtitle = `${ui.dim}Run browser flows from JSON${ui.reset}`;
277
+ const body = [
278
+ `${ui.bold}Quick start${ui.reset}`,
279
+ `- File: ${ui.dim}./artifact/test_flow.json${ui.reset}`,
280
+ `- URL : ${ui.dim}https://example.com/flow.json${ui.reset}`,
281
+ "",
282
+ `${ui.dim}Tip:${ui.reset} add ${ui.bold}--headed${ui.reset} or ${ui.bold}--headless${ui.reset} to override.`,
283
+ ];
284
+ const box = formatBox([
285
+ `${header} ${subtitle}`,
286
+ "",
287
+ ...body,
288
+ ]);
289
+ console.log(["", ...box, ""].join("\n"));
290
+ }
291
+ function getDebugOutputDir() {
292
+ const debugDir = path_1.default.join(process.cwd(), "debug-output");
293
+ if (!fs_1.default.existsSync(debugDir)) {
294
+ fs_1.default.mkdirSync(debugDir, { recursive: true });
295
+ }
296
+ return debugDir;
297
+ }
298
+ function getScreenshotPath(originalPath) {
299
+ const debugDir = getDebugOutputDir();
300
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
301
+ const baseName = originalPath
302
+ ? originalPath.replace(/\.png$/i, "")
303
+ : "screenshot";
304
+ return path_1.default.join(debugDir, `${baseName}_${timestamp}.png`);
305
+ }
306
+ function extractUsefulDom(html, maxLength = 10000) {
307
+ let cleaned = html.replace(/<!DOCTYPE[^>]*>/gi, "");
308
+ cleaned = cleaned.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, "");
309
+ cleaned = cleaned.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
310
+ cleaned = cleaned.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
311
+ cleaned = cleaned.replace(/<link[^>]*\/?>/gi, "");
312
+ cleaned = cleaned.replace(/<meta[^>]*\/?>/gi, "");
313
+ cleaned = cleaned.replace(/<noscript[^>]*>[\s\S]*?<\/noscript>/gi, "");
314
+ cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, "");
315
+ cleaned = cleaned.replace(/\s+style="[^"]*"/gi, "");
316
+ cleaned = cleaned.replace(/\s+class="([^"]*)"/gi, (match, classes) => {
317
+ if (classes.length < 50)
318
+ return match;
319
+ const meaningfulClasses = classes
320
+ .split(/\s+/)
321
+ .filter((cls) => !cls.match(/^(flex|grid|p-|m-|w-|h-|bg-|text-|border-|rounded-|shadow-|hover:|focus:|active:|min-|max-|overflow-|transition-|duration-|ease-|transform|translate|scale|rotate|skew|origin-|cursor-|pointer-|select-|resize-|fill-|stroke-|opacity-|z-|gap-|space-|divide-|place-|justify-|items-|content-|self-|order-|col-|row-|auto-|sr-|not-|group-|peer-|first:|last:|odd:|even:|disabled:|checked:|required:|invalid:|placeholder:|before:|after:|dark:|sm:|md:|lg:|xl:|2xl:)/))
322
+ .join(" ");
323
+ return meaningfulClasses ? ` class="${meaningfulClasses}"` : "";
324
+ });
325
+ cleaned = cleaned.replace(/\s+data-(?!testid)[a-z-]+="[^"]*"/gi, "");
326
+ cleaned = cleaned.replace(/\s+/g, " ");
327
+ cleaned = cleaned.replace(/<(\w+)[^>]*>\s*<\/\1>/g, "");
328
+ cleaned = cleaned.trim();
329
+ if (cleaned.length > maxLength) {
330
+ const cutPoint = cleaned.lastIndexOf(">", maxLength);
331
+ if (cutPoint > maxLength * 0.8) {
332
+ cleaned = cleaned.substring(0, cutPoint + 1) + "... [truncated]";
333
+ }
334
+ else {
335
+ cleaned = cleaned.substring(0, maxLength) + "... [truncated]";
336
+ }
337
+ }
338
+ return cleaned;
339
+ }
340
+ function isUrl(value) {
341
+ return /^https?:\/\//i.test(value);
342
+ }
343
+ function askQuestion(rl, prompt) {
344
+ return new Promise((resolve) => rl.question(prompt, resolve));
345
+ }
346
+ function tryMacFilePicker() {
347
+ if (process.platform !== "darwin")
348
+ return null;
349
+ try {
350
+ const result = (0, child_process_1.execSync)('osascript -e \'POSIX path of (choose file with prompt "Select a Playflow JSON file")\'', { stdio: ["ignore", "pipe", "ignore"] })
351
+ .toString("utf8")
352
+ .trim();
353
+ return result || null;
354
+ }
355
+ catch {
356
+ return null;
357
+ }
358
+ }
359
+ async function promptForFilePath(rl) {
360
+ const picked = tryMacFilePicker();
361
+ if (picked)
362
+ return picked;
363
+ while (true) {
364
+ const answer = (await askQuestion(rl, `${ui.bold}File path${ui.reset}: `)).trim();
365
+ if (!answer) {
366
+ console.log(`${ui.yellow}Please enter a file path.${ui.reset}`);
367
+ continue;
368
+ }
369
+ const localPath = path_1.default.resolve(process.cwd(), answer);
370
+ if (!fs_1.default.existsSync(localPath)) {
371
+ console.log(`${ui.yellow}File not found:${ui.reset} ${localPath}`);
372
+ continue;
373
+ }
374
+ return answer;
375
+ }
376
+ }
377
+ async function promptForUrl(rl) {
378
+ while (true) {
379
+ const answer = (await askQuestion(rl, `${ui.bold}Flow URL${ui.reset}: `)).trim();
380
+ if (!answer) {
381
+ console.log(`${ui.yellow}Please enter a URL.${ui.reset}`);
382
+ continue;
383
+ }
384
+ if (!isUrl(answer)) {
385
+ console.log(`${ui.yellow}URL must start with http:// or https://.${ui.reset}`);
386
+ continue;
387
+ }
388
+ return answer;
389
+ }
390
+ }
391
+ async function promptForFlowInput() {
392
+ const rl = readline_1.default.createInterface({
393
+ input: process.stdin,
394
+ output: process.stdout,
395
+ });
396
+ try {
397
+ while (true) {
398
+ let selection;
399
+ try {
400
+ selection = await selectMenu([
401
+ { label: "File", value: "file", hint: "(open selector / path)" },
402
+ { label: "URL", value: "url", hint: "(https://...)" },
403
+ ]);
404
+ }
405
+ catch {
406
+ console.log(`${ui.yellow}Selection cancelled.${ui.reset}`);
407
+ process.exit(1);
408
+ }
409
+ if (selection === "file") {
410
+ const filePath = await promptForFilePath(rl);
411
+ return { kind: "file", value: filePath };
412
+ }
413
+ if (selection === "url") {
414
+ const url = await promptForUrl(rl);
415
+ return { kind: "url", value: url };
416
+ }
417
+ console.log(`${ui.yellow}Please choose a valid option.${ui.reset}`);
418
+ }
419
+ }
420
+ finally {
421
+ rl.close();
422
+ }
423
+ }
424
+ function fetchJsonFromUrl(url) {
425
+ return new Promise((resolve, reject) => {
426
+ const client = url.startsWith("https://") ? https_1.default : http_1.default;
427
+ client
428
+ .get(url, (res) => {
429
+ if (!res.statusCode || res.statusCode >= 400) {
430
+ reject(new Error(`Failed to fetch flow URL (${res.statusCode ?? "unknown"})`));
431
+ res.resume();
432
+ return;
433
+ }
434
+ const chunks = [];
435
+ res.on("data", (chunk) => chunks.push(chunk));
436
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
437
+ })
438
+ .on("error", (err) => reject(err));
439
+ });
440
+ }
441
+ async function loadFlowFromSource(source) {
442
+ if (isUrl(source)) {
443
+ const body = await fetchJsonFromUrl(source);
444
+ return JSON.parse(body);
445
+ }
446
+ const flowPath = path_1.default.resolve(process.cwd(), source);
447
+ return JSON.parse(fs_1.default.readFileSync(flowPath, "utf8"));
448
+ }
449
+ async function loadFlowFromInput(input) {
450
+ if (input.kind === "url") {
451
+ const body = await fetchJsonFromUrl(input.value);
452
+ return JSON.parse(body);
453
+ }
454
+ const flowPath = path_1.default.resolve(process.cwd(), input.value);
455
+ return JSON.parse(fs_1.default.readFileSync(flowPath, "utf8"));
456
+ }
457
+ async function getElement(selector, role, page) {
458
+ let element = page.locator(selector);
459
+ if (role) {
460
+ element = element.getByRole(role.type, role.opts);
461
+ }
462
+ const el = element.first();
463
+ if (!el) {
464
+ throw new Error(`Element not found: ${selector}`);
465
+ }
466
+ await el.scrollIntoViewIfNeeded();
467
+ return el;
468
+ }
469
+ async function runStep(step, page) {
470
+ switch (step.type) {
471
+ case "goto":
472
+ console.log(`goto ${step.url}`);
473
+ await page.goto(step.url);
474
+ break;
475
+ case "click":
476
+ console.log(`click ${step.selector}`);
477
+ await (await getElement(step.selector, step.role, page)).click();
478
+ break;
479
+ case "hover":
480
+ console.log(`hover ${step.selector}`);
481
+ await (await getElement(step.selector, step.role, page)).hover();
482
+ break;
483
+ case "choose_file": {
484
+ console.log(`choose_file ${step.selector} ${step.file_name}`);
485
+ const fileChooserPromise = page.waitForEvent("filechooser");
486
+ await (await getElement(step.selector, step.role, page)).click();
487
+ const fileChooser = await fileChooserPromise;
488
+ const tempFilePath = path_1.default.join((0, os_1.tmpdir)(), `${step.file_name}`);
489
+ const folderPath = path_1.default.join((0, os_1.tmpdir)(), step.file_name.split("/").slice(0, -1).join("/"));
490
+ if (folderPath && !fs_1.default.existsSync(folderPath)) {
491
+ fs_1.default.mkdirSync(folderPath, { recursive: true });
492
+ }
493
+ fs_1.default.writeFileSync(tempFilePath, `Hello, world! ${step.file_name}`);
494
+ await fileChooser.setFiles(tempFilePath);
495
+ break;
496
+ }
497
+ case "fill":
498
+ console.log(`fill ${step.selector}`);
499
+ await (await getElement(step.selector, step.role, page)).fill(step.value);
500
+ break;
501
+ case "waitForSelector":
502
+ console.log(`waitForSelector ${step.selector} ${step.timeoutMs ?? 10000}`);
503
+ await (await getElement(step.selector, step.role, page)).waitFor({
504
+ timeout: step.timeoutMs ?? 10000,
505
+ });
506
+ break;
507
+ case "focus":
508
+ console.log(`focus ${step.selector}`);
509
+ await (await getElement(step.selector, step.role, page)).focus();
510
+ break;
511
+ case "screenshot": {
512
+ const screenshotPath = getScreenshotPath(step.path);
513
+ await page.screenshot({ path: screenshotPath });
514
+ console.log(`Screenshot saved to: ${screenshotPath}`);
515
+ break;
516
+ }
517
+ case "keyboard_press":
518
+ console.log(`keyboard_press ${step.keys}`);
519
+ await page.keyboard.press(step.keys);
520
+ break;
521
+ case "keyboard_type":
522
+ console.log(`keyboard_type ${step.text}`);
523
+ await page.keyboard.type(step.text);
524
+ break;
525
+ case "keyboard_down":
526
+ console.log(`keyboard_down ${step.keys}`);
527
+ await page.keyboard.down(step.keys);
528
+ break;
529
+ case "waitForTimeout":
530
+ await page.waitForTimeout(step.ms);
531
+ break;
532
+ case "waitForLoadState":
533
+ await page.waitForLoadState(step.state, { timeout: step.timeout });
534
+ break;
535
+ default: {
536
+ const unreachable = step;
537
+ throw new Error(`Unsupported step type: ${unreachable}`);
538
+ }
539
+ }
540
+ }
541
+ async function captureFailureContext(page, step, stepIndex, stepsTotal, error) {
542
+ const pageUrl = page.url();
543
+ let screenshotPath;
544
+ let domSnapshot;
545
+ try {
546
+ const screenshotBuffer = await page.screenshot({ fullPage: true });
547
+ screenshotPath = getScreenshotPath(`error_step${stepIndex + 1}_${step.type}`);
548
+ fs_1.default.writeFileSync(screenshotPath, screenshotBuffer);
549
+ }
550
+ catch {
551
+ console.warn("Failed to capture screenshot after step failure");
552
+ }
553
+ try {
554
+ const content = await page.content();
555
+ domSnapshot = extractUsefulDom(content, 8000);
556
+ }
557
+ catch {
558
+ console.warn("Failed to capture DOM snapshot after step failure");
559
+ }
560
+ const errorMessage = error instanceof Error ? error.message : String(error);
561
+ const selector = "selector" in step ? step.selector : undefined;
562
+ console.error("Step execution failed", {
563
+ stepIndex,
564
+ stepType: step.type,
565
+ selector,
566
+ pageUrl,
567
+ error: errorMessage,
568
+ message: `Step ${stepIndex + 1}/${stepsTotal} failed`,
569
+ });
570
+ return {
571
+ stepIndex,
572
+ stepType: step.type,
573
+ selector,
574
+ pageUrl,
575
+ errorMessage,
576
+ screenshotPath,
577
+ domSnapshot,
578
+ };
579
+ }
580
+ async function main() {
581
+ const args = parseArgs(process.argv.slice(2));
582
+ if (args.help) {
583
+ printUsage();
584
+ process.exit(0);
585
+ }
586
+ if (args.unknown?.length) {
587
+ console.error(`Unknown flags: ${args.unknown.join(", ")}`);
588
+ printUsage();
589
+ process.exit(1);
590
+ }
591
+ const interactive = args._.length === 0;
592
+ const runOnce = async () => {
593
+ if (interactive) {
594
+ printWelcome();
595
+ const input = await promptForFlowInput();
596
+ return await loadFlowFromInput(input);
597
+ }
598
+ const flowSource = args._[0];
599
+ return await loadFlowFromSource(flowSource);
600
+ };
601
+ while (true) {
602
+ const flow = await runOnce();
603
+ const videoConfig = interactive ? await promptForVideoChoice() : { enabled: false };
604
+ if (!Array.isArray(flow.steps)) {
605
+ throw new Error("Flow file must include a steps array.");
606
+ }
607
+ const headless = typeof args.headless === "boolean"
608
+ ? args.headless
609
+ : typeof flow.headless === "boolean"
610
+ ? flow.headless
611
+ : false;
612
+ const slowMo = typeof args.slowMo === "number"
613
+ ? args.slowMo
614
+ : typeof flow.slowMo === "number"
615
+ ? flow.slowMo
616
+ : undefined;
617
+ let browser = null;
618
+ let page = null;
619
+ let currentStepIndex = -1;
620
+ let recordedVideoPath = null;
621
+ let runFailed = false;
622
+ try {
623
+ browser = await playwright_1.chromium.launch({ headless, slowMo });
624
+ const contextOptions = {
625
+ ...(flow.contextOptions || {}),
626
+ };
627
+ if (videoConfig.enabled && videoConfig.dir) {
628
+ contextOptions.recordVideo = { dir: videoConfig.dir };
629
+ }
630
+ const context = await browser.newContext(contextOptions);
631
+ page = await context.newPage();
632
+ if (typeof args.timeout === "number") {
633
+ page.setDefaultTimeout(args.timeout);
634
+ }
635
+ else if (typeof flow.timeout === "number") {
636
+ page.setDefaultTimeout(flow.timeout);
637
+ }
638
+ for (let i = 0; i < flow.steps.length; i += 1) {
639
+ currentStepIndex = i;
640
+ const step = flow.steps[i];
641
+ if (args.debug) {
642
+ console.log(`Step ${i + 1}/${flow.steps.length}: ${step.type}`);
643
+ }
644
+ try {
645
+ await runStep(step, page);
646
+ }
647
+ catch (stepError) {
648
+ await captureFailureContext(page, step, i, flow.steps.length, stepError);
649
+ runFailed = true;
650
+ break;
651
+ }
652
+ }
653
+ if (!runFailed && args.debug) {
654
+ console.log("Run completed successfully.");
655
+ }
656
+ }
657
+ catch (error) {
658
+ console.error("Error running playwright flow", {
659
+ error: error instanceof Error ? error.message : String(error),
660
+ currentStepIndex,
661
+ });
662
+ runFailed = true;
663
+ }
664
+ finally {
665
+ if (page) {
666
+ const pageVideo = page.video();
667
+ await page.close();
668
+ if (pageVideo) {
669
+ try {
670
+ recordedVideoPath = await pageVideo.path();
671
+ }
672
+ catch {
673
+ recordedVideoPath = null;
674
+ }
675
+ }
676
+ }
677
+ await browser?.close();
678
+ }
679
+ if (videoConfig.enabled && recordedVideoPath) {
680
+ const saveFolder = await promptForFolderPath(`${ui.bold}Save video folder${ui.reset}: `);
681
+ if (saveFolder) {
682
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
683
+ const filename = `playflow-${timestamp}.webm`;
684
+ const destination = path_1.default.join(saveFolder, filename);
685
+ try {
686
+ fs_1.default.renameSync(recordedVideoPath, destination);
687
+ console.log(`Video saved to: ${destination}`);
688
+ }
689
+ catch {
690
+ fs_1.default.copyFileSync(recordedVideoPath, destination);
691
+ console.log(`Video saved to: ${destination}`);
692
+ }
693
+ }
694
+ else {
695
+ console.log(`Video saved to: ${recordedVideoPath}`);
696
+ }
697
+ }
698
+ if (!interactive) {
699
+ process.exitCode = runFailed ? 1 : 0;
700
+ break;
701
+ }
702
+ let runAgain = false;
703
+ if (process.stdin.isTTY && process.stdout.isTTY) {
704
+ const next = await selectSimpleMenu("Run another flow?", [
705
+ { label: "Yes, choose another flow", value: "yes", hint: "(repeat)" },
706
+ { label: "No, exit Playflow", value: "no", hint: "(quit)" },
707
+ ], "no");
708
+ runAgain = next === "yes";
709
+ }
710
+ else {
711
+ runAgain = await promptRunAgain();
712
+ }
713
+ if (!runAgain) {
714
+ console.log("Bye!");
715
+ process.exitCode = runFailed ? 1 : 0;
716
+ break;
717
+ }
718
+ }
719
+ }
720
+ main().catch((error) => {
721
+ console.error(error);
722
+ process.exit(1);
723
+ });
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@originator/playflow",
3
+ "version": "1.0.0",
4
+ "main": "./dist/playflow.js",
5
+ "bin": {
6
+ "playflow": "./dist/playflow.js"
7
+ },
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "postinstall": "playwright install chromium",
11
+ "prepublishOnly": "npm run build",
12
+ "test": "echo \"Error: no test specified\" && exit 1",
13
+ "test:flow": "node dist/playflow.js ./artifact/test_flow.json --headless"
14
+ },
15
+ "description": "Run Playwright browser flows from JSON",
16
+ "readme": "# Playflow\n\nRun Playwright browser flows from a JSON file.\n\nSee README.md for full documentation.\n",
17
+ "keywords": [
18
+ "playwright",
19
+ "cli",
20
+ "automation",
21
+ "browser",
22
+ "flows"
23
+ ],
24
+ "author": "Hamza Lhioui",
25
+ "license": "ISC",
26
+ "files": [
27
+ "dist",
28
+ "README.md"
29
+ ],
30
+ "dependencies": {
31
+ "playwright": "^1.57.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^25.0.9",
35
+ "typescript": "^5.9.3"
36
+ }
37
+ }