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

thorstenalpers / CleanMyPosts / 15192658460

22 May 2025 05:08PM UTC coverage: 22.379% (+1.1%) from 21.249%
15192658460

push

github

thorstenalpers
Fix retry to get username from dom created by SPA

91 of 364 branches covered (25.0%)

Branch coverage included in aggregate %.

8 of 19 new or added lines in 1 file covered. (42.11%)

3 existing lines in 1 file now uncovered.

274 of 1267 relevant lines covered (21.63%)

0.62 hits per line

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

14.85
/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 Microsoft.Extensions.Logging;
6

7
namespace CleanMyPosts.UI.Services;
8

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

16
    public async Task ShowPostsAsync()
17
    {
18

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

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

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

39
        _logger.LogInformation("Navigated to {Url}", url);
1✔
40
    }
1✔
41

42
    public async Task<int> DeletePostsAsync()
43
    {
NEW
44
        await EnsureUserNameAsync();
×
UNCOV
45
        Guard.Against.Null(_userName);
×
46

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

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

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

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

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

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

105
    public async Task ShowLikesAsync()
106
    {
107
        await EnsureUserNameAsync();
1✔
108
        Guard.Against.Null(_userName);
1✔
109

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

112
        if (_webViewHostService.Source == url)
1!
113
        {
114
            _webViewHostService.Reload();
×
115
        }
116
        else
117
        {
118
            _webViewHostService.Source = url;
1✔
119
        }
120

121
        if (!await WaitForDocumentReadyAsync())
1!
122
        {
123
            _logger.LogWarning("Navigation to {Url} failed.", url);
×
124
        }
125

126
        _logger.LogInformation("Navigated to {Url}", url);
1✔
127
    }
1✔
128

129
    public async Task<int> DeleteLikesAsync()
130
    {
NEW
131
        await EnsureUserNameAsync();
×
UNCOV
132
        Guard.Against.Null(_userName);
×
133

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

136
        if (_webViewHostService.Source == url)
×
137
        {
138
            _webViewHostService.Reload();
×
139
        }
140
        else
141
        {
142
            _webViewHostService.Source = url;
×
143
        }
NEW
144
        if (!await WaitForDocumentReadyAsync())
×
145
        {
146
            _logger.LogWarning("Navigation to search page failed.");
×
147
            return 0;
×
148
        }
149

150
        int totalLikes = 0;
×
151

152
        var postNumber = 1;
×
153

154
        while (await LikesExistAsync())
×
155
        {
156
            var countBefore = await GetLikesCountAsync();
×
157
            if (postNumber == 1)
×
158
            {
159
                totalLikes = countBefore;
×
160
            }
161

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

164
            try
165
            {
166
                _logger.LogInformation("Deleting like #{Number}...", postNumber);
×
167
                await DeleteSingleLikeAsync();
×
168

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

187
        _webViewHostService.Reload();
×
NEW
188
        await WaitForDocumentReadyAsync();
×
189

190
        _logger.LogInformation("Deleted {TotalLikes} Likes.", totalLikes);
×
191
        return totalLikes;
×
192
    }
×
193

194
    public async Task ShowFollowingAsync()
195
    {
196
        await EnsureUserNameAsync();
1✔
197
        Guard.Against.Null(_userName);
1✔
198

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

201
        if (_webViewHostService.Source == url)
1!
202
        {
203
            _webViewHostService.Reload();
×
204
        }
205
        else
206
        {
207
            _webViewHostService.Source = url;
1✔
208
        }
209

210
        if (!await WaitForDocumentReadyAsync())
1!
211
        {
212
            _logger.LogWarning("Navigation to {Url} failed.", url);
×
213
        }
214

215
        _logger.LogInformation("Navigated to {Url}", url);
1✔
216
    }
1✔
217

218
    public async Task<int> DeleteFollowingAsync()
219
    {
NEW
220
        await EnsureUserNameAsync();
×
UNCOV
221
        Guard.Against.Null(_userName);
×
222

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

225
        if (_webViewHostService.Source == url)
×
226
        {
227
            _webViewHostService.Reload();
×
228
        }
229
        else
230
        {
231
            _webViewHostService.Source = url;
×
232
        }
NEW
233
        if (!await WaitForDocumentReadyAsync())
×
234
        {
235
            _logger.LogWarning("Navigation to search page failed.");
×
236
            return 0;
×
237
        }
238

239
        int totalFollowings = 0;
×
240
        var postNumber = 1;
×
241
        while (await FollowingExistAsync())
×
242
        {
243
            var countBefore = await GetFollowingCountAsync();
×
244
            if (postNumber == 1)
×
245
            {
246
                totalFollowings = countBefore;
×
247
            }
248

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

251
            try
252
            {
253
                _logger.LogInformation("Deleting following #{Number}...", postNumber);
×
254
                await DeleteSingleFollowingAsync();
×
255

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

274
        _webViewHostService.Reload();
×
NEW
275
        await WaitForDocumentReadyAsync();
×
276

277
        _logger.LogInformation("Deleted {TotalFollowings} Followings.", totalFollowings);
×
278
        return totalFollowings;
×
279
    }
×
280

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

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

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

323
    private async Task DeleteSinglePostAsync()
324
    {
325
        var timeout = _userSettingsService.GetTimeoutSettings();
×
326
        var waitAfterDelete = timeout.WaitAfterDelete;
×
327
        var waitBetweenDeleteAttempts = timeout.WaitBetweenRetryDeleteAttempts;
×
328

329
        var js = $@"
×
330
        (() => {{
×
331
            const caret = document.querySelector(""div[data-testid='primaryColumn'] section button[data-testid='caret']"");
×
332
            if (!caret) return;
×
333

×
334
            caret.click();
×
335

×
336
            setTimeout(() => {{
×
337
                const delays = [ {waitBetweenDeleteAttempts}, 
×
338
                                {waitBetweenDeleteAttempts * 2}, 
×
339
                                {waitBetweenDeleteAttempts * 3}, 
×
340
                                {waitBetweenDeleteAttempts * 4}, 
×
341
                                {waitBetweenDeleteAttempts * 5} ];
×
342

×
343
                function tryClickDelete(attempt = 0) {{
×
344
                    if (attempt >= delays.length) return;
×
345
                    setTimeout(() => {{
×
346
                        const menu = document.querySelector(""[role='menu']"");
×
347
                        if (menu && menu.style.display !== ""none"") {{
×
348
                            const items = document.querySelectorAll(""[role='menuitem']"");
×
349
                            for (const item of items) {{
×
350
                                const span = item.querySelector(""span"");
×
351
                                if (!span) continue;
×
352

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

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

×
382
                tryClickDelete();
×
383
            }}, {waitAfterDelete});
×
384
        }})();";
×
385

386
        await _webViewHostService.ExecuteScriptAsync(js);
×
387
    }
×
388
    private async Task DeleteSingleLikeAsync()
389
    {
390
        var waitBeforeTryClickDelete = _userSettingsService.GetTimeoutSettings().WaitAfterDelete;
×
391

392
        var js = $@"
×
393
        (() => {{
×
394
            const unlikeButton = document.querySelector('button[data-testid=""unlike""]');
×
395
            if (unlikeButton) {{
×
396
                unlikeButton.click();
×
397
            }}
×
398
            window.scrollBy(0, 300);
×
399
        }})();";
×
400
        await Task.Delay(waitBeforeTryClickDelete);
×
401
        await _webViewHostService.ExecuteScriptAsync(js);
×
402
    }
×
403

404
    private async Task DeleteSingleFollowingAsync()
405
    {
406
        var timeout = _userSettingsService.GetTimeoutSettings();
×
407
        var waitBeforeTryClickDelete = timeout.WaitAfterDelete;
×
408
        var waitBetweenTryClickDeleteAttempts = timeout.WaitBetweenRetryDeleteAttempts;
×
409

410
        var js = $@"
×
411
    (() => {{
×
412
        const unfollowingButton = document.querySelector('button[data-testid$=""-unfollow""]');
×
413
        if (!unfollowingButton) return;
×
414

×
415
        unfollowingButton.click();
×
416

×
417
        const delays = [
×
418
            {waitBetweenTryClickDeleteAttempts},
×
419
            {waitBetweenTryClickDeleteAttempts * 2},
×
420
            {waitBetweenTryClickDeleteAttempts * 3},
×
421
            {waitBetweenTryClickDeleteAttempts * 4},
×
422
            {waitBetweenTryClickDeleteAttempts * 5}
×
423
        ];
×
424

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

×
438
        setTimeout(() => {{
×
439
            tryClickConfirm();
×
440
        }}, {waitBeforeTryClickDelete});
×
441
    }})();";
×
442

443
        await _webViewHostService.ExecuteScriptAsync(js);
×
444
    }
×
445

446

447
    private async Task<int> GetPostsCountAsync()
448
    {
449
        const string js = """
450
        (() => document.querySelectorAll('div[data-testid="primaryColumn"] section button[data-testid="caret"]').length)()
451
        """;
452

453
        var result = await _webViewHostService.ExecuteScriptAsync(js);
×
454
        return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
×
455
    }
×
456

457
    private async Task<int> GetFollowingCountAsync()
458
    {
459
        const string js = """
460
        (() => document.querySelectorAll('button[data-testid$="-unfollow"]').length)()        
461
        """;
462

463
        var result = await _webViewHostService.ExecuteScriptAsync(js);
×
464
        return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
×
465
    }
×
466

467
    private async Task<int> GetLikesCountAsync()
468
    {
469
        const string js = """
470
        (() => document.querySelectorAll('button[data-testid="unlike"]').length)()
471
        """;
472

473
        var result = await _webViewHostService.ExecuteScriptAsync(js);
×
474
        return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
×
475
    }
×
476

477
    private async Task<bool> WaitForPostDeletedAsync(int beforeCount)
478
    {
479
        int elapsed = 0, interval = 200, timeout = 5000;
×
480
        while (elapsed < timeout)
×
481
        {
482
            if (await GetPostsCountAsync() < beforeCount)
×
483
            {
484
                return true;
×
485
            }
486
            await Task.Delay(interval);
×
487
            elapsed += interval;
×
488
        }
489
        return false;
×
490
    }
×
491

492
    private async Task<bool> WaitForFollowingDeletedAsync(int beforeCount)
493
    {
494
        int elapsed = 0, interval = 200, timeout = 5000;
×
495
        while (elapsed < timeout)
×
496
        {
497
            if (await GetFollowingCountAsync() < beforeCount)
×
498
            {
499
                return true;
×
500
            }
501
            await Task.Delay(interval);
×
502
            elapsed += interval;
×
503
        }
504
        return false;
×
505
    }
×
506

507
    private async Task<bool> WaitForLikeDeletedAsync(int beforeCount)
508
    {
509
        int elapsed = 0, interval = 200, timeout = 5000;
×
510
        while (elapsed < timeout)
×
511
        {
512
            if (await GetLikesCountAsync() < beforeCount)
×
513
            {
514
                return true;
×
515
            }
516
            await Task.Delay(interval);
×
517
            elapsed += interval;
×
518
        }
519
        return false;
×
520
    }
×
521

522
    public async Task<string> GetUserNameAsync()
523
    {
NEW
524
        await WaitForDocumentReadyAsync();
×
525

526
        const string jsScript = @"
527
            (() => {
528
              const el = document.querySelector('a[data-testid=""AppTabBar_Profile_Link""]');
529
              const href = el?.getAttribute('href');
530
              return href?.split('/')[1] ?? '';
531
            })()";
532

533
        var userName = await _webViewHostService.ExecuteScriptAsync(jsScript);
×
534
        _userName = Helper.CleanJsonResult(userName);
×
535
        return _userName;
×
536
    }
×
537

538
    private async Task<bool> WaitForDocumentReadyAsync()
539
    {
540
        const int maxAttempts = 50;
541
        const int delayMs = 100;
542
        int waitAfterDocumentLoad = _userSettingsService.GetTimeoutSettings().WaitAfterDocumentLoad;
3✔
543

544
        for (var i = 0; i < maxAttempts; i++)
6!
545
        {
546
            try
547
            {
548
                var readyStateJson = await _webViewHostService.ExecuteScriptAsync("document.readyState");
3✔
549
                var readyState = readyStateJson?.Trim('"').ToLowerInvariant();
3!
550

551
                if (readyState == "complete")
3!
552
                {
553
                    await Task.Delay(waitAfterDocumentLoad);
3✔
554
                    return true;
3✔
555
                }
556
            }
×
557
            catch (Exception ex)
×
558
            {
559
                _logger.LogWarning(ex, "Error checking document.readyState.");
×
560
            }
×
561
            await Task.Delay(delayMs);
×
562
        }
563
        _logger.LogWarning("Timed out waiting for document.readyState = complete.");
×
564
        return false;
×
565
    }
3✔
566

567
    private async Task EnsureUserNameAsync()
568
    {
569
        if (string.IsNullOrEmpty(_userName))
3!
570
        {
NEW
571
            await GetUserNameAsync();
×
572
        }
573
    }
3✔
574
}
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