@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.
- package/README.md +106 -0
- package/dist/playflow.js +723 -0
- 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.
|
package/dist/playflow.js
ADDED
|
@@ -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
|
+
}
|