playwright-repl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +377 -0
- package/RELEASES.md +70 -0
- package/bin/daemon-launcher.cjs +13 -0
- package/bin/playwright-repl.mjs +79 -0
- package/examples/01-add-todos.pw +14 -0
- package/examples/02-complete-and-filter.pw +21 -0
- package/examples/03-record-session.pw +13 -0
- package/examples/04-replay-session.pw +17 -0
- package/examples/05-ci-pipe.pw +14 -0
- package/examples/06-edit-todo.pw +11 -0
- package/package.json +52 -0
- package/src/colors.mjs +17 -0
- package/src/connection.mjs +119 -0
- package/src/index.mjs +15 -0
- package/src/parser.mjs +141 -0
- package/src/recorder.mjs +241 -0
- package/src/repl.mjs +582 -0
- package/src/resolve.mjs +82 -0
- package/src/workspace.mjs +104 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "playwright-repl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive REPL for Playwright browser automation — keyword-driven testing from your terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"playwright-repl": "./bin/playwright-repl.mjs"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.mjs",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node bin/playwright-repl.mjs",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:coverage": "vitest run --coverage"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"playwright",
|
|
20
|
+
"repl",
|
|
21
|
+
"browser",
|
|
22
|
+
"automation",
|
|
23
|
+
"testing",
|
|
24
|
+
"mcp",
|
|
25
|
+
"keyword-driven"
|
|
26
|
+
],
|
|
27
|
+
"author": "Steve Zhang",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/stevez/playwright-repl.git"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"bin/",
|
|
35
|
+
"src/",
|
|
36
|
+
"examples/",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"README.md",
|
|
39
|
+
"RELEASES.md"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18.0.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"minimist": "^1.2.8",
|
|
46
|
+
"playwright": ">=1.59.0-alpha-2026-02-01"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
50
|
+
"vitest": "^4.0.18"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/colors.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal color helpers.
|
|
3
|
+
* No dependency — just ANSI escape codes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const c = {
|
|
7
|
+
reset: '\x1b[0m',
|
|
8
|
+
bold: '\x1b[1m',
|
|
9
|
+
dim: '\x1b[2m',
|
|
10
|
+
red: '\x1b[31m',
|
|
11
|
+
green: '\x1b[32m',
|
|
12
|
+
yellow: '\x1b[33m',
|
|
13
|
+
blue: '\x1b[34m',
|
|
14
|
+
magenta: '\x1b[35m',
|
|
15
|
+
cyan: '\x1b[36m',
|
|
16
|
+
gray: '\x1b[90m',
|
|
17
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DaemonConnection — persistent Unix socket client.
|
|
3
|
+
*
|
|
4
|
+
* Wire protocol: newline-delimited JSON.
|
|
5
|
+
*
|
|
6
|
+
* Send: {"id":1,"method":"run","params":{"args":{...},"cwd":"/"},"version":"0.1.0"}\n
|
|
7
|
+
* Receive: {"id":1,"result":{"text":"..."},"version":"0.1.0"}\n
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
|
|
12
|
+
export class DaemonConnection {
|
|
13
|
+
constructor(sockPath, version) {
|
|
14
|
+
this.sockPath = sockPath;
|
|
15
|
+
this.version = version;
|
|
16
|
+
this.socket = null;
|
|
17
|
+
this.nextId = 1;
|
|
18
|
+
this.callbacks = new Map();
|
|
19
|
+
this.pendingBuffers = [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async connect() {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const sock = net.createConnection(this.sockPath, () => {
|
|
25
|
+
this.socket = sock;
|
|
26
|
+
resolve(true);
|
|
27
|
+
});
|
|
28
|
+
sock.on('data', (buf) => this._onData(buf));
|
|
29
|
+
sock.on('error', (err) => {
|
|
30
|
+
if (!this.socket) reject(err);
|
|
31
|
+
else this._handleError(err);
|
|
32
|
+
});
|
|
33
|
+
sock.on('close', () => {
|
|
34
|
+
this.socket = null;
|
|
35
|
+
for (const cb of this.callbacks.values()) {
|
|
36
|
+
cb.reject(new Error('Connection closed'));
|
|
37
|
+
}
|
|
38
|
+
this.callbacks.clear();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get connected() {
|
|
44
|
+
return this.socket !== null && !this.socket.destroyed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Send a raw message to the daemon.
|
|
49
|
+
* Returns the result from the daemon response.
|
|
50
|
+
*/
|
|
51
|
+
async send(method, params = {}) {
|
|
52
|
+
if (!this.connected) throw new Error('Not connected to daemon');
|
|
53
|
+
const id = this.nextId++;
|
|
54
|
+
const msg = { id, method, params, version: this.version };
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
this.callbacks.set(id, { resolve, reject });
|
|
57
|
+
this.socket.write(JSON.stringify(msg) + '\n', (err) => {
|
|
58
|
+
if (err) {
|
|
59
|
+
this.callbacks.delete(id);
|
|
60
|
+
reject(err);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Send a "run" command — the standard way to execute CLI commands.
|
|
68
|
+
* `minimistArgs` is a pre-parsed minimist object, e.g. { _: ["click", "e5"] }
|
|
69
|
+
*/
|
|
70
|
+
async run(minimistArgs) {
|
|
71
|
+
return this.send('run', { args: minimistArgs, cwd: process.cwd() });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
close() {
|
|
75
|
+
if (this.socket) {
|
|
76
|
+
this.socket.destroy();
|
|
77
|
+
this.socket = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Internal: newline-delimited JSON parsing ────────────────────────
|
|
82
|
+
|
|
83
|
+
_onData(buffer) {
|
|
84
|
+
let end = buffer.indexOf('\n');
|
|
85
|
+
if (end === -1) {
|
|
86
|
+
this.pendingBuffers.push(buffer);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.pendingBuffers.push(buffer.slice(0, end));
|
|
90
|
+
this._dispatch(Buffer.concat(this.pendingBuffers).toString());
|
|
91
|
+
let start = end + 1;
|
|
92
|
+
end = buffer.indexOf('\n', start);
|
|
93
|
+
while (end !== -1) {
|
|
94
|
+
this._dispatch(buffer.toString(undefined, start, end));
|
|
95
|
+
start = end + 1;
|
|
96
|
+
end = buffer.indexOf('\n', start);
|
|
97
|
+
}
|
|
98
|
+
this.pendingBuffers = [buffer.slice(start)];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_dispatch(message) {
|
|
102
|
+
try {
|
|
103
|
+
const obj = JSON.parse(message);
|
|
104
|
+
if (obj.id && this.callbacks.has(obj.id)) {
|
|
105
|
+
const cb = this.callbacks.get(obj.id);
|
|
106
|
+
this.callbacks.delete(obj.id);
|
|
107
|
+
if (obj.error) cb.reject(new Error(obj.error));
|
|
108
|
+
else cb.resolve(obj.result);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Ignore parse errors on partial messages
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_handleError(err) {
|
|
116
|
+
if (err.code !== 'EPIPE')
|
|
117
|
+
console.error(`\n⚠️ Socket error: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* playwright-repl — public API
|
|
3
|
+
*
|
|
4
|
+
* Usage as CLI:
|
|
5
|
+
* npx playwright-repl [options]
|
|
6
|
+
*
|
|
7
|
+
* Usage as library:
|
|
8
|
+
* import { DaemonConnection, parseInput, SessionRecorder } from 'playwright-repl';
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { DaemonConnection } from './connection.mjs';
|
|
12
|
+
export { parseInput, ALIASES, ALL_COMMANDS } from './parser.mjs';
|
|
13
|
+
export { SessionRecorder, SessionPlayer } from './recorder.mjs';
|
|
14
|
+
export { socketPath, isDaemonRunning, startDaemon, findWorkspaceDir } from './workspace.mjs';
|
|
15
|
+
export { startRepl } from './repl.mjs';
|
package/src/parser.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input parser — transforms human input into minimist-style args.
|
|
3
|
+
*
|
|
4
|
+
* Flow: "c e5" → alias resolve → ["click", "e5"] → minimist → { _: ["click", "e5"] }
|
|
5
|
+
*
|
|
6
|
+
* The resulting object is sent to the daemon as-is. The daemon runs
|
|
7
|
+
* parseCliCommand() which maps it to a tool call.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { minimist, COMMANDS } from './resolve.mjs';
|
|
11
|
+
|
|
12
|
+
// ─── Command aliases ─────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const ALIASES = {
|
|
15
|
+
// Navigation
|
|
16
|
+
'o': 'open',
|
|
17
|
+
'g': 'goto',
|
|
18
|
+
'go': 'goto',
|
|
19
|
+
'back': 'go-back',
|
|
20
|
+
'fwd': 'go-forward',
|
|
21
|
+
'r': 'reload',
|
|
22
|
+
|
|
23
|
+
// Interaction
|
|
24
|
+
'c': 'click',
|
|
25
|
+
'dc': 'dblclick',
|
|
26
|
+
't': 'type',
|
|
27
|
+
'f': 'fill',
|
|
28
|
+
'h': 'hover',
|
|
29
|
+
'p': 'press',
|
|
30
|
+
'sel': 'select',
|
|
31
|
+
'chk': 'check',
|
|
32
|
+
'unchk':'uncheck',
|
|
33
|
+
|
|
34
|
+
// Inspection
|
|
35
|
+
's': 'snapshot',
|
|
36
|
+
'snap': 'snapshot',
|
|
37
|
+
'ss': 'screenshot',
|
|
38
|
+
'e': 'eval',
|
|
39
|
+
'con': 'console',
|
|
40
|
+
'net': 'network',
|
|
41
|
+
|
|
42
|
+
// Tabs
|
|
43
|
+
'tl': 'tab-list',
|
|
44
|
+
'tn': 'tab-new',
|
|
45
|
+
'tc': 'tab-close',
|
|
46
|
+
'ts': 'tab-select',
|
|
47
|
+
|
|
48
|
+
// Assertions (Phase 2 — mapped to daemon tools that exist but have no CLI keywords)
|
|
49
|
+
'vt': 'verify-text',
|
|
50
|
+
've': 'verify-element',
|
|
51
|
+
'vv': 'verify-value',
|
|
52
|
+
'vl': 'verify-list',
|
|
53
|
+
|
|
54
|
+
// Session
|
|
55
|
+
'q': 'close',
|
|
56
|
+
'ls': 'list',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ─── Known boolean options ───────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export const booleanOptions = new Set([
|
|
62
|
+
'headed', 'persistent', 'extension', 'submit', 'clear',
|
|
63
|
+
'fullPage', 'includeStatic',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// ─── All known commands ──────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export const ALL_COMMANDS = Object.keys(COMMANDS);
|
|
69
|
+
|
|
70
|
+
// ─── Tokenizer ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Tokenize input respecting quoted strings.
|
|
74
|
+
* "fill e7 'hello world'" → ["fill", "e7", "hello world"]
|
|
75
|
+
*/
|
|
76
|
+
function tokenize(line) {
|
|
77
|
+
const tokens = [];
|
|
78
|
+
let current = '';
|
|
79
|
+
let inQuote = null;
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < line.length; i++) {
|
|
82
|
+
const ch = line[i];
|
|
83
|
+
if (inQuote) {
|
|
84
|
+
if (ch === inQuote) {
|
|
85
|
+
inQuote = null;
|
|
86
|
+
} else {
|
|
87
|
+
current += ch;
|
|
88
|
+
}
|
|
89
|
+
} else if (ch === '"' || ch === "'") {
|
|
90
|
+
inQuote = ch;
|
|
91
|
+
} else if (ch === ' ' || ch === '\t') {
|
|
92
|
+
if (current) {
|
|
93
|
+
tokens.push(current);
|
|
94
|
+
current = '';
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
current += ch;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (current) tokens.push(current);
|
|
101
|
+
return tokens;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Main parse function ─────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse a REPL input line into a minimist args object ready for the daemon.
|
|
108
|
+
* Returns null if the line is empty.
|
|
109
|
+
*/
|
|
110
|
+
export function parseInput(line) {
|
|
111
|
+
const tokens = tokenize(line);
|
|
112
|
+
if (tokens.length === 0) return null;
|
|
113
|
+
|
|
114
|
+
// Resolve alias
|
|
115
|
+
const cmd = tokens[0].toLowerCase();
|
|
116
|
+
if (ALIASES[cmd]) tokens[0] = ALIASES[cmd];
|
|
117
|
+
|
|
118
|
+
// Parse with minimist (same lib and boolean set as playwright-cli)
|
|
119
|
+
const args = minimist(tokens, { boolean: [...booleanOptions] });
|
|
120
|
+
|
|
121
|
+
// Stringify non-boolean values (playwright-cli does this)
|
|
122
|
+
for (const key of Object.keys(args)) {
|
|
123
|
+
if (key === '_') continue;
|
|
124
|
+
if (typeof args[key] !== 'boolean')
|
|
125
|
+
args[key] = String(args[key]);
|
|
126
|
+
}
|
|
127
|
+
for (let i = 0; i < args._.length; i++)
|
|
128
|
+
args._[i] = String(args._[i]);
|
|
129
|
+
|
|
130
|
+
// Remove boolean options set to false that weren't explicitly passed.
|
|
131
|
+
// minimist sets all declared booleans to false by default, but the
|
|
132
|
+
// daemon rejects unknown options like --headed false.
|
|
133
|
+
for (const opt of booleanOptions) {
|
|
134
|
+
if (args[opt] === false) {
|
|
135
|
+
const hasExplicitNo = tokens.some(t => t === `--no-${opt}`);
|
|
136
|
+
if (!hasExplicitNo) delete args[opt];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return args;
|
|
141
|
+
}
|
package/src/recorder.mjs
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session recorder and player.
|
|
3
|
+
*
|
|
4
|
+
* Records REPL commands to .pw files and replays them.
|
|
5
|
+
*
|
|
6
|
+
* File format (.pw):
|
|
7
|
+
* - One command per line (exactly as typed in REPL)
|
|
8
|
+
* - Comments start with #
|
|
9
|
+
* - Blank lines are ignored
|
|
10
|
+
* - First line is a metadata comment with timestamp
|
|
11
|
+
*
|
|
12
|
+
* Example:
|
|
13
|
+
* # Login test
|
|
14
|
+
* # recorded 2026-02-09T19:30:00Z
|
|
15
|
+
*
|
|
16
|
+
* open https://myapp.com
|
|
17
|
+
* snapshot
|
|
18
|
+
* click e5
|
|
19
|
+
* fill e7 admin@test.com
|
|
20
|
+
* fill e9 password123
|
|
21
|
+
* click e12
|
|
22
|
+
* verify-text Welcome back
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
// ─── Session Recorder ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export class SessionRecorder {
|
|
31
|
+
constructor() {
|
|
32
|
+
this.commands = [];
|
|
33
|
+
this.recording = false;
|
|
34
|
+
this.filename = null;
|
|
35
|
+
this.paused = false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Start recording commands.
|
|
40
|
+
* @param {string} [filename] - Output file path. If not provided, uses a timestamp.
|
|
41
|
+
*/
|
|
42
|
+
start(filename) {
|
|
43
|
+
this.filename = filename || `session-${new Date().toISOString().replace(/[:.]/g, '-')}.pw`;
|
|
44
|
+
this.commands = [];
|
|
45
|
+
this.recording = true;
|
|
46
|
+
this.paused = false;
|
|
47
|
+
return this.filename;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record a command (called after each successful REPL command).
|
|
52
|
+
* Skips meta-commands (lines starting with .).
|
|
53
|
+
*/
|
|
54
|
+
record(line) {
|
|
55
|
+
if (!this.recording || this.paused) return;
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (!trimmed || trimmed.startsWith('.')) return;
|
|
58
|
+
this.commands.push(trimmed);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pause recording (toggle).
|
|
63
|
+
*/
|
|
64
|
+
pause() {
|
|
65
|
+
this.paused = !this.paused;
|
|
66
|
+
return this.paused;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Stop recording and save to file.
|
|
71
|
+
* @returns {{ filename: string, count: number }}
|
|
72
|
+
*/
|
|
73
|
+
save() {
|
|
74
|
+
if (!this.recording) throw new Error('Not recording');
|
|
75
|
+
|
|
76
|
+
const header = [
|
|
77
|
+
`# Playwright REPL session`,
|
|
78
|
+
`# recorded ${new Date().toISOString()}`,
|
|
79
|
+
``,
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const content = [...header, ...this.commands, ''].join('\n');
|
|
83
|
+
|
|
84
|
+
// Ensure directory exists
|
|
85
|
+
const dir = path.dirname(this.filename);
|
|
86
|
+
if (dir && dir !== '.') {
|
|
87
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fs.writeFileSync(this.filename, content, 'utf-8');
|
|
91
|
+
|
|
92
|
+
const result = { filename: this.filename, count: this.commands.length };
|
|
93
|
+
|
|
94
|
+
this.recording = false;
|
|
95
|
+
this.commands = [];
|
|
96
|
+
this.filename = null;
|
|
97
|
+
this.paused = false;
|
|
98
|
+
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Discard recording without saving.
|
|
104
|
+
*/
|
|
105
|
+
discard() {
|
|
106
|
+
this.recording = false;
|
|
107
|
+
this.commands = [];
|
|
108
|
+
this.filename = null;
|
|
109
|
+
this.paused = false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get status() {
|
|
113
|
+
if (!this.recording) return 'idle';
|
|
114
|
+
if (this.paused) return 'paused';
|
|
115
|
+
return 'recording';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get commandCount() {
|
|
119
|
+
return this.commands.length;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Session Player ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export class SessionPlayer {
|
|
126
|
+
/**
|
|
127
|
+
* Load commands from a .pw file.
|
|
128
|
+
* @param {string} filename
|
|
129
|
+
* @returns {string[]} Array of command lines
|
|
130
|
+
*/
|
|
131
|
+
static load(filename) {
|
|
132
|
+
if (!fs.existsSync(filename)) {
|
|
133
|
+
throw new Error(`File not found: ${filename}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const content = fs.readFileSync(filename, 'utf-8');
|
|
137
|
+
return content
|
|
138
|
+
.split('\n')
|
|
139
|
+
.map(line => line.trim())
|
|
140
|
+
.filter(line => line && !line.startsWith('#'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create a player that yields commands one at a time.
|
|
145
|
+
* Supports step-through mode where it pauses between commands.
|
|
146
|
+
*/
|
|
147
|
+
constructor(filename) {
|
|
148
|
+
this.filename = filename;
|
|
149
|
+
this.commands = SessionPlayer.load(filename);
|
|
150
|
+
this.index = 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get done() {
|
|
154
|
+
return this.index >= this.commands.length;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get current() {
|
|
158
|
+
return this.commands[this.index] || null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get progress() {
|
|
162
|
+
return `[${this.index}/${this.commands.length}]`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
next() {
|
|
166
|
+
if (this.done) return null;
|
|
167
|
+
return this.commands[this.index++];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
reset() {
|
|
171
|
+
this.index = 0;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Session Manager (state machine) ────────────────────────────────────────
|
|
176
|
+
//
|
|
177
|
+
// States: idle → recording ⇄ paused → idle
|
|
178
|
+
// idle → replaying → idle
|
|
179
|
+
//
|
|
180
|
+
|
|
181
|
+
export class SessionManager {
|
|
182
|
+
#recorder = new SessionRecorder();
|
|
183
|
+
#player = null;
|
|
184
|
+
#step = false;
|
|
185
|
+
|
|
186
|
+
/** Current mode: 'idle' | 'recording' | 'paused' | 'replaying' */
|
|
187
|
+
get mode() {
|
|
188
|
+
if (this.#player && !this.#player.done) return 'replaying';
|
|
189
|
+
return this.#recorder.status;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Recording ──────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
startRecording(filename) {
|
|
195
|
+
if (this.mode !== 'idle') throw new Error(`Cannot record while ${this.mode}`);
|
|
196
|
+
return this.#recorder.start(filename);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
save() {
|
|
200
|
+
if (this.mode !== 'recording' && this.mode !== 'paused')
|
|
201
|
+
throw new Error('Not recording');
|
|
202
|
+
return this.#recorder.save();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
togglePause() {
|
|
206
|
+
if (this.mode !== 'recording' && this.mode !== 'paused')
|
|
207
|
+
throw new Error('Not recording');
|
|
208
|
+
return this.#recorder.pause();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
discard() {
|
|
212
|
+
if (this.mode !== 'recording' && this.mode !== 'paused')
|
|
213
|
+
throw new Error('Not recording');
|
|
214
|
+
this.#recorder.discard();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Called after each successful command — records if active. */
|
|
218
|
+
record(line) {
|
|
219
|
+
this.#recorder.record(line);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
get recordingFilename() { return this.#recorder.filename; }
|
|
223
|
+
get recordedCount() { return this.#recorder.commandCount; }
|
|
224
|
+
|
|
225
|
+
// ── Playback ───────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
startReplay(filename, step = false) {
|
|
228
|
+
if (this.mode !== 'idle') throw new Error(`Cannot replay while ${this.mode}`);
|
|
229
|
+
this.#player = new SessionPlayer(filename);
|
|
230
|
+
this.#step = step;
|
|
231
|
+
return this.#player;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
endReplay() {
|
|
235
|
+
this.#player = null;
|
|
236
|
+
this.#step = false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
get player() { return this.#player; }
|
|
240
|
+
get step() { return this.#step; }
|
|
241
|
+
}
|