viewpoint-core 0.4.2

High-level browser automation API for Viewpoint
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
#![cfg(feature = "integration")]

//! Tests for automatic page tracking via CDP Target events.
//!
//! These tests verify that pages opened externally (e.g., via `window.open()`,
//! `target="_blank"` links, or Ctrl+click) are properly tracked and trigger
//! `on_page` events.

mod common;

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::Mutex;
use viewpoint_core::Browser;

/// Helper function to launch browser and get a context.
async fn setup() -> (Browser, viewpoint_core::BrowserContext) {
    common::init_tracing();
    let browser = common::launch_browser().await;
    let context = browser
        .new_context()
        .await
        .expect("Failed to create context");
    (browser, context)
}

/// Test that window.open() creates a tracked page and triggers on_page event.
#[tokio::test]
async fn test_window_open_creates_tracked_page() {
    let (browser, context) = setup().await;

    // Track if on_page was called
    let page_received = Arc::new(AtomicBool::new(false));
    let page_received_clone = page_received.clone();

    // Set up on_page handler
    context
        .on_page(move |_page| {
            let received = page_received_clone.clone();
            async move {
                received.store(true, Ordering::SeqCst);
            }
        })
        .await;

    // Create initial page
    let page = context.new_page().await.expect("Failed to create page");

    // Set up HTML page with a button that opens a popup
    page.set_content(
        r#"
        <!DOCTYPE html>
        <html>
        <body>
            <button id="open-popup" onclick="window.open('about:blank', '_blank', 'width=400,height=300')">Open Popup</button>
        </body>
        </html>
        "#,
    )
    .set()
    .await
    .expect("Failed to set content");

    // Reset the flag (on_page was called for the initial page by new_page)
    page_received.store(false, Ordering::SeqCst);

    // Click the button to open a popup
    page.locator("#open-popup")
        .click()
        .await
        .expect("Failed to click button");

    // Wait a bit for the popup to be detected
    tokio::time::sleep(Duration::from_millis(500)).await;

    // Verify on_page was triggered
    assert!(
        page_received.load(Ordering::SeqCst),
        "on_page event should have been triggered for the popup"
    );

    // Verify pages count increased
    let pages = context.pages().await.expect("Failed to get pages");
    assert!(
        pages.len() >= 2,
        "Should have at least 2 pages (original + popup), got {}",
        pages.len()
    );

    browser.close().await.expect("Failed to close browser");
}

/// Test that target="_blank" link creates a tracked page.
#[tokio::test]
async fn test_target_blank_link_creates_tracked_page() {
    let (browser, context) = setup().await;

    // Track received pages
    let pages_received = Arc::new(Mutex::new(Vec::<String>::new()));
    let pages_received_clone = pages_received.clone();

    // Set up on_page handler
    context
        .on_page(move |page| {
            let received = pages_received_clone.clone();
            async move {
                if let Ok(url) = page.url().await {
                    received.lock().await.push(url);
                }
            }
        })
        .await;

    // Create initial page
    let page = context.new_page().await.expect("Failed to create page");

    // Clear the pages received list (on_page was called for the initial page)
    pages_received.lock().await.clear();

    // Set up HTML page with a target="_blank" link
    page.set_content(
        r#"
        <!DOCTYPE html>
        <html>
        <body>
            <a id="external-link" href="about:blank" target="_blank">Open in new tab</a>
        </body>
        </html>
        "#,
    )
    .set()
    .await
    .expect("Failed to set content");

    // Click the link to open a new tab
    page.locator("#external-link")
        .click()
        .await
        .expect("Failed to click link");

    // Wait a bit for the new page to be detected
    tokio::time::sleep(Duration::from_millis(500)).await;

    // Verify on_page was triggered for the new page
    let received = pages_received.lock().await;
    assert!(
        !received.is_empty(),
        "on_page event should have been triggered for the new tab"
    );

    browser.close().await.expect("Failed to close browser");
}

