Skip to content

Commit 04063f1

Browse files
authored
Merge pull request #550 from testdouble/fix-526-getters
Fix getters being imitated as `undefined`, and `td.replace` on getter properties
2 parents ef3dc36 + 92268f5 commit 04063f1

15 files changed

Lines changed: 423 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@
2828
condition now gets the browser build, which can't replace modules. That
2929
includes Jest's jsdom environment with `--experimental-vm-modules`, and Node
3030
run with `--conditions=browser`. `require('testdouble')` is unaffected
31+
* Fix getter properties coming out of `td.imitate` as `undefined`. This affected
32+
`td.replace(path)` (including through testdouble-jest), `td.object`,
33+
`td.constructor` and `td.imitate`. Most visibly, every export of a module
34+
compiled by SWC, and re-exports compiled by Babel or `tsc`, couldn't be
35+
stubbed [#526](https://github.com/testdouble/testdouble.js/issues/526)
36+
* Fix `td.replace(object, 'property')` throwing `Cannot set property ... which
37+
has only a getter` when the property is a getter with no setter. It now
38+
replaces the getter, and `td.reset()` restores the original getter. A getter
39+
that can't be redefined (as SWC defines exports) gets an error that suggests
40+
`td.replace('path/to/module')` instead
41+
[#526](https://github.com/testdouble/testdouble.js/issues/526)
42+
* **Behavior change:** when imitating an object, testdouble now calls its
43+
getters and imitates what they return, instead of using `undefined`. Any side
44+
effects of those getters now run. Getters that throw, getters on prototypes
45+
(like those `td.constructor` reads), and getters nested more than 3 deep still
46+
give `undefined`
3147

3248
## 3.23.0
3349

‎examples/babel/lib/calculator.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Adder from './adder'
22
import Subtractor from './subtractor'
33
import Multiplier from './multiplier'
44
import divider from './divider'
5+
import { square } from './operations'
56

67
export default class Calculator {
78
calculate(operation, ...operands) {
@@ -16,6 +17,8 @@ export default class Calculator {
1617
divider.numerator.set(operands[0])
1718
divider.otherOptions.divisor.setThatToo(operands[1])
1819
return new divider.ohAndAlso.Executor().execute()
20+
case 'square':
21+
return square(operands[0])
1922
}
2023
}
2124
}

‎examples/babel/lib/operations.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Babel compiles re-exports like this one to getters
2+
export { square } from './squarer'

‎examples/babel/lib/squarer.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function square (n) {
2+
throw 'not implemented!'
3+
}

‎examples/babel/test/lib/calculator-test.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import assert from 'assert'
22
import { describe, it, beforeEach, afterEach } from 'node:test'
33
import { td, setup, teardown } from '../helper'
44

5-
let subject, Adder, Subtractor, Multiplier, divider, result
5+
let subject, Adder, Subtractor, Multiplier, divider, operations, result
66
describe('Calculator', () => {
77
describe('#calculate', () => {
88
beforeEach(() => {
@@ -12,6 +12,7 @@ describe('Calculator', () => {
1212
Subtractor = td.replace('../../lib/subtractor')
1313
Multiplier = td.replace('../../lib/multiplier').default
1414
divider = td.replace('../../lib/divider').default
15+
operations = td.replace('../../lib/operations')
1516
const Calculator = require('../../lib/calculator').default
1617
subject = new Calculator()
1718
})
@@ -55,5 +56,14 @@ describe('Calculator', () => {
5556
td.verify(divider.otherOptions.divisor.setThatToo(2))
5657
assert.equal(result, 'lol')
5758
})
59+
60+
it('delegates to a squarer re-exported from a barrel file', () => {
61+
assert.equal(td.explain(operations.square).isTestDouble, true)
62+
td.when(operations.square(3)).thenReturn('squared math!')
63+
64+
result = subject.calculate('square', 3)
65+
66+
assert.equal(result, 'squared math!')
67+
})
5868
})
5969
})

‎src/imitate/overwrite-children/copy-props.js‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import _ from '../../wrap/lodash'
22

3-
export default (target, props, visitor) => {
3+
// Getters can return fresh objects with getters of their own, so cap how
4+
// deeply getter values are expanded to keep imitation finite
5+
const MAX_GETTER_DEPTH = 3
6+
let getterDepth = 0
7+
8+
export default (target, props, visitor, original) => {
49
Object.defineProperties(target, _.transform(props, (acc, descriptor, name) => {
510
if (propOnTargetAndNotWritable(target, name, descriptor)) {
611
if (name === 'prototype') {
712
// Functions' prototype is not configurable but is assignable:
8-
target.prototype = newValue(name, descriptor.value, visitor)
13+
target.prototype = valueFor(name, descriptor, original, visitor)
914
}
1015
} else {
1116
acc[name] = {
1217
configurable: true,
1318
writable: true,
14-
value: newValue(name, descriptor.value, visitor),
19+
value: valueFor(name, descriptor, original, visitor),
1520
enumerable: descriptor.enumerable
1621
}
1722
}
@@ -26,6 +31,30 @@ const propOnTargetAndNotWritable = (target, name, originalDescriptor) => {
2631
}
2732
}
2833

34+
// Accessor properties have no `value`, so call the getter on the original
35+
// object and imitate what it returns. Fall back to `undefined` whenever that
36+
// isn't possible or safe
37+
const valueFor = (name, descriptor, original, visitor) => {
38+
if (!('get' in descriptor)) return newValue(name, descriptor.value, visitor)
39+
if (!descriptor.get || original == null || getterDepth >= MAX_GETTER_DEPTH) return undefined
40+
41+
getterDepth++
42+
try {
43+
if (isPrototype(original)) return undefined
44+
return newValue(name, descriptor.get.call(original), visitor)
45+
} catch (e) {
46+
return undefined
47+
} finally {
48+
getterDepth--
49+
}
50+
}
51+
52+
// A prototype has no instance to act as `this`, so its getters can't be run
53+
const isPrototype = (thing) =>
54+
Object.prototype.hasOwnProperty.call(thing, 'constructor') &&
55+
_.isFunction(thing.constructor) &&
56+
thing.constructor.prototype === thing
57+
2958
const newValue = (name, value, visitor) => {
3059
return visitor ? visitor(name, value) : value
3160
}

‎src/imitate/overwrite-children/index.js‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export default (original, target, overwriteChild) => {
1515
} else {
1616
copyProps(target, gatherProps(original), (name, originalValue) =>
1717
chainPrototype(original, target, name, originalValue,
18-
overwriteChild(originalValue, `.${name}`))
19-
)
18+
overwriteChild(originalValue, `.${name}`)), original)
2019
}
2120
}

‎src/replace/property.js‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,23 @@ export default function (object, property, manualReplacement) {
1010
Object.prototype.hasOwnProperty.call(object, property)
1111

1212
if (isManual || realThingExists) {
13+
const getter = findGetterOnly(object, property)
14+
if (getter && getter.isOwn && !getter.descriptor.configurable) {
15+
return log.error('td.replace', `"${property}" is defined with a getter that cannot be redefined. Compilers like SWC and Babel define ES module exports this way. To replace a module's export, replace the whole module with td.replace('path/to/module') instead.`)
16+
}
1317
const realThing = object[property]
1418
return _.tap(getFake(isManual, property, manualReplacement, realThing), (fakeThing) => {
15-
object[property] = fakeThing
19+
if (getter) {
20+
defineValue(object, property, fakeThing, getter.descriptor.enumerable)
21+
} else {
22+
object[property] = fakeThing
23+
}
1624
reset.onNextReset(() => {
17-
if (realThingExists) {
25+
if (getter && getter.isOwn) {
26+
Object.defineProperty(object, property, getter.descriptor)
27+
} else if (getter) {
28+
delete object[property]
29+
} else if (realThingExists) {
1830
object[property] = realThing
1931
} else {
2032
delete object[property]
@@ -26,6 +38,30 @@ export default function (object, property, manualReplacement) {
2638
}
2739
}
2840

41+
// Assigning to a getter with no setter throws, so find any such accessor
42+
// (on the object or up its prototype chain) to replace it by definition
43+
const findGetterOnly = (object, property) => {
44+
let thing = object
45+
while (thing != null) {
46+
const descriptor = Object.getOwnPropertyDescriptor(thing, property)
47+
if (descriptor) {
48+
return descriptor.get && !descriptor.set
49+
? { descriptor, isOwn: thing === object }
50+
: undefined
51+
}
52+
thing = Object.getPrototypeOf(thing)
53+
}
54+
}
55+
56+
const defineValue = (object, property, value, enumerable) => {
57+
Object.defineProperty(object, property, {
58+
configurable: true,
59+
writable: true,
60+
enumerable,
61+
value
62+
})
63+
}
64+
2965
const getFake = (isManual, property, manualReplacement, realThing) => {
3066
if (isManual) {
3167
warnIfTypeMismatch(property, manualReplacement, realThing)

‎test/safe/constructor.test.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,5 +115,19 @@ module.exports = {
115115
const fakeFunc = td.constructor(boringFunc)
116116

117117
assert._isEqual(td.explain(fakeFunc.foo).isTestDouble, true)
118+
},
119+
'getters on the prototype' () {
120+
let called = false
121+
class Foo {
122+
get bar () {
123+
called = true
124+
return 'real bar'
125+
}
126+
}
127+
128+
FakeConstructor = td.constructor(Foo)
129+
130+
assert._isEqual(new FakeConstructor().bar, undefined)
131+
assert._isEqual(called, false)
118132
}
119133
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// CommonJS in the shape SWC compiles ES module exports to: every export is a
2+
// non-configurable getter
3+
Object.defineProperty(exports, '__esModule', { value: true })
4+
5+
function doTheThing () {
6+
return 'real thing'
7+
}
8+
9+
const helpers = {
10+
beep () { return 'real beep' }
11+
}
12+
13+
Object.defineProperty(exports, 'doTheThing', {
14+
enumerable: true,
15+
get: function () { return doTheThing }
16+
})
17+
18+
// A re-export, which Babel and tsc also compile to a getter
19+
Object.defineProperty(exports, 'beep', {
20+
enumerable: true,
21+
get: function () { return helpers.beep }
22+
})

0 commit comments

Comments
 (0)