sessionsnap 0.0.1
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 +325 -0
- package/bin/sessionsnap.js +333 -0
- package/package.json +29 -0
- package/src/core/heuristics.js +53 -0
- package/src/core/recorder.js +576 -0
- package/src/core/replayer.js +886 -0
- package/src/core/snapshot.js +58 -0
- package/src/playwright/capture.js +65 -0
- package/src/playwright/open.js +65 -0
- package/src/playwright/replay.js +43 -0
- package/src/puppeteer/capture.js +69 -0
- package/src/puppeteer/open.js +65 -0
- package/src/puppeteer/replay.js +44 -0
- package/src/store.js +123 -0
- package/test/cli.test.js +56 -0
- package/test/heuristics.test.js +105 -0
- package/test/snapshot.test.js +147 -0
- package/test/store.test.js +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# sessionsnap
|
|
2
|
+
|
|
3
|
+
> **KISS — Keep It Simple, Stupid.**
|
|
4
|
+
>
|
|
5
|
+
> Authenticated browser sessions shouldn't require complex auth flows, token management, cookie injection hacks, or custom login scripts per site. Just open a browser, log in like a human, and let the tool save & reuse that session. That's it.
|
|
6
|
+
|
|
7
|
+
Capture and reuse authenticated browser sessions using **manual login**. Supports both Puppeteer and Playwright.
|
|
8
|
+
|
|
9
|
+
Designed for test automation, internal tooling, and any workflow that requires persistent authenticated sessions — without scraping, bot detection bypass, or fingerprint spoofing.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Human-in-the-loop** — You log in manually (CAPTCHA / 2FA friendly)
|
|
14
|
+
- **Persistent profiles** — Sessions saved to disk and reusable across runs
|
|
15
|
+
- **Auto-detect login completion** — URL and cookie heuristics
|
|
16
|
+
- **Session auto-update** — Session snapshot is refreshed when the browser closes
|
|
17
|
+
- **Automatic screenshots** — Captures a PNG after login and on session open
|
|
18
|
+
- **Action recording** — Inject a recorder to capture clicks, inputs, navigations
|
|
19
|
+
- **Action replay** — Replay recorded actions with configurable speed
|
|
20
|
+
- **Desktop viewport** — 1440×900 default for realistic rendering
|
|
21
|
+
- **Dual runner support** — Puppeteer (bundled) and Playwright (optional)
|
|
22
|
+
- **Works for any website** — SaaS dashboards, admin panels, internal tools
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Puppeteer is included as a direct dependency. For Playwright support, install it separately:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install playwright
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## CLI Usage
|
|
37
|
+
|
|
38
|
+
### Capture a session
|
|
39
|
+
|
|
40
|
+
Launch a headed browser, log in manually, and save the session:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
sessionsnap capture <url> \
|
|
44
|
+
--profile <name> \
|
|
45
|
+
[--runner puppeteer|playwright] \
|
|
46
|
+
[--out <file>] \
|
|
47
|
+
[--wait <minutes>] \
|
|
48
|
+
[--record]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`--runner` defaults to `puppeteer` since it is bundled as a direct dependency.
|
|
52
|
+
|
|
53
|
+
**Example:**
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
sessionsnap capture https://app.acmecorp.io/login --profile acme
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The tool will:
|
|
60
|
+
1. Open a headed browser (1440×900 viewport)
|
|
61
|
+
2. Navigate to the URL
|
|
62
|
+
3. Wait for you to log in manually
|
|
63
|
+
4. Detect login completion (URL no longer contains `login`/`signin`/`auth`, or cookie count increases)
|
|
64
|
+
5. Take a screenshot of the post-login state
|
|
65
|
+
6. Capture cookies and save a session snapshot
|
|
66
|
+
|
|
67
|
+
### Open a URL with a saved session
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
sessionsnap open <url> \
|
|
71
|
+
--profile <name> \
|
|
72
|
+
[--runner puppeteer|playwright] \
|
|
73
|
+
[--record]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Example:**
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
sessionsnap open https://app.acmecorp.io/dashboard --profile acme
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
When you close the browser, the session snapshot is automatically updated with the latest cookies.
|
|
83
|
+
|
|
84
|
+
### List all profiles
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
sessionsnap list [--json]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Shows all saved profiles with a summary: runner, URL, relative date, screenshot/recording counts.
|
|
91
|
+
|
|
92
|
+
**Example output:**
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
[sessionsnap] 2 profile(s) found:
|
|
96
|
+
|
|
97
|
+
acme [puppeteer] https://app.acmecorp.io/dashboard (2h ago) 📸 3 🔴 2
|
|
98
|
+
staging [playwright] https://staging.acmecorp.io/home (1d ago) 📸 1
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Use `--json` for machine-readable output (useful for scripting).
|
|
102
|
+
|
|
103
|
+
### Show profile status
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
sessionsnap status --profile <name>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Prints profile details including directory path, session metadata, cookie/origin counts, and a list of saved screenshots and recordings.
|
|
110
|
+
|
|
111
|
+
**Example output:**
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
Profile: acme
|
|
115
|
+
Directory: /Users/you/.sessionsnap/profiles/acme
|
|
116
|
+
Runner: puppeteer
|
|
117
|
+
Captured at: 2026-02-09T12:00:00.000Z
|
|
118
|
+
Updated at: 2026-02-09T12:05:00.000Z
|
|
119
|
+
Start URL: https://app.acmecorp.io/login
|
|
120
|
+
Final URL: https://app.acmecorp.io/dashboard
|
|
121
|
+
Cookies: 12
|
|
122
|
+
Origins: 0
|
|
123
|
+
Screenshots:
|
|
124
|
+
- capture-2026-02-09T12-00-00-000Z.png
|
|
125
|
+
- open-2026-02-09T12-05-00-000Z.png
|
|
126
|
+
Recordings:
|
|
127
|
+
- actions-capture-2026-02-09T12-00-00-000Z.json
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Snapshot Format
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"meta": {
|
|
135
|
+
"runner": "puppeteer",
|
|
136
|
+
"profile": "acme",
|
|
137
|
+
"capturedAt": "2026-02-09T12:00:00.000Z",
|
|
138
|
+
"startUrl": "https://app.acmecorp.io/login",
|
|
139
|
+
"finalUrl": "https://app.acmecorp.io/dashboard"
|
|
140
|
+
},
|
|
141
|
+
"cookies": [
|
|
142
|
+
{
|
|
143
|
+
"name": "session_token",
|
|
144
|
+
"value": "abc123",
|
|
145
|
+
"domain": ".acmecorp.io",
|
|
146
|
+
"path": "/",
|
|
147
|
+
"expires": 1700000000,
|
|
148
|
+
"httpOnly": true,
|
|
149
|
+
"secure": true,
|
|
150
|
+
"sameSite": "Lax"
|
|
151
|
+
}
|
|
152
|
+
],
|
|
153
|
+
"origins": []
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
After using `open`, an `updatedAt` field is added to `meta`.
|
|
158
|
+
|
|
159
|
+
## Screenshots
|
|
160
|
+
|
|
161
|
+
Screenshots are automatically saved to the profile directory on every `capture` and `open` command:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
~/.sessionsnap/profiles/acme/
|
|
165
|
+
snapshot.json
|
|
166
|
+
capture-2026-02-09T12-00-00-000Z.png
|
|
167
|
+
open-2026-02-09T12-05-00-000Z.png
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- **capture** — taken right after login is detected
|
|
171
|
+
- **open** — taken after the page loads with the saved session
|
|
172
|
+
|
|
173
|
+
## Action Recording
|
|
174
|
+
|
|
175
|
+
Use `--record` on `capture` or `open` to inject a lightweight recorder into the page. It captures 11 action types:
|
|
176
|
+
|
|
177
|
+
| Action | What it records | Replay |
|
|
178
|
+
|--------|----------------|--------|
|
|
179
|
+
| **click** | selector, tag, text, coordinates | CSS selector → text → coordinates fallback |
|
|
180
|
+
| **dblclick** | selector, tag, text, coordinates | Same 3-tier fallback as click |
|
|
181
|
+
| **hover** | selector, tag, text | `page.hover(selector)` with text fallback |
|
|
182
|
+
| **input** | selector, value (passwords masked) | Clear + type / fill |
|
|
183
|
+
| **select** | selector, selected value & text | `page.select` / `selectOption` |
|
|
184
|
+
| **checkbox** | selector, checked state, type (checkbox/radio) | Smart toggle (checks current state) |
|
|
185
|
+
| **keydown** | key, combo (Ctrl/Meta/Alt+key), selector | `page.keyboard.press` with modifiers |
|
|
186
|
+
| **submit** | form selector, action, method | `form.requestSubmit()` / Enter fallback |
|
|
187
|
+
| **scroll** | scrollX, scrollY, scrollHeight, viewportHeight | `window.scrollTo(x, y)` |
|
|
188
|
+
| **file** | selector, file names, file count | Log only (cannot replay file selection) |
|
|
189
|
+
| **navigation** | from/to URLs (SPA & traditional) | `page.goto(url)` |
|
|
190
|
+
|
|
191
|
+
Noise reduction: hover is debounced (200ms, interactive elements only), scroll is debounced (400ms), keydown only captures special keys (Enter/Escape/Tab/arrows/F-keys) and modifier combos (Ctrl+key, Meta+key).
|
|
192
|
+
|
|
193
|
+
Recorded actions are saved as timestamped JSON files in the profile directory:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
~/.sessionsnap/profiles/acme/
|
|
197
|
+
actions-capture-2026-02-09T12-00-00-000Z.json
|
|
198
|
+
actions-open-2026-02-09T12-05-00-000Z.json
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**Example:**
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
sessionsnap capture https://app.acmecorp.io/login --profile acme --record
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Replaying actions
|
|
208
|
+
|
|
209
|
+
Replay a previously recorded session:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
sessionsnap replay \
|
|
213
|
+
--profile <name> \
|
|
214
|
+
[--runner puppeteer|playwright] \
|
|
215
|
+
[--file <actions-file>] \
|
|
216
|
+
[--speed <multiplier>] \
|
|
217
|
+
[--headless] \
|
|
218
|
+
[--bail]
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Examples:**
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# Replay the latest recording at normal speed
|
|
225
|
+
sessionsnap replay --profile acme
|
|
226
|
+
|
|
227
|
+
# Replay at 2x speed
|
|
228
|
+
sessionsnap replay --profile acme --speed 2
|
|
229
|
+
|
|
230
|
+
# Replay a specific recording file
|
|
231
|
+
sessionsnap replay --profile acme --file actions-capture-2026-02-09T12-00-00-000Z.json
|
|
232
|
+
|
|
233
|
+
# Replay in headless mode (no visible browser)
|
|
234
|
+
sessionsnap replay --profile acme --headless
|
|
235
|
+
|
|
236
|
+
# Fail fast — stop on first failure (exit code 1)
|
|
237
|
+
sessionsnap replay --profile acme --bail
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The replayer:
|
|
241
|
+
1. Launches a browser with the saved session profile
|
|
242
|
+
2. Navigates to the starting URL from the recording
|
|
243
|
+
3. Executes each action in order (click, dblclick, hover, type, select, checkbox, keydown, submit, scroll, navigate)
|
|
244
|
+
4. Uses real timing from the recording (clamped to 50ms–5s per action)
|
|
245
|
+
5. Takes a screenshot after replay completes
|
|
246
|
+
6. Password fields are skipped during replay for security
|
|
247
|
+
|
|
248
|
+
#### Error handling
|
|
249
|
+
|
|
250
|
+
By default, replay is **fault-tolerant**. If an individual action fails (element not found, timeout, etc.), it is logged as `FAILED` and the replay **continues with the next action** — the whole flow does not crash.
|
|
251
|
+
|
|
252
|
+
Use `--bail` for **fail-fast** mode: the replay stops immediately on the first failure and exits with code `1`. This is useful in CI/CD pipelines or when you need strict action accuracy.
|
|
253
|
+
|
|
254
|
+
For click actions, there is a 3-step fallback strategy:
|
|
255
|
+
1. **CSS selector** — tries the recorded selector (2s timeout)
|
|
256
|
+
2. **Text content** — searches all visible elements for matching text
|
|
257
|
+
3. **Coordinates** — clicks at the recorded x/y position
|
|
258
|
+
|
|
259
|
+
If all three strategies fail, the action is logged as `FAILED` and skipped.
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
[sessionsnap] [1/5] click: #submit-btn "Submit"
|
|
263
|
+
[sessionsnap] [2/5] click FAILED: Element not found: #old-selector (text: "Gone")
|
|
264
|
+
[sessionsnap] [3/5] input: input[name="email"] → "user@example.com"
|
|
265
|
+
[sessionsnap] [4/5] navigation: /login → /dashboard
|
|
266
|
+
[sessionsnap] [5/5] click: a[href="/settings"] "Settings"
|
|
267
|
+
[sessionsnap] Replay complete.
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Each action entry includes a `type`, `timestamp`, `url`, and type-specific fields:
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
[
|
|
274
|
+
{ "type": "click", "selector": "#login-button", "tag": "button", "text": "Sign In", "x": 720, "y": 450 },
|
|
275
|
+
{ "type": "dblclick", "selector": ".cell", "tag": "td", "text": "Edit me", "x": 300, "y": 200 },
|
|
276
|
+
{ "type": "hover", "selector": "nav > .dropdown", "tag": "button", "text": "Menu" },
|
|
277
|
+
{ "type": "input", "selector": "input[name=\"email\"]", "tag": "input", "inputType": "email", "value": "user@example.com" },
|
|
278
|
+
{ "type": "checkbox", "selector": "input[name=\"remember\"]", "inputType": "checkbox", "checked": true },
|
|
279
|
+
{ "type": "keydown", "key": "Escape", "combo": "Escape", "selector": ".modal", "tag": "div" },
|
|
280
|
+
{ "type": "scroll", "scrollX": 0, "scrollY": 800, "scrollHeight": 3200, "viewportHeight": 900 },
|
|
281
|
+
{ "type": "file", "selector": "input[type=\"file\"]", "fileNames": ["photo.jpg"], "fileCount": 1 },
|
|
282
|
+
{ "type": "navigation", "from": "/login", "to": "/dashboard" }
|
|
283
|
+
]
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Project Structure
|
|
287
|
+
|
|
288
|
+
```
|
|
289
|
+
sessionsnap/
|
|
290
|
+
bin/
|
|
291
|
+
sessionsnap.js # CLI entry point
|
|
292
|
+
src/
|
|
293
|
+
core/
|
|
294
|
+
snapshot.js # Canonical session format & normalization
|
|
295
|
+
heuristics.js # Login detection logic (URL + cookie polling)
|
|
296
|
+
recorder.js # Injected script for recording user actions
|
|
297
|
+
replayer.js # Replay engine for recorded actions
|
|
298
|
+
puppeteer/
|
|
299
|
+
capture.js # Capture session via CDP
|
|
300
|
+
open.js # Open URL with saved profile + update on close
|
|
301
|
+
replay.js # Replay recorded actions via Puppeteer
|
|
302
|
+
playwright/
|
|
303
|
+
capture.js # Capture session via storageState
|
|
304
|
+
open.js # Open URL with saved profile + update on close
|
|
305
|
+
replay.js # Replay recorded actions via Playwright
|
|
306
|
+
store.js # Profile directory & JSON I/O
|
|
307
|
+
test/
|
|
308
|
+
store.test.js
|
|
309
|
+
snapshot.test.js
|
|
310
|
+
heuristics.test.js
|
|
311
|
+
cli.test.js
|
|
312
|
+
package.json
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Testing
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
npm test
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Uses the Node.js built-in test runner (`node:test`). No additional test dependencies required.
|
|
322
|
+
|
|
323
|
+
## License
|
|
324
|
+
|
|
325
|
+
MIT
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { getProfileDir, loadSnapshot, loadActions, listProfiles, listActionFiles } from '../src/store.js';
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('sessionsnap')
|
|
12
|
+
.description('Capture and reuse authenticated browser sessions')
|
|
13
|
+
.version('1.0.0');
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.command('capture <url>')
|
|
17
|
+
.description('Launch a browser, login manually, and capture the session')
|
|
18
|
+
.option('--runner <runner>', 'Browser runner: puppeteer or playwright', 'puppeteer')
|
|
19
|
+
.requiredOption('--profile <name>', 'Profile name to save the session under')
|
|
20
|
+
.option('--out <file>', 'Custom output path for the snapshot JSON')
|
|
21
|
+
.option('--wait <minutes>', 'Max minutes to wait for login', parseFloat)
|
|
22
|
+
.option('--record', 'Record user actions (clicks, inputs, navigations)')
|
|
23
|
+
.action(async (url, opts) => {
|
|
24
|
+
validateRunner(opts.runner);
|
|
25
|
+
try {
|
|
26
|
+
if (opts.runner === 'puppeteer') {
|
|
27
|
+
const { capture } = await import('../src/puppeteer/capture.js');
|
|
28
|
+
await capture(url, { profile: opts.profile, out: opts.out, waitMinutes: opts.wait, record: opts.record });
|
|
29
|
+
} else {
|
|
30
|
+
const { capture } = await import('../src/playwright/capture.js');
|
|
31
|
+
await capture(url, { profile: opts.profile, out: opts.out, waitMinutes: opts.wait, record: opts.record });
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(`[sessionsnap] Error: ${err.message}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
program
|
|
40
|
+
.command('open <url>')
|
|
41
|
+
.description('Open a URL using a previously captured session profile')
|
|
42
|
+
.option('--runner <runner>', 'Browser runner: puppeteer or playwright', 'puppeteer')
|
|
43
|
+
.requiredOption('--profile <name>', 'Profile name to load')
|
|
44
|
+
.option('--record', 'Record user actions (clicks, inputs, navigations)')
|
|
45
|
+
.action(async (url, opts) => {
|
|
46
|
+
validateRunner(opts.runner);
|
|
47
|
+
try {
|
|
48
|
+
if (opts.runner === 'puppeteer') {
|
|
49
|
+
const { open } = await import('../src/puppeteer/open.js');
|
|
50
|
+
await open(url, { profile: opts.profile, record: opts.record });
|
|
51
|
+
} else {
|
|
52
|
+
const { open } = await import('../src/playwright/open.js');
|
|
53
|
+
await open(url, { profile: opts.profile, record: opts.record });
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error(`[sessionsnap] Error: ${err.message}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
program
|
|
62
|
+
.command('list')
|
|
63
|
+
.description('List all saved profiles')
|
|
64
|
+
.option('--json', 'Output as JSON')
|
|
65
|
+
.action(async (opts) => {
|
|
66
|
+
const profiles = await listProfiles();
|
|
67
|
+
if (profiles.length === 0) {
|
|
68
|
+
console.log('[sessionsnap] No profiles found.');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (opts.json) {
|
|
73
|
+
const result = [];
|
|
74
|
+
for (const name of profiles) {
|
|
75
|
+
const entry = { name };
|
|
76
|
+
try {
|
|
77
|
+
const snap = await loadSnapshot(name);
|
|
78
|
+
entry.runner = snap.meta.runner;
|
|
79
|
+
entry.capturedAt = snap.meta.capturedAt;
|
|
80
|
+
entry.updatedAt = snap.meta.updatedAt || null;
|
|
81
|
+
entry.startUrl = snap.meta.startUrl;
|
|
82
|
+
entry.finalUrl = snap.meta.finalUrl;
|
|
83
|
+
entry.cookies = snap.cookies.length;
|
|
84
|
+
} catch { entry.snapshot = null; }
|
|
85
|
+
try {
|
|
86
|
+
const dir = getProfileDir(name);
|
|
87
|
+
const files = await readdir(dir);
|
|
88
|
+
entry.screenshots = files.filter((f) => f.endsWith('.png')).length;
|
|
89
|
+
const actionFiles = files.filter((f) => f.startsWith('actions-') && f.endsWith('.json'));
|
|
90
|
+
entry.recordings = actionFiles.length;
|
|
91
|
+
} catch { entry.screenshots = 0; entry.recordings = 0; }
|
|
92
|
+
result.push(entry);
|
|
93
|
+
}
|
|
94
|
+
console.log(JSON.stringify(result, null, 2));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ANSI color helpers
|
|
99
|
+
const c = {
|
|
100
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
101
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
102
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
103
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
104
|
+
magenta: (s) => `\x1b[35m${s}\x1b[0m`,
|
|
105
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
106
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
console.log(`\n${c.bold(`[sessionsnap] ${profiles.length} profile(s) found:`)}\n`);
|
|
110
|
+
for (const name of profiles) {
|
|
111
|
+
let line = ` ${c.bold(c.green(name))}`;
|
|
112
|
+
try {
|
|
113
|
+
const snap = await loadSnapshot(name);
|
|
114
|
+
const runner = snap.meta.runner;
|
|
115
|
+
const date = snap.meta.updatedAt || snap.meta.capturedAt;
|
|
116
|
+
const relDate = timeSince(date);
|
|
117
|
+
const url = snap.meta.finalUrl || snap.meta.startUrl;
|
|
118
|
+
line += ` ${c.cyan(`[${runner}]`)} ${c.dim(url)} ${c.yellow(`(${relDate})`)}`;
|
|
119
|
+
} catch {
|
|
120
|
+
line += ` ${c.red('(no snapshot)')}`;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const dir = getProfileDir(name);
|
|
124
|
+
const files = await readdir(dir);
|
|
125
|
+
const screenshots = files.filter((f) => f.endsWith('.png')).length;
|
|
126
|
+
const recordings = files.filter((f) => f.startsWith('actions-') && f.endsWith('.json')).length;
|
|
127
|
+
const extras = [];
|
|
128
|
+
if (screenshots > 0) extras.push(`📸 ${screenshots}`);
|
|
129
|
+
if (recordings > 0) extras.push(`🔴 ${recordings}`);
|
|
130
|
+
if (extras.length > 0) line += ` ${c.magenta(extras.join(' '))}`;
|
|
131
|
+
} catch { /* no dir */ }
|
|
132
|
+
console.log(line);
|
|
133
|
+
}
|
|
134
|
+
console.log();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
function timeSince(dateStr) {
|
|
138
|
+
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
|
139
|
+
if (seconds < 60) return 'just now';
|
|
140
|
+
const minutes = Math.floor(seconds / 60);
|
|
141
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
142
|
+
const hours = Math.floor(minutes / 60);
|
|
143
|
+
if (hours < 24) return `${hours}h ago`;
|
|
144
|
+
const days = Math.floor(hours / 24);
|
|
145
|
+
if (days < 30) return `${days}d ago`;
|
|
146
|
+
const months = Math.floor(days / 30);
|
|
147
|
+
return `${months}mo ago`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
program
|
|
151
|
+
.command('recordings')
|
|
152
|
+
.description('List recorded action files for a profile')
|
|
153
|
+
.requiredOption('--profile <name>', 'Profile name')
|
|
154
|
+
.option('--json', 'Output as JSON')
|
|
155
|
+
.action(async (opts) => {
|
|
156
|
+
const c = {
|
|
157
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
158
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
159
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
160
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
161
|
+
magenta: (s) => `\x1b[35m${s}\x1b[0m`,
|
|
162
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
163
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const files = await listActionFiles(opts.profile);
|
|
167
|
+
if (files.length === 0) {
|
|
168
|
+
console.log(`[sessionsnap] No recordings found for profile "${opts.profile}".`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const dir = getProfileDir(opts.profile);
|
|
173
|
+
const entries = [];
|
|
174
|
+
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
const raw = await readFile(join(dir, file), 'utf-8');
|
|
177
|
+
const actions = JSON.parse(raw);
|
|
178
|
+
const count = Array.isArray(actions) ? actions.length : 0;
|
|
179
|
+
|
|
180
|
+
// Extract label and timestamp from filename: actions-<label>-<timestamp>.json
|
|
181
|
+
const match = file.match(/^actions-(.+?)-(\d{4}-\d{2}-\d{2}T.+)\.json$/);
|
|
182
|
+
const label = match ? match[1] : '?';
|
|
183
|
+
const tsRaw = match ? match[2].replace(/-/g, (m, offset) => {
|
|
184
|
+
// Restore ISO date: first 10 chars use '-', rest use ':' and '.'
|
|
185
|
+
return offset < 10 ? '-' : offset === 19 ? '.' : ':';
|
|
186
|
+
}) : '';
|
|
187
|
+
|
|
188
|
+
let dateStr = '';
|
|
189
|
+
let relDate = '';
|
|
190
|
+
if (count > 0 && actions[0].timestamp) {
|
|
191
|
+
dateStr = new Date(actions[0].timestamp).toISOString();
|
|
192
|
+
relDate = timeSince(dateStr);
|
|
193
|
+
} else if (tsRaw) {
|
|
194
|
+
try {
|
|
195
|
+
dateStr = new Date(tsRaw).toISOString();
|
|
196
|
+
relDate = timeSince(dateStr);
|
|
197
|
+
} catch { /* invalid date */ }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Summarize action types
|
|
201
|
+
const types = {};
|
|
202
|
+
if (Array.isArray(actions)) {
|
|
203
|
+
for (const a of actions) {
|
|
204
|
+
types[a.type] = (types[a.type] || 0) + 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
entries.push({ file, label, count, date: dateStr, relDate, types });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (opts.json) {
|
|
212
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log(`\n${c.bold(`[sessionsnap] ${entries.length} recording(s) for profile "${opts.profile}":`)}\n`);
|
|
217
|
+
for (let i = 0; i < entries.length; i++) {
|
|
218
|
+
const e = entries[i];
|
|
219
|
+
const num = c.dim(`${i + 1}.`);
|
|
220
|
+
const name = c.bold(c.green(e.file));
|
|
221
|
+
const countStr = e.count > 0 ? c.cyan(`${e.count} actions`) : c.red('empty');
|
|
222
|
+
const labelStr = c.magenta(`[${e.label}]`);
|
|
223
|
+
const dateInfo = e.relDate ? c.yellow(`(${e.relDate})`) : '';
|
|
224
|
+
|
|
225
|
+
console.log(` ${num} ${name}`);
|
|
226
|
+
console.log(` ${labelStr} ${countStr} ${dateInfo}`);
|
|
227
|
+
|
|
228
|
+
if (e.count > 0) {
|
|
229
|
+
const typeSummary = Object.entries(e.types)
|
|
230
|
+
.map(([t, n]) => `${t}:${n}`)
|
|
231
|
+
.join(' ');
|
|
232
|
+
console.log(` ${c.dim(typeSummary)}`);
|
|
233
|
+
}
|
|
234
|
+
console.log();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
program
|
|
239
|
+
.command('status')
|
|
240
|
+
.description('Show profile details: path, session info, and screenshots')
|
|
241
|
+
.requiredOption('--profile <name>', 'Profile name')
|
|
242
|
+
.action(async (opts) => {
|
|
243
|
+
const dir = getProfileDir(opts.profile);
|
|
244
|
+
console.log(`Profile: ${opts.profile}`);
|
|
245
|
+
console.log(`Directory: ${dir}`);
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const snap = await loadSnapshot(opts.profile);
|
|
249
|
+
console.log(`Runner: ${snap.meta.runner}`);
|
|
250
|
+
console.log(`Captured at: ${snap.meta.capturedAt}`);
|
|
251
|
+
if (snap.meta.updatedAt) {
|
|
252
|
+
console.log(`Updated at: ${snap.meta.updatedAt}`);
|
|
253
|
+
}
|
|
254
|
+
console.log(`Start URL: ${snap.meta.startUrl}`);
|
|
255
|
+
console.log(`Final URL: ${snap.meta.finalUrl}`);
|
|
256
|
+
console.log(`Cookies: ${snap.cookies.length}`);
|
|
257
|
+
console.log(`Origins: ${snap.origins.length}`);
|
|
258
|
+
} catch {
|
|
259
|
+
console.log(`Snapshot: (not yet captured)`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const files = await readdir(dir);
|
|
264
|
+
const screenshots = files.filter((f) => f.endsWith('.png')).sort();
|
|
265
|
+
if (screenshots.length > 0) {
|
|
266
|
+
console.log(`Screenshots:`);
|
|
267
|
+
for (const s of screenshots) {
|
|
268
|
+
console.log(` - ${s}`);
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
console.log(`Screenshots: (none)`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const recordings = files.filter((f) => f.startsWith('actions-') && f.endsWith('.json')).sort();
|
|
275
|
+
if (recordings.length > 0) {
|
|
276
|
+
console.log(`Recordings:`);
|
|
277
|
+
for (const r of recordings) {
|
|
278
|
+
console.log(` - ${r}`);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
console.log(`Recordings: (none)`);
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
console.log(`Screenshots: (directory not found)`);
|
|
285
|
+
console.log(`Recordings: (directory not found)`);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
program
|
|
290
|
+
.command('replay')
|
|
291
|
+
.description('Replay previously recorded actions on a saved session')
|
|
292
|
+
.option('--runner <runner>', 'Browser runner: puppeteer or playwright', 'puppeteer')
|
|
293
|
+
.requiredOption('--profile <name>', 'Profile name to load')
|
|
294
|
+
.option('--file <name>', 'Specific actions JSON file to replay (defaults to latest)')
|
|
295
|
+
.option('--speed <multiplier>', 'Playback speed multiplier (default: 1)', parseFloat, 1)
|
|
296
|
+
.option('--headless', 'Run in headless mode')
|
|
297
|
+
.option('--bail', 'Stop replay immediately on first failure (fail fast)')
|
|
298
|
+
.option('--visual', 'Show a visual mouse cursor during replay')
|
|
299
|
+
.action(async (opts) => {
|
|
300
|
+
validateRunner(opts.runner);
|
|
301
|
+
try {
|
|
302
|
+
const actions = await loadActions(opts.profile, opts.file);
|
|
303
|
+
console.log(`[sessionsnap] Loaded ${actions.length} actions.`);
|
|
304
|
+
|
|
305
|
+
const replayOpts = {
|
|
306
|
+
profile: opts.profile,
|
|
307
|
+
speed: opts.speed,
|
|
308
|
+
headed: !opts.headless,
|
|
309
|
+
bail: opts.bail || false,
|
|
310
|
+
visual: opts.visual || false,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
if (opts.runner === 'puppeteer') {
|
|
314
|
+
const { replay } = await import('../src/puppeteer/replay.js');
|
|
315
|
+
await replay(actions, replayOpts);
|
|
316
|
+
} else {
|
|
317
|
+
const { replay } = await import('../src/playwright/replay.js');
|
|
318
|
+
await replay(actions, replayOpts);
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error(`[sessionsnap] Error: ${err.message}`);
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
function validateRunner(runner) {
|
|
327
|
+
if (!['puppeteer', 'playwright'].includes(runner)) {
|
|
328
|
+
console.error(`[sessionsnap] Invalid runner: "${runner}". Use puppeteer or playwright.`);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sessionsnap",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Capture and reuse authenticated browser sessions (Puppeteer + Playwright)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sessionsnap": "./bin/sessionsnap.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test test/*.test.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"commander": "^12.1.0",
|
|
14
|
+
"css-selector-generator": "^3.8.0",
|
|
15
|
+
"puppeteer": "^24.2.1"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"playwright": ">=1.40.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependenciesMeta": {
|
|
21
|
+
"playwright": {
|
|
22
|
+
"optional": true
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|