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 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
+ }