Coverage for sm / test_browser.py: 0%

107 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 13:46 +0000

1import os 

2import asyncio 

3from django.contrib.staticfiles.testing import StaticLiveServerTestCase 

4from django.urls import get_resolver 

5from django.contrib.auth import get_user_model 

6from django.test import tag 

7from playwright.async_api import async_playwright 

8 

9 

10@tag("browser") 

11class BrowserIntegrationTest(StaticLiveServerTestCase): 

12 @classmethod 

13 def setUpClass(cls): 

14 os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" 

15 super().setUpClass() 

16 # Create a superuser for testing all pages 

17 User = get_user_model() 

18 cls.username = "testadmin" 

19 cls.password = "testpass123" 

20 cls.user = User.objects.create_superuser( 

21 cls.username, "admin@example.com", cls.password 

22 ) 

23 

24 def get_all_urls(self): 

25 resolver = get_resolver() 

26 urls = [] 

27 

28 def collect_urls(patterns, prefix=""): 

29 for pattern in patterns: 

30 if hasattr(pattern, "url_patterns"): 

31 collect_urls(pattern.url_patterns, prefix + str(pattern.pattern)) 

32 else: 

33 url = prefix + str(pattern.pattern) 

34 url = url.replace("^", "").replace("$", "") 

35 if any( 

36 skip in url 

37 for skip in [ 

38 "admin", 

39 "logout", 

40 "debug", 

41 "__", 

42 "password", 

43 "delete", 

44 "detail", 

45 "schema", 

46 "api", 

47 "accounts", 

48 "2fa", 

49 "social", 

50 "server", 

51 "group/filter", 

52 "terms", 

53 "privacy", 

54 "impressum", 

55 "avatar", 

56 "history", 

57 "search", 

58 "cluster", 

59 "vendor", 

60 "status", 

61 "location", 

62 "domain", 

63 "patchtime", 

64 "operatingsystem", 

65 "servermodel", 

66 "clustersoftware", 

67 "clusterpackage", 

68 "clusterpackagetype", 

69 ] 

70 ): 

71 continue 

72 if "<" in url or "(" in url: 

73 continue 

74 if not url.startswith("/"): 

75 url = "/" + url 

76 if url not in urls: 

77 urls.append(url) 

78 

79 collect_urls(resolver.url_patterns) 

80 return sorted(urls) 

81 

82 def test_js_integrity_anonymous(self): 

83 """ 

84 Test public pages as an anonymous user to ensure login snippets etc are 

85 OK. 

86 """ 

87 results = asyncio.run(self._async_test_js(is_anonymous=True)) 

88 

89 if results: 

90 errors_msg = "\n\n".join( 

91 [f"{url}:\n" + "\n".join(errors) for url, errors in results] 

92 ) 

93 self.fail(f"JS/Resource errors found for anonymous user:\n\n{errors_msg}") 

94 

95 def test_js_integrity_authenticated(self): 

96 """ 

97 Test project pages as an authenticated user. 

98 """ 

99 results = asyncio.run(self._async_test_js(is_anonymous=False)) 

100 

101 if results: 

102 errors_msg = "\n\n".join( 

103 [f"{url}:\n" + "\n".join(errors) for url, errors in results] 

104 ) 

105 self.fail( 

106 f"JS/Resource errors found for authenticated user:\n\n{errors_msg}" 

107 ) 

108 

109 async def _async_test_js(self, is_anonymous=False): 

110 async with async_playwright() as p: 

111 # Test in multiple browsers 

112 browser_types = [p.chromium, p.firefox] 

113 all_results = [] 

114 

115 for browser_type in browser_types: 

116 browser_name = browser_type.name 

117 browser = await browser_type.launch(headless=True) 

118 context = await browser.new_context() 

119 

120 # Setup SocialApps in DB (inside async context) 

121 from django.contrib.sites.models import Site 

122 from django.apps import apps 

123 

124 if apps.is_installed("allauth.socialaccount"): 

