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()
)
}
}