/// Test that context.pages() includes externally-opened pages.
#[tokio::test]
async fn test_pages_includes_externally_opened() {
    let (browser, context) = setup().await;

    // Create initial page
    let page = context.new_page().await.expect("Failed to create page");

    // Check initial pages count
    let initial_pages = context.pages().await.expect("Failed to get pages");
    let initial_count = initial_pages.len();

    // Set up HTML page with a button that opens a popup
    page.set_content(
        r#"
        <!DOCTYPE html>
        <html>
        <body>
            <button id="open-popup" onclick="window.open('about:blank')">Open Popup</button>
        </body>
        </html>
        "#,
    )
    .set()
    .await
    .expect("Failed to set content");

    // Click the button to open a popup
    page.locator("#open-popup")
        .click()
        .await
        .expect("Failed to click button");

    // Wait a bit for the popup to be detected
    tokio::time::sleep(Duration::from_millis(500)).await;

    // Verify pages count increased
    let final_pages = context.pages().await.expect("Failed to get pages");
    assert!(
        final_pages.len() > initial_count,
        "Pages count should increase after popup, was {} now {}",
        initial_count,
        final_pages.len()
    );

    browser.close().await.expect("Failed to close browser");
}

/// Test that no duplicate events are fired for pages created via new_page().
#[tokio::test]
async fn test_no_duplicate_events_for_new_page() {
    let (browser, context) = setup().await;

    // Count on_page calls
    let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
    let call_count_clone = call_count.clone();

    // Set up on_page handler
    context
        .on_page(move |_page| {
            let count = call_count_clone.clone();
            async move {
                count.fetch_add(1, Ordering::SeqCst);
            }
        })
        .await;

    // Create a page via new_page() - should trigger exactly 1 on_page event
    let _page = context.new_page().await.expect("Failed to create page");

    // Wait a bit to ensure no delayed duplicate events
    tokio::time::sleep(Duration::from_millis(200)).await;

    // Verify only one on_page event was triggered
    let count = call_count.load(Ordering::SeqCst);
    assert_eq!(
        count, 1,
        "on_page should be called exactly once for new_page(), got {}",
        count
    );

    browser.close().await.expect("Failed to close browser");
}

/// Test that wait_for_popup still works correctly with the automatic page tracking.
#[tokio::test]
async fn test_wait_for_popup_compatibility() {
    let (browser, context) = setup().await;

    // Create initial page
    let page = context.new_page().await.expect("Failed to create page");

    // Set up HTML page with a button that opens a popup
    page.set_content(
        r#"
        <!DOCTYPE html>
        <html>
        <body>
            <button id="open-popup" onclick="window.open('about:blank', 'popup', 'width=400,height=300')">Open Popup</button>
        </body>
        </html>
        "#,
    )
    .set()
    .await
    .expect("Failed to set content");

    // Use wait_for_popup to capture the popup
    let popup = page
        .wait_for_popup(|| async {
            page.locator("#open-popup")
                .click()
                .await
                .expect("Failed to click button");
            Ok(())
        })
        .wait()
        .await
        .expect("wait_for_popup should succeed");

    // Verify we got a popup page
    let popup_url = popup.url().await.expect("Failed to get popup URL");
    assert!(
        popup_url.contains("blank"),
        "Popup should be at about:blank, got {}",
        popup_url
    );

    browser.close().await.expect("Failed to close browser");
}

/// Test that on_page_activated handler registration and removal works correctly.
///
/// Note: The `Target.targetInfoChanged` event may not fire in headless mode for
/// `bring_to_front` calls. This test verifies the handler infrastructure works correctly.
#[tokio::test]
async fn test_page_activated_handler_registration() {
    let (browser, context) = setup().await;

    // Track activation count
    let activation_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
    let activation_count_clone = activation_count.clone();

    // Set up on_page_activated handler
    let handler_id = context
        .on_page_activated(move |_page| {
            let count = activation_count_clone.clone();
            async move {
                count.fetch_add(1, Ordering::SeqCst);
            }
        })
        .await;

    // Create a page
    let _page = context.new_page().await.expect("Failed to create page");

    // Give time for any events
    tokio::time::sleep(Duration::from_millis(200)).await;

    // Verify the handler was registered (we can remove it successfully)
    let removed = context.off_page_activated(handler_id).await;
    assert!(
        removed,
        "Handler should have been successfully registered and removed"
    );

    // Verify removing again returns false
    let removed_again = context.off_page_activated(handler_id).await;
    assert!(!removed_again, "Handler should not be found after removal");

    browser.close().await.expect("Failed to close browser");
}

