├── .github └── workflows │ ├── example.yaml │ ├── lint.yaml │ └── test.yaml ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.mjs ├── example ├── playwright.config.ts ├── src │ ├── index.html │ └── server.ts └── test │ ├── fixtures.ts │ └── index.spec.ts ├── lint-staged.config.mjs ├── package-lock.json ├── package.json ├── playwright.config.ts ├── prettier.config.mjs ├── scripts ├── git-hooks │ ├── pre-commit │ └── pre-push └── release.sh ├── src ├── CacheRoute │ ├── defaults.ts │ ├── index.ts │ └── options.ts ├── CacheRouteHandler │ ├── BodyFile.ts │ ├── HeadersFile.ts │ ├── PwApiResponse.ts │ ├── SyntheticApiResponse.ts │ └── index.ts ├── debug.ts ├── index.ts └── utils.ts ├── test ├── app │ ├── cat1.webp │ ├── cat2.png │ ├── index.html │ └── server.ts ├── fixtures.ts ├── global-setup.ts └── specs │ ├── forceUpdate.spec.ts │ ├── index.spec.ts │ ├── modify.spec.ts │ └── noCache.spec.ts ├── tsconfig.build.json └── tsconfig.json /.github/workflows/example.yaml: -------------------------------------------------------------------------------- 1 | name: example 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [main, beta] 7 | schedule: 8 | # run daily at 00:00 9 | - cron: 0 0 * * * 10 | 11 | jobs: 12 | examples: 13 | strategy: 14 | matrix: 15 | os: [ubuntu-22.04, windows-latest] 16 | node-version: [18, 20] 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'npm' 24 | - run: npm ci 25 | - run: npx playwright install --with-deps chromium 26 | - run: npm run example 27 | # run example second time with cache 28 | - run: npm run example 29 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | cache: 'npm' 16 | - run: npm ci 17 | - run: npm run lint 18 | - run: npm run prettier 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [main, beta] 7 | schedule: 8 | # run daily at 00:00 9 | - cron: 0 0 * * * 10 | 11 | jobs: 12 | get-playwright-versions: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/github-script@v7 16 | id: get-versions 17 | with: 18 | script: | 19 | const minimalVersion = '1.35'; 20 | const { execSync } = require('node:child_process'); 21 | const info = execSync('npm show --json @playwright/test').toString(); 22 | const { versions } = JSON.parse(info); 23 | return versions 24 | .filter((v) => v.match(/\.0$/) && v >= minimalVersion) 25 | .map((v) => v.replace(/\.0$/, '')) 26 | .concat([ 'beta' ]); 27 | outputs: 28 | versions: ${{ steps.get-versions.outputs.result }} 29 | 30 | test: 31 | needs: get-playwright-versions 32 | strategy: 33 | matrix: 34 | playwrightVersion: ${{ fromJson(needs.get-playwright-versions.outputs.versions) }} 35 | os: [ubuntu-22.04] 36 | include: 37 | - playwrightVersion: latest 38 | os: windows-latest 39 | runs-on: ${{ matrix.os }} 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: actions/setup-node@v4 43 | with: 44 | node-version: 18 45 | cache: 'npm' 46 | - run: npm ci 47 | - run: npm install @playwright/test@${{ matrix.playwrightVersion }} 48 | - run: npx playwright install --with-deps chromium 49 | - run: npm test 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | npm-debug.log 8 | 9 | # my 10 | /dist 11 | test-results 12 | playwright-report 13 | /todo.txt 14 | .network-cache 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | playwright-report 3 | **/*.md 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | > This project follows the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) approach. 4 | 5 | ## [Unreleased] 6 | 7 | ## [0.2.2] - 2025-03-02 8 | * Support Playwright 1.51 9 | 10 | ## [0.2.1] 11 | * Handle requests after page is closed 12 | 13 | ## [0.2.0] 14 | * New api released (breaking) 15 | 16 | [unreleased]: https://github.com/vitalets/playwright-network-cache/compare/v0.2.2...HEAD 17 | [0.2.2]: https://github.com/vitalets/playwright-network-cache/compare/v0.2.1...v0.2.2 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vitaliy Potapov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # playwright-network-cache 2 | [![lint](https://github.com/vitalets/playwright-network-cache/actions/workflows/lint.yaml/badge.svg)](https://github.com/vitalets/playwright-network-cache/actions/workflows/lint.yaml) 3 | [![test](https://github.com/vitalets/playwright-network-cache/actions/workflows/test.yaml/badge.svg)](https://github.com/vitalets/playwright-network-cache/actions/workflows/test.yaml) 4 | [![npm version](https://img.shields.io/npm/v/playwright-network-cache)](https://www.npmjs.com/package/playwright-network-cache) 5 | [![license](https://img.shields.io/npm/l/playwright-network-cache)](https://github.com/vitalets/playwright-network-cache/blob/main/LICENSE) 6 | 7 | Speed up [Playwright](https://playwright.dev/) tests by caching network requests on the filesystem. 8 | 9 | #### ✨ Features 10 | 11 | - Automatically cache network requests during test execution 12 | - Save responses to the filesystem in a clear, organized structure 13 | - Modify cached responses dynamically during runtime 14 | - Reuse cached data across multiple test runs 15 | - Configure TTL to automatically refresh the cache and keep responses up-to-date 16 | - View response bodies in a pretty formatted JSON 17 | - No need for manual mocks management 18 | - No mess with the HAR format — see [motivation](#motivation) 19 | 20 | Example of cache structure: 21 | ``` 22 | .network-cache 23 | └── example.com 24 | └── api-cats 25 | └── GET 26 | ├── headers.json 27 | └── body.json 28 | ``` 29 | 30 | ## Index 31 | 32 | - [Installation](#installation) 33 | - [Basic usage](#basic-usage) 34 | - [Examples](#examples) 35 | - [Invalidate cache once in a hour](#invalidate-cache-once-in-a-hour) 36 | - [Modify cached response](#modify-cached-response) 37 | - [Disable cache](#disable-cache) 38 | - [Force cache update](#force-cache-update) 39 | - [Auto-cache request for all tests](#auto-cache-request-for-all-tests) 40 | - [Additional match by HTTP status](#additional-match-by-http-status) 41 | - [Additional match by request fields](#additional-match-by-request-fields) 42 | - [Split cache by test title](#split-cache-by-test-title) 43 | - [Split cache by request URL params](#split-cache-by-request-url-params) 44 | - [Split cache by request body](#split-cache-by-request-body) 45 | - [Change base dir](#change-base-dir) 46 | - [Multi-step cache in complex scenarios](#multi-step-cache-in-complex-scenarios) 47 | - [API](#api) 48 | - [Constructor](#constructor) 49 | - [Methods](#methods) 50 | - [Options](#options) 51 | - [Debug](#debug) 52 | - [Motivation](#motivation) 53 | - [Alternatives](#alternatives) 54 | - [Changelog](#changelog) 55 | - [Feedback](#feedback) 56 | - [License](#license) 57 | 58 | 59 | ## Installation 60 | Install from npm: 61 | ``` 62 | npm i -D playwright-network-cache 63 | ``` 64 | 65 | ## Basic usage 66 | 67 | #### 1. Setup `cacheRoute` fixture 68 | 69 | Extend Playwright's `test` instance with `cacheRoute` fixture: 70 | ```ts 71 | // fixtures.ts 72 | import { test as base } from '@playwright/test'; 73 | import { CacheRoute } from 'playwright-network-cache'; 74 | 75 | type Fixtures = { 76 | cacheRoute: CacheRoute; 77 | }; 78 | 79 | export const test = base.extend({ 80 | cacheRoute: async ({ page }, use) => { 81 | await use(new CacheRoute(page, { /* cache options */ })); 82 | }, 83 | }); 84 | ``` 85 | 86 | #### 2. Use `cacheRoute` inside test 87 | For example, to cache a GET request to `https://example.com/api/cats`: 88 | ```ts 89 | // test.ts 90 | test('test', async ({ page, cacheRoute }) => { 91 | await cacheRoute.GET('https://example.com/api/cats*'); 92 | // ... perform usual test actions 93 | }); 94 | ``` 95 | 96 | On the first run, the test will hit real API and store the response on the filesystem: 97 | ``` 98 | .network-cache 99 | └── example.com 100 | └── api-cats 101 | └── GET 102 | ├── headers.json 103 | └── body.json 104 | ``` 105 | All subsequent test runs will re-use cached response and execute much faster. You can invalidate that cache by manually deleting the files. Or provide `ttlMinutes` option to hit real API once in some period of time. 106 | 107 | You can call `cacheRoute.GET|POST|PUT|PATCH|DELETE|ALL` to cache routes with respective HTTP method. Url can contain `*` or `**` to match url segments and query params, see [url pattern](https://playwright.dev/docs/api/class-page#page-route-option-url). 108 | 109 | To catch requests targeting your own app APIs, you can omit hostname in url: 110 | ```ts 111 | test('test', async ({ page, cacheRoute }) => { 112 | await cacheRoute.GET('/api/cats*'); 113 | // ... 114 | }); 115 | ``` 116 | 117 | Default cache path is: 118 | ``` 119 | {baseDir}/{hostname}/{pathname}/{httpMethod}/{extraDir}/{httpStatus} 120 | ``` 121 | 122 | See more examples below or check [configuration options](#options). 123 | 124 | ## Examples 125 | 126 | ### Invalidate cache once in a hour 127 | 128 |
129 | Click to expand 130 | 131 | To keep response data up-to-date, you can automatically invalidate cache after configured time period. Set `ttlMinutes` option to desired value in minutes: 132 | ```ts 133 | test('test', async ({ page, cacheRoute }) => { 134 | await cacheRoute.GET('/api/cats', { 135 | ttlMinutes: 60 // hit real API once in a hour 136 | }); 137 | // ... 138 | }); 139 | ``` 140 |
141 | 142 | ### Modify cached response 143 | 144 |
145 | Click to expand 146 | 147 | You can modify the cached response by setting the `modify` option to a custom function. In this function, you retrieve the response data, make your changes, and then call [`route.fulfill`](https://playwright.dev/docs/mock#modify-api-responses) with the updated data. 148 | ```ts 149 | test('test', async ({ page, cacheRoute }) => { 150 | await cacheRoute.GET('/api/cats', { 151 | modify: async (route, response) => { 152 | const json = await response.json(); 153 | json[0].name = 'Kitty-1'; 154 | await route.fulfill({ json }); 155 | } 156 | }); 157 | // ... 158 | }); 159 | ``` 160 | For modifying JSON responses, there is a helper option `modifyJSON`: 161 | ```ts 162 | test('test', async ({ page, cacheRoute }) => { 163 | await cacheRoute.GET('/api/cats', { 164 | modifyJSON: (json) => { 165 | json[0].name = 'Kitty'; 166 | }, 167 | }); 168 | // ... 169 | }); 170 | ``` 171 | `modifyJSON` can modify response json in-place (like above) or return some result, which will overwrite the original data. 172 |
173 | 174 | ### Disable cache 175 | 176 |
177 | Click to expand 178 | 179 | To disable cache in a **single test**, set `cacheRoute.options.noCache` to `true`: 180 | ```ts 181 | test('test', async ({ page, cacheRoute }) => { 182 | cacheRoute.options.noCache = true; 183 | await cacheRoute.GET('/api/cats'); // <- this will not cache the request 184 | // ... 185 | }); 186 | ``` 187 | 188 | To disable cache in **all tests**, set the `noCache` option to `true` in the fixture: 189 | ```ts 190 | export const test = base.extend<{ cacheRoute: CacheRoute }>({ 191 | cacheRoute: async ({ page }, use, testInfo) => { 192 | await use(new CacheRoute(page, { 193 | noCache: true 194 | })); 195 | } 196 | }); 197 | ``` 198 | 199 | > **Note:** When cache is disabled, `modify` functions still run 200 | 201 |
202 | 203 | ### Force cache update 204 | 205 |
206 | Click to expand 207 | 208 | To force updating cache files for a **single test**, set `cacheRoute.options.forceUpdate` to `true`: 209 | ```ts 210 | test('test', async ({ page, cacheRoute }) => { 211 | cacheRoute.options.forceUpdate = true; 212 | await cacheRoute.GET('/api/cats'); 213 | // ... 214 | }); 215 | ``` 216 | 217 | To force updating cache files for **all tests**, set the `forceUpdate` option to `true` in the fixture: 218 | ```ts 219 | export const test = base.extend<{ cacheRoute: CacheRoute }>({ 220 | cacheRoute: async ({ page }, use, testInfo) => { 221 | await use(new CacheRoute(page, { 222 | forceUpdate: true 223 | })); 224 | } 225 | }); 226 | ``` 227 |
228 | 229 | ### Auto-cache request for all tests 230 | 231 |
232 | Click to expand 233 | 234 | You can setup caching of some request for all tests. Define `cacheRoute` as [auto fixture](https://playwright.dev/docs/test-fixtures#automatic-fixtures) and setup cached routes: 235 | ```ts 236 | export const test = base.extend<{ cacheRoute: CacheRoute }>({ 237 | cacheRoute: [async ({ page }, use) => { 238 | const cacheRoute = new CacheRoute(page); 239 | await cacheRoute.GET('/api/cats'); 240 | await use(cacheRoute); 241 | }, { auto: true }] 242 | }); 243 | ``` 244 |
245 | 246 | ### Additional match by HTTP status 247 | 248 |
249 | Click to expand 250 | 251 | By default, only responses with `2xx` status are considered valid and stored in cache. 252 | To test error responses, provide additional `httpStatus` option to cache route: 253 | 254 | ```ts 255 | test('test', async ({ page, cacheRoute }) => { 256 | await cacheRoute.GET('/api/cats', { 257 | httpStatus: 500 258 | }); 259 | // ... 260 | }); 261 | ``` 262 | Now error response will be cached in the following structure: 263 | ``` 264 | .network-cache 265 | └── example.com 266 | └── api-cats 267 | └── GET 268 | └── 500 269 | ├── headers.json 270 | └── body.json 271 | ``` 272 |
273 | 274 | ### Additional match by request fields 275 | 276 |
277 | Click to expand 278 | 279 | By default, requests are matched by: 280 | ``` 281 | HTTP method + URL pattern + (optionally) HTTP status 282 | ``` 283 | If you need to match by other request fields, provide custom function to `match` option. 284 | Example of matching GET requests with query param `/api/cats?foo=bar`: 285 | 286 | ```ts 287 | test('test', async ({ page, cacheRoute }) => { 288 | await cacheRoute.GET('/api/cats*', { 289 | match: req => new URL(req.url()).searchParams.get('foo') === 'bar' 290 | }); 291 | // ... 292 | }); 293 | ``` 294 | 295 | > Notice `*` in `/api/cats*` to match query params 296 | 297 |
298 | 299 | ### Split cache by test title 300 | 301 |
302 | Click to expand 303 | 304 | By default, cached responses are stored in a shared directory and re-used across tests. 305 | If you want to isolate cache files for a particular test, utilize `cacheRoute.options.extraDir` - an array of extra directories to be inserted into the cache path: 306 | 307 | ```ts 308 | test('test', async ({ page, cacheRoute }) => { 309 | cacheRoute.options.extraDir.push('custom-test'); 310 | await cacheRoute.GET('/api/cats'); 311 | // ... 312 | }); 313 | ``` 314 | Generated cache structure: 315 | ``` 316 | .network-cache 317 | └── example.com 318 | └── api-cats 319 | └── GET 320 | └── custom-test # <- extra directory 321 | ├── headers.json 322 | └── body.json 323 | ``` 324 | You can freely transform `extraDir` during the test and create nested directories if needed. 325 | 326 | To automatically store cache files in a separate directories for **each test**, 327 | you can set `extraDir` option in a fixture setup: 328 | ```ts 329 | export const test = base.extend<{ cacheRoute: CacheRoute }>({ 330 | cacheRoute: async ({ page }, use, testInfo) => { 331 | await use(new CacheRoute(page, { 332 | extraDir: testInfo.title // <- use testInfo.title as a unique extraDir 333 | })); 334 | } 335 | }); 336 | ``` 337 | After running two tests with titles `custom test 1` and `custom test 2`, 338 | the generated structure is: 339 | ``` 340 | .network-cache 341 | └── example.com 342 | └── api-cats 343 | └── GET 344 | ├── custom-test-1 345 | │ ├── headers.json 346 | │ └── body.json 347 | └── custom-test-2 348 | ├── headers.json 349 | └── body.json 350 | ``` 351 |
352 | 353 | ### Split cache by request URL params 354 | 355 |
356 | Click to expand 357 | 358 | To split cache by request query params, you can set `extraDir` to a function. It accepts `request` as a first argument and gives access to any prop of the request: 359 | ```ts 360 | test('test', async ({ page, cacheRoute }) => { 361 | await cacheRoute.GET('/api/cats*', { 362 | extraDir: req => new URL(req.url()).searchParams.toString() 363 | }); 364 | // ... 365 | }); 366 | ``` 367 | 368 | > Notice `*` in `/api/cats*` to match query params 369 | 370 | Given the following requests: 371 | ``` 372 | GET /api/cats?foo=1 373 | GET /api/cats?foo=2 374 | ``` 375 | Cache structure will be: 376 | ``` 377 | .network-cache 378 | └── example.com 379 | └── api-cats 380 | └── GET 381 | ├── foo=1 382 | │ ├── headers.json 383 | │ └── body.json 384 | └── foo=2 385 | ├── headers.json 386 | └── body.json 387 | ``` 388 |
389 | 390 | ### Split cache by request body 391 | 392 |
393 | Click to expand 394 | 395 | To split cache by request body, you can set `extraDir` to a function. It accepts `request` as a first argument and gives access to any prop of the request: 396 | ```ts 397 | test('test', async ({ page, cacheRoute }) => { 398 | await cacheRoute.GET('/api/cats', { 399 | extraDir: req => req.postDataJSON().email 400 | }); 401 | // ... 402 | }); 403 | ``` 404 | Having the following requests: 405 | ``` 406 | POST -d '{"email":"user1@example.com"}' /api/cats 407 | POST -d '{"email":"user2@example.com"}' /api/cats 408 | ``` 409 | Cache structure will be: 410 | ``` 411 | .network-cache 412 | └── example.com 413 | └── api-cats 414 | └── POST 415 | ├── user1@example.com 416 | │ ├── headers.json 417 | │ └── body.json 418 | └── user2@example.com 419 | ├── headers.json 420 | └── body.json 421 | ``` 422 |
423 | 424 | ### Change base dir 425 | 426 |
427 | Click to expand 428 | 429 | By default, cache files are stored in `.network-cache` base directory. To change this location, set `baseDir` option: 430 | 431 | ```ts 432 | export const test = base.extend<{ cacheRoute: CacheRoute }>({ 433 | cacheRoute: async ({ page }, use, testInfo) => { 434 | await use(new CacheRoute(page, { 435 | baseDir: `test/.network-cache` 436 | })); 437 | } 438 | }); 439 | ``` 440 | Moreover, you can set separate `baseDir` for each Playwright project or each test: 441 | ```ts 442 | export const test = base.extend<{ cacheRoute: CacheRoute }>({ 443 | cacheRoute: async ({ page }, use, testInfo) => { 444 | await use(new CacheRoute(page, { 445 | baseDir: `test/.network-cache/${testInfo.project.name}` 446 | })); 447 | } 448 | }); 449 | ``` 450 | Example of generated structure 451 | ``` 452 | .network-cache 453 | ├── project-one 454 | │ └── example.com 455 | │ └── api-cats 456 | │ └── GET 457 | │ ├── headers.json 458 | │ └── body.json 459 | └── project-two 460 | └── example.com 461 | └── api-cats 462 | └── GET 463 | ├── headers.json 464 | └── body.json 465 | 466 | ``` 467 | 468 | > In that example, you get more isolation, but less cache re-use. It's a trade-off, as always 🤷‍♂️ 469 | 470 |
471 | 472 | ### Multi-step cache in complex scenarios 473 | 474 |
475 | Click to expand 476 | 477 | For complex scenarios, you may want to have different cached responses for the same API. Example: adding a new todo item into the todo list. 478 | 479 | With caching in mind, the plan for such test can be the following: 480 | 481 | 1. Set cache for GET request to load original todo items 482 | 2. Open the todo items page 483 | 3. Set cache for POST request to create new todo item 484 | 4. Set cache for GET request to load updated todo items 485 | 5. Enter todo text and click "Add" button 486 | 6. Assert todo list is updated 487 | 488 | The implementation utilizes `extraDir` option to dynamically change cache path in the test: 489 | 490 | ```ts 491 | test('adding todo', async ({ page, cacheRoute }) => { 492 | // set cache for GET request to load todo items 493 | await cacheRoute.GET('/api/todo'); 494 | 495 | // ...load page 496 | 497 | // CHECKPOINT: change cache dir, all subsequent requests will be cached in `after-add` dir 498 | cacheRoute.options.extraDir.push('after-add'); 499 | 500 | // set cache for POST request to create a todo item 501 | await cacheRoute.POST('/api/todo'); 502 | 503 | // ...add todo item 504 | // ...reload page 505 | // ...assert todo list is updated 506 | }); 507 | ``` 508 | Generated cache structure: 509 | ``` 510 | .network-cache 511 | └── example.com 512 | └── api-todo 513 | ├── GET 514 | │ ├── headers.json 515 | │ ├── body.json 516 | │ └── after-add 517 | │ ├── headers.json 518 | │ └── body.json 519 | └── POST 520 | └── after-add 521 | ├── headers.json 522 | └── body.json 523 | ``` 524 | 525 | > You may still modify cached responses to match test expectation. But it's better to make it as *replacement* modifications, not changing the structure of the response body. Keeping response structure unchanged is more "end-2-end" approach. 526 | 527 |
528 | 529 | ## API 530 | The `CacheRoute` class manages caching of routes for a Playwright `Page` or `BrowserContext`. It simplifies setting up HTTP method handlers for specific routes with caching options. 531 | 532 | ### Constructor 533 | 534 | ```ts 535 | const cacheRoute = new CacheRoute(page, options?) 536 | ``` 537 | 538 | - **page**: The Playwright `Page` or `BrowserContext` to manage routes. 539 | - **options**: Optional configuration to control caching behavior. 540 | 541 | ### Methods 542 | 543 | These methods enable caching for specific HTTP routes: 544 | 545 | - `cacheRoute.GET(url, optionsOrFn?)` 546 | - `cacheRoute.POST(url, optionsOrFn?)` 547 | - `cacheRoute.PUT(url, optionsOrFn?)` 548 | - `cacheRoute.PATCH(url, optionsOrFn?)` 549 | - `cacheRoute.DELETE(url, optionsOrFn?)` 550 | - `cacheRoute.HEAD(url, optionsOrFn?)` 551 | - `cacheRoute.ALL(url, optionsOrFn?)` 552 | 553 | #### Params 554 | - **url**: [Url pattern](https://playwright.dev/docs/api/class-page#page-route-option-url) 555 | - **optionsOrFn**: Caching options or a function to modify the response 556 | 557 | ### Options 558 | You can provide options to `CacheRoute` constructor or modify them dynamically via `cacheRoute.options`. All values are optional. 559 | 560 | #### baseDir 561 | `string` 562 | 563 | Base directory for cache files. 564 | 565 | #### extraDir 566 | `string | string[] | ((req: Request) => string | string[])` 567 | 568 | Additional directory for cache files. Can be a string, array of strings, or a function that accepts a request and returns a string or an array of strings. 569 | 570 | #### match 571 | `(req: Request) => boolean` 572 | 573 | Function to add additional matching logic for requests. Returns `true` to cache, or `false` to skip. 574 | 575 | #### httpStatus 576 | `number` 577 | 578 | Cache responses with the specified HTTP status code. 579 | 580 | #### ttlMinutes 581 | `number` 582 | 583 | Time to live for cached responses, in minutes. 584 | 585 | #### overrides 586 | `RequestOverrides | ((req: Request) => RequestOverrides)` 587 | 588 | Object or function that provides request [overrides](https://playwright.dev/docs/api/class-route#route-fetch) (e.g., headers, body) when making real calls. 589 | 590 | #### modify 591 | `(route: Route, response: APIResponse) => Promise` 592 | 593 | Function to modify the response before caching. This is called for each route. 594 | 595 | #### modifyJSON 596 | `(json: any) => any` 597 | 598 | Helper function to modify JSON responses before caching. 599 | 600 | #### noCache 601 | `boolean` 602 | 603 | If `true`, disables caching and always makes requests to the server. 604 | 605 | #### forceUpdate 606 | `boolean` 607 | 608 | If `true`, always requests from the server and updates the cached files. 609 | 610 | #### buildCacheDir 611 | `(ctx: BuildCacheDirArg) => string[]` 612 | 613 | Function to build a custom cache directory, providing fine-grained control over the cache file location. 614 | [Default implementation](https://github.com/vitalets/playwright-network-cache/blob/main/src/CacheRoute/defaults.ts). 615 | 616 | ## Debug 617 | To debug caching, run Playwright with the following `DEBUG` environment variable: 618 | ```bash 619 | DEBUG=playwright-network-cache npx playwright test 620 | ``` 621 | 622 | ## Motivation 623 | Playwright has built-in [support for HAR format](https://playwright.dev/docs/mock#mocking-with-har-files) to record and replay network requests. 624 | But when you need more fine-grained control of network, it becomes messy. Check out these issues where people struggle with HAR: 625 | 626 | - [#21405](https://github.com/microsoft/playwright/issues/21405) 627 | - [#30754](https://github.com/microsoft/playwright/issues/30754) 628 | - [#29190](https://github.com/microsoft/playwright/issues/29190) 629 | 630 | This library intentionally does not use HAR. Instead, it generates file-based cache structure, giving you full control of what and how is cached. 631 | 632 | ## Alternatives 633 | Alternatively, you can check the following packages: 634 | * [playwright-intercept](https://github.com/alectrocute/playwright-intercept) - uses Cypress-influenced API 635 | * [playwright-advanced-har](https://github.com/NoamGaash/playwright-advanced-har) - uses HAR format 636 | * [playwright-request-mocker](https://github.com/kousenlsn/playwright-request-mocker) uses HAR format, looks abandoned 637 | 638 | ## Changelog 639 | See [CHANGELOG.md](./CHANGELOG.md). 640 | 641 | ## Feedback 642 | Feel free to share your feedback and suggestions in [issues](https://github.com/vitalets/playwright-network-cache/issues). 643 | 644 | ## License 645 | [MIT](https://github.com/vitalets/playwright-network-cache/blob/main/LICENSE) -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import js from '@eslint/js'; 3 | import tseslint from 'typescript-eslint'; 4 | import playwright from 'eslint-plugin-playwright'; 5 | import visualComplexity from 'eslint-plugin-visual-complexity'; 6 | 7 | export default [ 8 | { 9 | ignores: ['dist'], 10 | }, 11 | js.configs.recommended, 12 | ...tseslint.configs.recommended, 13 | { 14 | languageOptions: { 15 | globals: globals.node, 16 | }, 17 | }, 18 | // all files 19 | { 20 | files: ['**/*.ts'], 21 | rules: { 22 | 'no-console': 'error', 23 | }, 24 | }, 25 | // src files 26 | { 27 | files: ['src/**/*.ts'], 28 | plugins: { 29 | visual: visualComplexity, 30 | }, 31 | rules: { 32 | 'visual/complexity': ['error', { max: 5 }], 33 | complexity: 0, 34 | 35 | 'max-depth': ['error', { max: 2 }], 36 | 'max-nested-callbacks': ['error', { max: 2 }], 37 | 'max-params': ['error', { max: 3 }], 38 | 'max-statements': ['error', { max: 12 }, { ignoreTopLevelFunctions: false }], 39 | 'max-len': ['error', { code: 120, ignoreUrls: true }], 40 | 'max-lines': ['error', { max: 200, skipComments: true, skipBlankLines: true }], 41 | semi: ['error', 'always'], 42 | 'no-multiple-empty-lines': ['error', { max: 1 }], 43 | 'space-before-function-paren': [ 44 | 'error', 45 | { anonymous: 'always', named: 'never', asyncArrow: 'always' }, 46 | ], 47 | '@typescript-eslint/triple-slash-reference': 0, 48 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 49 | 'no-undef': 0, 50 | 'no-empty-pattern': 0, 51 | }, 52 | }, 53 | { 54 | files: ['example/**/*.ts'], 55 | rules: { 56 | 'max-statements': 0, 57 | complexity: 0, 58 | }, 59 | }, 60 | { 61 | files: ['test/**/*.{ts,js}'], 62 | plugins: { 63 | playwright, 64 | }, 65 | rules: { 66 | 'max-params': 0, 67 | 'max-statements': 0, 68 | 'no-empty-pattern': 0, 69 | complexity: 0, 70 | '@typescript-eslint/no-empty-function': 0, 71 | 'playwright/no-focused-test': 'error', 72 | }, 73 | }, 74 | ]; 75 | -------------------------------------------------------------------------------- /example/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | 3 | const baseURL = `http://localhost:4321`; 4 | 5 | export default defineConfig({ 6 | testDir: 'test', 7 | fullyParallel: true, 8 | reporter: [['html', { open: 'never' }]], 9 | use: { 10 | baseURL, 11 | screenshot: 'only-on-failure', 12 | }, 13 | webServer: { 14 | command: 'npx ts-node ./src/server', 15 | url: baseURL, 16 | env: { 17 | PORT: new URL(baseURL).port, 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cat Info 7 | 41 | 42 | 43 |

Cats Info Page

44 | 45 |
46 |
47 | 48 | 49 |
    50 |
    51 | 52 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /example/src/server.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import fs from 'fs/promises'; 3 | import path from 'path'; 4 | import timers from 'timers/promises'; 5 | 6 | const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4321; 7 | const logger = console; 8 | 9 | export interface Cat { 10 | id: number; 11 | name: string; 12 | breed: string; 13 | age: number; 14 | } 15 | 16 | const cats: Cat[] = [ 17 | { id: 1, name: 'Whiskers', breed: 'Siamese', age: 2 }, 18 | { id: 2, name: 'Mittens', breed: 'Maine Coon', age: 3 }, 19 | { id: 3, name: 'Shadow', breed: 'Russian Blue', age: 4 }, 20 | ]; 21 | 22 | const server = http.createServer(async (req, res) => { 23 | logger.log(req.method, req.url); 24 | 25 | if (req.method === 'GET' && req.url === '/') { 26 | const filePath = path.join(__dirname, 'index.html'); 27 | const content = await fs.readFile(filePath); 28 | res.setHeader('Content-Type', 'text/html'); 29 | res.end(content); 30 | return; 31 | } 32 | 33 | // get cats 34 | if (req.method === 'GET' && req.url === '/api/cats') { 35 | await timers.setTimeout(1000); // delay 36 | res.setHeader('Content-Type', 'application/json'); 37 | res.end(JSON.stringify(cats)); 38 | return; 39 | } 40 | 41 | // add cat 42 | if (req.method === 'POST' && req.url?.startsWith('/api/cats')) { 43 | const name = new URLSearchParams(req.url.split('?')[1]).get('name'); 44 | if (!name) { 45 | res.statusCode = 400; 46 | res.setHeader('Content-Type', 'application/json'); 47 | res.end(JSON.stringify({ error: 'Name is required' })); 48 | } else { 49 | cats.unshift({ id: cats.length + 1, name, breed: 'Just Added', age: 42 }); 50 | res.setHeader('Content-Type', 'application/json'); 51 | res.end(JSON.stringify({ ok: true })); 52 | } 53 | 54 | return; 55 | } 56 | 57 | res.statusCode = 404; 58 | res.setHeader('Content-Type', 'text/plain'); 59 | res.end('Not Found'); 60 | }); 61 | 62 | server.listen(PORT, () => { 63 | logger.log(`Server running at http://localhost:${PORT}/`); 64 | }); 65 | -------------------------------------------------------------------------------- /example/test/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test'; 2 | import { CacheRoute } from '../../src'; 3 | 4 | export const test = base.extend<{ cacheRoute: CacheRoute }>({ 5 | cacheRoute: async ({ page }, use) => { 6 | await use(new CacheRoute(page)); 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /example/test/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test'; 2 | import { test } from './fixtures'; 3 | 4 | test('load cats', async ({ page, cacheRoute }) => { 5 | await cacheRoute.GET('/api/cats*'); 6 | 7 | await page.goto('/'); 8 | await expect(page.getByRole('list')).toContainText('Whiskers'); 9 | }); 10 | 11 | test('add cat (success)', async ({ page, cacheRoute }) => { 12 | await cacheRoute.ALL('/api/cats*'); 13 | 14 | await page.goto('/'); 15 | await expect(page.getByRole('list')).toContainText('Whiskers'); 16 | 17 | cacheRoute.options.extraDir.push('after-add-cat'); 18 | 19 | await page.getByRole('textbox').fill('Tomas'); 20 | await page.getByRole('button', { name: 'Add Cat' }).click(); 21 | 22 | await expect(page.getByRole('list')).toContainText('Tomas'); 23 | }); 24 | 25 | test('add cat (error)', async ({ page, cacheRoute }, testInfo) => { 26 | cacheRoute.options.extraDir.push(testInfo.title); 27 | await cacheRoute.GET('/api/cats*'); 28 | 29 | await page.goto('/'); 30 | await expect(page.getByRole('list')).toContainText('Whiskers'); 31 | 32 | await cacheRoute.POST('/api/cats*', { httpStatus: 400 }); 33 | 34 | await page.getByRole('textbox').fill(''); 35 | await page.getByRole('button', { name: 'Add Cat' }).click(); 36 | 37 | await expect(page.getByRole('alert')).toContainText('Name is required'); 38 | }); 39 | -------------------------------------------------------------------------------- /lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | '*.{ts,js}': ['eslint --fix', 'prettier --write --ignore-unknown'], 3 | '!(*.{ts,js})': ['prettier --write --ignore-unknown'], 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playwright-network-cache", 3 | "description": "Cache network requests in Playwright tests", 4 | "version": "0.2.2", 5 | "type": "commonjs", 6 | "main": "./dist/index.js", 7 | "exports": { 8 | ".": "./dist/index.js" 9 | }, 10 | "engines": { 11 | "node": ">=18" 12 | }, 13 | "files": [ 14 | "dist", 15 | "src", 16 | "README.md" 17 | ], 18 | "scripts": { 19 | "prepare": "git config core.hooksPath scripts/git-hooks", 20 | "lint": "eslint .", 21 | "tsc": "tsc", 22 | "knip": "knip -c knip.config.ts", 23 | "prettier": "prettier --check --ignore-unknown .", 24 | "prettier:w": "prettier --write --ignore-unknown .", 25 | "test": "npx playwright test", 26 | "test:d": "DEBUG=playwright-network-cache npm test", 27 | "pw": "npm i --no-save @playwright/test@$PW", 28 | "browsers": "npx cross-env PLAYWRIGHT_SKIP_BROWSER_GC=1 npx playwright install chromium", 29 | "example": "npx playwright test -c example", 30 | "example:debug": "DEBUG=playwright-network-cache npx playwright test -c example", 31 | "example:nocache": "rm -rf .network-cache && npx playwright test -c example", 32 | "example:serve": "npx ts-node ./example/src/server", 33 | "toc": "md-magic --files README.md", 34 | "build": "rm -rf dist && tsc -p tsconfig.build.json", 35 | "release": "release-it" 36 | }, 37 | "release-it": { 38 | "$schema": "https://unpkg.com/release-it/schema/release-it.json", 39 | "git": { 40 | "requireCleanWorkingDir": false 41 | }, 42 | "hooks": { 43 | "before:init": [ 44 | "npm run lint", 45 | "npm run prettier", 46 | "npm ci", 47 | "npm test", 48 | "npm run example", 49 | "npm run build" 50 | ] 51 | }, 52 | "plugins": { 53 | "@release-it/keep-a-changelog": { 54 | "filename": "CHANGELOG.md", 55 | "addUnreleased": true, 56 | "addVersionUrl": true 57 | } 58 | } 59 | }, 60 | "dependencies": { 61 | "debug": "^4.4.0", 62 | "mime-types": "2.1.35" 63 | }, 64 | "devDependencies": { 65 | "@eslint/js": "^9.21.0", 66 | "@playwright/test": "1.50", 67 | "@release-it/keep-a-changelog": "^6.0.0", 68 | "@types/debug": "4.1.12", 69 | "@types/mime-types": "2.1.4", 70 | "@types/node": "^18.15.0", 71 | "eslint": "^9.21.0", 72 | "eslint-plugin-playwright": "^2.2.0", 73 | "eslint-plugin-visual-complexity": "0.1.4", 74 | "globals": "^16.0.0", 75 | "knip": "^5.45.0", 76 | "lint-staged": "^15.4.3", 77 | "markdown-magic": "^3.4.0", 78 | "np": "^10.2.0", 79 | "prettier": "^3.5.2", 80 | "publint": "^0.3.7", 81 | "release-it": "^18.1.2", 82 | "ts-node": "^10.9.2", 83 | "typescript": "5.4.5", 84 | "typescript-eslint": "^8.25.0" 85 | }, 86 | "repository": { 87 | "type": "git", 88 | "url": "git://github.com/vitalets/playwright-network-cache.git" 89 | }, 90 | "keywords": [ 91 | "playwright", 92 | "network", 93 | "cache", 94 | "testing", 95 | "e2e" 96 | ], 97 | "funding": "https://github.com/sponsors/vitalets", 98 | "license": "MIT" 99 | } 100 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | 3 | const baseURL = 'http://localhost:3000'; 4 | 5 | export default defineConfig({ 6 | testDir: 'test', 7 | fullyParallel: true, 8 | globalSetup: './test/global-setup.ts', 9 | use: { 10 | baseURL, 11 | viewport: { width: 800, height: 600 }, 12 | }, 13 | expect: { 14 | timeout: 1000, 15 | }, 16 | webServer: { 17 | command: 'npx ts-node test/app/server', 18 | url: baseURL, 19 | reuseExistingServer: !process.env.CI, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | tabWidth: 2, 3 | useTabs: false, 4 | singleQuote: true, 5 | printWidth: 100, 6 | semi: true, 7 | trailingComma: 'all', 8 | bracketSpacing: true, 9 | }; 10 | -------------------------------------------------------------------------------- /scripts/git-hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Exit on any error 4 | set -euo pipefail 5 | 6 | if [[ -n "${SKIP_GIT_HOOKS-}" ]]; then exit 0; fi 7 | 8 | npx lint-staged --relative 9 | npm run tsc 10 | npm test -------------------------------------------------------------------------------- /scripts/git-hooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Exit on any error 4 | set -euo pipefail 5 | 6 | if [[ -n "${SKIP_GIT_HOOKS-}" ]]; then exit 0; fi 7 | 8 | npm run lint 9 | npm run prettier 10 | # npm run knip 11 | npm test 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on any error 4 | set -euo pipefail 5 | 6 | npm run lint 7 | npm run prettier 8 | npm ci 9 | npx npm test 10 | npm run example 11 | npm run build 12 | SKIP_GIT_HOOKS=1 npx np --yolo --no-release-draft --any-branch 13 | -------------------------------------------------------------------------------- /src/CacheRoute/defaults.ts: -------------------------------------------------------------------------------- 1 | import { CacheRouteOptions } from './options'; 2 | 3 | export const defaults = { 4 | baseDir: '.network-cache', 5 | buildCacheDir: (ctx) => [ 6 | ctx.hostname, // prettier-ignore 7 | ctx.pathname, 8 | ctx.httpMethod, 9 | ctx.extraDir, 10 | ctx.httpStatus, 11 | ], 12 | } satisfies Pick; 13 | -------------------------------------------------------------------------------- /src/CacheRoute/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CacheRoute class manages cached routes for the particular page. 3 | * 4 | * Note: 5 | * The name "CacheRoute" does not fully express the behavior, 6 | * b/c it's not about only single route. 7 | * But it's very semantic in usage: cacheRoute.GET('/api/cats') 8 | */ 9 | 10 | import { BrowserContext, Page } from '@playwright/test'; 11 | import { CacheRouteOptions } from './options'; 12 | import { defaults } from './defaults'; 13 | import { CacheRouteHandler } from '../CacheRouteHandler'; 14 | import { toArray } from '../utils'; 15 | 16 | type UrlPredicate = Parameters[0]; 17 | export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'ALL'; 18 | type CacheRouteOptionsOrFn = CacheRouteOptions | CacheRouteOptions['modify']; 19 | export type ResolvedCacheRouteOptions = ReturnType; 20 | 21 | export class CacheRoute { 22 | public options: ResolvedCacheRouteOptions; 23 | 24 | constructor( 25 | protected page: Page | BrowserContext, 26 | options: CacheRouteOptions = {}, 27 | ) { 28 | this.options = this.resolveConstructorOptions(options); 29 | } 30 | 31 | async GET(url: UrlPredicate, optionsOrFn?: CacheRouteOptionsOrFn) { 32 | await this.registerCachedRoute('GET', url, optionsOrFn); 33 | } 34 | 35 | async POST(url: UrlPredicate, optionsOrFn?: CacheRouteOptionsOrFn) { 36 | await this.registerCachedRoute('POST', url, optionsOrFn); 37 | } 38 | 39 | async PUT(url: UrlPredicate, optionsOrFn?: CacheRouteOptionsOrFn) { 40 | await this.registerCachedRoute('PUT', url, optionsOrFn); 41 | } 42 | 43 | async PATCH(url: UrlPredicate, optionsOrFn?: CacheRouteOptionsOrFn) { 44 | await this.registerCachedRoute('PATCH', url, optionsOrFn); 45 | } 46 | 47 | async DELETE(url: UrlPredicate, optionsOrFn?: CacheRouteOptionsOrFn) { 48 | await this.registerCachedRoute('DELETE', url, optionsOrFn); 49 | } 50 | 51 | async HEAD(url: UrlPredicate, optionsOrFn?: CacheRouteOptionsOrFn) { 52 | await this.registerCachedRoute('HEAD', url, optionsOrFn); 53 | } 54 | 55 | async ALL(url: UrlPredicate, optionsOrFn?: CacheRouteOptionsOrFn) { 56 | await this.registerCachedRoute('ALL', url, optionsOrFn); 57 | } 58 | 59 | protected async registerCachedRoute( 60 | httpMethod: HttpMethod, 61 | url: UrlPredicate, 62 | optionsOrFn?: CacheRouteOptionsOrFn, 63 | ) { 64 | await this.page.route(url, async (route) => { 65 | const options = this.resolveMethodOptions(optionsOrFn); 66 | try { 67 | await new CacheRouteHandler(httpMethod, route, options).handle(); 68 | } catch (e) { 69 | // ignore errors -> page can be already closed as route is not used in test 70 | if (await this.isPageClosed()) return; 71 | throw e; 72 | } 73 | }); 74 | } 75 | 76 | protected resolveConstructorOptions(options: CacheRouteOptions) { 77 | const extraDir = options.extraDir ? toArray(options.extraDir) : []; 78 | return { ...defaults, ...options, extraDir }; 79 | } 80 | 81 | protected resolveMethodOptions(optionsOrFn: CacheRouteOptionsOrFn = {}) { 82 | const methodOptions = typeof optionsOrFn === 'function' ? { modify: optionsOrFn } : optionsOrFn; 83 | 84 | // extraDir is the only prop that is merged, not overwritten 85 | const extraDir = this.options.extraDir.slice(); 86 | if (methodOptions.extraDir) { 87 | extraDir.push(...toArray(methodOptions.extraDir)); 88 | } 89 | 90 | return { ...this.options, ...methodOptions, extraDir }; 91 | } 92 | 93 | protected async isPageClosed() { 94 | return 'isClosed' in this.page ? this.page.isClosed() : !this.page?.pages().length; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/CacheRoute/options.ts: -------------------------------------------------------------------------------- 1 | import { APIResponse, Request, Route } from '@playwright/test'; 2 | 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | 5 | export type CacheRouteOptions = { 6 | /* Base directory for cache files */ 7 | baseDir?: string; 8 | /* Extra directory for cache files */ 9 | extraDir?: string | string[] | ((req: Request) => string | string[]); 10 | /* Additional matching function for request */ 11 | match?: (req: Request) => boolean | void; 12 | /* Match responses with particular HTTP status */ 13 | httpStatus?: number; 14 | /* Cache time to live (in minutest) */ 15 | ttlMinutes?: number; 16 | /* Request overrides when making real call */ 17 | overrides?: RequestOverrides | ((req: Request) => RequestOverrides); 18 | /* Modify response for test */ 19 | modify?: (route: Route, response: APIResponse) => Promise; 20 | /* Modify JSON response (helper) */ 21 | modifyJSON?: (json: any) => any; 22 | /* Disable caching, always request from server */ 23 | noCache?: boolean; 24 | /* Disable caching, always request from server and update cached files */ 25 | forceUpdate?: boolean; 26 | /** Function to build cache dir for fine-grained control */ 27 | buildCacheDir?: (ctx: BuildCacheDirArg) => (string | string[] | number | undefined)[]; 28 | }; 29 | 30 | export type BuildCacheDirArg = { 31 | hostname: string; 32 | pathname: string; 33 | httpMethod: string; 34 | extraDir?: string[]; 35 | httpStatus?: number; 36 | req: Request; 37 | }; 38 | 39 | type RequestOverrides = Parameters[0]; 40 | -------------------------------------------------------------------------------- /src/CacheRouteHandler/BodyFile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class representing body cache file. 3 | */ 4 | import fs from 'node:fs'; 5 | import path from 'node:path'; 6 | import mime from 'mime-types'; 7 | import { prettifyJson } from '../utils'; 8 | import { ResponseInfo } from './HeadersFile'; 9 | 10 | export class BodyFile { 11 | path: string; 12 | 13 | constructor( 14 | dir: string, 15 | private responseInfo: ResponseInfo, 16 | ) { 17 | this.path = path.join(dir, this.getFilename()); 18 | } 19 | 20 | private isJson() { 21 | return this.path.endsWith('.json'); 22 | } 23 | 24 | async read() { 25 | return fs.promises.readFile(this.path); 26 | } 27 | 28 | async save(body: Buffer) { 29 | const content = this.isJson() ? prettifyJson(body.toString('utf8')) : body; 30 | await this.ensureDir(); 31 | await fs.promises.writeFile(this.path, content); 32 | } 33 | 34 | private async ensureDir() { 35 | await fs.promises.mkdir(path.dirname(this.path), { recursive: true }); 36 | } 37 | 38 | private getFilename() { 39 | const contentType = this.responseInfo.headers['content-type']; 40 | const extension = mime.extension(contentType || '') || 'bin'; 41 | return `body.${extension}`; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/CacheRouteHandler/HeadersFile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class representing headers cache file. 3 | */ 4 | import fs from 'node:fs'; 5 | import path from 'node:path'; 6 | import { prettifyJson } from '../utils'; 7 | 8 | export type ResponseInfo = { 9 | url: string; 10 | status: number; 11 | statusText: string; 12 | headers: Record; 13 | }; 14 | 15 | export class HeadersFile { 16 | path: string; 17 | 18 | constructor(dir: string) { 19 | this.path = path.join(dir, 'headers.json'); 20 | } 21 | 22 | getLastModified() { 23 | return this.stat()?.mtimeMs || 0; 24 | } 25 | 26 | async read() { 27 | const content = await fs.promises.readFile(this.path, 'utf8'); 28 | return JSON.parse(content) as ResponseInfo; 29 | } 30 | 31 | save(responseInfo: ResponseInfo) { 32 | this.ensureDir(); 33 | fs.writeFileSync(this.path, prettifyJson(responseInfo)); 34 | } 35 | 36 | private ensureDir() { 37 | fs.mkdirSync(path.dirname(this.path), { recursive: true }); 38 | } 39 | 40 | private stat() { 41 | return fs.existsSync(this.path) ? fs.statSync(this.path) : null; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/CacheRouteHandler/PwApiResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extracting Playwright's APIResponse class. 3 | * Use require() with abs path, because this class is not exported. 4 | * See: https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/client/fetch.ts#L286 5 | */ 6 | import path from 'node:path'; 7 | 8 | // can't make it lazy, because it's used in the class extends 9 | export const PwApiResponse = getPlaywrightClientApi().APIResponse; 10 | 11 | function getPlaywrightClientApi() { 12 | const pwCoreRoot = getPlaywrightCoreRoot(); 13 | // eslint-disable-next-line @typescript-eslint/no-require-imports 14 | return require(`${pwCoreRoot}/lib/client/api`); 15 | } 16 | 17 | function getPlaywrightCoreRoot() { 18 | const pwCoreRoot = resolvePackageRoot('playwright-core'); 19 | if (!pwCoreRoot) { 20 | throw new Error('Cannot find playwright-core package. Please install @playwright/test'); 21 | } 22 | return pwCoreRoot; 23 | } 24 | 25 | function resolvePackageRoot(packageName: string) { 26 | const packageJsonPath = require.resolve(`${packageName}/package.json`); 27 | return path.dirname(packageJsonPath); 28 | } 29 | -------------------------------------------------------------------------------- /src/CacheRouteHandler/SyntheticApiResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class representing cached response in Playwright's APIResponse interface. 3 | */ 4 | import { APIResponse } from '@playwright/test'; 5 | import { PwApiResponse } from './PwApiResponse'; 6 | import { ResponseInfo } from './HeadersFile'; 7 | 8 | // Important to inherit from Playwright's APIResponse, 9 | // because route.fulfill() checks response via instance of: 10 | // https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/client/network.ts#L364 11 | export class SyntheticApiResponse extends PwApiResponse implements APIResponse { 12 | constructor( 13 | private info: ResponseInfo, 14 | private bodyBuffer: Buffer, 15 | ) { 16 | super({ _platform: {} }, { headers: [] }); 17 | } 18 | 19 | ok() { 20 | return this.status() >= 200 && this.status() < 300; 21 | } 22 | 23 | status() { 24 | return this.info.status; 25 | } 26 | 27 | statusText() { 28 | return this.info.statusText; 29 | } 30 | 31 | url() { 32 | return this.info.url; 33 | } 34 | 35 | headers() { 36 | return this.info.headers; 37 | } 38 | 39 | headersArray() { 40 | return Object.entries(this.info.headers).map(([name, value]) => ({ name, value })); 41 | } 42 | 43 | async body() { 44 | return this.bodyBuffer; 45 | } 46 | 47 | async text() { 48 | return (await this.body()).toString('utf8'); 49 | } 50 | 51 | async json() { 52 | return JSON.parse(await this.text()); 53 | } 54 | 55 | async dispose() {} 56 | async [Symbol.asyncDispose]() {} 57 | } 58 | -------------------------------------------------------------------------------- /src/CacheRouteHandler/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handle caching of particular request route. 3 | */ 4 | import path from 'node:path'; 5 | import { APIResponse, Request, Route } from '@playwright/test'; 6 | import { filenamify, toArray, toFunction, trimSlash } from '../utils'; 7 | import { HeadersFile, ResponseInfo } from './HeadersFile'; 8 | import { BodyFile } from './BodyFile'; 9 | import { SyntheticApiResponse } from './SyntheticApiResponse'; 10 | import { BuildCacheDirArg } from '../CacheRoute/options'; 11 | import { HttpMethod, ResolvedCacheRouteOptions } from '../CacheRoute'; 12 | import { debug } from '../debug'; 13 | 14 | export class CacheRouteHandler { 15 | private req: Request; 16 | private cacheDir: string = ''; 17 | private lastModified = 0; 18 | 19 | constructor( 20 | private httpMethod: HttpMethod, 21 | private route: Route, 22 | private options: ResolvedCacheRouteOptions, 23 | ) { 24 | this.req = route.request(); 25 | } 26 | 27 | // eslint-disable-next-line visual/complexity, max-statements 28 | async handle() { 29 | if (!this.isRequestMatched()) { 30 | await this.route.fallback(); 31 | return; 32 | } 33 | 34 | const { noCache, forceUpdate } = this.options; 35 | 36 | if (noCache) { 37 | const response = await this.fetchFromServer(); 38 | await this.fulfillRoute(response); 39 | return; 40 | } 41 | 42 | this.buildCacheDir(); 43 | this.storeLastModified(); 44 | 45 | const useRealRequest = forceUpdate || this.isExpired(); 46 | const response = useRealRequest 47 | ? await this.fetchFromServer() // prettier-ignore 48 | : await this.fetchFromCache(); 49 | 50 | if (useRealRequest && this.matchHttpStatus(response)) { 51 | await this.saveResponse(response); 52 | } 53 | 54 | await this.fulfillRoute(response); 55 | } 56 | 57 | private isRequestMatched() { 58 | return this.isRequestMatchedByMethod() && this.isRequestMatchedByFn(); 59 | } 60 | 61 | private isRequestMatchedByMethod() { 62 | return this.httpMethod === 'ALL' || this.httpMethod === this.req.method(); 63 | } 64 | 65 | private isRequestMatchedByFn() { 66 | const matchFn = this.options.match || (() => true); 67 | return matchFn(this.req); 68 | } 69 | 70 | private async fetchFromServer() { 71 | debug(`Fetching from server: ${this.req.method()} ${this.req.url()}`); 72 | const overrides = toFunction(this.options.overrides)(this.req); 73 | return this.route.fetch(overrides); 74 | } 75 | 76 | private async fetchFromCache() { 77 | debug(`Fetching from cache: ${this.req.method()} ${this.req.url()}`); 78 | const responseInfo = await new HeadersFile(this.cacheDir).read(); 79 | const bodyFile = new BodyFile(this.cacheDir, responseInfo); 80 | const body = await bodyFile.read(); 81 | return new SyntheticApiResponse(responseInfo, body); 82 | } 83 | 84 | private isExpired() { 85 | if (this.options.ttlMinutes === undefined) return !this.lastModified; 86 | const age = Date.now() - this.lastModified; 87 | return age > this.options.ttlMinutes * 60 * 1000; 88 | } 89 | 90 | private isUpdated() { 91 | const lastModified = new HeadersFile(this.cacheDir).getLastModified(); 92 | return lastModified > this.lastModified; 93 | } 94 | 95 | private async saveResponse(response: APIResponse) { 96 | const responseInfo: ResponseInfo = { 97 | url: response.url(), 98 | status: response.status(), 99 | statusText: response.statusText(), 100 | headers: response.headers(), 101 | }; 102 | const body = await response.body(); 103 | // file can be updated by another worker 104 | if (!this.isUpdated()) { 105 | debug(`Writing cache: ${this.cacheDir}`); 106 | new HeadersFile(this.cacheDir).save(responseInfo); 107 | new BodyFile(this.cacheDir, responseInfo).save(body); 108 | } else { 109 | debug(`Skip writing cache, updated by another worker: ${this.cacheDir}`); 110 | } 111 | } 112 | 113 | private async fulfillRoute(response: APIResponse) { 114 | const { modify, modifyJSON } = this.options; 115 | if (modify) { 116 | await modify(this.route, response); 117 | } else if (modifyJSON) { 118 | const origJson = await response.json(); 119 | const resJson = await modifyJSON(origJson); 120 | await this.route.fulfill({ json: resJson || origJson }); 121 | } else { 122 | await this.route.fulfill({ response }); 123 | } 124 | } 125 | 126 | private buildCacheDir() { 127 | const { hostname, pathname } = new URL(this.req.url()); 128 | const extraDir = toArray(this.options.extraDir || []) 129 | .map((item) => { 130 | return toFunction(item)(this.req); 131 | }) 132 | .flat(); 133 | 134 | const ctx: BuildCacheDirArg = { 135 | hostname, 136 | pathname, 137 | httpMethod: this.req.method(), 138 | extraDir, 139 | httpStatus: this.options.httpStatus, 140 | req: this.req, 141 | }; 142 | 143 | const dirs = this.options 144 | .buildCacheDir(ctx) 145 | .flat() 146 | .map((dir) => (dir ? filenamify(trimSlash(dir.toString())) : '')) 147 | .filter(Boolean); 148 | 149 | this.cacheDir = path.join(this.options.baseDir, ...dirs); 150 | } 151 | 152 | private matchHttpStatus(response: APIResponse) { 153 | const { httpStatus } = this.options; 154 | return httpStatus ? response.status() === httpStatus : response.ok(); 155 | } 156 | 157 | private storeLastModified() { 158 | this.lastModified = new HeadersFile(this.cacheDir).getLastModified(); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug'; 2 | 3 | export const debug = createDebug('playwright-network-cache'); 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { CacheRoute } from './CacheRoute'; 2 | export { filenamify } from './utils'; 3 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function prettifyJson(data: string | Record) { 2 | const obj = typeof data === 'string' ? JSON.parse(data) : data; 3 | return JSON.stringify(obj, null, 2); 4 | } 5 | 6 | const reRelativePath = /^\.+(\\|\/)|^\.+$/; 7 | // eslint-disable-next-line no-control-regex 8 | const filenameReservedRegex = /[<>:"/\\|?*\u0000-\u001F]/g; 9 | 10 | /** 11 | * See: https://github.com/sindresorhus/filenamify 12 | * 13 | * There is implementation in Playwright, it replaces more characters with "-", compared to filenamify. 14 | * At least, " " and ".". But for our case, it's not suitable. We need to keep "." in dir as file extension from URL. 15 | * E.g. caching "example.com/index.html" creates dir "index.html", not "index-html" 16 | * So, just replace spaces with "-". 17 | * See: https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/utils/fileUtils.ts#L55 18 | */ 19 | export function filenamify(s: string, replacement = '-') { 20 | return ( 21 | s 22 | .replace(reRelativePath, replacement) // prettier-ignore 23 | .replace(filenameReservedRegex, replacement) 24 | // Replace spaces with replacement, from Playwright 25 | .replace(/\s+/g, replacement) 26 | ); 27 | } 28 | 29 | export function toArray(value: T | T[]) { 30 | return Array.isArray(value) ? value : [value]; 31 | } 32 | 33 | export function toFunction(value: T | ((...args: K[]) => T)) { 34 | return typeof value === 'function' ? (value as (...args: K[]) => T) : () => value; 35 | } 36 | 37 | export function trimSlash(s: string) { 38 | return s.replace(/^\/+|\/+$/, ''); 39 | } 40 | -------------------------------------------------------------------------------- /test/app/cat1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitalets/playwright-network-cache/50c3bf026fe0ca90048c71f8913aefa2c2e8afc1/test/app/cat1.webp -------------------------------------------------------------------------------- /test/app/cat2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitalets/playwright-network-cache/50c3bf026fe0ca90048c71f8913aefa2c2e8afc1/test/app/cat2.png -------------------------------------------------------------------------------- /test/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cat Info 7 | 41 | 42 | 43 |

    Welcome to the Cat Info Page

    44 |

    This is a simple page serving cat information.

    45 |
    46 |

    Cat List

    47 |
      48 |
      49 | 50 | 51 |
      52 | 53 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /test/app/server.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import mime from 'mime-types'; 5 | import timers from 'timers/promises'; 6 | 7 | const PORT = 3000; 8 | 9 | export interface Cat { 10 | id: number; 11 | name: string; 12 | breed: string; 13 | age: number; 14 | } 15 | 16 | const cats: Cat[] = [ 17 | { id: 1, name: 'Whiskers', breed: 'Siamese', age: 2 }, 18 | { id: 2, name: 'Mittens', breed: 'Maine Coon', age: 3 }, 19 | { id: 3, name: 'Shadow', breed: 'Russian Blue', age: 4 }, 20 | ]; 21 | 22 | const server = http.createServer(async (req, res) => { 23 | if (req.method === 'GET' && req.url?.includes('/api/cats')) { 24 | if (req.url.includes('delay')) await timers.setTimeout(1000); 25 | res.setHeader('Content-Type', 'application/json; charset=utf-8'); 26 | res.end(JSON.stringify(cats)); 27 | return; 28 | } 29 | 30 | const urlFilePath = req.url && req.url.length > 1 ? path.join(__dirname, req.url) : null; 31 | const indexFilePath = path.join(__dirname, 'index.html'); 32 | const filePath = urlFilePath && fs.existsSync(urlFilePath) ? urlFilePath : indexFilePath; 33 | const content = await fs.promises.readFile(filePath); 34 | const contentType = mime.contentType(path.extname(filePath)) || 'application/octet-stream'; 35 | res.setHeader('Content-Type', contentType); 36 | res.end(content); 37 | }); 38 | 39 | server.listen(PORT, () => { 40 | // eslint-disable-next-line no-console 41 | console.log(`Server running at http://localhost:${PORT}/`); 42 | }); 43 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { test as base, expect, Page } from '@playwright/test'; 4 | import { CacheRoute, filenamify } from '../src'; 5 | 6 | type Fixtures = { 7 | cacheRoute: CacheRoute; 8 | json: (relPath: string) => Record; 9 | exists: (relPath: string) => boolean; 10 | resolve: (relPath: string) => string; 11 | }; 12 | 13 | export const test = base.extend({ 14 | cacheRoute: async ({ page }, use, testInfo) => { 15 | await use( 16 | new CacheRoute(page, { 17 | baseDir: `test/.network-cache/${filenamify(testInfo.title)}`, 18 | }), 19 | ); 20 | }, 21 | resolve: async ({ cacheRoute }, use) => { 22 | await use((relPath: string) => { 23 | return path.join(cacheRoute.options.baseDir, relPath); 24 | }); 25 | }, 26 | json: async ({ resolve }, use) => { 27 | await use((relPath: string) => { 28 | const fullPath = resolve(relPath); 29 | const content = fs.readFileSync(fullPath, 'utf8'); 30 | return JSON.parse(content); 31 | }); 32 | }, 33 | exists: async ({ resolve }, use) => { 34 | await use((relPath: string) => { 35 | const fullPath = resolve(relPath); 36 | return fs.existsSync(fullPath); 37 | }); 38 | }, 39 | }); 40 | 41 | export async function openHomePage(page: Page) { 42 | await page.goto('/'); 43 | await expect(page.getByRole('list').getByRole('listitem').nth(1)).toBeVisible(); 44 | } 45 | -------------------------------------------------------------------------------- /test/global-setup.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | export default function clearCacheDir() { 4 | fs.rmSync('test/.network-cache', { recursive: true, force: true }); 5 | } 6 | -------------------------------------------------------------------------------- /test/specs/forceUpdate.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { expect } from '@playwright/test'; 3 | import { openHomePage, test } from '../fixtures'; 4 | 5 | test('forceUpdate', async ({ page, cacheRoute, resolve }) => { 6 | await cacheRoute.GET('/api/cats'); 7 | const getStat = () => fs.statSync(resolve(`localhost/api-cats/GET/headers.json`)); 8 | 9 | await openHomePage(page); 10 | const stat1 = getStat(); 11 | 12 | await openHomePage(page); 13 | const stat2 = getStat(); 14 | 15 | cacheRoute.options.forceUpdate = true; 16 | 17 | await openHomePage(page); 18 | const stat3 = getStat(); 19 | 20 | expect(stat1.mtime).toEqual(stat2.mtime); 21 | expect(stat2.mtime).not.toEqual(stat3.mtime); 22 | }); 23 | -------------------------------------------------------------------------------- /test/specs/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test'; 2 | import { openHomePage, test } from '../fixtures'; 3 | 4 | test('without options', async ({ page, cacheRoute, json }) => { 5 | await cacheRoute.GET('/api/cats'); 6 | 7 | await openHomePage(page); 8 | 9 | expect(json(`localhost/api-cats/GET/headers.json`)).toHaveProperty('status', 200); 10 | // note: .toHaveProperty('[0].id', 1) does not work in PW 1.35 11 | expect(json(`localhost/api-cats/GET/body.json`)[0]).toHaveProperty('id', 1); 12 | }); 13 | 14 | test('extra dir for request', async ({ page, cacheRoute, exists }) => { 15 | await cacheRoute.GET('/api/cats', { extraDir: 'foo' }); 16 | await openHomePage(page); 17 | 18 | await cacheRoute.GET('/api/cats', { extraDir: ['bar'] }); 19 | await openHomePage(page); 20 | 21 | expect(exists(`localhost/api-cats/GET/foo/headers.json`)).toBe(true); 22 | expect(exists(`localhost/api-cats/GET/bar/headers.json`)).toBe(true); 23 | }); 24 | 25 | test('extra dir for test', async ({ page, cacheRoute, json }) => { 26 | cacheRoute.options.extraDir.push('foo'); 27 | await cacheRoute.GET('/api/cats'); 28 | 29 | await openHomePage(page); 30 | 31 | cacheRoute.options.extraDir.push('bar'); 32 | await openHomePage(page); 33 | 34 | expect(json(`localhost/api-cats/GET/foo/headers.json`)).toHaveProperty('status', 200); 35 | expect(json(`localhost/api-cats/GET/foo/bar/headers.json`)).toHaveProperty('status', 200); 36 | }); 37 | 38 | test('baseDir', async ({ page, cacheRoute, exists }) => { 39 | cacheRoute.options.baseDir += '/foo'; 40 | await cacheRoute.GET('/api/cats'); 41 | 42 | await openHomePage(page); 43 | 44 | expect(exists(`../foo/localhost/api-cats/GET/headers.json`)).toBe(true); 45 | }); 46 | 47 | test('re-define route', async ({ page, cacheRoute }) => { 48 | await cacheRoute.GET('/api/cats', async (route, response) => { 49 | const json = await response.json(); 50 | json[0].name = 'Kitty-1'; 51 | await route.fulfill({ json }); 52 | }); 53 | 54 | await openHomePage(page); 55 | await expect(page.getByRole('list')).toContainText('Kitty-1'); 56 | 57 | await cacheRoute.GET('/api/cats', async (route, response) => { 58 | const json = await response.json(); 59 | json[0].name = 'Kitty-2'; 60 | await route.fulfill({ json }); 61 | }); 62 | 63 | await openHomePage(page); 64 | await expect(page.getByRole('list')).toContainText('Kitty-2'); 65 | }); 66 | 67 | test('cache-images', async ({ page, cacheRoute, json, exists }) => { 68 | await cacheRoute.GET('/api/cats'); 69 | await cacheRoute.GET('/cat*'); 70 | 71 | await openHomePage(page); 72 | 73 | expect(json(`localhost/cat1.webp/GET/headers.json`)).toHaveProperty('status', 200); 74 | expect(exists('localhost/cat1.webp/GET/body.webp')).toBe(true); 75 | 76 | expect(json(`localhost/cat2.png/GET/headers.json`)).toHaveProperty('status', 200); 77 | expect(exists('localhost/cat2.png/GET/body.png')).toBe(true); 78 | }); 79 | 80 | test('http status (matched)', async ({ page, cacheRoute, exists }) => { 81 | await cacheRoute.GET('/api/cats', { httpStatus: 200 }); 82 | 83 | await openHomePage(page); 84 | 85 | expect(exists('localhost/api-cats/GET/200/headers.json')).toBe(true); 86 | }); 87 | 88 | test('http status (not matched, no file saved)', async ({ page, cacheRoute, exists }) => { 89 | await cacheRoute.GET('/api/cats', { httpStatus: 500 }); 90 | 91 | await openHomePage(page); 92 | 93 | expect(exists('localhost/api-cats/GET/500/headers.json')).toBe(false); 94 | }); 95 | 96 | test('split by request params', async ({ page, cacheRoute, json }) => { 97 | await cacheRoute.GET('/api/cats*', { 98 | extraDir: (req) => new URL(req.url()).searchParams.toString(), 99 | }); 100 | 101 | await openHomePage(page); 102 | await page.evaluate(async () => { 103 | await fetch('/api/cats?foo=1'); 104 | await fetch('/api/cats?foo=2'); 105 | }); 106 | 107 | expect(json(`localhost/api-cats/GET/headers.json`)).toHaveProperty('status', 200); 108 | expect(json(`localhost/api-cats/GET/foo=1/headers.json`)).toHaveProperty('status', 200); 109 | expect(json(`localhost/api-cats/GET/foo=2/headers.json`)).toHaveProperty('status', 200); 110 | }); 111 | 112 | test('additional match', async ({ page, cacheRoute, exists }) => { 113 | await cacheRoute.GET('/api/cats*', { 114 | match: (req) => new URL(req.url()).searchParams.get('foo') === 'bar', 115 | extraDir: 'with-foo', 116 | }); 117 | 118 | await openHomePage(page); 119 | 120 | expect(exists('localhost/api-cats/GET/with-foo/headers.json')).toBe(false); 121 | 122 | await page.evaluate(() => fetch('/api/cats?foo=bar')); 123 | 124 | expect(exists('localhost/api-cats/GET/with-foo/headers.json')).toBe(true); 125 | }); 126 | 127 | test('page closed', async ({ page, cacheRoute }) => { 128 | await cacheRoute.GET('/api/cats*'); 129 | 130 | await page.goto('/?delay'); 131 | await page.close(); 132 | }); 133 | -------------------------------------------------------------------------------- /test/specs/modify.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test'; 2 | import { openHomePage, test } from '../fixtures'; 3 | 4 | test('modify response', async ({ page, cacheRoute }) => { 5 | await cacheRoute.GET('/api/cats', { 6 | modify: async (route, response) => { 7 | const json = await response.json(); 8 | json[0].name = 'Kitty'; 9 | await route.fulfill({ json }); 10 | }, 11 | }); 12 | 13 | await openHomePage(page); 14 | await expect(page.getByRole('list')).toContainText('Kitty'); 15 | 16 | await openHomePage(page); 17 | await expect(page.getByRole('list')).toContainText('Kitty'); 18 | }); 19 | 20 | test('modifyJSON', async ({ page, cacheRoute }) => { 21 | await cacheRoute.GET('/api/cats', { 22 | modifyJSON: (json) => { 23 | json[0].name = 'Kitty'; 24 | }, 25 | }); 26 | 27 | await openHomePage(page); 28 | await expect(page.getByRole('list')).toContainText('Kitty'); 29 | 30 | await openHomePage(page); 31 | await expect(page.getByRole('list')).toContainText('Kitty'); 32 | }); 33 | -------------------------------------------------------------------------------- /test/specs/noCache.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test'; 2 | import { openHomePage, test } from '../fixtures'; 3 | 4 | test('noCache', async ({ page, cacheRoute, exists }) => { 5 | cacheRoute.options.noCache = true; 6 | await cacheRoute.GET('/api/cats'); 7 | 8 | await openHomePage(page); 9 | 10 | expect(exists(`localhost/api-cats/GET/headers.json`)).toBe(false); 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "noEmit": false, 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true 10 | }, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "module": "nodenext", 5 | "strict": true, 6 | "noUncheckedIndexedAccess": true, 7 | "useUnknownInCatchVariables": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "skipLibCheck": true, 10 | "noEmit": true, 11 | "resolveJsonModule": true 12 | }, 13 | "include": ["**/*.ts"], 14 | "exclude": ["dist"] 15 | } 16 | --------------------------------------------------------------------------------