clipwise 0.1.1 → 0.2.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/README.ko.md CHANGED
@@ -114,15 +114,57 @@ steps:
114
114
 
115
115
  ### 액션
116
116
 
117
- | 액션 | 파라미터 | 설명 |
118
- |------|---------|------|
119
- | `navigate` | `url`, `waitUntil?` | URL로 이동 |
120
- | `click` | `selector`, `delay?` | 요소 클릭 |
121
- | `type` | `selector`, `text`, `delay?` | 텍스트 입력 (한 글자씩) |
122
- | `hover` | `selector` | 요소에 마우스 올리기 |
123
- | `scroll` | `y?`, `x?`, `selector?`, `smooth?` | 스크롤 |
124
- | `wait` | `duration` | 대기 (ms) |
125
- | `screenshot` | `name?`, `fullPage?` | 캡처 마커 |
117
+ #### 기본 액션
118
+
119
+ | 액션 | 파라미터 | 기본값 | 설명 |
120
+ |------|---------|--------|------|
121
+ | `navigate` | `url`, `waitUntil?` | `waitUntil: "networkidle"` | URL로 이동 |
122
+ | `click` | `selector`, `delay?`, `timeout?` | | 요소 클릭 |
123
+ | `type` | `selector`, `text`, `delay?`, `timeout?` | `delay: 50` | 텍스트 입력 (한 글자씩) |
124
+ | `hover` | `selector`, `timeout?` | | 요소에 마우스 올리기 |
125
+ | `scroll` | `y?`, `x?`, `selector?`, `smooth?`, `timeout?` | `y: 0`, `x: 0`, `smooth: true` | 스크롤 |
126
+ | `wait` | `duration` | | 대기 (ms) |
127
+ | `screenshot` | `name?`, `fullPage?` | `fullPage: false` | 캡처 마커 |
128
+
129
+ #### 비동기 대기 액션
130
+
131
+ | 액션 | 파라미터 | 기본값 | 설명 |
132
+ |------|---------|--------|------|
133
+ | `waitForSelector` | `selector`, `state?`, `timeout?` | `state: "visible"`, `timeout: 15000` | 요소 상태 대기 |
134
+ | `waitForNavigation` | `waitUntil?`, `timeout?` | `waitUntil: "networkidle"`, `timeout: 15000` | 페이지 로드 대기 |
135
+ | `waitForURL` | `url`, `timeout?` | `timeout: 15000` | URL 매칭 대기 |
136
+ | `waitForFunction` | `expression`, `polling?`, `timeout?` | `polling: "raf"`, `timeout: 30000` | JS 표현식이 truthy가 될 때까지 대기 |
137
+ | `waitForResponse` | `url`, `status?`, `timeout?` | `timeout: 30000` | 네트워크 응답 대기 (URL 부분 문자열 매칭) |
138
+
139
+ **`waitUntil`** 옵션: `"load"`, `"domcontentloaded"`, `"networkidle"` (기본)
140
+ **`state`** 옵션: `"visible"` (기본), `"attached"`, `"hidden"`
141
+ **`polling`** 옵션: `"raf"` (requestAnimationFrame, 기본) 또는 밀리초 숫자 (예: `500`)
142
+
143
+ #### 비동기 대기 예시
144
+
145
+ ```yaml
146
+ # 요소가 나타날 때까지 대기
147
+ - action: waitForSelector
148
+ selector: ".result-panel"
149
+ state: visible
150
+ timeout: 20000
151
+
152
+ # AI 스트리밍 응답 완료 대기
153
+ - action: waitForFunction
154
+ expression: "document.querySelector('.ai-response')?.dataset.done === 'true'"
155
+ timeout: 60000
156
+
157
+ # API 응답 완료 대기
158
+ - action: waitForResponse
159
+ url: "/api/chat/completions"
160
+ status: 200
161
+ timeout: 60000
162
+
163
+ # 동적 콘텐츠 길이 대기
164
+ - action: waitForFunction
165
+ expression: "document.querySelector('.output')?.textContent?.length > 100"
166
+ polling: 500
167
+ ```
126
168
 
127
169
  ### 타이밍 팁
128
170
 
@@ -234,13 +276,14 @@ speedRamp:
234
276
 