125 from allauth.socialaccount.models import SocialApp 

126 

127 site = await asyncio.to_thread(Site.objects.get_current) 

128 for p_id in ["facebook", "google"]: 

129 app, _ = await asyncio.to_thread( 

130 SocialApp.objects.get_or_create, 

131 provider=p_id, 

132 name=p_id.title(), 

133 defaults={"client_id": "123", "secret": "abc"}, 

134 ) 

135 await asyncio.to_thread(app.sites.add, site) 

136 

137 if not is_anonymous: 

138 # Login 

139 page = await context.new_page() 

140 await page.goto(f"{self.live_server_url}/accounts/login/") 

141 await page.fill('input[name="login"]', self.username) 

142 await page.fill('input[name="password"]', self.password) 

143 await page.click('button[type="submit"]') 

144 await page.wait_for_load_state("networkidle") 

145 await page.close() 

146 

147 target_urls = self.get_all_urls() 

148 if is_anonymous: 

149 # For anonymous, only test root and login-related pages 

150 target_urls = ["/", "/accounts/login/"] 

151 

152 print( 

153 f"\n[{browser_name}] Testing {len(target_urls)} URLs " 

154 f"(Anonymous={is_anonymous})..." 

155 ) 

156 

157 async def check_url(url, browser_name=browser_name): 

158 new_page = await context.new_page() 

159 errors = [] 

160 

161 # Capture ALL console messages (errors AND warnings) 

162 new_page.on( 

163 "console", 

164 lambda msg: ( 

165 errors.append(f"Console {msg.type.upper()}: {msg.text}") 

166 if msg.type in ["error", "warning"] 

167 else None 

168 ), 

169 ) 

170 

171 # Capture unhandled exceptions 

172 new_page.on( 

173 "pageerror", 

174 lambda exc: errors.append(f"JS Exception: {exc}"), 

175 ) 

176 

177 # Capture failed network requests 

178 new_page.on( 

179 "requestfailed", 

180 lambda req: errors.append( 

181 f"Network Failure ({req.method}): {req.url} - " 

182 + ( 

183 getattr(req.failure, "error_text", req.failure) 

184 if req.failure 

185 else "Unknown Error" 

186 ) 

187 ), 

188 ) 

189 

190 # Capture non-OK responses and MIME mismatches 

191 async def handle_response(res): 

192 # 3xx are fine (redirects), but 4xx and 5xx are errors 

193 if res.status >= 400: 

194 errors.append(f"HTTP {res.status} on {res.url}") 

195 

196 # Check for MIME type conflicts on scripts 

197 content_type = res.headers.get("content-type", "") 

198 if res.ok and ".js" in res.url and "text/html" in content_type: 

199 errors.append( 

200 f"MIME Type Conflict: {res.url} returned " 

201 f"{content_type} (expected javascript)" 

202 ) 

203 

204 new_page.on("response", handle_response) 

205 

206 try: 

207 await new_page.goto( 

208 f"{self.live_server_url}{url}", 

209 wait_until="networkidle", 

210 timeout=15000, 

211 ) 

212 # Explicit wait for any late-firing JS 

213 await asyncio.sleep(1.0) 

214 

215 if errors: 

216 return (f"[{browser_name}] {url}", errors) 

217 return None 

218 except Exception as e: 

219 return ( 

220 f"[{browser_name}] {url}", 

221 [f"Navigation Error: {str(e)}"], 

222 ) 

223 finally: 

224 await new_page.close() 

225 

226 # Run up to 5 concurrent checks 

227 semaphore = asyncio.Semaphore(5) 

228 

229 async def sem_check(url): 

230 async with semaphore: 

231 return await check_url(url) 

232 

233 tasks = [sem_check(url) for url in target_urls] 

234 results = await asyncio.gather(*tasks) 

235 all_results.extend([r for r in results if r]) 

236 

237 await browser.close() 

238 

239 from django.db import connection 

240 

241 connection.close() 

242 

243 return all_results