@crouton-kit/capture 1.3.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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * HAR recording management — create, read, append, delete.
3
+ *
4
+ * Extracted from cdp/index.ts for reuse by TabSession and other modules.
5
+ */
6
+
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+
10
+ export interface HAREntry {
11
+ request: {
12
+ method: string;
13
+ url: string;
14
+ headers: Array<{ name: string; value: string }>;
15
+ postData?: { text: string };
16
+ };
17
+ response: {
18
+ status: number;
19
+ headers: Array<{ name: string; value: string }>;
20
+ content: { text?: string };
21
+ };
22
+ startedDateTime: string;
23
+ }
24
+
25
+ export type HarFile = { log: { entries: HAREntry[] } };
26
+
27
+ export const HAR_DIR = '/tmp/capture-har';
28
+
29
+ export function harFilePath(id: string): string {
30
+ return path.join(HAR_DIR, `${id}.json`);
31
+ }
32
+
33
+ export function createHarRecording(): { id: string; path: string } {
34
+ const id = Math.random().toString(36).slice(2, 8);
35
+ const harPath = harFilePath(id);
36
+ fs.mkdirSync(HAR_DIR, { recursive: true });
37
+ fs.writeFileSync(harPath, JSON.stringify({ log: { entries: [] } }, null, 2));
38
+ return { id, path: harPath };
39
+ }
40
+
41
+ export function readHarRecording(id: string): HarFile | null {
42
+ try {
43
+ return JSON.parse(fs.readFileSync(harFilePath(id), 'utf-8'));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function appendToHarRecording(id: string, entries: HAREntry[]): void {
50
+ const harPath = harFilePath(id);
51
+ let har: HarFile;
52
+
53
+ try {
54
+ har = JSON.parse(fs.readFileSync(harPath, 'utf-8'));
55
+ } catch {
56
+ har = { log: { entries: [] } };
57
+ }
58
+
59
+ har.log.entries.push(...entries);
60
+ fs.writeFileSync(harPath, JSON.stringify(har, null, 2));
61
+ }
62
+
63
+ export function deleteHarRecording(id: string): void {
64
+ try {
65
+ fs.unlinkSync(harFilePath(id));
66
+ } catch {
67
+ // best-effort — file may already be gone
68
+ }
69
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * A11y-driven interactions — click and type using the accessibility tree
3
+ * instead of brittle CSS selectors.
4
+ *
5
+ * Pipeline: getFullAXTree → find by name/role → DOM.getBoxModel → Input.dispatchMouseEvent
6
+ *
7
+ * Uses getFullAXTree instead of queryAXTree because queryAXTree hangs on
8
+ * complex pages (7000+ AX nodes) when given the DOM root nodeId.
9
+ */
10
+
11
+ import type { CDPClient } from './cdp.js';
12
+
13
+ interface ClickResult {
14
+ x: number;
15
+ y: number;
16
+ role: string;
17
+ name: string;
18
+ }
19
+
20
+ interface FullAXNode {
21
+ nodeId: string;
22
+ backendDOMNodeId?: number;
23
+ role?: { value: string };
24
+ name?: { value: string };
25
+ childIds?: string[];
26
+ }
27
+
28
+ /**
29
+ * Click an element by its accessible name. Optionally filter by ARIA role.
30
+ *
31
+ * Uses Accessibility.getFullAXTree to find elements by name/role,
32
+ * then dispatches mouse events at the element's center coordinates.
33
+ */
34
+ export async function clickByName(
35
+ client: CDPClient,
36
+ name: string,
37
+ role?: string,
38
+ ): Promise<ClickResult> {
39
+ await client.send('Accessibility.enable');
40
+ await client.send('DOM.enable');
41
+
42
+ const { nodes } = (await client.send('Accessibility.getFullAXTree')) as {
43
+ nodes: FullAXNode[];
44
+ };
45
+ await client.send('Accessibility.disable');
46
+
47
+ // Find nodes matching the accessible name (exact match)
48
+ const nameLower = name.toLowerCase();
49
+ const matches = nodes.filter((n) => {
50
+ if (!n.backendDOMNodeId) return false;
51
+ if (!n.name?.value) return false;
52
+ if (n.name.value.toLowerCase() !== nameLower) return false;
53
+ if (role && n.role?.value !== role) return false;
54
+ return true;
55
+ });
56
+
57
+ if (matches.length === 0) {
58
+ throw new Error(
59
+ `No element found with accessible name "${name}"${role ? ` and role "${role}"` : ''}. ` +
60
+ 'Run `capture a11y --interactive` to see available elements.',
61
+ );
62
+ }
63
+
64
+ if (matches.length > 1 && !role) {
65
+ // Auto-prefer input-like roles for better type --into ergonomics
66
+ const INPUT_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'textarea', 'spinbutton']);
67
+ const inputMatches = matches.filter((n) => n.role?.value && INPUT_ROLES.has(n.role.value));
68
+ if (inputMatches.length === 1) {
69
+ // Unambiguous input element — use it
70
+ matches.length = 0;
71
+ matches.push(inputMatches[0]);
72
+ } else {
73
+ const summary = matches
74
+ .slice(0, 5)
75
+ .map((n) => ` ${n.role?.value} "${n.name?.value}"`)
76
+ .join('\n');
77
+ throw new Error(
78
+ `Multiple elements match "${name}" — disambiguate with --role:\n${summary}`,
79
+ );
80
+ }
81
+ }
82
+
83
+ const match = matches[0];
84
+ const backendNodeId = match.backendDOMNodeId!;
85
+
86
+ // Scroll into view
87
+ await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId });
88
+
89
+ // Get box model for click coordinates
90
+ const { model } = (await client.send('DOM.getBoxModel', { backendNodeId })) as {
91
+ model: { content: number[] };
92
+ };
93
+
94
+ // Content quad is [x1,y1, x2,y2, x3,y3, x4,y4] — calculate center
95
+ const quad = model.content;
96
+ const x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
97
+ const y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
98
+
99
+ // Click: mousePressed + mouseReleased
100
+ await client.send('Input.dispatchMouseEvent', {
101
+ type: 'mousePressed',
102
+ x,
103
+ y,
104
+ button: 'left',
105
+ clickCount: 1,
106
+ });
107
+ await client.send('Input.dispatchMouseEvent', {
108
+ type: 'mouseReleased',
109
+ x,
110
+ y,
111
+ button: 'left',
112
+ clickCount: 1,
113
+ });
114
+
115
+ return {
116
+ x: Math.round(x),
117
+ y: Math.round(y),
118
+ role: match.role?.value ?? 'unknown',
119
+ name: match.name?.value ?? name,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Type text into the currently focused element via CDP Input.insertText.
125
+ */
126
+ export async function typeText(client: CDPClient, text: string): Promise<void> {
127
+ await client.send('Input.insertText', { text });
128
+ }
129
+
130
+ /**
131
+ * Click a field by accessible name, then type text into it.
132
+ */
133
+ export async function focusAndType(
134
+ client: CDPClient,
135
+ fieldName: string,
136
+ text: string,
137
+ role?: string,
138
+ ): Promise<ClickResult> {
139
+ const result = await clickByName(client, fieldName, role);
140
+ // Small delay for focus to settle
141
+ await new Promise((r) => setTimeout(r, 100));
142
+ await typeText(client, text);
143
+ return result;
144
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Session Context — persists active session state so CDP commands
3
+ * can auto-fill --target, --har, and --out without manual threading.
4
+ *
5
+ * State is written to /tmp/capture-sessions/.active (JSON).
6
+ * Stale sessions (dir deleted, crashed process) are cleaned up on read.
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as os from 'os';
12
+
13
+ const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
14
+ const ACTIVE_PATH = path.join(CAPTURE_ROOT, '.active');
15
+
16
+ export interface ActiveSessionState {
17
+ sessionId: string;
18
+ dir: string;
19
+ harId: string | null;
20
+ targetId: string | null;
21
+ stepCount: number;
22
+ }
23
+
24
+ export function getActiveSession(): ActiveSessionState | null {
25
+ try {
26
+ if (!fs.existsSync(ACTIVE_PATH)) return null;
27
+ const state = JSON.parse(fs.readFileSync(ACTIVE_PATH, 'utf-8')) as ActiveSessionState;
28
+ // Validate session dir still exists — clean up stale files
29
+ if (!fs.existsSync(state.dir)) {
30
+ clearActiveSession();
31
+ return null;
32
+ }
33
+ return state;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function setActiveSession(state: ActiveSessionState): void {
40
+ fs.mkdirSync(CAPTURE_ROOT, { recursive: true });
41
+ fs.writeFileSync(ACTIVE_PATH, JSON.stringify(state, null, 2));
42
+ }
43
+
44
+ export function clearActiveSession(): void {
45
+ try {
46
+ fs.unlinkSync(ACTIVE_PATH);
47
+ } catch {
48
+ // Already gone
49
+ }
50
+ }
51
+
52
+ export function updateActiveSession(patch: Partial<ActiveSessionState>): ActiveSessionState | null {
53
+ const current = getActiveSession();
54
+ if (!current) return null;
55
+ const updated = { ...current, ...patch };
56
+ setActiveSession(updated);
57
+ return updated;
58
+ }
59
+
60
+ function sanitizeLabel(label: string): string {
61
+ return label
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]+/g, '-')
64
+ .replace(/^-|-$/g, '')
65
+ .slice(0, 40);
66
+ }
67
+
68
+ /**
69
+ * Returns the next auto-numbered screenshot path for the active session.
70
+ * Format: {dir}/shots/{NN}-{action}-{sanitized-label}.png
71
+ * Increments stepCount in the session state.
72
+ */
73
+ export function nextStepPath(action: string, label: string): string | null {
74
+ const session = getActiveSession();
75
+ if (!session) return null;
76
+
77
+ const step = session.stepCount + 1;
78
+ const nn = String(step).padStart(2, '0');
79
+ const sanitized = sanitizeLabel(label);
80
+ const filename = `${nn}-${action}-${sanitized}.png`;
81
+ const shotPath = path.join(session.dir, 'shots', filename);
82
+
83
+ updateActiveSession({ stepCount: step });
84
+ return shotPath;
85
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * TabSession — universal tab lifecycle abstraction for parallel-safe CDP usage.
3
+ *
4
+ * Manages: tab creation, HAR recording, environment injection, and cleanup.
5
+ * Callers create a TabSession, pass its env() to downstream scripts, and close() when done.
6
+ *
7
+ * Usage:
8
+ * const session = await TabSession.create({ url: 'https://app.hubspot.com' });
9
+ * console.log(session.env()); // { CDP_TARGET: '...', CDP_HAR_ID: '...' }
10
+ * await session.validate(); // throws if tab is gone
11
+ * await session.close(); // closes tab + deletes HAR
12
+ */
13
+
14
+ import {
15
+ navigateAndWait,
16
+ findTabById,
17
+ detectCdpPort,
18
+ CDPClient,
19
+ } from './cdp.js';
20
+ import { createHarRecording, deleteHarRecording } from './har-manager.js';
21
+
22
+ export interface TabSessionOptions {
23
+ url: string;
24
+ forceNew?: boolean; // Default true (parallel-safe)
25
+ createHar?: boolean; // Default true
26
+ cdpPort?: number; // Auto-detect if not provided
27
+ }
28
+
29
+ export interface TabSessionEnv {
30
+ CDP_TARGET: string;
31
+ CDP_HAR_ID?: string;
32
+ }
33
+
34
+ export class TabSession {
35
+ readonly tabTarget: string;
36
+ readonly harId: string | null;
37
+ readonly cdpPort: number;
38
+ private closed = false;
39
+
40
+ private constructor(
41
+ tabTarget: string,
42
+ harId: string | null,
43
+ cdpPort: number,
44
+ ) {
45
+ this.tabTarget = tabTarget;
46
+ this.harId = harId;
47
+ this.cdpPort = cdpPort;
48
+ }
49
+
50
+ static async create(options: TabSessionOptions): Promise<TabSession> {
51
+ const port = options.cdpPort ?? (await detectCdpPort());
52
+ const tab = await navigateAndWait(port, options.url, {
53
+ forceNew: options.forceNew ?? true,
54
+ });
55
+ let harId: string | null = null;
56
+ if (options.createHar !== false) {
57
+ harId = createHarRecording().id;
58
+ }
59
+ return new TabSession(tab.id, harId, port);
60
+ }
61
+
62
+ async validate(): Promise<void> {
63
+ const tab = await findTabById(this.cdpPort, this.tabTarget);
64
+ if (!tab) throw new Error(`Tab ${this.tabTarget} no longer exists`);
65
+ }
66
+
67
+ env(): TabSessionEnv {
68
+ const result: TabSessionEnv = { CDP_TARGET: this.tabTarget };
69
+ if (this.harId) result.CDP_HAR_ID = this.harId;
70
+ return result;
71
+ }
72
+
73
+ async close(): Promise<void> {
74
+ if (this.closed) return;
75
+ this.closed = true;
76
+ try {
77
+ const tab = await findTabById(this.cdpPort, this.tabTarget);
78
+ if (tab?.webSocketDebuggerUrl) {
79
+ const client = new CDPClient(tab.webSocketDebuggerUrl);
80
+ await client.waitReady();
81
+ await client.send('Runtime.evaluate', {
82
+ expression: 'window.close()',
83
+ });
84
+ client.close();
85
+ }
86
+ } catch {
87
+ /* best-effort */
88
+ }
89
+ if (this.harId) deleteHarRecording(this.harId);
90
+ }
91
+ }