diff --git a/README.md b/README.md index beb20f0..01e7345 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ type AssetsRetryOptions = { onRetry?: (context: AssetsRetryHookContext) => void; onSuccess?: (context: AssetsRetryHookContext) => void; onFail?: (context: AssetsRetryHookContext) => void; +} | { + rules: RuntimeRetryOptions[]; + inlineScript?: boolean; + minify?: boolean; }; ``` @@ -325,6 +329,76 @@ pluginAssetsRetry({ }); ``` +### rules + +- **Type:** `RuntimeRetryOptions[]` +- **Default:** `undefined` + +Configure multiple retry rules with different options. Each rule will be evaluated in order, and the first matching rule will be used for retry logic. This is useful when you have different retry requirements for different types of assets or domains. + +When using `rules`, the plugin will: +1. Check each rule in order +2. Use the first rule whose `test` condition matches the asset URL +3. If no rule matches, the asset will not be retried + +Each rule supports all the same options as the top-level configuration, including `type`, `domain`, `max`, `test`, `crossOrigin`, `delay`, `onRetry`, `onSuccess`, and `onFail`. + +Example - Different retry strategies for different CDNs: + +```js +pluginAssetsRetry({ + rules: [ + { + // Rule for primary CDN + test: /cdn1\.example\.com/, + domain: ['cdn1.example.com', 'cdn1-backup.example.com'], + max: 3, + delay: 1000, + }, + { + // Rule for secondary CDN with more retries + test: /cdn2\.example\.com/, + domain: ['cdn2.example.com', 'cdn2-backup.example.com'], + max: 5, + delay: 500, + }, + { + // Default rule for other assets + domain: ['default.example.com', 'default-backup.example.com'], + max: 2, + }, + ], +}); +``` + +Example - Different retry strategies for different asset types: + +```js +pluginAssetsRetry({ + rules: [ + { + // Critical JavaScript files get more retries + test: /\.js$/, + max: 5, + delay: 1000, + onFail: ({ url }) => console.error(`Critical JS failed: ${url}`), + }, + { + // CSS files get fewer retries + test: /\.css$/, + max: 2, + delay: 500, + }, + { + // Images get minimal retries + test: /\.(png|jpg|gif|svg)$/, + max: 1, + delay: 0, + }, + ], +}); +``` + ## Notes When you use Assets Retry plugin, the Rsbuild injects some runtime code into the HTML and [Rspack Runtime](https://rspack.dev/misc/glossary#runtime), then serializes the Assets Retry plugin config, inserting it into the runtime code. Therefore, you need to be aware of the following: diff --git a/README.zh-CN.md b/README.zh-CN.md index 73dda31..09ed6f3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -68,6 +68,10 @@ type AssetsRetryOptions = { onRetry?: (context: AssetsRetryHookContext) => void; onSuccess?: (context: AssetsRetryHookContext) => void; onFail?: (context: AssetsRetryHookContext) => void; +} | { + rules: RuntimeRetryOptions[]; + inlineScript?: boolean; + minify?: boolean; }; ``` @@ -323,6 +327,76 @@ pluginAssetsRetry({ }); ``` +### rules + +- **类型:** `RuntimeRetryOptions[]` +- **默认值:** `undefined` + +配置多个重试规则,每个规则可以有不同的选项。规则会按顺序进行评估,第一个匹配的规则将用于重试逻辑。这在你对不同类型的资源或域名有不同的重试需求时非常有用。 + +使用 `rules` 时,插件会: +1. 按顺序检查每个规则 +2. 使用第一个 `test` 条件匹配资源 URL 的规则 +3. 如果没有规则匹配,则不会重试该资源 + +每个规则支持与顶层配置相同的所有选项,包括 `type`、`domain`、`max`、`test`、`crossOrigin`、`delay`、`onRetry`、`onSuccess` 和 `onFail`。 + +示例 - 不同 CDN 的不同重试策略: + +```js +pluginAssetsRetry({ + rules: [ + { + // 主 CDN 的规则 + test: /cdn1\.example\.com/, + domain: ['cdn1.example.com', 'cdn1-backup.example.com'], + max: 3, + delay: 1000, + }, + { + // 次要 CDN 的规则,更多重试次数 + test: /cdn2\.example\.com/, + domain: ['cdn2.example.com', 'cdn2-backup.example.com'], + max: 5, + delay: 500, + }, + { + // 其他资源的默认规则 + domain: ['default.example.com', 'default-backup.example.com'], + max: 2, + }, + ], +}); +``` + +示例 - 不同资源类型的不同重试策略: + +```js +pluginAssetsRetry({ + rules: [ + { + // 关键 JavaScript 文件获得更多重试次数 + test: /\.js$/, + max: 5, + delay: 1000, + onFail: ({ url }) => console.error(`关键 JS 失败: ${url}`), + }, + { + // CSS 文件获得较少的重试次数 + test: /\.css$/, + max: 2, + delay: 500, + }, + { + // 图片获得最少的重试次数 + test: /\.(png|jpg|gif|svg)$/, + max: 1, + delay: 0, + }, + ], +}); +``` + ## 注意事项 当你使用 Assets Retry 插件时,Rsbuild 会分别向 HTML 和 [Rspack Runtime](https://rspack.dev/zh/misc/glossary#runtime) 中注入运行时代码,并将 Assets Retry 插件配置的内容序列化后插入到这些代码中,因此你需要注意: diff --git a/src/AsyncChunkRetryPlugin.ts b/src/AsyncChunkRetryPlugin.ts index bc6ee5d..e9a1fe2 100644 --- a/src/AsyncChunkRetryPlugin.ts +++ b/src/AsyncChunkRetryPlugin.ts @@ -50,10 +50,12 @@ class AsyncChunkRetryPlugin implements Rspack.RspackPluginInstance { readonly name = 'ASYNC_CHUNK_RETRY_PLUGIN'; readonly isRspack: boolean; readonly minify: boolean; - readonly runtimeOptions: NormalizedRuntimeRetryOptions; + readonly runtimeOptions: + | NormalizedRuntimeRetryOptions + | NormalizedRuntimeRetryOptions[]; constructor( - options: NormalizedRuntimeRetryOptions, + options: NormalizedRuntimeRetryOptions | NormalizedRuntimeRetryOptions[], isRspack: boolean, minify: boolean, ) { diff --git a/src/index.ts b/src/index.ts index 9218ddc..c66e2b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,10 @@ import { ensureAssetPrefix } from '@rsbuild/core'; import serialize from 'serialize-javascript'; import { AsyncChunkRetryPlugin } from './AsyncChunkRetryPlugin.js'; import type { + CompileTimeRetryOptions, NormalizedRuntimeRetryOptions, PluginAssetsRetryOptions, + RuntimeRetryOptions, } from './types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -22,8 +24,21 @@ export const PLUGIN_ASSETS_RETRY_NAME = 'rsbuild:assets-retry'; function getRuntimeOptions( userOptions: PluginAssetsRetryOptions, +): NormalizedRuntimeRetryOptions | NormalizedRuntimeRetryOptions[] { + // Check if using rules mode + if ('rules' in userOptions && Array.isArray(userOptions.rules)) { + return userOptions.rules.map((rule) => normalizeRuntimeOptions(rule)); + } + + // Single options mode + const { inlineScript, minify, ...restOptions } = + userOptions as RuntimeRetryOptions & CompileTimeRetryOptions; + return normalizeRuntimeOptions(restOptions); +} + +function normalizeRuntimeOptions( + options: RuntimeRetryOptions, ): NormalizedRuntimeRetryOptions { - const { inlineScript, minify, ...restOptions } = userOptions; const defaultOptions: NormalizedRuntimeRetryOptions = { max: 3, type: ['link', 'script', 'img'], @@ -35,7 +50,7 @@ function getRuntimeOptions( const result: NormalizedRuntimeRetryOptions = { ...defaultOptions, - ...restOptions, + ...options, }; // Normalize config @@ -53,12 +68,18 @@ function getRuntimeOptions( } async function getRetryCode( - runtimeOptions: NormalizedRuntimeRetryOptions, + runtimeOptions: + | NormalizedRuntimeRetryOptions + | NormalizedRuntimeRetryOptions[], minify: boolean, ): Promise { const filename = 'initialChunkRetry'; + // In production, files are in dist/runtime, in development they are in src/runtime + const baseDir = __dirname.includes('/dist/') + ? __dirname + : path.join(__dirname, '..', 'dist'); const runtimeFilePath = path.join( - __dirname, + baseDir, 'runtime', minify ? `${filename}.min.js` : `${filename}.js`, ); @@ -87,6 +108,21 @@ export const pluginAssetsRetry = ( } => { const options = { ...userOptions }; + // Handle rules mode + if ('rules' in options) { + if (options.minify === undefined) { + const minify = + typeof config.output.minify === 'boolean' + ? config.output.minify + : config.output.minify?.js; + options.minify = minify && config.mode === 'production'; + } + return options as PluginAssetsRetryOptions & { + minify: boolean; + crossorigin: boolean | 'anonymous' | 'use-credentials'; + }; + } + // options.crossOrigin should be same as html.crossorigin by default if (options.crossOrigin === undefined) { options.crossOrigin = config.html.crossorigin; diff --git a/src/runtime/asyncChunkRetry.ts b/src/runtime/asyncChunkRetry.ts index 37ef0a9..81c68f5 100644 --- a/src/runtime/asyncChunkRetry.ts +++ b/src/runtime/asyncChunkRetry.ts @@ -58,8 +58,7 @@ declare global { } // init retryCollector and nextRetry function -const config = __RETRY_OPTIONS__; -const maxRetries = config.max; +const configs = __RETRY_OPTIONS__; const retryCollector: RetryCollector = {}; const retryCssCollector: RetryCollector = {}; @@ -68,6 +67,37 @@ const globalCurrRetrying: Record = {}; // shared between ensureChunk and loadStyleSheet const globalCurrRetryingCss: Record = {}; +function findMatchingConfig( + url: string, + configs: NormalizedRuntimeRetryOptions | NormalizedRuntimeRetryOptions[], +): NormalizedRuntimeRetryOptions | null { + // If single config, return it + if (!Array.isArray(configs)) { + return configs; + } + + // Find the first matching config + for (const config of configs) { + // If no test, this config matches all + if (!config.test) { + return config; + } + + // Test the URL against the config's test + let tester = config.test; + if (typeof tester === 'string') { + const regexp = new RegExp(tester); + tester = (str: string) => regexp.test(str); + } + + if (typeof tester === 'function' && tester(url)) { + return config; + } + } + + return null; +} + function getCurrentRetry( chunkId: string, existRetryTimes: number, @@ -78,7 +108,11 @@ function getCurrentRetry( : retryCollector[chunkId]?.[existRetryTimes]; } -function initRetry(chunkId: string, isCssAsyncChunk: boolean): Retry { +function initRetry( + chunkId: string, + isCssAsyncChunk: boolean, + config: NormalizedRuntimeRetryOptions, +): Retry { const originalScriptFilename = isCssAsyncChunk ? originalGetCssFilename(chunkId) : originalGetChunkScriptFilename(chunkId); @@ -117,6 +151,7 @@ function nextRetry( chunkId: string, existRetryTimes: number, isCssAsyncChunk: boolean, + config: NormalizedRuntimeRetryOptions, ): Retry { const currRetry = getCurrentRetry(chunkId, existRetryTimes, isCssAsyncChunk); @@ -124,7 +159,7 @@ function nextRetry( const nextExistRetryTimes = existRetryTimes + 1; if (existRetryTimes === 0 || currRetry === undefined) { - nextRetry = initRetry(chunkId, isCssAsyncChunk); + nextRetry = initRetry(chunkId, isCssAsyncChunk, config); if (isCssAsyncChunk) { retryCssCollector[chunkId] = []; } else { @@ -237,11 +272,42 @@ function ensureChunk(chunkId: string): Promise { ? cssExistRetryTimes : jsExistRetryTimes; + // Get original filename to find matching config + try { + const filename = isCssAsyncChunkLoadFailed + ? originalGetCssFilename(chunkId) + : originalGetChunkScriptFilename(chunkId); + + if (!filename) { + throw new Error('Failed to get original filename'); + } + + originalScriptFilename = filename; + } catch (e) { + console.error(ERROR_PREFIX, 'failed to get original filename', e); + throw error; + } + + const originalPublicPath = __RUNTIME_GLOBALS_PUBLIC_PATH__; + const originalSrcUrl = + originalPublicPath[0] === '/' && originalPublicPath[1] !== '/' + ? window.origin + originalPublicPath + originalScriptFilename + : originalPublicPath + originalScriptFilename; + + // Find matching config + const config = findMatchingConfig(originalSrcUrl, configs); + if (!config) { + throw error; + } + + const maxRetries = config.max; + try { const retryResult = nextRetry( chunkId, existRetryTimes, isCssAsyncChunkLoadFailed, + config, ); originalScriptFilename = retryResult.originalScriptFilename; nextRetryUrl = retryResult.nextRetryUrl; @@ -271,19 +337,7 @@ function ensureChunk(chunkId: string): Promise { throw error; } - // Filter by config.test and config.domain - let tester = config.test; - if (tester) { - if (typeof tester === 'string') { - const regexp = new RegExp(tester); - tester = (str: string) => regexp.test(str); - } - - if (typeof tester !== 'function' || !tester(nextRetryUrl)) { - throw error; - } - } - + // Domain check if ( config.domain && config.domain.length > 0 && diff --git a/src/runtime/initialChunkRetry.ts b/src/runtime/initialChunkRetry.ts index f4176cd..d5ce156 100644 --- a/src/runtime/initialChunkRetry.ts +++ b/src/runtime/initialChunkRetry.ts @@ -286,13 +286,44 @@ function load(config: NormalizedRuntimeRetryOptions, e: Event) { } } +function findMatchingConfig( + url: string, + configs: NormalizedRuntimeRetryOptions | NormalizedRuntimeRetryOptions[], +): NormalizedRuntimeRetryOptions | null { + // If single config, return it + if (!Array.isArray(configs)) { + return configs; + } + + // Find the first matching config + for (const config of configs) { + // If no test, this config matches all + if (!config.test) { + return config; + } + + // Test the URL against the config's test + let tester = config.test; + if (typeof tester === 'string') { + const regexp = new RegExp(tester); + tester = (str: string) => regexp.test(str); + } + + if (typeof tester === 'function' && tester(url)) { + return config; + } + } + + return null; +} + function registerInitialChunkRetry() { // init global variables shared with async chunk if (typeof window !== 'undefined' && !window.__RB_ASYNC_CHUNKS__) { window.__RB_ASYNC_CHUNKS__ = {}; } try { - const config = __RETRY_OPTIONS__; + const configs = __RETRY_OPTIONS__; // Bind event in window if ( typeof window !== 'undefined' && @@ -303,7 +334,20 @@ function registerInitialChunkRetry() { (e) => { if (e && e.target instanceof Element) { try { - retry(config, e); + // For multiple rules, we need to find the matching config first + if (Array.isArray(configs)) { + const target = e.target as HTMLElement; + const url = getRequestUrl(target); + if (url) { + const config = findMatchingConfig(url, configs); + if (config) { + retry(config, e); + } + } + } else { + // For single config, directly call retry + retry(configs, e); + } } catch (err) { console.error('retry error captured', err); } @@ -316,7 +360,20 @@ function registerInitialChunkRetry() { (e) => { if (e && e.target instanceof Element) { try { - load(config, e); + // For multiple rules, we need to find the matching config first + if (Array.isArray(configs)) { + const target = e.target as HTMLElement; + const url = getRequestUrl(target); + if (url) { + const config = findMatchingConfig(url, configs); + if (config) { + load(config, e); + } + } + } else { + // For single config, directly call load + load(configs, e); + } } catch (err) { console.error('load error captured', err); } diff --git a/src/runtime/runtime.d.ts b/src/runtime/runtime.d.ts index 25a6b8e..5ee11fe 100644 --- a/src/runtime/runtime.d.ts +++ b/src/runtime/runtime.d.ts @@ -6,4 +6,4 @@ declare type NormalizedRuntimeRetryOptions = import('../types.js').NormalizedRun // global variables shared between initialChunkRetry and asyncChunkRetry var __RB_ASYNC_CHUNKS__: Record; -var __RETRY_OPTIONS__: NormalizedRuntimeRetryOptions; +var __RETRY_OPTIONS__: NormalizedRuntimeRetryOptions | NormalizedRuntimeRetryOptions[]; diff --git a/src/types.ts b/src/types.ts index 4359bff..0ee0ea7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -90,5 +90,12 @@ export type CompileTimeRetryOptions = { minify?: boolean; }; -export type PluginAssetsRetryOptions = RuntimeRetryOptions & - CompileTimeRetryOptions; +export type PluginAssetsRetryOptions = + | (RuntimeRetryOptions & CompileTimeRetryOptions) + | ({ + /** + * Multiple retry rules with different configurations. + * Each rule will be evaluated in order, and the first matching rule will be used. + */ + rules: RuntimeRetryOptions[]; + } & CompileTimeRetryOptions); diff --git a/test/basic/multiRules.test.ts b/test/basic/multiRules.test.ts new file mode 100644 index 0000000..26c45c7 --- /dev/null +++ b/test/basic/multiRules.test.ts @@ -0,0 +1,301 @@ +import { expect, test } from '@playwright/test'; +import { logger } from '@rsbuild/core'; +import { + count404Response, + createBlockMiddleware, + createRsbuildWithMiddleware, + delay, + gotoPage, + proxyConsole, + proxyPageConsole, +} from './helper'; + +test('should support multiple retry rules', async ({ page }) => { + logger.level = 'verbose'; + const { logs, restore } = proxyConsole(); + + // Block first 2 requests for JS files, first 1 request for CSS files + const jsBlockedMiddleware = createBlockMiddleware({ + blockNum: 2, + urlPrefix: '/static/js/', + }); + + const cssBlockedMiddleware = createBlockMiddleware({ + blockNum: 1, + urlPrefix: '/static/css/', + }); + + const rsbuild = await createRsbuildWithMiddleware( + [jsBlockedMiddleware, cssBlockedMiddleware], + { + rules: [ + { + // Rule for JS files - allow 2 retries + test: '\\.js$', + max: 2, + delay: 100, + onRetry(context) { + console.info('onRetry', context); + }, + onSuccess(context) { + console.info('onSuccess', context); + }, + }, + { + // Rule for CSS files - allow 1 retry + test: '\\.css$', + max: 1, + delay: 50, + onRetry(context) { + console.info('onRetry', context); + }, + onSuccess(context) { + console.info('onSuccess', context); + }, + }, + { + // Default rule for other assets - no retry + max: 0, + }, + ], + }, + ); + + const { onRetryContextList, onSuccessContextList } = await proxyPageConsole( + page, + rsbuild.port, + ); + + await gotoPage(page, rsbuild); + const compTestElement = page.locator('#comp-test'); + await expect(compTestElement).toHaveText('Hello CompTest'); + await delay(); + + // Check JS retries (should retry 2 times) + const jsBlockedResponseCount = count404Response(logs, '/static/js/'); + expect(jsBlockedResponseCount).toBeGreaterThanOrEqual(2); + + // Check CSS retries (should retry 1 time) + const cssBlockedResponseCount = count404Response(logs, '/static/css/'); + expect(cssBlockedResponseCount).toBeGreaterThanOrEqual(1); + + // Check retry contexts + const jsRetries = onRetryContextList.filter((ctx) => ctx.url.includes('.js')); + const cssRetries = onRetryContextList.filter((ctx) => + ctx.url.includes('.css'), + ); + + expect(jsRetries.length).toBeGreaterThanOrEqual(2); + expect(cssRetries.length).toBeGreaterThanOrEqual(1); + + // Check success contexts + const jsSuccess = onSuccessContextList.filter((ctx) => + ctx.url.includes('.js'), + ); + const cssSuccess = onSuccessContextList.filter((ctx) => + ctx.url.includes('.css'), + ); + + expect(jsSuccess.length).toBeGreaterThanOrEqual(1); + expect(cssSuccess.length).toBeGreaterThanOrEqual(1); + + await rsbuild.server.close(); + restore(); + logger.level = 'log'; +}); + +test('should match rules by test function', async ({ page }) => { + logger.level = 'verbose'; + const { logs, restore } = proxyConsole(); + + const blockedMiddleware = createBlockMiddleware({ + blockNum: 100, // Block all requests to ensure failure + urlPrefix: '/static/js/async/src_AsyncCompTest_tsx.js', + }); + + const rsbuild = await createRsbuildWithMiddleware(blockedMiddleware, { + rules: [ + { + // Use function test for async chunks + test: (url) => url.includes('async/'), + max: 3, + onRetry(context) { + console.info('onRetry', context); + }, + onFail(context) { + console.info('onFail', context); + }, + }, + { + // Default rule + max: 1, + }, + ], + }); + + const { onRetryContextList, onFailContextList } = await proxyPageConsole( + page, + rsbuild.port, + ); + + await gotoPage(page, rsbuild); + await delay(); + + // Wait for the async component error to appear + const asyncCompTestElement = page.locator('#async-comp-test-error'); + await expect(asyncCompTestElement).toHaveText( + /ChunkLoadError: Loading chunk src_AsyncCompTest_tsx from "static\/js\/async\/src_AsyncCompTest_tsx\.js" failed after 3 retries/, + ); + + // Should retry 3 times based on the rule + const blockedResponseCount = count404Response( + logs, + '/static/js/async/src_AsyncCompTest_tsx.js', + ); + expect(blockedResponseCount).toBe(4); // 1 initial + 3 retries + + // Check contexts + expect(onRetryContextList.length).toBe(3); + expect(onFailContextList.length).toBe(1); + + await rsbuild.server.close(); + restore(); + logger.level = 'log'; +}); + +test('should use first matching rule when multiple rules match', async ({ + page, +}) => { + logger.level = 'verbose'; + const { logs, restore } = proxyConsole(); + + // Block 3 requests to ensure retries happen + const blockedMiddleware = createBlockMiddleware({ + blockNum: 3, + urlPrefix: '/static/js/index.js', + }); + + const rsbuild = await createRsbuildWithMiddleware(blockedMiddleware, { + rules: [ + { + // First rule - matches all .js files + test: '\\.js$', + max: 3, + delay: 100, + onRetry(context) { + console.info('onRetry', context); + }, + onSuccess(context) { + console.info('onSuccess', context); + }, + onFail(context) { + console.info('onFail', context); + }, + }, + ], + }); + + const { onRetryContextList, onSuccessContextList, onFailContextList } = + await proxyPageConsole(page, rsbuild.port); + + await gotoPage(page, rsbuild); + const compTestElement = page.locator('#comp-test'); + await expect(compTestElement).toHaveText('Hello CompTest'); + await delay(); + + // Should retry 3 times + const blockedResponseCount = count404Response(logs, '/static/js/index.js'); + expect(blockedResponseCount).toBe(3); + + // Check callbacks were triggered + expect(onRetryContextList.length).toBe(3); + expect(onSuccessContextList.length).toBe(1); + expect(onFailContextList.length).toBe(0); + + await rsbuild.server.close(); + restore(); + logger.level = 'log'; +}); + +test('should support rules with different domains', async ({ page }) => { + logger.level = 'verbose'; + const { restore } = proxyConsole(); + + // Use different ports for different domains + const port = 15000 + Math.floor(Math.random() * 10000); + + const blockedMiddleware = createBlockMiddleware({ + blockNum: 1, + urlPrefix: '/static/', + }); + + const rsbuild = await createRsbuildWithMiddleware( + blockedMiddleware, + { + rules: [ + { + test: '\\.css$', + domain: [`localhost:${port}`, `localhost:${port + 1}`], + max: 2, + }, + { + test: '\\.js$', + domain: [`localhost:${port}`, `localhost:${port + 2}`], + max: 2, + }, + ], + }, + undefined, + port, + ); + + await gotoPage(page, rsbuild); + const compTestElement = page.locator('#comp-test'); + await expect(compTestElement).toHaveText('Hello CompTest'); + + await rsbuild.server.close(); + restore(); + logger.level = 'log'; +}); + +test('should fall back to no retry when no rule matches', async ({ page }) => { + logger.level = 'verbose'; + const { logs, restore } = proxyConsole(); + + const blockedMiddleware = createBlockMiddleware({ + blockNum: 100, // Block all requests + urlPrefix: '/static/js/index.js', + }); + + const rsbuild = await createRsbuildWithMiddleware(blockedMiddleware, { + rules: [ + { + // Only match CSS files + test: '\\.css$', + max: 3, + }, + { + // Only match async chunks + test: 'async/', + max: 3, + }, + ], + }); + + // Set a timeout for the page load since it will fail + try { + await gotoPage(page, rsbuild); + // Wait a bit to ensure no retries happen + await page.waitForTimeout(1000); + } catch (error) { + // Expected to fail loading + } + + // Should not retry since no rule matches + const blockedResponseCount = count404Response(logs, '/static/js/index.js'); + expect(blockedResponseCount).toBe(1); // Only initial request, no retries + + await rsbuild.server.close(); + restore(); + logger.level = 'log'; +});