235
277
  [PROMPTS.md](./PROMPTS.md)에 바로 사용할 수 있는 AI 프롬프트 템플릿이 있습니다. ChatGPT나 Claude에 복붙하고 내 사이트 URL만 넣으면 YAML 시나리오를 생성해줍니다.
236
278
 
237
- ## 데모 사이트 호스팅 (GitHub Pages)
279
+ ## GitHub Pages
238
280
 
239
- `docs/index.html`에 데모 대시보드가 포함되어 있습니다:
281
+ `docs/` 폴더에 문서 사이트와 라이브 데모 대시보드가 포함되어 있습니다:
240
282
 
241
283
  1. GitHub에 push: `git push origin main`
242
284
  2. **Settings > Pages** > source: `main`, folder: `/docs`
243
- 3. `https://username.github.io/clipwise/`에서 라이브
285
+ 3. 문서: `https://username.github.io/clipwise/`
286
+ 4. 데모: `https://username.github.io/clipwise/demo/`
244
287
 
245
288
  ## 보안
246
289
 
package/README.md CHANGED
@@ -114,17 +114,57 @@ steps:
114
114
 
115
115
  ### Actions
116
116
 
117
- | Action | Parameters | Description |
118
- |--------|-----------|-------------|
119
- | `navigate` | `url`, `waitUntil?` | Navigate to URL |
120
- | `click` | `selector`, `delay?` | Click an element |
121
- | `type` | `selector`, `text`, `delay?` | Type text (char-by-char) |
122
- | `hover` | `selector` | Hover over element |
123
- | `scroll` | `y?`, `x?`, `selector?`, `smooth?` | Scroll by offset |
124
- | `wait` | `duration` | Wait (ms) |
125
- | `screenshot` | `name?`, `fullPage?` | Capture marker |
117
+ #### Basic Actions
118
+
119
+ | Action | Parameters | Default | Description |
120
+ |--------|-----------|---------|-------------|
121
+ | `navigate` | `url`, `waitUntil?` | `waitUntil: "networkidle"` | Navigate to URL |
122
+ | `click` | `selector`, `delay?`, `timeout?` | | Click an element |
123
+ | `type` | `selector`, `text`, `delay?`, `timeout?` | `delay: 50` | Type text (char-by-char) |
124
+ | `hover` | `selector`, `timeout?` | | Hover over element |
125
+ | `scroll` | `y?`, `x?`, `selector?`, `smooth?`, `timeout?` | `y: 0`, `x: 0`, `smooth: true` | Scroll by offset |
126
+ | `wait` | `duration` | | Wait (ms) |
127
+ | `screenshot` | `name?`, `fullPage?` | `fullPage: false` | Capture marker |
128
+
129
+ #### Async Wait Actions
130
+
131
+ | Action | Parameters | Default | Description |
132
+ |--------|-----------|---------|-------------|
133
+ | `waitForSelector` | `selector`, `state?`, `timeout?` | `state: "visible"`, `timeout: 15000` | Wait for element state |
134
+ | `waitForNavigation` | `waitUntil?`, `timeout?` | `waitUntil: "networkidle"`, `timeout: 15000` | Wait for page load |
135
+ | `waitForURL` | `url`, `timeout?` | `timeout: 15000` | Wait for URL match |
136
+ | `waitForFunction` | `expression`, `polling?`, `timeout?` | `polling: "raf"`, `timeout: 30000` | Wait for JS expression to be truthy |
137
+ | `waitForResponse` | `url`, `status?`, `timeout?` | `timeout: 30000` | Wait for network response (URL substring match) |
126
138
 
127
139
  **`waitUntil`** options: `"load"`, `"domcontentloaded"`, `"networkidle"` (default)
140
+ **`state`** options: `"visible"` (default), `"attached"`, `"hidden"`
141
+ **`polling`** options: `"raf"` (requestAnimationFrame, default) or milliseconds (e.g. `500`)
142
+
143
+ #### Async Wait Examples
144
+
145
+ ```yaml
146
+ # Wait for element to appear
147
+ - action: waitForSelector
148
+ selector: ".result-panel"
149
+ state: visible
150
+ timeout: 20000
151
+
152
+ # Wait for AI streaming response to complete
153
+ - action: waitForFunction
154
+ expression: "document.querySelector('.ai-response')?.dataset.done === 'true'"
155
+ timeout: 60000
156
+
157
+ # Wait for API response
158
+ - action: waitForResponse
159
+ url: "/api/chat/completions"
160
+ status: 200
161
+ timeout: 60000
162
+
163
+ # Wait for dynamic content length
164
+ - action: waitForFunction
165
+ expression: "document.querySelector('.output')?.textContent?.length > 100"
166
+ polling: 500
167
+ ```
128
168
 
