Skip to content

Commit ad4bf37

Browse files
adamalstonjennifer-shehaneAtofStryker
authored
fix: invalidate cache when fixture is updated via writeFile (#32161)
* fix: invalidate cache when fixture is updated via writeFile * refactor: delete space * fix: improve fixture path resolution * fix: improve fixture path resolution * fix: update cache key generation * fix: fetch config at execution time * fix: invalidate fixture cache entries across encodings and paths * fix optimized dependencies failures * fix: relative pathing on windows as path polyfill only works with unix style paths * Update packages/driver/src/cy/commands/fixtures.ts --------- Co-authored-by: Jennifer Shehane <[email protected]> Co-authored-by: Bill Glesias <[email protected]>
1 parent 9831f49 commit ad4bf37

File tree

8 files changed

+118
-9
lines changed

8 files changed

+118
-9
lines changed

.eslintrc.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ module.exports = {
4242
'npm/eslint-plugin-dev/test/fixtures/**',
4343
// Cloud generated
4444
'system-tests/lib/validations/**',
45+
// ignore as the file has invalid syntax
46+
'system-tests/projects/no-specs-babel-conflict/src/Invalid.jsx',
4547
],
4648
overrides: [
4749
{

cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ _Released 08/12/2025 (PENDING)_
3737
- Fixed an issue where `isSecureContext` would be `false` on localhost when testing with Cypress. Addresses [#18217](https://github.com/cypress-io/cypress/issues/18217).
3838
- Fixed an issue where Angular legacy `Output()` decorators were broken when making component instance field references safe. Fixes [#32137](https://github.com/cypress-io/cypress/issues/32137).
3939
- Upgraded `tmp` from `~0.2.3` to `~0.2.4`. This removes the [CVE-2025-54798](https://github.com/advisories/GHSA-52f5-9888-hmc6) vulnerability being reported in security scans. Addresses [#32176](https://github.com/cypress-io/cypress/issues/32176).
40+
- Fixed an issue where `.fixture()` would not return updated content after the underlying file was modified via `.writeFile()`. The fixture cache is now properly invalidated when the backing file is written to, ensuring updated content is returned in subsequent `.fixture()` calls. Fixes [#4716](https://github.com/cypress-io/cypress/issues/4716).
4041
- Fixed an issue where `.fixture()` calls with a specified encoding would sometimes still attempt to parse the file based on its extension. Files with an explicit encoding are now always treated as raw content. Fixes [#32139](https://github.com/cypress-io/cypress/issues/32139).
4142
- Fixed an issue where `.fixture()` calls with different encoding options would return inconsistent content based on execution order. Fixes [#32138](https://github.com/cypress-io/cypress/issues/32138).
4243
- Fixed an issue where Angular Component Testing was printing extraneous warnings to the console by default. By default, errors only will now print to the console. This can still be overridden by passing in a custom webpack config or setting the `verbose` option inside your `angular.json`. Addresses [#26456](https://github.com/cypress-io/cypress/issues/26456).

packages/app/vite.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const config = makeConfig({
2222
'@headlessui/vue',
2323
'@cypress-design/vue-icon',
2424
'@cypress-design/vue-statusicon',
25+
'@module-federation/runtime',
2526
'human-interval',
2627
'floating-vue',
2728
'dayjs',
@@ -40,6 +41,7 @@ const config = makeConfig({
4041
'@opentelemetry/semantic-conventions',
4142
'@opentelemetry/exporter-trace-otlp-http',
4243
'@opentelemetry/core',
44+
'semver/functions/major',
4345
],
4446
esbuildOptions: {
4547
target: 'ES2022',

packages/driver/cypress/e2e/commands/fixtures.cy.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ const stripAnsi = require('strip-ansi')
33
const { assertLogLength } = require('../../support/utils')
44
const { Promise } = Cypress
55

6+
const { fixturesFolder } = Cypress.config()
7+
68
describe('src/cy/commands/fixtures', () => {
79
beforeEach(() => {
810
return Cypress.emit('clear:fixtures:cache')
@@ -266,6 +268,24 @@ describe('src/cy/commands/fixtures', () => {
266268
})
267269
})
268270

271+
it('should invalidate fixture cache entry when `writeFile` modifies the fixture', () => {
272+
const fixtureBaseName = 'invalidate'
273+
const filePath = `${fixturesFolder}/${fixtureBaseName}.json`
274+
const contents = [
275+
{ scene: '🌸🌷🐝🦋🌱' },
276+
{ scene: '🌞🌊🕶️🍉🏖️' },
277+
{ scene: '🍁🎃🦃🌰🍎' },
278+
{ scene: '❄️⛄🎄🎁🦌' },
279+
]
280+
281+
contents.forEach((content, i) => {
282+
const fixtureName = `${fixtureBaseName}${(i % 2) ? '.json' : ''}`
283+
284+
cy.writeFile(filePath, content)
285+
cy.fixture(fixtureName).should('deep.equal', content)
286+
})
287+
})
288+
269289
it('should respect encoding specification', () => {
270290
const fixture = 'comma-separated.csv'
271291

packages/driver/src/cy/commands/files.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import _ from 'lodash'
2-
import { basename } from 'path'
2+
import { basename, isAbsolute, relative, resolve } from 'path'
33

44
import $errUtils from '../../cypress/error_utils'
55
import type { Log } from '../../cypress/log'
@@ -157,6 +157,8 @@ export default (Commands, Cypress, cy, state) => {
157157

158158
Commands.addAll({
159159
writeFile (fileName: string, contents: string, encoding: Cypress.Encodings | WriteFileOptions | undefined, userOptions: WriteFileOptions, ...extras: never[]) {
160+
const { defaultCommandTimeout, fixturesFolder } = Cypress.config()
161+
160162
if (_.isObject(encoding)) {
161163
userOptions = encoding
162164
encoding = undefined
@@ -173,7 +175,7 @@ export default (Commands, Cypress, cy, state) => {
173175
encoding: encoding === undefined ? 'utf8' : encoding,
174176
flag: userOptions.flag ? userOptions.flag : 'w',
175177
log: true,
176-
timeout: Cypress.config('defaultCommandTimeout'),
178+
timeout: defaultCommandTimeout,
177179
})
178180

179181
const consoleProps = {}
@@ -225,6 +227,28 @@ export default (Commands, Cypress, cy, state) => {
225227
consoleProps['File Path'] = filePath
226228
consoleProps['Contents'] = contents
227229

230+
if (fixturesFolder !== false) {
231+
const normalizePath = (path) => path.replace(/\\/g, '/')
232+
const resolvedFixturesFolder = normalizePath(resolve(fixturesFolder))
233+
const resolvedFilePath = normalizePath(resolve(filePath))
234+
const relativePath = relative(resolvedFixturesFolder, resolvedFilePath)
235+
236+
// If `relativePath` does not start with ".." and is not equal to itself
237+
// with a leading "..", then `filePath` is inside `fixturesFolder`.
238+
const isInsideFixturesFolder =
239+
relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)
240+
241+
if (isInsideFixturesFolder) {
242+
/**
243+
* Relative path from the fixtures folder to the written file,
244+
* normalized with forward slashes.
245+
*/
246+
const fixtureName = relativePath.replace(/\\/g, '/')
247+
248+
Cypress.emit('fixture:cache:invalidate', fixtureName)
249+
}
250+
}
251+
228252
return null
229253
})
230254
.catch((err) => {

packages/driver/src/cy/commands/fixtures.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import _ from 'lodash'
22
import Promise from 'bluebird'
3-
import { basename } from 'path'
3+
import { basename, extname, sep } from 'path'
44

55
import $errUtils from '../../cypress/error_utils'
66

7+
const NULL_SEP = '\u0000'
8+
79
const clone = (obj) => {
810
if (Buffer.isBuffer(obj)) {
911
return Buffer.from(obj)
@@ -12,15 +14,73 @@ const clone = (obj) => {
1214
return JSON.parse(JSON.stringify(obj))
1315
}
1416

17+
/**
18+
* Given a path, returns an array containing the path with and without its extension.
19+
* If there is no extension, returns an array containing only the original path.
20+
*
21+
* Used so invalidation can match both "foo.json" and "foo".
22+
*
23+
* @returns [pathWithExtension, pathWithoutExtension] if extension exists, otherwise [path].
24+
*/
25+
const withAndWithoutExt = (path: string) => {
26+
const extension = extname(path)
27+
28+
return extension ? [path, path.slice(0, -extension.length)] : [path]
29+
}
30+
31+
/**
32+
* Builds path prefixes that might have been used in a cache key's fixture
33+
* portion. Includes forward and backslash variants, with and without
34+
* extensions, and lowercase variants on Windows.
35+
*/
36+
const buildPrefixes = (rawPath: string): string[] => {
37+
const forward = rawPath.split(sep).join('/')
38+
const backslash = forward.replace(/\//g, '\\')
39+
40+
const bases = [
41+
...withAndWithoutExt(forward),
42+
...withAndWithoutExt(backslash),
43+
]
44+
45+
if (Cypress.platform === 'win32') {
46+
bases.push(
47+
...withAndWithoutExt(forward.toLowerCase()),
48+
...withAndWithoutExt(backslash.toLowerCase()),
49+
)
50+
}
51+
52+
return Array.from(new Set(bases))
53+
}
54+
55+
/** Turn fixture path prefixes into matchable key prefixes. */
56+
const makeKeyPrefixes = (fixturePath: string): string[] => {
57+
return buildPrefixes(fixturePath).map((prefix) => `${prefix}${NULL_SEP}`)
58+
}
59+
1560
export default (Commands, Cypress, cy, state, config) => {
1661
// this is called at the beginning of run, so clear the cache
1762
let cache = {}
1863

19-
const reset = () => {
64+
const clearCache = () => {
2065
cache = {}
2166
}
2267

23-
Cypress.on('clear:fixtures:cache', reset)
68+
/**
69+
* Removes all cached fixture entries that correspond to the given path,
70+
* across all encodings.
71+
*/
72+
const invalidateCacheEntry = (fixturePath: string) => {
73+
const prefixes = makeKeyPrefixes(fixturePath)
74+
75+
for (const key of Object.keys(cache)) {
76+
if (prefixes.some((prefix) => key.startsWith(prefix))) {
77+
delete cache[key]
78+
}
79+
}
80+
}
81+
82+
Cypress.on('clear:fixtures:cache', clearCache)
83+
Cypress.on('fixture:cache:invalidate', invalidateCacheEntry)
2484

2585
return Commands.addAll({
2686
fixture (fixture, ...args) {
@@ -40,7 +100,7 @@ export default (Commands, Cypress, cy, state, config) => {
40100
options.encoding = args[0]
41101
}
42102

43-
const cacheKey = `${fixture}\u0000${options.encoding || ''}`
103+
const cacheKey = `${fixture}${NULL_SEP}${options.encoding || ''}`
44104
const cachedContent = cache[cacheKey]
45105

46106
if (cachedContent) {

system-tests/projects/no-specs-babel-conflict/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
},
1010
"devDependencies": {
1111
"@babel/core": "^7.12.0",
12-
"babel-plugin-add-react-displayname": "^0.0.5",
1312
"@babel/plugin-proposal-class-properties": "^7.12.0",
1413
"@babel/plugin-proposal-decorators": "^7.12.0",
1514
"@babel/plugin-transform-react-jsx": "^7.12.0",
1615
"@babel/plugin-transform-runtime": "^7.12.0",
1716
"@babel/preset-env": "^7.12.0",
1817
"@babel/preset-react": "^7.12.0",
1918
"@babel/preset-typescript": "^7.12.0",
20-
"@babel/runtime-corejs3": "^7.12.0"
19+
"@babel/runtime-corejs3": "^7.12.0",
20+
"babel-plugin-add-react-displayname": "^0.0.5"
2121
}
2222
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
import React from 'react'
22

3-
/*eslint-disable */
3+
/* eslint-disable */
44
export function MyComponent (({

0 commit comments

Comments
 (0)