Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions changelogs/DP-46328.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#
# Write your changelog entry here. Every pull request must have a changelog yml file.
#
# Change types:
# #############################################################################
# You can use one of the following types:
# - Added: For new features.
# - Changed: For changes to existing functionality.
# - Deprecated: For soon-to-be removed features.
# - Removed: For removed features.
# - Fixed: For any bug fixes.
# - Security: In case of vulnerabilities.
#
# Format
# #############################################################################
# The format is crucial. Please follow the examples below. For reference, the requirements are:
# - All 3 parts are required and you must include "Type", "description" and "issue".
# - "Type" must be left aligned and followed by a colon.
# - "description" must be indented with 2 spaces followed by a colon
# - "issue" must be indented with 4 spaces followed by a colon.
# - "issue" is for the Jira ticket number only e.g. DP-1234
# - No extra spaces, indents, or blank lines are allowed.
#
# Example:
# #############################################################################
# Fixed:
# - description: Fixes scrolling on edit pages in Safari.
# issue: DP-13314
#
# You may add more than 1 description & issue for each type using the following format:
# Changed:
# - description: Automating the release branch.
# issue: DP-10166
# - description: Second change item that needs a description.
# issue: DP-19875
# - description: Third change item that needs a description along with an issue.
# issue: DP-19843
#
Fixed:
- description: Restore asset_cache_bust module.
issue: DP-46328
16 changes: 8 additions & 8 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions conf/drupal/config/core.extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module:
ai_provider_aws_bedrock: 0
akamai: 0
allowed_formats: 0
asset_cache_bust: 0
aws: 0
better_exposed_filters: 0
block: 0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

namespace Drupal\Tests\mass_caching\ExistingSite;

use Drupal\Core\Asset\AssetQueryString;
use MassGov\Dtt\MassExistingSiteBase;
use weitzman\DrupalTestTraits\ConfigTrait;

/**
* Verifies asset_cache_bust cache-busting behavior on aggregate assets.
*/
class AssetCacheBustBehaviorTest extends MassExistingSiteBase {

use ConfigTrait;

/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();

$this->assertTrue(
\Drupal::moduleHandler()->moduleExists('asset_cache_bust'),
'asset_cache_bust module must be enabled for this test.'
);

// Ensure CSS/JS aggregation is enabled so aggregate URLs are rendered.
$this->setConfigValues([
'system.performance' => [
'css' => ['preprocess' => TRUE],
'js' => ['preprocess' => TRUE],
],
]);
$this->container->get('config.factory')->clearStaticCache();
drupal_flush_all_caches();
}

/**
* {@inheritdoc}
*/
protected function tearDown(): void {
$this->restoreConfigValues();
parent::tearDown();
}

/**
* Ensures CSS/JS aggregate URLs get the active cache-bust token.
*/
public function testAggregateAssetTokenAppended(): void {
$expected_token = $this->getActiveAssetToken();
$this->assertNotSame('0', $expected_token, 'Asset query string token is initialized.');

$result = $this->collectTokensFromFrontPage();
$this->assertNotEmpty($result['css_urls'], 'Aggregated CSS URLs are present.');
$this->assertNotEmpty($result['js_urls'], 'Aggregated JS URLs are present.');
$this->assertNotEmpty($result['css_token'], 'Aggregated CSS has a bare token query value.');
$this->assertNotEmpty($result['js_token'], 'Aggregated JS has a bare token query value.');
$this->assertSame($result['css_token'], $result['js_token'], 'CSS and JS use the same cache-bust token.');
$this->assertSame($expected_token, $result['css_token'], 'CSS token matches active asset.query_string token.');
$this->assertSame($expected_token, $result['js_token'], 'JS token matches active asset.query_string token.');

$previous_token = $expected_token;
$this->rebuildCachesAndRotateAssetToken($previous_token);
$new_token = $this->getActiveAssetToken();

$this->assertNotSame($previous_token, $new_token, 'Asset token changes after cache clear.');

$post_clear = $this->collectTokensFromFrontPage();
$this->assertSame($new_token, $post_clear['css_token'], 'CSS token matches refreshed token after cache clear.');
$this->assertSame($new_token, $post_clear['js_token'], 'JS token matches refreshed token after cache clear.');
}

/**
* Collects aggregate CSS/JS URLs and discovered bare token values.
*
* @return array<string, mixed>
* Collected aggregate URLs and token values.
*/
private function collectTokensFromFrontPage(): array {
$this->drupalGet('<front>');
$html = $this->getSession()->getPage()->getContent();

preg_match_all('/<link[^>]+href="([^"]*\/sites\/default\/files\/css\/[^"]+)"/', $html, $css_matches);
preg_match_all('/<script[^>]+src="([^"]*\/sites\/default\/files\/js\/[^"]+)"/', $html, $js_matches);

$css_urls = $css_matches[1] ?? [];
$js_urls = $js_matches[1] ?? [];

return [
'css_urls' => $css_urls,
'js_urls' => $js_urls,
'css_token' => $this->extractBareTokenFromUrls($css_urls),
'js_token' => $this->extractBareTokenFromUrls($js_urls),
];
}

/**
* Finds a keyless query token appended to aggregate URL.
*
* @param string[] $urls
* Aggregate asset URLs.
*
* @return string|null
* Keyless token value if found.
*/
private function extractBareTokenFromUrls(array $urls): ?string {
foreach ($urls as $url) {
$decoded_url = html_entity_decode($url);
$query = parse_url($decoded_url, PHP_URL_QUERY);
if (!$query) {
continue;
}

foreach (explode('&', $query) as $query_part) {
if ($query_part !== '' && !str_contains($query_part, '=')) {
return $query_part;
}
}
}

return NULL;
}

/**
* Returns the active asset token across Drupal core versions.
*/
private function getActiveAssetToken(): string {
if (\Drupal::hasService('asset.query_string')) {
return \Drupal::service('asset.query_string')->get();
}

return (string) \Drupal::state()->get('system.css_js_query_string', '0');
}

/**
* Rebuilds caches and ensures asset token rotates in-process.
*/
private function rebuildCachesAndRotateAssetToken(string $previous_token): void {
drupal_flush_all_caches();
\Drupal::service('asset.query_string')->reset();

// This test runs in one PHP process, so the time can stay the same.
// If the token did not change after cache clear, set a new token value
// so we can still confirm the "after cache clear" behavior clearly.
$current = $this->getActiveAssetToken();
if ($current === $previous_token) {
$next = base_convert((string) (\Drupal::time()->getCurrentTime() + 1), 10, 36);
\Drupal::state()->set(AssetQueryString::STATE_KEY, $next);
}
}

}
Loading