diff --git a/inc/spbc-admin.php b/inc/spbc-admin.php
index cff800041..51a7fbdad 100644
--- a/inc/spbc-admin.php
+++ b/inc/spbc-admin.php
@@ -27,6 +27,7 @@
use CleantalkSP\SpbctWP\UsersPassCheckModule\UsersPassCheckHandler;
use CleantalkSP\SpbctWP\Scanner\ScannerAjaxEndpoints;
use CleantalkSP\SpbctWP\Scanner\ScannerActions\BackupsActions;
+use CleantalkSP\SpbctWP\UpdateChangelogNotice;
// Prevent direct call
if ( ! defined('ABSPATH') ) {
@@ -300,6 +301,8 @@ function spbc_plugin_action_links($links)
return $links;
}
+UpdateChangelogNotice::register();
+
add_action('after_plugin_row', 'spbc_plugin_list_show_vulnerability', 20, 3);
function spbc_plugin_list_show_vulnerability($plugin_file, $plugin_data, $_status)
{
diff --git a/lib/CleantalkSP/Common/AbstractUpdateChangelogNotice.php b/lib/CleantalkSP/Common/AbstractUpdateChangelogNotice.php
new file mode 100644
index 000000000..b2528a38a
--- /dev/null
+++ b/lib/CleantalkSP/Common/AbstractUpdateChangelogNotice.php
@@ -0,0 +1,604 @@
+getPluginFile());
+ }
+
+ /**
+ * "Owner/repo" used as a fallback source. Return null to disable the fallback.
+ *
+ * @return string|null
+ */
+ protected function getGithubRepo()
+ {
+ return null;
+ }
+
+ /**
+ * Entry point. Wraps the native update row to inject the changelog
+ * into the very same notice block.
+ *
+ * @return void
+ */
+ public static function register()
+ {
+ $instance = new static();
+ $hook = 'after_plugin_row_' . $instance->getPluginFile();
+
+ // wp_plugin_update_row() is hooked with priority 10, so the row output
+ // is captured and post-processed around it.
+ add_action($hook, array($instance, 'startRowBuffer'), 9, 3);
+ add_action($hook, array($instance, 'flushRowBuffer'), 11, 3);
+ }
+
+ /**
+ * Hook handler. Starts capturing the native update row output.
+ *
+ * @return void
+ */
+ public function startRowBuffer()
+ {
+ ob_start();
+ }
+
+ /**
+ * Hook handler. Injects the changelog into the captured update row.
+ *
+ * @param string $plugin_file
+ *
+ * @return void
+ */
+ public function flushRowBuffer($plugin_file = '')
+ {
+ $row = ob_get_clean();
+
+ if ( ! is_string($row) ) {
+ return;
+ }
+
+ echo $this->injectNotice($row, $plugin_file !== '' ? $plugin_file : $this->getPluginFile());
+ }
+
+ /**
+ * Places the changelog markup right before the end of the notice container.
+ *
+ * @param string $row captured update row markup
+ * @param string $plugin_file
+ *
+ * @return string
+ */
+ protected function injectNotice($row, $plugin_file)
+ {
+ if ( $row === '' || strpos($row, 'update-message') === false ) {
+ return $row;
+ }
+
+ $version = $this->getOfferedVersion($plugin_file);
+
+ if ( $version === '' ) {
+ return $row;
+ }
+
+ $changelog_html = $this->getChangelogHtml($version);
+
+ if ( $changelog_html === '' ) {
+ return $row;
+ }
+
+ $notice_html = $this->getNoticeHtml($version, $changelog_html);
+
+ if ( ! is_string($notice_html) || $notice_html === '' ) {
+ return $row;
+ }
+
+ $position = strrpos($row, '
');
+
+ if ( $position === false ) {
+ return $row;
+ }
+
+ return substr_replace($row, '' . $notice_html . '', $position, strlen(''));
+ }
+
+ /**
+ * Version offered by the WordPress updates transient.
+ *
+ * @param string $plugin_file
+ *
+ * @return string empty string when no update is available
+ */
+ protected function getOfferedVersion($plugin_file)
+ {
+ $updates = get_site_transient('update_plugins');
+
+ return isset($updates->response[$plugin_file]->new_version)
+ ? (string)$updates->response[$plugin_file]->new_version
+ : '';
+ }
+
+ /**
+ * Returns the sanitized changelog of the given version. Result is cached,
+ * including the negative one, to avoid hammering remote sources.
+ *
+ * @param string $version
+ *
+ * @return string empty string when the changelog can not be obtained
+ */
+ protected function getChangelogHtml($version)
+ {
+ $cache_key = static::CACHE_PREFIX . md5($this->getSlug() . '|' . $version);
+ $cached = $this->getCache($cache_key);
+
+ if ( $cached !== false ) {
+ return is_string($cached) ? $cached : '';
+ }
+
+ $html = $this->fetchFromWpOrg($version);
+
+ if ( $html === '' ) {
+ $html = $this->fetchFromGitHub($version);
+ }
+
+ $this->setCache(
+ $cache_key,
+ $html,
+ $html !== '' ? static::CACHE_TTL_SUCCESS : static::CACHE_TTL_FAIL
+ );
+
+ return $html;
+ }
+
+ /**
+ * Primary source: WordPress.org plugin information API.
+ *
+ * @param string $version
+ *
+ * @return string
+ */
+ protected function fetchFromWpOrg($version)
+ {
+ $info = $this->requestPluginInformation();
+
+ if ( is_wp_error($info) ) {
+ return '';
+ }
+
+ $info = (array) $info;
+ $sections = $info['sections'] ?? [];
+
+ if ( ! is_array($sections) ) {
+ return '';
+ }
+
+ $changelog = $sections['changelog'] ?? '';
+
+ if ( ! is_string($changelog) || $changelog === '' ) {
+ return '';
+ }
+
+ return $this->extractVersionBlock($changelog, $version);
+ }
+
+ /**
+ * Fallback source: GitHub release notes of the tag equal to the offered version.
+ *
+ * @param string $version
+ *
+ * @return string
+ */
+ protected function fetchFromGitHub($version)
+ {
+ $repo = $this->getGithubRepo();
+
+ if ( ! is_string($repo) || $repo === '' ) {
+ return '';
+ }
+
+ $repo_path = implode('/', array_map('rawurlencode', explode('/', $repo)));
+
+ // Only the exact tag is requested: any "latest release" fallback could show
+ // the notes of an unrelated (or pre-) release under the offered version.
+ $body = $this->doRequest(
+ 'https://api.github.com/repos/' . $repo_path . '/releases/tags/' . rawurlencode($version)
+ );
+
+ if ( $body === '' ) {
+ return '';
+ }
+
+ $data = json_decode($body, true);
+
+ if ( ! is_array($data) || ! isset($data['body']) || ! is_string($data['body']) || $data['body'] === '' ) {
+ return '';
+ }
+
+ // Double check that the release really belongs to the offered version.
+ $tag_name = isset($data['tag_name']) && is_string($data['tag_name']) ? $data['tag_name'] : '';
+
+ if ( ltrim($tag_name, 'vV') !== ltrim($version, 'vV') ) {
+ return '';
+ }
+
+ return $this->readmeToHtml($data['body']);
+ }
+
+ /**
+ * Seam: the only call to the WordPress.org API.
+ *
+ * @return array|object|\WP_Error
+ */
+ protected function requestPluginInformation()
+ {
+ if ( ! function_exists('plugins_api') ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
+ }
+
+ return plugins_api('plugin_information', array(
+ 'slug' => $this->getSlug(),
+ 'fields' => array(
+ 'sections' => true,
+ 'banners' => false,
+ 'screenshots' => false,
+ 'reviews' => false,
+ 'contributors' => false,
+ ),
+ ));
+ }
+
+ /**
+ * Seam: the only outgoing HTTP request.
+ *
+ * @param string $url
+ *
+ * @return string response body or empty string on any failure
+ */
+ protected function doRequest($url)
+ {
+ $response = wp_remote_get($url, array(
+ 'timeout' => static::HTTP_TIMEOUT,
+ 'headers' => array(
+ 'Accept' => 'application/vnd.github+json',
+ 'User-Agent' => 'WordPress/' . $this->getSlug(),
+ ),
+ ));
+
+ if ( is_wp_error($response) || (int)wp_remote_retrieve_response_code($response) !== 200 ) {
+ return '';
+ }
+
+ return (string)wp_remote_retrieve_body($response);
+ }
+
+ /**
+ * @param string $key
+ *
+ * @return mixed false when the cache is empty
+ */
+ protected function getCache($key)
+ {
+ return get_transient($key);
+ }
+
+ /**
+ * @param string $key
+ * @param string $value
+ * @param int $ttl
+ *
+ * @return void
+ */
+ protected function setCache($key, $value, $ttl)
+ {
+ set_transient($key, $value, (int)$ttl);
+ }
+
+ /**
+ * Picks a single version block from the WordPress.org changelog HTML.
+ * Falls back to the first (newest) block when the exact version is not found.
+ *
+ * @param string $changelog_html
+ * @param string $version
+ *
+ * @return string sanitized HTML
+ */
+ protected function extractVersionBlock($changelog_html, $version)
+ {
+ $parts = preg_split(
+ '/(]*>.*?<\/h[1-6]>)/is',
+ $changelog_html,
+ -1,
+ PREG_SPLIT_DELIM_CAPTURE
+ );
+
+ if ( ! is_array($parts) ) {
+ return '';
+ }
+
+ $blocks = array();
+ $parts_count = count($parts);
+
+ for ( $i = 1; $i < $parts_count; $i += 2 ) {
+ $blocks[] = array(
+ 'title' => wp_strip_all_tags($parts[$i]),
+ 'body' => isset($parts[$i + 1]) ? $parts[$i + 1] : '',
+ );
+ }
+
+ if ( empty($blocks) ) {
+ return '';
+ }
+
+ $chosen = $blocks[0];
+
+ foreach ( $blocks as $block ) {
+ if ( preg_match('/(?sanitize($this->normalizeChangelogBody($chosen['body']));
+ }
+
+ /**
+ * WordPress.org returns the changelog body in two shapes:
+ * - a ready-made list: "";
+ * - a flat block of lines separated by "
" (or plain newlines).
+ *
+ * The flat shape is normalized into a list so both variants render
+ * identically and never leak bare "" tags (which would duplicate
+ * the native update icon rendered via ".update-message p:before").
+ *
+ * @param string $body
+ *
+ * @return string
+ */
+ protected function normalizeChangelogBody($body)
+ {
+ if ( ! is_string($body) || trim($body, " \f\n\r\t\v\x00") === '' ) {
+ return '';
+ }
+
+ // Already a list, nothing to do.
+ if ( stripos($body, '
|\R/i', $body);
+
+ if ( ! is_array($lines) ) {
+ return $body;
+ }
+
+ $items = array();
+
+ foreach ( $lines as $line ) {
+ $line = trim($line, " \f\n\r\t\v\x00");
+
+ if ( $line === '' ) {
+ continue;
+ }
+
+ $items[] = '' . $line . '';
+ }
+
+ return $items ? '' . implode('', $items) . '
' : $body;
+ }
+
+ /**
+ * Converts a readme-style changelog block ("= 2.188 ... =" and "* item" lines)
+ * to a plain list. The source text is escaped before links are generated.
+ *
+ * @param string $text
+ *
+ * @return string sanitized HTML
+ */
+ protected function readmeToHtml($text)
+ {
+ $lines = preg_split('/\R/', $text);
+
+ if ( ! is_array($lines) ) {
+ return '';
+ }
+
+ $items = array();
+
+ foreach ( $lines as $line ) {
+ $line = trim($line, " \f\n\r\t\v\x00");
+
+ if ( $line === '' || strpos($line, '=') === 0 || strpos($line, '#') === 0 ) {
+ continue;
+ }
+
+ $line = ltrim($line, "*-+ \t");
+
+ if ( $line === '' ) {
+ continue;
+ }
+
+ $items[] = '' . esc_html($line) . '';
+ }
+
+ return $items
+ ? $this->sanitize('' . implode('', $items) . '
')
+ : '';
+ }
+
+ /**
+ * Strict whitelist for the third-party changelog HTML. Dangerous elements are
+ * dropped together with their content, the rest is escaped by the child.
+ *
+ * @param string $html
+ *
+ * @return string
+ */
+ protected function sanitize($html)
+ {
+ if ( ! is_string($html) || $html === '' ) {
+ return '';
+ }
+
+ // Drop dangerous elements together with their content:
+ // kses strips the tags but keeps the inner text.
+ $stripped = preg_replace(
+ '#<(script|style|iframe|object|embed|svg|math|template|noscript|frameset|frame|applet)\b[^>]*>.*?\1\s*>#is',
+ '',
+ $html
+ );
+ $html = is_string($stripped) ? $stripped : '';
+
+ $stripped = preg_replace(
+ '#<(script|style|iframe|object|embed|svg|math|template|noscript|frameset|frame|applet)\b[^>]*/?>#is',
+ '',
+ $html
+ );
+ $html = is_string($stripped) ? $stripped : '';
+
+ return $this->escapeChangelogHtml($this->forceDiscListStyle($this->removeLinks($html)));
+ }
+
+ /**
+ * Forces visible round bullets on every "" of the changelog, regardless
+ * of its source (wp.org markup, our own normalized list or the GitHub
+ * markdown conversion). Admin themes commonly reset "ul { list-style: none }",
+ * so the marker is set inline to guarantee it survives everywhere.
+ *
+ * The longhand "list-style-type" is used on purpose: the "list-style" shorthand
+ * is not in the default WordPress "safe_style_css" allowlist and would be
+ * stripped by kses.
+ *
+ * @param string $html
+ *
+ * @return string
+ */
+ protected function forceDiscListStyle($html)
+ {
+ if ( ! is_string($html) || $html === '' ) {
+ return $html;
+ }
+
+ $result = preg_replace_callback(
+ '#]*)>#i',
+ static function ($matches) {
+ $attrs = isset($matches[1]) ? $matches[1] : '';
+
+ if (
+ preg_match('/\bstyle\s*=\s*(["\'])(.*?)\1/i', $attrs, $style_match)
+ && isset($style_match[2])
+ ) {
+ $style = rtrim(trim($style_match[2], " \f\n\r\t\v\x00"), ';')
+ . '; list-style-type: disc; padding-left: 20px;';
+ $new_attrs = preg_replace(
+ '/\bstyle\s*=\s*(["\']).*?\1/i',
+ 'style="' . $style . '"',
+ $attrs,
+ 1
+ );
+ } else {
+ $new_attrs = $attrs . ' style="list-style-type: disc; padding-left: 20px;"';
+ }
+
+ return '';
+ },
+ $html
+ );
+
+ return is_string($result) ? $result : $html;
+ }
+
+ /**
+ * Removes every link from the changelog: anchors are unwrapped and bare URLs
+ * are cut out, so the notice body never contains links.
+ *
+ * @param string $html
+ *
+ * @return string
+ */
+ protected function removeLinks($html)
+ {
+ // text -> text
+ $result = preg_replace('#]*>(.*?)#is', '$1', $html);
+ $html = is_string($result) ? $result : $html;
+
+ // Leftover unclosed anchors.
+ $result = preg_replace('#?a\b[^>]*>#i', '', $html);
+ $html = is_string($result) ? $result : $html;
+
+ // Bare URLs, including the www-style ones.
+ $result = preg_replace('#\b(?:https?://|www\.)[^\s<"\']+#i', '', $html);
+ $html = is_string($result) ? $result : $html;
+
+ // Clean up separators left after the removal.
+ $result = preg_replace('#[ \t]+([.,;:])#', '$1', $html);
+ $html = is_string($result) ? $result : $html;
+
+ $result = preg_replace('#[ \t]{2,}#', ' ', $html);
+ $html = is_string($result) ? $result : $html;
+
+ $result = preg_replace('#[ \t]+()#', '$1', $html);
+
+ return is_string($result) ? $result : $html;
+ }
+}
diff --git a/lib/CleantalkSP/SpbctWP/Escape.php b/lib/CleantalkSP/SpbctWP/Escape.php
index ba882afbc..4a25b2eb7 100644
--- a/lib/CleantalkSP/SpbctWP/Escape.php
+++ b/lib/CleantalkSP/SpbctWP/Escape.php
@@ -191,6 +191,26 @@ public static function escKsesPreset($string, $preset = null, $_allowed_protocol
'selected' => true,
),
),
+ 'spbc_update_changelog_notice' => array(
+ 'details' => array(
+ 'open' => true,
+ ),
+ 'summary' => array(),
+ 'ul' => array(
+ 'style' => true,
+ ),
+ 'ol' => array(
+ 'style' => true,
+ ),
+ 'li' => array(),
+ //'p' => array(),
+ 'br' => array(),
+ 'strong' => array(),
+ 'b' => array(),
+ 'em' => array(),
+ 'i' => array(),
+ 'code' => array(),
+ ),
);
add_filter('safe_style_css', function ($styles) use ($allowed_style_props) {
diff --git a/lib/CleantalkSP/SpbctWP/UpdateChangelogNotice.php b/lib/CleantalkSP/SpbctWP/UpdateChangelogNotice.php
new file mode 100644
index 000000000..c49170cfb
--- /dev/null
+++ b/lib/CleantalkSP/SpbctWP/UpdateChangelogNotice.php
@@ -0,0 +1,65 @@
+'
+ . '%s'
+ . '%s'
+ . '',
+ esc_html(
+ __('A few useful tweaks in the new version', 'security-malware-firewall')
+ ),
+ $changelog_html
+ );
+ }
+}
diff --git a/tests/lib/CleantalkSP/Common/AbstractUpdateChangelogNoticeTest.php b/tests/lib/CleantalkSP/Common/AbstractUpdateChangelogNoticeTest.php
new file mode 100644
index 000000000..78e7a4b16
--- /dev/null
+++ b/tests/lib/CleantalkSP/Common/AbstractUpdateChangelogNoticeTest.php
@@ -0,0 +1,248 @@
+' . $version . '' . $changelog_html . '';
+ }
+
+ protected function escapeChangelogHtml($html)
+ {
+ // No WP kses whitelist here on purpose: keeps the test focused on
+ // this class's own logic (normalization / disc style injection).
+ return $html;
+ }
+
+ /**
+ * Mocked HTTP seam: no real requests are made from the tests.
+ *
+ * @param string $url
+ *
+ * @return string
+ */
+ protected function doRequest($url)
+ {
+ $this->requested_urls[] = $url;
+
+ return $this->fake_response;
+ }
+}
+
+class AbstractUpdateChangelogNoticeTest extends \SpbcTestCase
+{
+ /**
+ * @return SpbcUpdateChangelogNoticeStub
+ */
+ private function makeInstance()
+ {
+ return new SpbcUpdateChangelogNoticeStub();
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return \ReflectionMethod
+ */
+ private function getMethod($name)
+ {
+ $method = new \ReflectionMethod(SpbcUpdateChangelogNoticeStub::class, $name);
+ $method->setAccessible(true);
+
+ return $method;
+ }
+
+ public function testNormalizeChangelogBodyConvertsFlatBrSeparatedLinesToList()
+ {
+ $body = "\nNew. Feature added.
\nFix. Bug fixed.
\nUpd. Something updated.";
+
+ $result = $this->getMethod('normalizeChangelogBody')->invoke($this->makeInstance(), $body);
+
+ $this->assertSame(
+ '- New. Feature added.
- Fix. Bug fixed.
- Upd. Something updated.
',
+ $result
+ );
+ }
+
+ public function testNormalizeChangelogBodyLeavesReadyMadeListUntouched()
+ {
+ $body = "\n\n- Fix. Code. Re-minify JS.
\n- Upd. Scan. Improve UX.
\n
\n";
+
+ $result = $this->getMethod('normalizeChangelogBody')->invoke($this->makeInstance(), $body);
+
+ $this->assertSame($body, $result);
+ }
+
+ public function testNormalizeChangelogBodyReturnsEmptyStringForBlankInput()
+ {
+ $result = $this->getMethod('normalizeChangelogBody')->invoke($this->makeInstance(), " \n ");
+
+ $this->assertSame('', $result);
+ }
+
+ public function testForceDiscListStyleAddsStyleWhenMissing()
+ {
+ $result = $this->getMethod('forceDiscListStyle')->invoke($this->makeInstance(), '');
+
+ $this->assertSame('', $result);
+ }
+
+ public function testForceDiscListStyleMergesWithExistingStyle()
+ {
+ $result = $this->getMethod('forceDiscListStyle')->invoke(
+ $this->makeInstance(),
+ ''
+ );
+
+ $this->assertSame(
+ '',
+ $result
+ );
+ }
+
+ public function testForceDiscListStyleIgnoresContentWithoutUl()
+ {
+ $html = 'no lists here
';
+
+ $result = $this->getMethod('forceDiscListStyle')->invoke($this->makeInstance(), $html);
+
+ $this->assertSame($html, $result);
+ }
+
+ public function testInjectNoticeReturnsRowUnchangedWhenNoUpdateMessageFound()
+ {
+ $row = '';
+
+ $result = $this->getMethod('injectNotice')->invoke(
+ $this->makeInstance(),
+ $row,
+ 'security-malware-firewall/security-malware-firewall.php'
+ );
+
+ $this->assertSame($row, $result);
+ }
+
+ public function testInjectNoticeReturnsRowUnchangedWhenNoUpdateIsOffered()
+ {
+ // No update_plugins transient is set, so getOfferedVersion() yields ''.
+ delete_site_transient('update_plugins');
+
+ $row = '';
+
+ $result = $this->getMethod('injectNotice')->invoke(
+ $this->makeInstance(),
+ $row,
+ 'security-malware-firewall/security-malware-firewall.php'
+ );
+
+ $this->assertSame($row, $result);
+ }
+
+ public function testRemoveLinksDropsAnchorsAndBareUrls()
+ {
+ $html = '- Fix. Something. details'
+ . ' see https://example.com/b and www.example.org/c
';
+
+ $result = $this->getMethod('removeLinks')->invoke($this->makeInstance(), $html);
+
+ $this->assertStringNotContainsString('assertStringNotContainsString('http', $result);
+ $this->assertStringNotContainsString('www.', $result);
+ $this->assertStringContainsString('Fix. Something.', $result);
+ $this->assertStringContainsString('details', $result);
+ }
+
+ public function testReadmeToHtmlMakesListItemsRegardlessOfMarkers()
+ {
+ $text = "= 2.5 Jan 1 2026 =\n* Fix. Marked.\n- Upd. Dashed.\nNew. Not marked.\n Upd. Indented.\n";
+
+ $result = $this->getMethod('readmeToHtml')->invoke($this->makeInstance(), $text);
+
+ $this->assertStringContainsString('- Fix. Marked.
', $result);
+ $this->assertStringContainsString('- Upd. Dashed.
', $result);
+ $this->assertStringContainsString('- New. Not marked.
', $result);
+ $this->assertStringContainsString('- Upd. Indented.
', $result);
+ $this->assertStringNotContainsString('2.5 Jan 1 2026', $result);
+ }
+
+ public function testExtractVersionBlockPicksTheRequestedVersion()
+ {
+ $changelog = '2.189
'
+ . '2.188
';
+
+ $result = $this->getMethod('extractVersionBlock')->invoke($this->makeInstance(), $changelog, '2.188');
+
+ $this->assertStringContainsString('Old release item.', $result);
+ $this->assertStringNotContainsString('New release item.', $result);
+ }
+
+ public function testFetchFromGitHubUsesExactTagEndpointOnly()
+ {
+ $instance = $this->makeInstance();
+ $instance->fake_response = wp_json_encode(array(
+ 'tag_name' => '2.188',
+ 'body' => "= 2.188 =\n* Fix. Something useful.",
+ ));
+
+ $result = $this->getMethod('fetchFromGitHub')->invoke($instance, '2.188');
+
+ $this->assertCount(1, $instance->requested_urls);
+ $this->assertStringEndsWith('/releases/tags/2.188', $instance->requested_urls[0]);
+ $this->assertStringContainsString('Fix. Something useful.', $result);
+ }
+
+ public function testFetchFromGitHubRejectsMismatchedTag()
+ {
+ $instance = $this->makeInstance();
+ $instance->fake_response = wp_json_encode(array(
+ 'tag_name' => '2.999',
+ 'body' => "= 2.999 =\n* Notes of an unrelated release.",
+ ));
+
+ $result = $this->getMethod('fetchFromGitHub')->invoke($instance, '2.188');
+
+ $this->assertSame('', $result);
+ }
+
+ public function testGetChangelogHtmlReturnsCachedValueWithoutRequests()
+ {
+ $instance = $this->makeInstance();
+ $cached = '';
+ $cache_key = SpbcUpdateChangelogNoticeStub::CACHE_PREFIX . md5('security-malware-firewall|2.188');
+
+ set_transient($cache_key, $cached, 60);
+
+ $result = $this->getMethod('getChangelogHtml')->invoke($instance, '2.188');
+
+ delete_transient($cache_key);
+
+ $this->assertSame($cached, $result);
+ $this->assertCount(0, $instance->requested_urls);
+ }
+}