129
169
  ### Timing Tips
130
170
 
@@ -335,19 +375,20 @@ npx clipwise record my-scenario.yaml -f mp4 -o ./output
335
375
 
336
376
  See [PROMPTS.md](./PROMPTS.md) for a ready-to-use prompt template. Copy-paste it to ChatGPT or Claude with your site URL, and get a working YAML scenario back.
337
377
 
338
- ## Hosting the Demo Site (GitHub Pages)
378
+ ## GitHub Pages
339
379
 
340
- Clipwise includes a demo dashboard in `docs/index.html`. To host it:
380
+ Clipwise includes a documentation site and a live demo dashboard in the `docs/` folder. To host it:
341
381
 
342
382
  1. Push to GitHub: `git push origin main`
343
383
  2. Go to **Settings > Pages**
344
384
  3. Set source to **Deploy from a branch**, select `main`, folder `/docs`
345
- 4. Demo goes live at `https://kwakseongjae.github.io/clipwise/`
385
+ 4. Docs go live at `https://kwakseongjae.github.io/clipwise/`
386
+ 5. Demo dashboard at `https://kwakseongjae.github.io/clipwise/demo/`
346
387
 
347
388
  Then anyone can record the demo site:
348
389
 
349
390
  ```bash
350
- npx clipwise demo --url https://kwakseongjae.github.io/clipwise/
391
+ npx clipwise demo --url https://kwakseongjae.github.io/clipwise/demo/
351
392
  ```
352
393
 
353
394
  ## Security