/// Test that multiple on_page_activated handlers can be registered.
#[tokio::test]
async fn test_multiple_page_activated_handlers() {
    let (browser, context) = setup().await;

    // Register multiple handlers
    let handler_id1 = context.on_page_activated(|_page| async {}).await;
    let handler_id2 = context.on_page_activated(|_page| async {}).await;
    let handler_id3 = context.on_page_activated(|_page| async {}).await;

    // All handlers should have unique IDs
    assert_ne!(handler_id1, handler_id2);
    assert_ne!(handler_id2, handler_id3);
    assert_ne!(handler_id1, handler_id3);

    // All handlers should be removable
    assert!(context.off_page_activated(handler_id1).await);
    assert!(context.off_page_activated(handler_id2).await);
    assert!(context.off_page_activated(handler_id3).await);

    // None should be removable again
    assert!(!context.off_page_activated(handler_id1).await);
    assert!(!context.off_page_activated(handler_id2).await);
    assert!(!context.off_page_activated(handler_id3).await);

    browser.close().await.expect("Failed to close browser");
}

/// Test that on_page_activated only fires for pages in the same context.
#[tokio::test]
async fn test_page_activated_only_for_own_context() {
    let (browser, _) = setup().await;

    // Create two separate contexts
    let context_a = browser
        .new_context()
        .await
        .expect("Failed to create context A");
    let context_b = browser
        .new_context()
        .await
        .expect("Failed to create context B");

    // Track activations for context A
    let context_a_activations = Arc::new(std::sync::atomic::AtomicUsize::new(0));
    let context_a_activations_clone = context_a_activations.clone();

    // Set up on_page_activated handler only on context A
    context_a
        .on_page_activated(move |_page| {
            let count = context_a_activations_clone.clone();
            async move {
                count.fetch_add(1, Ordering::SeqCst);
            }
        })
        .await;

    // Create pages in both contexts
    let _page_a = context_a
        .new_page()
        .await
        .expect("Failed to create page in context A");
    let page_b = context_b
        .new_page()
        .await
        .expect("Failed to create page in context B");

    // Give pages time to initialize
    tokio::time::sleep(Duration::from_millis(200)).await;

    // Reset counter
    context_a_activations.store(0, Ordering::SeqCst);

    // Bring page from context B to front
    page_b
        .bring_to_front()
        .await
        .expect("Failed to bring page B to front");
    tokio::time::sleep(Duration::from_millis(500)).await;

    // Context A's handler should NOT have been triggered by context B's page
    let count = context_a_activations.load(Ordering::SeqCst);
    assert_eq!(
        count, 0,
        "Context A's handler should not be triggered by context B's page activation"
    );

    browser.close().await.expect("Failed to close browser");
}

/// Test that closed pages are removed from tracking.
#[tokio::test]
async fn test_closed_page_removed_from_tracking() {
    let (browser, context) = setup().await;

    // Create initial page
    let page = context.new_page().await.expect("Failed to create page");

    // Set up HTML page with a button that opens a popup
    page.set_content(
        r#"
        <!DOCTYPE html>
        <html>
        <body>
            <button id="open-popup" onclick="window.open('about:blank')">Open Popup</button>
        </body>
        </html>
        "#,
    )
    .set()
    .await
    .expect("Failed to set content");

    // Use wait_for_popup to capture and then close the popup
    let mut popup = page
        .wait_for_popup(|| async {
            page.locator("#open-popup")
                .click()
                .await
                .expect("Failed to click button");
            Ok(())
        })
        .wait()
        .await
        .expect("wait_for_popup should succeed");

    // Get pages count with popup open
    let pages_with_popup = context.pages().await.expect("Failed to get pages");
    let count_with_popup = pages_with_popup.len();

    // Close the popup
    popup.close().await.expect("Failed to close popup");

    // Wait a bit for the targetDestroyed event to be processed
    tokio::time::sleep(Duration::from_millis(200)).await;

    // Get pages count after popup closed
    let pages_after_close = context.pages().await.expect("Failed to get pages");
    let count_after_close = pages_after_close.len();

    // Note: The pages count should be equal or less after close
    // (pages() queries CDP directly, so it should reflect the actual state)
    assert!(
        count_after_close < count_with_popup,
        "Pages count should decrease after popup close, was {} now {}",
        count_with_popup,
        count_after_close
    );

    browser.close().await.expect("Failed to close browser");
}