• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

thorstenalpers / CleanMyPosts / 15191450575

22 May 2025 04:04PM UTC coverage: 21.236% (-0.05%) from 21.288%
15191450575

push

github

thorstenalpers
Remove test which needs a document ready event

84 of 364 branches covered (23.08%)

Branch coverage included in aggregate %.

263 of 1270 relevant lines covered (20.71%)

0.61 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

10.32
/src/UI/Services/XScriptService.cs
1
using System.Net;
2
using Ardalis.GuardClauses;
3
using CleanMyPosts.UI.Contracts.Services;
4
using CleanMyPosts.UI.Helpers;
5
using CleanMyPosts.UI.Models;
6
using Microsoft.Extensions.Logging;
7

8
namespace CleanMyPosts.UI.Services;
9

10
public class XScriptService(ILogger<XScriptService> logger, IWebViewHostService webViewHostService, IUserSettingsService userSettingsService) : IXScriptService
5✔
11
{
12
    private readonly ILogger<XScriptService> _logger = logger;
5✔
13
    private readonly IWebViewHostService _webViewHostService = webViewHostService;
5✔
14
    private readonly IUserSettingsService _userSettingsService = userSettingsService;
5✔
15
    private string _userName;
16

17
    public async Task ShowPostsAsync()
18
    {
19
        Guard.Against.Null(_userName);
1✔
20
        var searchQuery = $"from:{_userName}";
1✔
21
        var encodedQuery = WebUtility.UrlEncode(searchQuery);
1✔
22
        var url = new Uri($"https://x.com/search?q={encodedQuery}&src=typed_query");
1✔
23

24
        if (_webViewHostService.Source == url)
1!
25
        {
26
            _webViewHostService.Reload();
×
27
        }
28
        else
29
        {
30
            _webViewHostService.Source = url;
1✔
31
        }
32

33
        if (!await WaitForFullDocumentReadyAsync())
1!
34
        {
35
            _logger.LogWarning("Navigation to {Url} failed.", url);
×
36
        }
37

38
        _logger.LogInformation("Navigated to {Url}", url);
×
39
    }
×
40

41
    public async Task<int> DeletePostsAsync()
42
    {
43
        Guard.Against.Null(_userName);
×
44

45
        var searchQuery = $"from:{_userName}";
×
46
        var encodedQuery = WebUtility.UrlEncode(searchQuery);
×
47
        var url = new Uri($"https://x.com/search?q={encodedQuery}&src=typed_query");
×
48

49
        if (_webViewHostService.Source == url)
×
50
        {
51
            _webViewHostService.Reload();
×
52
        }
53
        else
54
        {
55
            _webViewHostService.Source = url;
×
56
        }
57
        if (!await WaitForFullDocumentReadyAsync())
×
58
        {
59
            _logger.LogWarning("Navigation to search page failed.");
×
60
            return 0;
×
61
        }
62
        var totalPosts = 0;
×
63
        var postNumber = 1;
×
64
        while (await PostsExistAsync())
×
65
        {
66
            var countBefore = await GetPostsCountAsync();
×
67
            if (postNumber == 1)
×
68
            {
69
                totalPosts = countBefore;
×
70
            }
71

72
            _logger.LogInformation("Found {Count} posts before deletion.", countBefore);
×
73

74
            try
75
            {
76
                _logger.LogInformation("Deleting post #{Number}...", postNumber);
×
77
                await DeleteSinglePostAsync();
×
78

79
                if (await WaitForPostDeletedAsync(countBefore))
×
80
                {
81
                    _logger.LogInformation("Post #{Number} deleted successfully.", postNumber);
×
82
                }
83
                else
84
                {
85
                    _logger.LogWarning("Post #{Number} was not deleted (DOM unchanged).", postNumber);
×
86
                    break;
×
87
                }
88
            }
×
89
            catch (Exception ex)
×
90
            {
91
                _logger.LogError(ex, "Error deleting post #{Number}.", postNumber);
×
92
                break;
×
93
            }
94
            postNumber++;
×
95
        }
96
        _webViewHostService.Reload();
×
97
        await WaitForFullDocumentReadyAsync();
×
98

99
        _logger.LogInformation("Deleted {TotalPosts} posts.", totalPosts);
×
100
        return totalPosts;
×
101
    }
×
102

103
    public async Task ShowLikesAsync()
104
    {
105
        Guard.Against.Null(_userName);
1✔
106

107
        var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/likes");
1✔
108

109
        if (_webViewHostService.Source == url)
1!
110
        {
111
            _webViewHostService.Reload();
×
112
        }
113
        else
114
        {
115
            _webViewHostService.Source = url;
1✔
116
        }
117

118
        if (!await WaitForFullDocumentReadyAsync())
1!
119
        {
120
            _logger.LogWarning("Navigation to {Url} failed.", url);
×
121
        }
122

123
        _logger.LogInformation("Navigated to {Url}", url);
×
124
    }
×
125

126
    public async Task<int> DeleteLikesAsync()
127
    {
128
        Guard.Against.Null(_userName);
×
129

130
        var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/likes");
×
131

132
        if (_webViewHostService.Source == url)
×
133
        {
134
            _webViewHostService.Reload();
×
135
        }
136
        else
137
        {
138
            _webViewHostService.Source = url;
×
139
        }
140
        if (!await WaitForFullDocumentReadyAsync())
×
141
        {
142
            _logger.LogWarning("Navigation to search page failed.");
×
143
            return 0;
×
144
        }
145

146
        int totalLikes = 0;
×
147

148
        var postNumber = 1;
×
149

150
        while (await LikesExistAsync())
×
151
        {
152
            var countBefore = await GetLikesCountAsync();
×
153
            if (postNumber == 1)
×
154
            {
155
                totalLikes = countBefore;
×
156
            }
157

158
            _logger.LogInformation("Found {Count} likes before deletion.", countBefore);
×
159

160
            try
161
            {
162
                _logger.LogInformation("Deleting like #{Number}...", postNumber);
×
163
                await DeleteSingleLikeAsync();
×
164

165
                if (await WaitForLikeDeletedAsync(countBefore))
×
166
                {
167
                    _logger.LogInformation("Like #{Number} deleted successfully.", postNumber);
×
168
                }
169
                else
170
                {
171
                    _logger.LogWarning("Like #{Number} was not deleted (DOM unchanged).", postNumber);
×
172
                    break;
×
173
                }
174
            }
×
175
            catch (Exception ex)
×
176
            {
177
                _logger.LogError(ex, "Error deleting like #{Number}.", postNumber);
×
178
                break;
×
179
            }
180
            postNumber++;
×
181
        }
182

183
        _webViewHostService.Reload();
×
184
        await WaitForFullDocumentReadyAsync();
×
185

186
        _logger.LogInformation("Deleted {TotalLikes} Likes.", totalLikes);
×
187
        return totalLikes;
×
188
    }
×
189

190
    public async Task ShowFollowingAsync()
191
    {
192
        Guard.Against.Null(_userName);
1✔
193

194
        var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/following");
1✔
195

196
        if (_webViewHostService.Source == url)
1!
197
        {
198
            _webViewHostService.Reload();
×
199
        }
200
        else
201
        {
202
            _webViewHostService.Source = url;
1✔
203
        }
204

205
        if (!await WaitForFullDocumentReadyAsync())
1!
206
        {
207
            _logger.LogWarning("Navigation to {Url} failed.", url);
×
208
        }
209

210
        _logger.LogInformation("Navigated to {Url}", url);
×
211
    }
×
212

213
    public async Task<int> DeleteFollowingAsync()
214
    {
215
        Guard.Against.Null(_userName);
×
216

217
        var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/following");
×
218

219
        if (_webViewHostService.Source == url)
×
220
        {
221
            _webViewHostService.Reload();
×
222
        }
223
        else
224
        {
225
            _webViewHostService.Source = url;
×
226
        }
227
        if (!await WaitForFullDocumentReadyAsync())
×
228
        {
229
            _logger.LogWarning("Navigation to search page failed.");
×
230
            return 0;
×
231
        }
232

233
        int totalFollowings = 0;
×
234
        var postNumber = 1;
×
235
        while (await FollowingExistAsync())
×
236
        {
237
            var countBefore = await GetFollowingCountAsync();
×
238
            if (postNumber == 1)
×
239
            {
240
                totalFollowings = countBefore;
×
241
            }
242

243
            _logger.LogInformation("Found {Count} following before deletion.", countBefore);
×
244

245
            try
246
            {
247
                _logger.LogInformation("Deleting following #{Number}...", postNumber);
×
248
                await DeleteSingleFollowingAsync();
×
249

250
                if (await WaitForFollowingDeletedAsync(countBefore))
×
251
                {
252
                    _logger.LogInformation("Following #{Number} deleted successfully.", postNumber);
×
253
                }
254
                else
255
                {
256
                    _logger.LogWarning("Following #{Number} was not deleted (DOM unchanged).", postNumber);
×
257
                    break;
×
258
                }
259
            }
×
260
            catch (Exception ex)
×
261
            {
262
                _logger.LogError(ex, "Error deleting following #{Number}.", postNumber);
×
263
                break;
×
264
            }
265
            postNumber++;
×
266
        }
267

268
        _webViewHostService.Reload();
×
269
        await WaitForFullDocumentReadyAsync();
×
270

271
        _logger.LogInformation("Deleted {TotalFollowings} Followings.", totalFollowings);
×
272
        return totalFollowings;
×
273
    }
×
274

275
    private async Task<bool> PostsExistAsync()
276
    {
277
        const string js = "document.querySelector('div[data-testid=\"primaryColumn\"] section button[data-testid=\"caret\"]') !== null";
278
        for (var i = 0; i < 5; i++)
14✔
279
        {
280
            if (await _webViewHostService.ExecuteScriptAsync(js) == "true")
6✔
281
            {
282
                return true;
1✔
283
            }
284
            await Task.Delay(500);
5✔
285
        }
286
        return false;
1✔
287
    }
2✔
288

289
    private async Task<bool> FollowingExistAsync()
290
    {
291
        const string js = "document.querySelector('button[data-testid$=\"unfollow\"]') !== null";
292
        for (var i = 0; i < 5; i++)
×
293
        {
294
            if (await _webViewHostService.ExecuteScriptAsync(js) == "true")
×
295
            {
296
                return true;
×
297
            }
298
            await Task.Delay(500);
×
299
        }
300
        return false;
×
301
    }
×
302

303
    private async Task<bool> LikesExistAsync()
304
    {
305
        const string js = "document.querySelector('button[data-testid=\"unlike\"]') !== null";
306
        for (var i = 0; i < 5; i++)
×
307
        {
308
            if (await _webViewHostService.ExecuteScriptAsync(js) == "true")
×
309
            {
310
                return true;
×
311
            }
312
            await Task.Delay(500);
×
313
        }
314
        return false;
×
315
    }
×
316

317
    private async Task DeleteSinglePostAsync()
318
    {
319
        var timeout = _userSettingsService.GetTimeoutSettings();
×
320
        var waitAfterDelete = timeout.WaitAfterDelete;
×
321
        var waitBetweenDeleteAttempts = timeout.WaitBetweenRetryDeleteAttempts;
×
322

323
        var js = $@"
×
324
        (() => {{
×
325
            const caret = document.querySelector(""div[data-testid='primaryColumn'] section button[data-testid='caret']"");
×
326
            if (!caret) return;
×
327

×
328
            caret.click();
×
329

×
330
            setTimeout(() => {{
×
331
                const delays = [ {waitBetweenDeleteAttempts}, 
×
332
                                {waitBetweenDeleteAttempts * 2}, 
×
333
                                {waitBetweenDeleteAttempts * 3}, 
×
334
                                {waitBetweenDeleteAttempts * 4}, 
×
335
                                {waitBetweenDeleteAttempts * 5} ];
×
336

×
337
                function tryClickDelete(attempt = 0) {{
×
338
                    if (attempt >= delays.length) return;
×
339
                    setTimeout(() => {{
×
340
                        const menu = document.querySelector(""[role='menu']"");
×
341
                        if (menu && menu.style.display !== ""none"") {{
×
342
                            const items = document.querySelectorAll(""[role='menuitem']"");
×
343
                            for (const item of items) {{
×
344
                                const span = item.querySelector(""span"");
×
345
                                if (!span) continue;
×
346

×
347
                                const color = getComputedStyle(span).color;
×
348
                                const rgb = color.match(/\d+/g).map(Number);
×
349
                                const [r, g, b] = rgb;
×
350
                                if (r > 180 && g < 100 && b < 100) {{
×
351
                                    span.click();
×
352
                                    confirmDelete();
×
353
                                    return;
×
354
                                }}
×
355
                            }}
×
356
                            tryClickDelete(attempt + 1);
×
357
                        }} else {{
×
358
                            tryClickDelete(attempt + 1);
×
359
                        }}
×
360
                    }}, delays[attempt]);
×
361
                }}
×
362

×
363
                function confirmDelete(attempt = 0) {{
×
364
                    if (attempt >= delays.length) return;
×
365
                    setTimeout(() => {{
×
366
                        const confirmBtn = document.querySelector(""button[data-testid='confirmationSheetConfirm']"");
×
367
                        if (confirmBtn && confirmBtn.offsetParent !== null) {{
×
368
                            confirmBtn.click();
×
369
                            window.scrollBy(0, 300);
×
370
                        }} else {{
×
371
                            confirmDelete(attempt + 1);
×
372
                        }}
×
373
                    }}, delays[attempt]);
×
374
                }}
×
375

×
376
                tryClickDelete();
×
377
            }}, {waitAfterDelete});
×
378
        }})();";
×
379

380
        await _webViewHostService.ExecuteScriptAsync(js);
×
381
    }
×
382
    private async Task DeleteSingleLikeAsync()
383
    {
384
        var waitBeforeTryClickDelete = _userSettingsService.GetTimeoutSettings().WaitAfterDelete;
×
385

386
        var js = $@"
×
387
        (() => {{
×
388
            const unlikeButton = document.querySelector('button[data-testid=""unlike""]');
×
389
            if (unlikeButton) {{
×
390
                unlikeButton.click();
×
391
            }}
×
392
            window.scrollBy(0, 300);
×
393
        }})();";
×
394
        await Task.Delay(waitBeforeTryClickDelete);
×
395
        await _webViewHostService.ExecuteScriptAsync(js);
×
396
    }
×
397

398
    private async Task DeleteSingleFollowingAsync()
399
    {
400
        var timeout = _userSettingsService.GetTimeoutSettings();
×
401
        var waitBeforeTryClickDelete = timeout.WaitAfterDelete;
×
402
        var waitBetweenTryClickDeleteAttempts = timeout.WaitBetweenRetryDeleteAttempts;
×
403

404
        var js = $@"
×
405
    (() => {{
×
406
        const unfollowingButton = document.querySelector('button[data-testid$=""-unfollow""]');
×
407
        if (!unfollowingButton) return;
×
408

×
409
        unfollowingButton.click();
×
410

×
411
        const delays = [
×
412
            {waitBetweenTryClickDeleteAttempts},
×
413
            {waitBetweenTryClickDeleteAttempts * 2},
×
414
            {waitBetweenTryClickDeleteAttempts * 3},
×
415
            {waitBetweenTryClickDeleteAttempts * 4},
×
416
            {waitBetweenTryClickDeleteAttempts * 5}
×
417
        ];
×
418

×
419
        function tryClickConfirm(attempt = 0) {{
×
420
            if (attempt >= delays.length) return;
×
421
            setTimeout(() => {{
×
422
                const confirmBtn = document.querySelector('button[data-testid=""confirmationSheetConfirm""]');
×
423
                if (confirmBtn && confirmBtn.offsetParent !== null) {{
×
424
                    confirmBtn.click();
×
425
                    window.scrollBy(0, 300);
×
426
                }} else {{
×
427
                    tryClickConfirm(attempt + 1);
×
428
                }}
×
429
            }}, delays[attempt]);
×
430
        }}
×
431

×
432
        setTimeout(() => {{
×
433
            tryClickConfirm();
×
434
        }}, {waitBeforeTryClickDelete});
×
435
    }})();";
×
436

437
        await _webViewHostService.ExecuteScriptAsync(js);
×
438
    }
×
439

440

441
    private async Task<int> GetPostsCountAsync()
442
    {
443
        const string js = """
444
        (() => document.querySelectorAll('div[data-testid="primaryColumn"] section button[data-testid="caret"]').length)()
445
        """;
446

447
        var result = await _webViewHostService.ExecuteScriptAsync(js);
×
448
        return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
×
449
    }
×
450

451
    private async Task<int> GetFollowingCountAsync()
452
    {
453
        const string js = """
454
        (() => document.querySelectorAll('button[data-testid$="-unfollow"]').length)()        
455
        """;
456

457
        var result = await _webViewHostService.ExecuteScriptAsync(js);
×
458
        return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
×
459
    }
×
460

461
    private async Task<int> GetLikesCountAsync()
462
    {
463
        const string js = """
464
        (() => document.querySelectorAll('button[data-testid="unlike"]').length)()
465
        """;
466

467
        var result = await _webViewHostService.ExecuteScriptAsync(js);
×
468
        return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
×
469
    }
×
470

471
    private async Task<bool> WaitForPostDeletedAsync(int beforeCount)
472
    {
473
        int elapsed = 0, interval = 200, timeout = 5000;
×
474
        while (elapsed < timeout)
×
475
        {
476
            if (await GetPostsCountAsync() < beforeCount)
×
477
            {
478
                return true;
×
479
            }
480
            await Task.Delay(interval);
×
481
            elapsed += interval;
×
482
        }
483
        return false;
×
484
    }
×
485

486
    private async Task<bool> WaitForFollowingDeletedAsync(int beforeCount)
487
    {
488
        int elapsed = 0, interval = 200, timeout = 5000;
×
489
        while (elapsed < timeout)
×
490
        {
491
            if (await GetFollowingCountAsync() < beforeCount)
×
492
            {
493
                return true;
×
494
            }
495
            await Task.Delay(interval);
×
496
            elapsed += interval;
×
497
        }
498
        return false;
×
499
    }
×
500

501
    private async Task<bool> WaitForLikeDeletedAsync(int beforeCount)
502
    {
503
        int elapsed = 0, interval = 200, timeout = 5000;
×
504
        while (elapsed < timeout)
×
505
        {
506
            if (await GetLikesCountAsync() < beforeCount)
×
507
            {
508
                return true;
×
509
            }
510
            await Task.Delay(interval);
×
511
            elapsed += interval;
×
512
        }
513
        return false;
×
514
    }
×
515

516
    public async Task<string> GetUserNameAsync()
517
    {
518
        await WaitForFullDocumentReadyAsync();
×
519

520
        const string jsScript = @"
521
        (() => { 
522
            const el = document.querySelector('a[data-testid=""AppTabBar_Profile_Link""]');
523
            const href = el?.getAttribute('href');
524
            return href?.split('/')[1] ?? '';
525
        })()";
526
        var userName = await _webViewHostService.ExecuteScriptAsync(jsScript);
×
527
        _userName = Helper.CleanJsonResult(userName);
×
528
        return _userName;
×
529
    }
×
530

531
    private Task<bool> WaitForNavigationAsync()
532
    {
533
        var tcs = new TaskCompletionSource<bool>();
3✔
534

535
        EventHandler<NavigationCompletedEventArgs> handler = null;
3✔
536
        handler = async (s, e) =>
3✔
537
        {
3✔
538
            _webViewHostService.NavigationCompleted -= handler;
×
539
            await Task.Delay(300);
×
540
            tcs.TrySetResult(e.IsSuccess);
×
541
        };
3✔
542

543
        _webViewHostService.NavigationCompleted += handler;
3✔
544
        return tcs.Task;
3✔
545
    }
546

547
    private async Task<bool> WaitForFullDocumentReadyAsync()
548
    {
549
        if (!await WaitForNavigationAsync())
3!
550
        {
551
            return false;
×
552
        }
553

554
        const int maxAttempts = 50;
555
        const int delayMs = 100;
556
        int waitAfterDocumentLoad = _userSettingsService.GetTimeoutSettings().WaitAfterDocumentLoad;
×
557

558
        for (var i = 0; i < maxAttempts; i++)
×
559
        {
560
            try
561
            {
562
                var readyStateJson = await _webViewHostService.ExecuteScriptAsync("document.readyState");
×
563
                var readyState = readyStateJson?.Trim('"').ToLowerInvariant();
×
564

565
                if (readyState == "complete")
×
566
                {
567
                    await Task.Delay(waitAfterDocumentLoad);
×
568
                    return true;
×
569
                }
570
            }
×
571
            catch (Exception ex)
×
572
            {
573
                _logger.LogWarning(ex, "Error checking document.readyState.");
×
574
            }
×
575
            await Task.Delay(delayMs);
×
576
        }
577
        _logger.LogWarning("Timed out waiting for document.readyState = complete.");
×
578
        return false;
×
579
    }
×
580
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc