package org.terst.feedreader.readability import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith /** * Extraction used to fail intermittently on perfectly ordinary articles. * * onPageFinished fires before the page has finished loading — observed at * progress=25 — and injecting the extraction script at that point is a race: * sometimes the DOM is ready enough, and sometimes evaluateJavascript hands * back a bare "null" because the document is still being replaced. Measured at * 2 failures in 6 attempts against the same URL, which is what produced a bug * report reading only "extraction failed" for one article while others worked. * * The fix retries an unusable result rather than treating it as final. * * HONEST LIMITATION: this test does not reproduce that race. A page served from * a local MockWebServer finishes loading before onPageFinished fires * (progress=100 every time), and adding deliberately slow subresources does not * help — WebView simply waits for them. The race needs a real remote page. * Verified manually instead: against the reported URL the extractor scored 4/6 * before the fix and 10/10 after. * * What this test IS worth keeping for: it asserts that repeated back-to-back * extractions in one process all succeed, which guards the WebView * create/destroy churn the retry loop now leans on, and would catch a * regression that broke extraction outright. */ @RunWith(AndroidJUnit4::class) class ReaderExtractorRetryTest { private lateinit var server: MockWebServer @Before fun startServer() { server = MockWebServer() // A page served instantly from localhost finishes loading before // onPageFinished fires (observed progress=100), which does NOT // reproduce the bug. A real article is still pulling subresources at // that point — the observed failure had progress=25. So the fixture // references subresources the server deliberately dawdles over, which // is what puts the document in the half-loaded state the extraction // script used to be injected into. server.dispatcher = object : Dispatcher() { override fun dispatch(request: RecordedRequest): MockResponse { val path = request.path.orEmpty() return when { path.startsWith("/slow") -> MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/javascript") .setBody("/* slow */") .setBodyDelay(3, TimeUnit.SECONDS) else -> MockResponse().setResponseCode(200) .setHeader("Content-Type", "text/html") .setBody(article) } } } server.start() } @After fun stopServer() { server.shutdown() } // Enough real prose that Readability is in no doubt there is an article // here; the failures under test are about timing, not content. private val article = buildString { append("A perfectly ordinary article") // Subresources the server is slow to answer, so the page is still // loading when onPageFinished fires. append("") append("") append("
") append("

A perfectly ordinary article

") repeat(12) { append( "

This paragraph exists so that Readability has a comfortably large " + "body of text to score, well above any minimum content threshold, so " + "that a failure to extract can only be a timing problem rather than a " + "judgement about whether this page contains an article at all.

" ) } append("
") } @Test fun repeatedExtractionOfTheSamePageAlwaysSucceeds() { val context = InstrumentationRegistry.getInstrumentation().targetContext val attempts = 8 val failures = mutableListOf() repeat(attempts) { i -> val url = server.url("/article-$i").toString() when (val r = runBlocking { ReaderExtractor.extractResult(context, url) }) { is ExtractionResult.Success -> assertTrue( "attempt ${i + 1} returned empty content", !r.article.content.isNullOrBlank() ) is ExtractionResult.Failure -> failures += "attempt ${i + 1}: ${r.reason.diagnostic()}" } } assertTrue( "extraction is not reliable — ${failures.size}/$attempts failed:\n" + failures.joinToString("\n"), failures.isEmpty() ) } }