package/dist/cli/index.js CHANGED
@@ -11,13 +11,13 @@ var __export = (target, all) => {
11
11
 
12
12
  // src/script/types.ts
13
13
  import { z } from "zod";
14
- var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, StepActionSchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepSchema, ScenarioSchema;
14
+ var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, StepActionSchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepSchema, ScenarioSchema;
15
15
  var init_types = __esm({
16
16
  "src/script/types.ts"() {
17
17
  "use strict";
18
- SafeSelectorSchema = z.string().regex(
19
- /^[a-zA-Z0-9\-_#.\[\]="':\s~^$|*,>+()@]+$/,
20
- "Selector contains invalid characters"
18
+ SafeSelectorSchema = z.string().min(1, "Selector must not be empty").regex(
19
+ /^[^\x00-\x1f\x7f;`\\{}]+$/,
20
+ "Selector contains invalid characters (control chars, semicolons, backticks, or backslashes are not allowed)"
21
21
  );
22
22
  NavigateActionSchema = z.object({
23
23
  action: z.literal("navigate"),
@@ -27,20 +27,23 @@ var init_types = __esm({
27
27
  ClickActionSchema = z.object({
28
28
  action: z.literal("click"),
29
29
  selector: SafeSelectorSchema,
30
- delay: z.number().optional()
30
+ delay: z.number().optional(),
31
+ timeout: z.number().min(0).optional()
31
32
  });
32
33
  TypeActionSchema = z.object({
33
34
  action: z.literal("type"),
34
35
  selector: SafeSelectorSchema,
35
36
  text: z.string(),
36
- delay: z.number().default(50)
37
+ delay: z.number().default(50),
38
+ timeout: z.number().min(0).optional()
37
39
  });
38
40
  ScrollActionSchema = z.object({
39
41
  action: z.literal("scroll"),
40
42
  selector: SafeSelectorSchema.optional(),
41
43
  y: z.number().default(0),
42
44
  x: z.number().default(0),
43
- smooth: z.boolean().default(true)
45
+ smooth: z.boolean().default(true),
46
+ timeout: z.number().min(0).optional()
44
47
  });
45
48
  WaitActionSchema = z.object({
46
49
  action: z.literal("wait"),
@@ -48,13 +51,42 @@ var init_types = __esm({
48
51
  });
49
52
  HoverActionSchema = z.object({
50
53
  action: z.literal("hover"),
51
- selector: SafeSelectorSchema
54
+ selector: SafeSelectorSchema,
55
+ timeout: z.number().min(0).optional()
52
56
  });
53
57
  ScreenshotActionSchema = z.object({
54
58
  action: z.literal("screenshot"),
55
59
  name: z.string().optional(),
56
60
  fullPage: z.boolean().default(false)
57
61
  });
62
+ WaitForSelectorActionSchema = z.object({
63
+ action: z.literal("waitForSelector"),
64
+ selector: SafeSelectorSchema,
65
+ state: z.enum(["visible", "attached", "hidden"]).default("visible"),
66
+ timeout: z.number().min(0).default(15e3)
67
+ });
68
+ WaitForNavigationActionSchema = z.object({
69
+ action: z.literal("waitForNavigation"),
70
+ waitUntil: z.enum(["load", "domcontentloaded", "networkidle"]).default("networkidle"),
71
+ timeout: z.number().min(0).default(15e3)
72
+ });
73
+ WaitForURLActionSchema = z.object({
74
+ action: z.literal("waitForURL"),
75
+ url: z.string().min(1),
76
+ timeout: z.number().min(0).default(15e3)
77
+ });
78
+ WaitForFunctionActionSchema = z.object({
79
+ action: z.literal("waitForFunction"),
80
+ expression: z.string().min(1),
81
+ polling: z.union([z.literal("raf"), z.number().min(0)]).default("raf"),
82
+ timeout: z.number().min(0).default(3e4)
83
+ });
84
+ WaitForResponseActionSchema = z.object({
85
+ action: z.literal("waitForResponse"),
86
+ url: z.string().min(1),
87
+ status: z.number().min(100).max(599).optional(),
88
+ timeout: z.number().min(0).default(3e4)
89
+ });
58
90
  StepActionSchema = z.discriminatedUnion("action", [
59
91
  NavigateActionSchema,
60
92
  ClickActionSchema,
@@ -62,7 +94,12 @@ var init_types = __esm({
62
94
  ScrollActionSchema,
63
95
  WaitActionSchema,
64
96
  HoverActionSchema,
65
- ScreenshotActionSchema
97
+ ScreenshotActionSchema,
98
+ WaitForSelectorActionSchema,
99
+ WaitForNavigationActionSchema,
100
+ WaitForURLActionSchema,
101
+ WaitForFunctionActionSchema,
102
+ WaitForResponseActionSchema
66
103
  ]);
67
104
  AutoZoomConfigSchema = z.object({
68
105
  followCursor: z.boolean().default(true),
@@ -328,12 +365,13 @@ function interpolatePath(from, to, steps) {
328
365
  }
329
366
 
330
367
  // src/core/screenshot.ts
331
- async function getElementCenter(page, selector) {
332
- if (!/^[\w\-#.\[\]="':\s,>+~*()@^$|]+$/.test(selector)) {
368
+ var DEFAULT_ELEMENT_TIMEOUT = 5e3;
369
+ async function getElementCenter(page, selector, timeout) {
370
+ if (/[\x00-\x1f\x7f;`\\{}]/.test(selector) || selector.length === 0) {
333
371
  throw new Error(`Invalid selector: ${selector}`);
334
372
  }
335
373
  const element = page.locator(selector).first();
336
- await element.waitFor({ state: "visible", timeout: 5e3 });
374
+ await element.waitFor({ state: "visible", timeout: timeout ?? DEFAULT_ELEMENT_TIMEOUT });
337
375
  const box = await element.boundingBox();
338
376
  if (!box) {
339
377
  throw new Error(
@@ -374,6 +412,7 @@ var ClipwiseRecorder = class {
374
412
  targetFps = 30;
375
413
  cursorSpeed = "fast";
376
414
  firstContentTimestamp = 0;
415
+ pendingResponsePromises = /* @__PURE__ */ new Map();
377
416
  /**
378
417
  * Launch the browser and create a page with the scenario viewport.
379
418
  */
@@ -458,8 +497,9 @@ var ClipwiseRecorder = class {
458
497
  for (let si = 0; si < scenario.steps.length; si++) {
459
498
  const step = scenario.steps[si];
460
499
  this.currentStepIndex = si;
461
- for (const action of step.actions) {
462
- await this.executeAction(action);
500
+ this.preRegisterResponseListeners(step.actions);
501
+ for (let ai = 0; ai < step.actions.length; ai++) {
502
+ await this.executeAction(step.actions[ai], ai);
463
503
  }
464
504
  if (step.captureDelay > 0) {
465
505
  await this.waitWithRepaints(step.captureDelay);
@@ -525,11 +565,33 @@ var ClipwiseRecorder = class {
525
565
  }
526
566
  }
527
567
  }
568
+ /**
569
+ * Pre-register waitForResponse listeners at the start of each step.
570
+ * This ensures the listener is active before any preceding action
571
+ * (e.g. click) triggers the request, preventing race conditions
572
+ * where the response arrives before the listener is set up.
573
+ */
574
+ preRegisterResponseListeners(actions) {
575
+ this.pendingResponsePromises.clear();
576
+ if (!this.page) return;
577
+ for (let i = 0; i < actions.length; i++) {
578
+ const action = actions[i];
579
+ if (action.action === "waitForResponse") {
580
+ this.pendingResponsePromises.set(
581
+ i,
582
+ this.page.waitForResponse(
583
+ (response) => response.url().includes(action.url) && (action.status === void 0 || response.status() === action.status),
584
+ { timeout: action.timeout }
585
+ )
586
+ );
587
+ }
588
+ }
589
+ }
528
590
  /**
529
591
  * Execute a single action. CDP screencast captures frames continuously
530
592
  * in the background while actions are performed.
531
593
  */
532
- async executeAction(action) {
594
+ async executeAction(action, actionIndex = 0) {
533
595
  if (!this.page) {
534
596
  throw new Error("Page not initialized. Call init() first.");
535
597
  }
@@ -547,7 +609,7 @@ var ClipwiseRecorder = class {
547
609
  break;
548
610
  }
549
611
  case "click": {
550
- const target = await getElementCenter(this.page, action.selector);
612
+ const target = await getElementCenter(this.page, action.selector, action.timeout);
551
613
  await this.moveCursorSmooth(target);
552
614
  this.clickTimeline.push({
553
615
  position: { ...target },
@@ -561,7 +623,8 @@ var ClipwiseRecorder = class {
561
623
  case "type": {
562
624
  const inputTarget = await getElementCenter(
563
625
  this.page,
564
- action.selector
626
+ action.selector,
627
+ action.timeout
565
628
  );
566
629
  await this.moveCursorSmooth(inputTarget);
567
630
  this.clickTimeline.push({
@@ -579,7 +642,7 @@ var ClipwiseRecorder = class {
579
642
  break;
580
643
  }
581
644
  case "scroll": {
582
- const scrollTarget = action.selector ? await getElementCenter(this.page, action.selector) : null;
645
+ const scrollTarget = action.selector ? await getElementCenter(this.page, action.selector, action.timeout) : null;
583
646
  await this.page.evaluate(
584
647
  ({ x, y, smooth, selector }) => {
585
648
  const target = selector ? document.querySelector(selector) : window;
@@ -623,7 +686,8 @@ var ClipwiseRecorder = class {
623
686
  case "hover": {
624
687
  const hoverTarget = await getElementCenter(
625
688
  this.page,
626
- action.selector
689
+ action.selector,
690
+ action.timeout
627
691
  );
628
692
  await this.moveCursorSmooth(hoverTarget);
629
693
  await this.page.hover(action.selector);
@@ -633,6 +697,33 @@ var ClipwiseRecorder = class {
633
697
  await this.waitWithRepaints(100);
634
698
  break;
635
699
  }
700
+ case "waitForSelector": {
701
+ const locator = this.page.locator(action.selector).first();
702
+ await locator.waitFor({ state: action.state, timeout: action.timeout });
703
+ break;
704
+ }
705
+ case "waitForNavigation": {
706
+ await this.page.waitForLoadState(action.waitUntil, { timeout: action.timeout });
707
+ break;
708
+ }
709
+ case "waitForURL": {
710
+ await this.page.waitForURL(action.url, { timeout: action.timeout });
711
+ break;
712
+ }
713
+ case "waitForFunction": {
714
+ await this.page.waitForFunction(action.expression, void 0, {
715
+ polling: action.polling,
716
+ timeout: action.timeout
717
+ });
718
+ break;
719
+ }
720
+ case "waitForResponse": {
721
+ const pending = this.pendingResponsePromises.get(actionIndex);
722
+ if (pending) {
723
+ await pending;
724
+ }
725
+ break;
726
+ }
636
727
  }
637
728
  await this.waitWithRepaints(ACTION_GAP_MS);
638
729
  }