@sparkleideas/browser 3.0.0-alpha.3

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,479 @@
1
+ /**
2
+ * @sparkleideas/browser - Workflow Templates
3
+ * Pre-built workflow templates for common browser automation tasks
4
+ */
5
+
6
+ import type { BrowserTrajectoryStep } from '../domain/types.js';
7
+
8
+ // ============================================================================
9
+ // Workflow Types
10
+ // ============================================================================
11
+
12
+ export interface WorkflowTemplate {
13
+ id: string;
14
+ name: string;
15
+ description: string;
16
+ category: WorkflowCategory;
17
+ steps: WorkflowStep[];
18
+ variables: WorkflowVariable[];
19
+ tags: string[];
20
+ estimatedDuration: number; // ms
21
+ successRate?: number;
22
+ }
23
+
24
+ export type WorkflowCategory =
25
+ | 'authentication'
26
+ | 'data-extraction'
27
+ | 'form-submission'
28
+ | 'navigation'
29
+ | 'testing'
30
+ | 'monitoring';
31
+
32
+ export interface WorkflowStep {
33
+ id: string;
34
+ action: BrowserAction;
35
+ target?: string; // Selector or variable reference ${var}
36
+ value?: string; // Value or variable reference
37
+ waitAfter?: number;
38
+ optional?: boolean;
39
+ onError?: 'continue' | 'abort' | 'retry';
40
+ maxRetries?: number;
41
+ condition?: string; // JavaScript condition
42
+ }
43
+
44
+ export type BrowserAction =
45
+ | 'open'
46
+ | 'click'
47
+ | 'fill'
48
+ | 'type'
49
+ | 'press'
50
+ | 'select'
51
+ | 'check'
52
+ | 'uncheck'
53
+ | 'hover'
54
+ | 'scroll'
55
+ | 'wait'
56
+ | 'screenshot'
57
+ | 'snapshot'
58
+ | 'get'
59
+ | 'eval'
60
+ | 'assert';
61
+
62
+ export interface WorkflowVariable {
63
+ name: string;
64
+ type: 'string' | 'number' | 'boolean' | 'selector';
65
+ required: boolean;
66
+ default?: string | number | boolean;
67
+ description: string;
68
+ sensitive?: boolean; // Will be masked in logs
69
+ }
70
+
71
+ export interface WorkflowExecution {
72
+ templateId: string;
73
+ variables: Record<string, unknown>;
74
+ status: 'pending' | 'running' | 'completed' | 'failed';
75
+ currentStep: number;
76
+ results: WorkflowStepResult[];
77
+ startedAt: string;
78
+ completedAt?: string;
79
+ error?: string;
80
+ }
81
+
82
+ export interface WorkflowStepResult {
83
+ stepId: string;
84
+ success: boolean;
85
+ duration: number;
86
+ data?: unknown;
87
+ error?: string;
88
+ retries?: number;
89
+ }
90
+
91
+ // ============================================================================
92
+ // Built-in Workflow Templates
93
+ // ============================================================================
94
+
95
+ export const WORKFLOW_TEMPLATES: WorkflowTemplate[] = [
96
+ // ============ Authentication ============
97
+ {
98
+ id: 'login-basic',
99
+ name: 'Basic Login',
100
+ description: 'Standard username/password login flow',
101
+ category: 'authentication',
102
+ tags: ['login', 'auth', 'form'],
103
+ estimatedDuration: 5000,
104
+ variables: [
105
+ { name: 'url', type: 'string', required: true, description: 'Login page URL' },
106
+ { name: 'usernameSelector', type: 'selector', required: false, default: '#username, #email, [name="email"], [type="email"]', description: 'Username/email field selector' },
107
+ { name: 'passwordSelector', type: 'selector', required: false, default: '#password, [name="password"], [type="password"]', description: 'Password field selector' },
108
+ { name: 'submitSelector', type: 'selector', required: false, default: '[type="submit"], button[type="submit"], #login-btn', description: 'Submit button selector' },
109
+ { name: 'username', type: 'string', required: true, description: 'Username or email', sensitive: false },
110
+ { name: 'password', type: 'string', required: true, description: 'Password', sensitive: true },
111
+ { name: 'successIndicator', type: 'selector', required: false, default: '.dashboard, .home, #welcome', description: 'Element that indicates successful login' },
112
+ ],
113
+ steps: [
114
+ { id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000 },
115
+ { id: 'snapshot-login', action: 'snapshot', onError: 'continue' },
116
+ { id: 'enter-username', action: 'fill', target: '\${usernameSelector}', value: '\${username}', onError: 'abort' },
117
+ { id: 'enter-password', action: 'fill', target: '\${passwordSelector}', value: '\${password}', onError: 'abort' },
118
+ { id: 'submit', action: 'click', target: '\${submitSelector}', waitAfter: 2000, onError: 'retry', maxRetries: 2 },
119
+ { id: 'verify-success', action: 'wait', target: '\${successIndicator}', onError: 'abort' },
120
+ { id: 'snapshot-dashboard', action: 'snapshot', optional: true },
121
+ ],
122
+ },
123
+ {
124
+ id: 'login-oauth',
125
+ name: 'OAuth/SSO Login',
126
+ description: 'Login via OAuth provider (Google, GitHub, etc.)',
127
+ category: 'authentication',
128
+ tags: ['login', 'oauth', 'sso', 'google', 'github'],
129
+ estimatedDuration: 8000,
130
+ variables: [
131
+ { name: 'url', type: 'string', required: true, description: 'App login page URL' },
132
+ { name: 'providerButton', type: 'selector', required: true, description: 'OAuth provider button selector (e.g., "Continue with Google")' },
133
+ { name: 'email', type: 'string', required: true, description: 'OAuth account email' },
134
+ { name: 'password', type: 'string', required: true, description: 'OAuth account password', sensitive: true },
135
+ { name: 'successUrl', type: 'string', required: false, description: 'URL pattern after successful login' },
136
+ ],
137
+ steps: [
138
+ { id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000 },
139
+ { id: 'click-oauth', action: 'click', target: '\${providerButton}', waitAfter: 2000 },
140
+ { id: 'enter-email', action: 'fill', target: '[type="email"], #identifierId', value: '\${email}' },
141
+ { id: 'next-email', action: 'click', target: '#identifierNext, [type="submit"]', waitAfter: 1500 },
142
+ { id: 'enter-password', action: 'fill', target: '[type="password"], [name="password"]', value: '\${password}' },
143
+ { id: 'submit', action: 'click', target: '#passwordNext, [type="submit"]', waitAfter: 3000 },
144
+ { id: 'wait-redirect', action: 'wait', target: '\${successUrl}', onError: 'continue' },
145
+ ],
146
+ },
147
+ {
148
+ id: 'logout',
149
+ name: 'Logout',
150
+ description: 'Standard logout flow',
151
+ category: 'authentication',
152
+ tags: ['logout', 'auth', 'session'],
153
+ estimatedDuration: 3000,
154
+ variables: [
155
+ { name: 'menuSelector', type: 'selector', required: false, default: '.user-menu, #user-dropdown, .avatar', description: 'User menu selector (if needed)' },
156
+ { name: 'logoutSelector', type: 'selector', required: true, description: 'Logout button/link selector' },
157
+ { name: 'confirmSelector', type: 'selector', required: false, description: 'Confirmation button if needed' },
158
+ ],
159
+ steps: [
160
+ { id: 'open-menu', action: 'click', target: '\${menuSelector}', optional: true, waitAfter: 500 },
161
+ { id: 'click-logout', action: 'click', target: '\${logoutSelector}', waitAfter: 1000 },
162
+ { id: 'confirm', action: 'click', target: '\${confirmSelector}', optional: true, waitAfter: 1000 },
163
+ { id: 'verify', action: 'wait', target: '/login, /signin, .login-form', onError: 'continue' },
164
+ ],
165
+ },
166
+
167
+ // ============ Data Extraction ============
168
+ {
169
+ id: 'scrape-table',
170
+ name: 'Scrape Table Data',
171
+ description: 'Extract data from HTML tables',
172
+ category: 'data-extraction',
173
+ tags: ['scrape', 'table', 'data', 'extract'],
174
+ estimatedDuration: 3000,
175
+ variables: [
176
+ { name: 'url', type: 'string', required: true, description: 'Page URL containing the table' },
177
+ { name: 'tableSelector', type: 'selector', required: false, default: 'table', description: 'Table selector' },
178
+ { name: 'includeHeaders', type: 'boolean', required: false, default: true, description: 'Include table headers' },
179
+ ],
180
+ steps: [
181
+ { id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000 },
182
+ { id: 'wait-table', action: 'wait', target: '\${tableSelector}' },
183
+ { id: 'extract-data', action: 'eval', value: `
184
+ (() => {
185
+ const table = document.querySelector('\${tableSelector}');
186
+ if (!table) return { error: 'Table not found' };
187
+
188
+ const rows = Array.from(table.querySelectorAll('tr'));
189
+ const data = rows.map(row => {
190
+ const cells = Array.from(row.querySelectorAll('td, th'));
191
+ return cells.map(cell => cell.textContent.trim());
192
+ });
193
+
194
+ return { headers: data[0], rows: data.slice(1), totalRows: data.length - 1 };
195
+ })()
196
+ `},
197
+ ],
198
+ },
199
+ {
200
+ id: 'scrape-list',
201
+ name: 'Scrape List Items',
202
+ description: 'Extract items from lists or repeated elements',
203
+ category: 'data-extraction',
204
+ tags: ['scrape', 'list', 'data', 'extract'],
205
+ estimatedDuration: 3000,
206
+ variables: [
207
+ { name: 'url', type: 'string', required: true, description: 'Page URL' },
208
+ { name: 'itemSelector', type: 'selector', required: true, description: 'Selector for each item' },
209
+ { name: 'fields', type: 'string', required: true, description: 'JSON object mapping field names to sub-selectors' },
210
+ ],
211
+ steps: [
212
+ { id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000 },
213
+ { id: 'wait-items', action: 'wait', target: '\${itemSelector}' },
214
+ { id: 'extract-items', action: 'eval', value: `
215
+ (() => {
216
+ const items = document.querySelectorAll('\${itemSelector}');
217
+ const fields = JSON.parse('\${fields}');
218
+
219
+ return Array.from(items).map(item => {
220
+ const result = {};
221
+ for (const [name, selector] of Object.entries(fields)) {
222
+ const el = item.querySelector(selector);
223
+ result[name] = el ? el.textContent.trim() : null;
224
+ }
225
+ return result;
226
+ });
227
+ })()
228
+ `},
229
+ ],
230
+ },
231
+
232
+ // ============ Form Submission ============
233
+ {
234
+ id: 'contact-form',
235
+ name: 'Contact Form Submission',
236
+ description: 'Fill and submit a contact form',
237
+ category: 'form-submission',
238
+ tags: ['form', 'contact', 'submit'],
239
+ estimatedDuration: 5000,
240
+ variables: [
241
+ { name: 'url', type: 'string', required: true, description: 'Contact page URL' },
242
+ { name: 'name', type: 'string', required: true, description: 'Your name' },
243
+ { name: 'email', type: 'string', required: true, description: 'Your email' },
244
+ { name: 'message', type: 'string', required: true, description: 'Message content' },
245
+ { name: 'submitSelector', type: 'selector', required: false, default: '[type="submit"], button[type="submit"]', description: 'Submit button' },
246
+ ],
247
+ steps: [
248
+ { id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000 },
249
+ { id: 'fill-name', action: 'fill', target: '#name, [name="name"], [placeholder*="name" i]', value: '\${name}' },
250
+ { id: 'fill-email', action: 'fill', target: '#email, [name="email"], [type="email"]', value: '\${email}' },
251
+ { id: 'fill-message', action: 'fill', target: '#message, [name="message"], textarea', value: '\${message}' },
252
+ { id: 'submit', action: 'click', target: '\${submitSelector}', waitAfter: 2000 },
253
+ { id: 'screenshot', action: 'screenshot', optional: true },
254
+ ],
255
+ },
256
+
257
+ // ============ Testing ============
258
+ {
259
+ id: 'visual-regression',
260
+ name: 'Visual Regression Test',
261
+ description: 'Take screenshots for visual comparison',
262
+ category: 'testing',
263
+ tags: ['test', 'visual', 'screenshot', 'regression'],
264
+ estimatedDuration: 5000,
265
+ variables: [
266
+ { name: 'urls', type: 'string', required: true, description: 'Comma-separated list of URLs to test' },
267
+ { name: 'viewport', type: 'string', required: false, default: '1280x720', description: 'Viewport size (WxH)' },
268
+ ],
269
+ steps: [
270
+ { id: 'set-viewport', action: 'eval', value: `
271
+ (() => {
272
+ const [w, h] = '\${viewport}'.split('x').map(Number);
273
+ return { width: w, height: h };
274
+ })()
275
+ `},
276
+ { id: 'test-urls', action: 'eval', value: `'\${urls}'.split(',').map(u => u.trim())` },
277
+ ],
278
+ },
279
+ {
280
+ id: 'smoke-test',
281
+ name: 'Smoke Test',
282
+ description: 'Basic smoke test to verify page loads correctly',
283
+ category: 'testing',
284
+ tags: ['test', 'smoke', 'health'],
285
+ estimatedDuration: 3000,
286
+ variables: [
287
+ { name: 'url', type: 'string', required: true, description: 'URL to test' },
288
+ { name: 'expectedTitle', type: 'string', required: false, description: 'Expected page title (partial match)' },
289
+ { name: 'requiredElements', type: 'string', required: false, description: 'Comma-separated selectors that must exist' },
290
+ ],
291
+ steps: [
292
+ { id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000, onError: 'abort' },
293
+ { id: 'check-title', action: 'get', target: 'title', condition: '\${expectedTitle}' },
294
+ { id: 'check-elements', action: 'eval', value: `
295
+ (() => {
296
+ const selectors = '\${requiredElements}'.split(',').map(s => s.trim()).filter(Boolean);
297
+ const results = selectors.map(sel => ({
298
+ selector: sel,
299
+ found: document.querySelector(sel) !== null
300
+ }));
301
+ const allFound = results.every(r => r.found);
302
+ return { results, allFound };
303
+ })()
304
+ `, optional: true },
305
+ { id: 'screenshot', action: 'screenshot' },
306
+ ],
307
+ },
308
+
309
+ // ============ Monitoring ============
310
+ {
311
+ id: 'uptime-check',
312
+ name: 'Uptime Check',
313
+ description: 'Check if a page is accessible and loads within timeout',
314
+ category: 'monitoring',
315
+ tags: ['monitor', 'uptime', 'health', 'availability'],
316
+ estimatedDuration: 10000,
317
+ variables: [
318
+ { name: 'url', type: 'string', required: true, description: 'URL to check' },
319
+ { name: 'timeout', type: 'number', required: false, default: 10000, description: 'Timeout in ms' },
320
+ { name: 'expectedStatus', type: 'number', required: false, default: 200, description: 'Expected HTTP status' },
321
+ ],
322
+ steps: [
323
+ { id: 'navigate', action: 'open', target: '\${url}', onError: 'abort' },
324
+ { id: 'measure', action: 'eval', value: `
325
+ (() => {
326
+ const timing = performance.timing;
327
+ return {
328
+ dns: timing.domainLookupEnd - timing.domainLookupStart,
329
+ connection: timing.connectEnd - timing.connectStart,
330
+ ttfb: timing.responseStart - timing.requestStart,
331
+ domLoad: timing.domContentLoadedEventEnd - timing.navigationStart,
332
+ fullLoad: timing.loadEventEnd - timing.navigationStart
333
+ };
334
+ })()
335
+ `},
336
+ { id: 'screenshot', action: 'screenshot', optional: true },
337
+ ],
338
+ },
339
+ ];
340
+
341
+ // ============================================================================
342
+ // Workflow Manager
343
+ // ============================================================================
344
+
345
+ export class WorkflowManager {
346
+ private templates: Map<string, WorkflowTemplate> = new Map();
347
+ private executions: Map<string, WorkflowExecution> = new Map();
348
+
349
+ constructor() {
350
+ // Load built-in templates
351
+ for (const template of WORKFLOW_TEMPLATES) {
352
+ this.templates.set(template.id, template);
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Get all available templates
358
+ */
359
+ listTemplates(category?: WorkflowCategory): WorkflowTemplate[] {
360
+ const templates = Array.from(this.templates.values());
361
+ if (category) {
362
+ return templates.filter((t) => t.category === category);
363
+ }
364
+ return templates;
365
+ }
366
+
367
+ /**
368
+ * Get a specific template
369
+ */
370
+ getTemplate(id: string): WorkflowTemplate | undefined {
371
+ return this.templates.get(id);
372
+ }
373
+
374
+ /**
375
+ * Register a custom template
376
+ */
377
+ registerTemplate(template: WorkflowTemplate): void {
378
+ this.templates.set(template.id, template);
379
+ }
380
+
381
+ /**
382
+ * Search templates by tags
383
+ */
384
+ searchTemplates(query: string): WorkflowTemplate[] {
385
+ const terms = query.toLowerCase().split(/\s+/);
386
+ return Array.from(this.templates.values()).filter((t) => {
387
+ const searchText = `${t.name} ${t.description} ${t.tags.join(' ')}`.toLowerCase();
388
+ return terms.every((term) => searchText.includes(term));
389
+ });
390
+ }
391
+
392
+ /**
393
+ * Validate variables for a template
394
+ */
395
+ validateVariables(
396
+ templateId: string,
397
+ variables: Record<string, unknown>
398
+ ): { valid: boolean; errors: string[] } {
399
+ const template = this.templates.get(templateId);
400
+ if (!template) {
401
+ return { valid: false, errors: [`Template '${templateId}' not found`] };
402
+ }
403
+
404
+ const errors: string[] = [];
405
+
406
+ for (const varDef of template.variables) {
407
+ const value = variables[varDef.name];
408
+
409
+ if (varDef.required && (value === undefined || value === null || value === '')) {
410
+ errors.push(`Required variable '${varDef.name}' is missing`);
411
+ continue;
412
+ }
413
+
414
+ if (value !== undefined && value !== null) {
415
+ switch (varDef.type) {
416
+ case 'number':
417
+ if (typeof value !== 'number' && isNaN(Number(value))) {
418
+ errors.push(`Variable '${varDef.name}' must be a number`);
419
+ }
420
+ break;
421
+ case 'boolean':
422
+ if (typeof value !== 'boolean' && !['true', 'false'].includes(String(value))) {
423
+ errors.push(`Variable '${varDef.name}' must be a boolean`);
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ }
429
+
430
+ return { valid: errors.length === 0, errors };
431
+ }
432
+
433
+ /**
434
+ * Interpolate variables in step values
435
+ */
436
+ interpolateStep(step: WorkflowStep, variables: Record<string, unknown>): WorkflowStep {
437
+ const interpolate = (value: string | undefined): string | undefined => {
438
+ if (!value) return value;
439
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => {
440
+ const val = variables[name];
441
+ return val !== undefined ? String(val) : `\${${name}}`;
442
+ });
443
+ };
444
+
445
+ return {
446
+ ...step,
447
+ target: interpolate(step.target),
448
+ value: interpolate(step.value),
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Get execution status
454
+ */
455
+ getExecution(executionId: string): WorkflowExecution | undefined {
456
+ return this.executions.get(executionId);
457
+ }
458
+ }
459
+
460
+ // ============================================================================
461
+ // Factory
462
+ // ============================================================================
463
+
464
+ let defaultManager: WorkflowManager | null = null;
465
+
466
+ export function getWorkflowManager(): WorkflowManager {
467
+ if (!defaultManager) {
468
+ defaultManager = new WorkflowManager();
469
+ }
470
+ return defaultManager;
471
+ }
472
+
473
+ export function listWorkflows(category?: WorkflowCategory): WorkflowTemplate[] {
474
+ return getWorkflowManager().listTemplates(category);
475
+ }
476
+
477
+ export function getWorkflow(id: string): WorkflowTemplate | undefined {
478
+ return getWorkflowManager().getTemplate(id);
479
+ }