Skip to content

Commit e2bf3e4

Browse files
omnibslydell
andauthored
Fix mismatched app and test versions for indirect dependencies (#653)
elm-test was running tests with indirect dependencies always at latest available version, regardless of whether the app under test had indirect dependencies pinned to an earlier version. This can lead to tests behaving different from real world apps. The reason is elm-solve-deps always picks the first acceptable version given to it in the versions array. This commit fixes the issue by ensuring indirect dependencies' versions are always sorted in this order: - the pinned version first - versions higher than the pinned version, in ascending order - versions lower than the pinned version, in descending order Co-authored-by: Simon Lydell <[email protected]>
1 parent 1f19768 commit e2bf3e4

File tree

7 files changed

+148
-22
lines changed

7 files changed

+148
-22
lines changed

lib/DependencyProvider.js

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,15 @@ const collator = new Intl.Collator('en', { numeric: true }); // for sorting SemV
99

1010
// Initialization work done only once.
1111
wasm.init();
12-
const syncGetWorker /*: {| get: (string) => string, shutDown: () => void |} */ =
13-
SyncGet.startWorker();
12+
// Lazily start the worker until needed.
13+
// This is important for the tests, which never exit otherwise.
14+
let syncGetWorker_ /*: void | typeof SyncGet.SyncGetWorker */ = undefined;
15+
function syncGetWorker() /*: typeof SyncGet.SyncGetWorker */ {
16+
if (syncGetWorker_ === undefined) {
17+
syncGetWorker_ = SyncGet.startWorker();
18+
}
19+
return syncGetWorker_;
20+
}
1421

1522
// Cache of existing versions according to the package website.
1623
class OnlineVersionsCache {
@@ -55,7 +62,7 @@ class OnlineVersionsCache {
5562

5663
// Complete cache with a remote call to the package server.
5764
const remoteUrl = remotePackagesUrl + '/since/' + (versionsCount - 1); // -1 to check if no package was deleted.
58-
const newVersions = JSON.parse(syncGetWorker.get(remoteUrl));
65+
const newVersions = JSON.parse(syncGetWorker().get(remoteUrl));
5966
if (newVersions.length === 0) {
6067
// Reload from scratch since it means at least one package was deleted from the registry.
6168
this.map = onlineVersionsFromScratch(cachePath, remotePackagesUrl);
@@ -104,10 +111,13 @@ class OnlineAvailableVersionLister {
104111
this.onlineCache = onlineCache;
105112
}
106113

107-
list(pkg /*: string */) /*: Array<string> */ {
114+
list(
115+
pkg /*: string */,
116+
pinnedVersion /*: void | string */
117+
) /*: Array<string> */ {
108118
const memoVersions = this.memoCache.get(pkg);
109119
if (memoVersions !== undefined) {
110-
return memoVersions;
120+
return prioritizePinnedIndirectVersion(memoVersions, pinnedVersion);
111121
}
112122
const offlineVersions = readVersionsInElmHomeAndSort(pkg);
113123
const allVersionsSet = new Set(this.onlineCache.getVersions(pkg));
@@ -117,24 +127,27 @@ class OnlineAvailableVersionLister {
117127
}
118128
const allVersions = [...allVersionsSet].sort(flippedSemverCompare);
119129
this.memoCache.set(pkg, allVersions);
120-
return allVersions;
130+
return prioritizePinnedIndirectVersion(allVersions, pinnedVersion);
121131
}
122132
}
123133

124134
class OfflineAvailableVersionLister {
125135
// Memoization cache to avoid doing the same work twice in list.
126136
cache /*: Map<string, Array<string>> */ = new Map();
127137

128-
list(pkg /*: string */) /*: Array<string> */ {
138+
list(
139+
pkg /*: string */,
140+
pinnedVersion /*: void | string */
141+
) /*: Array<string> */ {
129142
const memoVersions = this.cache.get(pkg);
130143
if (memoVersions !== undefined) {
131-
return memoVersions;
144+
return prioritizePinnedIndirectVersion(memoVersions, pinnedVersion);
132145
}
133146

134147
const offlineVersions = readVersionsInElmHomeAndSort(pkg);
135148

136149
this.cache.set(pkg, offlineVersions);
137-
return offlineVersions;
150+
return prioritizePinnedIndirectVersion(offlineVersions, pinnedVersion);
138151
}
139152
}
140153

@@ -162,13 +175,21 @@ class DependencyProvider {
162175
extra /*: { [string]: string } */
163176
) /*: string */ {
164177
const lister = new OfflineAvailableVersionLister();
178+
const dependencies = JSON.parse(elmJson).dependencies;
179+
const indirectDeps =
180+
dependencies === undefined ? undefined : dependencies.indirect;
181+
165182
try {
166183
return wasm.solve_deps(
167184
elmJson,
168185
useTest,
169186
extra,
170187
fetchElmJsonOffline,
171-
(pkg) => lister.list(pkg)
188+
(pkg) =>
189+
lister.list(
190+
pkg,
191+
indirectDeps === undefined ? undefined : indirectDeps[pkg]
192+
)
172193
);
173194
} catch (errorMessage) {
174195
throw new Error(errorMessage);
@@ -182,13 +203,21 @@ class DependencyProvider {
182203
extra /*: { [string]: string } */
183204
) /*: string */ {
184205
const lister = new OnlineAvailableVersionLister(this.cache);
206+
const dependencies = JSON.parse(elmJson).dependencies;
207+
const indirectDeps =
208+
dependencies === undefined ? undefined : dependencies.indirect;
209+
185210
try {
186211
return wasm.solve_deps(
187212
elmJson,
188213
useTest,
189214
extra,
190215
fetchElmJsonOnline,
191-
(pkg) => lister.list(pkg)
216+
(pkg) =>
217+
lister.list(
218+
pkg,
219+
indirectDeps === undefined ? undefined : indirectDeps[pkg]
220+
)
192221
);
193222
} catch (errorMessage) {
194223
throw new Error(errorMessage);
@@ -208,7 +237,7 @@ function fetchElmJsonOnline(
208237
// or because there was an error parsing `pkg` and `version`.
209238
// In such case, this will throw again with `cacheElmJsonPath()` so it's fine.
210239
const remoteUrl = remoteElmJsonUrl(pkg, version);
211-
const elmJson = syncGetWorker.get(remoteUrl);
240+
const elmJson = syncGetWorker().get(remoteUrl);
212241
const cachePath = cacheElmJsonPath(pkg, version);
213242
const parentDir = path.dirname(cachePath);
214243
fs.mkdirSync(parentDir, { recursive: true });
@@ -239,7 +268,7 @@ function onlineVersionsFromScratch(
239268
cachePath /*: string */,
240269
remotePackagesUrl /*: string */
241270
) /*: Map<string, Array<string>> */ {
242-
const onlineVersionsJson = syncGetWorker.get(remotePackagesUrl);
271+
const onlineVersionsJson = syncGetWorker().get(remotePackagesUrl);
243272
fs.writeFileSync(cachePath, onlineVersionsJson);
244273
const onlineVersions = JSON.parse(onlineVersionsJson);
245274
try {
@@ -253,6 +282,42 @@ function onlineVersionsFromScratch(
253282

254283
// Helper functions ##################################################
255284

285+
/**
286+
* Enforces respecting pinned indirect dependencies.
287+
*
288+
* When Elm apps have pinned indirect versions, e.g.:
289+
*
290+
* "indirect": {
291+
* "elm/virtual-dom": "1.0.3"
292+
* }
293+
*
294+
* We must prioritize these versions for the wasm dependency solver.
295+
*
296+
* Otherwise the wasm solver will take liberties that will result in
297+
* tests running with dependency versions distinct from those used by
298+
* the real live application.
299+
*
300+
* Assumes versions is sorted descending (newest -> oldest).
301+
*/
302+
function prioritizePinnedIndirectVersion(
303+
versions /*: Array<string> */,
304+
pinnedVersion /*: void | string */
305+
) /*: Array<string> */ {
306+
if (pinnedVersion === undefined || !versions.includes(pinnedVersion)) {
307+
return versions;
308+
}
309+
310+
// the pinned version and any newer version, in ascending order
311+
const desirableVersions = versions
312+
.filter((v) => v >= pinnedVersion)
313+
.reverse();
314+
315+
// older versions, in descending order
316+
const olderVersions = versions.filter((v) => v < pinnedVersion);
317+
318+
return desirableVersions.concat(olderVersions);
319+
}
320+
256321
/* Compares two versions so that newer versions appear first when sorting with this function. */
257322
function flippedSemverCompare(a /*: string */, b /*: string */) /*: number */ {
258323
return collator.compare(b, a);
@@ -340,4 +405,7 @@ function splitPkgVersion(str /*: string */) /*: {
340405
return { pkg: parts[0], version: parts[1] };
341406
}
342407

343-
module.exports = DependencyProvider;
408+
module.exports = {
409+
DependencyProvider,
410+
prioritizePinnedIndirectVersion,
411+
};

lib/Generate.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
const { supportsColor } = require('chalk');
44
const fs = require('fs');
55
const path = require('path');
6-
const DependencyProvider = require('./DependencyProvider.js');
6+
const { DependencyProvider } = require('./DependencyProvider.js');
77
const ElmJson = require('./ElmJson');
88
const Project = require('./Project');
99
const Report = require('./Report');

lib/RunTests.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const path = require('path');
66
const readline = require('readline');
77
const packageInfo = require('../package.json');
88
const Compile = require('./Compile');
9-
const DependencyProvider = require('./DependencyProvider.js');
9+
const { DependencyProvider } = require('./DependencyProvider.js');
1010
const ElmJson = require('./ElmJson');
1111
const FindTests = require('./FindTests');
1212
const Generate = require('./Generate');

lib/Solve.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const fs = require('fs');
55
const path = require('path');
66
const ElmJson = require('./ElmJson');
77
const Project = require('./Project');
8-
const DependencyProvider = require('./DependencyProvider.js');
8+
const { DependencyProvider } = require('./DependencyProvider.js');
99

1010
// These value are used _only_ in flow types. 'use' them with the javascript
1111
// void operator to keep eslint happy.

lib/SyncGet.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,19 @@ const {
88
// $FlowFixMe[cannot-resolve-module]: Flow doesn’t seem to know about the `worker_threads` module yet.
99
} = require('worker_threads');
1010

11-
// Start a worker thread and return a `syncGetWorker`
12-
// capable of making sync requests until shut down.
13-
function startWorker() /*: {
11+
// Poor man’s type alias. We can’t use /*:: type SyncGetWorker = ... */ because of:
12+
// https://github.com/prettier/prettier/issues/2597
13+
const SyncGetWorker /*: {
1414
get: (string) => string,
1515
shutDown: () => void,
16-
} */ {
16+
} */ = {
17+
get: (string) => string,
18+
shutDown: () => {},
19+
};
20+
21+
// Start a worker thread and return a `syncGetWorker`
22+
// capable of making sync requests until shut down.
23+
function startWorker() /*: typeof SyncGetWorker */ {
1724
const { port1: localPort, port2: workerPort } = new MessageChannel();
1825
const sharedLock = new SharedArrayBuffer(4);
1926
// $FlowFixMe[incompatible-call]: Flow is wrong and says `sharedLock` is not an accepted parameter here.
@@ -41,5 +48,6 @@ function startWorker() /*: {
4148
}
4249

4350
module.exports = {
51+
SyncGetWorker,
4452
startWorker,
4553
};

lib/elm-test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const path = require('path');
77
const which = require('which');
88
const packageInfo = require('../package.json');
99
const Compile = require('./Compile');
10-
const DependencyProvider = require('./DependencyProvider.js');
10+
const { DependencyProvider } = require('./DependencyProvider.js');
1111
const ElmJson = require('./ElmJson');
1212
const FindTests = require('./FindTests');
1313
const Generate = require('./Generate');

tests/DependencyProvider.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use strict';
2+
3+
const assert = require('assert');
4+
const {
5+
prioritizePinnedIndirectVersion,
6+
} = require('../lib/DependencyProvider');
7+
8+
describe('DependencyProvider', () => {
9+
describe('prioritizePinnedIndirectVersion', () => {
10+
const versions = ['1.0.5', '1.0.4', '1.0.3', '1.0.2', '1.0.1', '1.0.0'];
11+
12+
const testPinning = (pinnedVersion, expectedResult) => {
13+
assert.deepStrictEqual(
14+
prioritizePinnedIndirectVersion(versions, pinnedVersion),
15+
expectedResult
16+
);
17+
};
18+
19+
it('retains order when no pinned indirect dependency', () => {
20+
testPinning(undefined, versions);
21+
});
22+
23+
it("retains order when pinned version doesn't exist", () => {
24+
testPinning('1.0.6', versions);
25+
});
26+
27+
it('retains order if already at latest', () => {
28+
testPinning('1.0.5', versions);
29+
});
30+
31+
it("prioritizes a version in the middle, if we're pinned to it", () => {
32+
const expected = [
33+
// first, try the pinned version
34+
'1.0.3',
35+
// then, try upgrading
36+
'1.0.4',
37+
'1.0.5',
38+
// then, try downgrading
39+
'1.0.2',
40+
'1.0.1',
41+
'1.0.0',
42+
];
43+
testPinning('1.0.3', expected);
44+
});
45+
46+
it("prioritizes first version, if we're pinned to it", () => {
47+
testPinning('1.0.0', [...versions].sort());
48+
});
49+
});
50+
});

0 commit comments

Comments
 (0)