From b2fadae0ba8b4a7f1154e105a9362a39ca72213a Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 9 Aug 2026 09:32:57 +0200 Subject: [PATCH 1/7] Add --dependencies option --- README.md | 22 +++++ example-package/elm.json | 1 + example-package/tests/DependenciesTest.elm | 22 +++++ lib/DependencyProvider.js | 101 +++++++++++++++------ lib/Generate.js | 9 +- lib/RunTests.js | 5 +- lib/Solve.js | 29 ++++-- lib/elm-test.js | 14 ++- tests/flags.js | 30 +++++- 9 files changed, 192 insertions(+), 41 deletions(-) create mode 100644 example-package/tests/DependenciesTest.elm diff --git a/README.md b/README.md index ec510fcb..d9e0518f 100644 --- a/README.md +++ b/README.md @@ -221,3 +221,25 @@ To run a tool installed locally using `npm` you can use `npx`: `npx` adds the local `node_modules/.bin/` folder to `$PATH` when it executes the command passed to it. This means that if you have installed `elm` locally, `elm-test` will automatically find that local installation. As mentioned in [Installation](#installation) we recommend installing elm-test locally in every project. This ensures all contributors and CI use the same version, to avoid nasty “works on my computer” issues. + +### --dependencies + +This is useful when developing an Elm Package (`"type": "package"` in elm.json). + + elm-test --dependencies oldest + +Let’s say your package has the dependency `"elm/json": "1.0.0 <= v < 2.0.0"`. That allows a whole range of `elm/json` versions to be used with your package. Exactly which version of `elm/json` is going to be used in tests? Does it matter? + +Turns it it _does_ matter sometimes. For example, `elm/json` 1.1.0 added the `Json.Decode.oneOrMore` function. Let’s say you start using `oneOrMore` in your package. If the tests run with 1.1.0 or later, they are going to pass. But if they run with 1.0.0 (also allowed by the range), the tests are not even going to compile, because `oneOrMore` does not exist. Oops! + +When running `elm make` in a package project, the Elm compiler uses the _latest_ version permitted by the dependency ranges and makes sure that your project compiles. But it does not stop you from publishing a package with a too low version boundary. + +Tests to the rescue! By using `elm-test --dependencies oldest` your tests are going to be compiled and run with the _oldest_ permitted versions of your dependencies. If your use of the `oneOrMore` function has test coverage, the tests are going to fail (not compile)! The solution is to bump the lower bound: `"elm-test": "1.1.0 <= v < 2.0.0"`. + +`--dependencies` defaults to `newest`, because that was the behavior before the flag was added, and it is less surprising since it does the same thing as a plain `elm make`. But not now that you know about this little gotcha, go ahead and start using `--dependencies oldest` for your package! + +If you do start using `--dependencies oldest`, remember that your tests could fail due to bugs in a dependency that have been fixed in a later version. If that turns out to be the case, bump the lower bound. + +Note: Even with `--dependencies oldest` there are still edge cases. In the example above, let’s say your package also has another dependency, and that dependency in turn also depends on `elm/json`. But it has already specified that it wants at least 1.1.0. Then `--dependencies oldest` has no choice but installing 1.1.0, even if _your_ range allows 1.0.0. So `--dependencies oldest` is no guarantee that your lower version bounds are correct, but it does make it more likely. + +The flag is ignored for applications (`"type": "application"` in elm.json), because for applications all dependency versions are specified exactly (no ranges). (In rare edge cases, there _can_ be situations where your pinned _indirect_ dependencies can’t be honored perfectly, due to the merge between regular dependencies, test dependencies and the test runner dependencies that elm-test has to perform. But then we let the solver pick a working version and don’t consider the `--dependencies` flag.) diff --git a/example-package/elm.json b/example-package/elm.json index 18360111..dc6b7997 100644 --- a/example-package/elm.json +++ b/example-package/elm.json @@ -10,6 +10,7 @@ "elm-version": "0.19.0 <= v < 0.20.0", "dependencies": { "elm/core": "1.0.0 <= v < 2.0.0", + "elm/json": "1.0.0 <= v < 2.0.0", "elm/project-metadata-utils": "1.0.0 <= v < 2.0.0" }, "test-dependencies": { diff --git a/example-package/tests/DependenciesTest.elm b/example-package/tests/DependenciesTest.elm new file mode 100644 index 00000000..ad2727f9 --- /dev/null +++ b/example-package/tests/DependenciesTest.elm @@ -0,0 +1,22 @@ +module DependenciesTest exposing (testOneOrMore) + +import Expect +import Json.Decode as Decode +import Test exposing (Test, test) + + +{-| This test should pass with `--dependencies newest`, +but fail with `--dependencies oldest`, since the range +in elm.json is: `"elm/json": "1.0.0 <= v < 2.0.0"` and +`oneOrMore` was added in 1.1.0. +-} +testOneOrMore : Test +testOneOrMore = + test "Json.Decode.oneOrMore (added in elm/json 1.1.0) works" <| + \() -> + let + decoder = + Decode.oneOrMore (::) Decode.int + in + Decode.decodeString decoder "[1, 2, 3]" + |> Expect.equal (Ok [ 1, 2, 3 ]) diff --git a/lib/DependencyProvider.js b/lib/DependencyProvider.js index 8a334295..305bc800 100644 --- a/lib/DependencyProvider.js +++ b/lib/DependencyProvider.js @@ -138,13 +138,13 @@ class OnlineAvailableVersionLister { /** * @param { string } pkg - * @param { undefined | string } pinnedVersion + * @param { OrderConstraints } orderConstraints * @returns { Array } */ - list(pkg, pinnedVersion) { + list(pkg, orderConstraints) { const memoVersions = this.memoCache.get(pkg); if (memoVersions !== undefined) { - return prioritizePinnedIndirectVersion(memoVersions, pinnedVersion); + return applyOrderConstraints(memoVersions, orderConstraints); } const offlineVersions = readVersionsInElmHomeAndSort(pkg); const allVersionsSet = new Set(this.onlineCache.getVersions(pkg)); @@ -154,7 +154,7 @@ class OnlineAvailableVersionLister { } const allVersions = [...allVersionsSet].sort(flippedSemverCompare); this.memoCache.set(pkg, allVersions); - return prioritizePinnedIndirectVersion(allVersions, pinnedVersion); + return applyOrderConstraints(allVersions, orderConstraints); } } @@ -167,19 +167,19 @@ class OfflineAvailableVersionLister { /** * @param { string } pkg - * @param { undefined | string } pinnedVersion + * @param { OrderConstraints } orderConstraints * @returns { Array } */ - list(pkg, pinnedVersion) { + list(pkg, orderConstraints) { const memoVersions = this.cache.get(pkg); if (memoVersions !== undefined) { - return prioritizePinnedIndirectVersion(memoVersions, pinnedVersion); + return applyOrderConstraints(memoVersions, orderConstraints); } const offlineVersions = readVersionsInElmHomeAndSort(pkg); this.cache.set(pkg, offlineVersions); - return prioritizePinnedIndirectVersion(offlineVersions, pinnedVersion); + return applyOrderConstraints(offlineVersions, orderConstraints); } } @@ -206,7 +206,7 @@ function readVersionsInElmHomeAndSort(pkg) { * When doing `import('./DependencyProvider').DependencyProvider`, * TypeScript says `DependencyProvider` is not exported for some reason. * But `import('./DependencyProvider').DependencyProviderType` works. - * @typedef {DependencyProvider} DependencyProviderType + * @typedef { DependencyProvider } DependencyProviderType */ class DependencyProvider { @@ -221,29 +221,24 @@ class DependencyProvider { /** * Solve dependencies completely offline, without any http request. * - * @param { string } elmJson + * @param { import('./ElmJson').ElmJson } elmJson * @param { boolean } useTest * @param { Record } extra + * @param { PackageStrategy } packageStrategy * @returns { string } */ - solveOffline(elmJson, useTest, extra) { + solveOffline(elmJson, useTest, extra, packageStrategy) { const lister = new OfflineAvailableVersionLister(); - const dependencies = JSON.parse(elmJson).dependencies; - const indirectDeps = - dependencies === undefined ? undefined : dependencies.indirect; try { return wasm.solve_deps( - elmJson, + JSON.stringify(elmJson), useTest, extra, fetchElmJsonOffline, /** @type { (pkg: string) => Array } */ (pkg) => - lister.list( - pkg, - indirectDeps === undefined ? undefined : indirectDeps[pkg] - ) + lister.list(pkg, toOrderConstraints(elmJson, packageStrategy, pkg)) ); } catch (errorMessage) { throw new Error(errorMessage); @@ -253,30 +248,25 @@ class DependencyProvider { /** * Solve dependencies with http requests when required. * - * @param { string } elmJson + * @param { import('./ElmJson').ElmJson } elmJson * @param { boolean } useTest * @param { Record } extra + * @param { PackageStrategy } packageStrategy * @returns { string } */ - solveOnline(elmJson, useTest, extra) { + solveOnline(elmJson, useTest, extra, packageStrategy) { const lister = new OnlineAvailableVersionLister(this.cache); - const dependencies = JSON.parse(elmJson).dependencies; - const indirectDeps = - dependencies === undefined ? undefined : dependencies.indirect; try { return wasm.solve_deps( - elmJson, + JSON.stringify(elmJson), useTest, extra, /** @type { (pkg: string, version: string) => string } */ (pkg, version) => fetchElmJsonOnline(this.syncHttpGet, pkg, version), /** @type { (pkg: string) => Array } */ (pkg) => - lister.list( - pkg, - indirectDeps === undefined ? undefined : indirectDeps[pkg] - ) + lister.list(pkg, toOrderConstraints(elmJson, packageStrategy, pkg)) ); } catch (errorMessage) { throw new Error(errorMessage); @@ -350,6 +340,59 @@ function onlineVersionsFromScratch(syncHttpGet, cachePath, remotePackagesUrl) { // Helper functions ################################################## +/** + * @typedef { + | { for: 'application', pinnedVersion: string | undefined } + | { for: 'package', strategy: PackageStrategy } + } OrderConstraints + * + * @typedef { 'newest' | 'oldest' } PackageStrategy + * + * @param { import('./ElmJson').ElmJson } elmJson + * @param { PackageStrategy } packageStrategy + * @param { string } pkg + * @returns { OrderConstraints } + */ +function toOrderConstraints(elmJson, packageStrategy, pkg) { + switch (elmJson.type) { + case 'application': + return { + for: 'application', + pinnedVersion: elmJson.dependencies.indirect[pkg], + }; + + case 'package': + return { + for: 'package', + strategy: packageStrategy, + }; + } +} + +/** + * @param { Array } versions + * @param { OrderConstraints } orderConstraints + * @returns { Array } + */ +function applyOrderConstraints(versions, orderConstraints) { + switch (orderConstraints.for) { + case 'application': + return prioritizePinnedIndirectVersion( + versions, + orderConstraints.pinnedVersion + ); + + case 'package': + switch (orderConstraints.strategy) { + case 'newest': + return versions; + + case 'oldest': + return versions.slice().reverse(); + } + } +} + /** * Enforces respecting pinned indirect dependencies. * diff --git a/lib/Generate.js b/lib/Generate.js index 982c1124..40c20b0d 100644 --- a/lib/Generate.js +++ b/lib/Generate.js @@ -87,10 +87,11 @@ function getGeneratedSrcDir(generatedCodeDir) { /** * @param { import('./DependencyProvider').DependencyProviderType } dependencyProvider + * @param { import('./DependencyProvider').PackageStrategy } packageStrategy * @param { import('./Project').Project } project * @returns { void } */ -function generateElmJson(dependencyProvider, project) { +function generateElmJson(dependencyProvider, packageStrategy, project) { const generatedSrc = getGeneratedSrcDir(project.generatedCodeDir); fs.mkdirSync(generatedSrc, { recursive: true }); @@ -121,7 +122,11 @@ function generateElmJson(dependencyProvider, project) { type: 'application', 'source-directories': sourceDirs, 'elm-version': '0.19.2', - dependencies: Solve.getDependenciesCached(dependencyProvider, project), + dependencies: Solve.getDependenciesCached( + dependencyProvider, + packageStrategy, + project + ), 'test-dependencies': { direct: {}, indirect: {}, diff --git a/lib/RunTests.js b/lib/RunTests.js index 2f4e54b3..cd29ad50 100644 --- a/lib/RunTests.js +++ b/lib/RunTests.js @@ -144,6 +144,7 @@ function watcherEventMessage(queue) { report: import('./Report').Report, seed: number, fuzz: number, + dependencies: import('./DependencyProvider').PackageStrategy, } } Options * @param { import('./DependencyProvider').DependencyProviderType } dependencyProvider @@ -160,7 +161,7 @@ function runTests( pathToElmBinary, testFileGlobs, processes, - { watch, clearConsole, report, seed, fuzz } + { watch, clearConsole, report, seed, fuzz, dependencies: packageStrategy } ) { /** @type { import('chokidar').FSWatcher | undefined } */ let watcher = undefined; @@ -242,7 +243,7 @@ function runTests( const mainModule = Generate.getMainModule(project.generatedCodeDir); const dest = path.join(project.generatedCodeDir, 'elmTestOutput.js'); - Generate.generateElmJson(dependencyProvider, project); + Generate.generateElmJson(dependencyProvider, packageStrategy, project); progressLogger.log('Compiling'); diff --git a/lib/Solve.js b/lib/Solve.js index 00e8d974..988bed54 100644 --- a/lib/Solve.js +++ b/lib/Solve.js @@ -13,12 +13,15 @@ function sha256(string) { /** * @param { import('./DependencyProvider').DependencyProviderType } dependencyProvider + * @param { import('./DependencyProvider').PackageStrategy } packageStrategy * @param { import('./Project').Project } project * @returns { import('./ElmJson').DirectAndIndirectDependencies } */ -function getDependenciesCached(dependencyProvider, project) { +function getDependenciesCached(dependencyProvider, packageStrategy, project) { const hash = sha256( JSON.stringify({ + packageStrategy: + project.elmJson.type === 'package' ? packageStrategy : undefined, dependencies: project.elmJson.dependencies, 'test-dependencies': project.elmJson['test-dependencies'], }) @@ -39,7 +42,11 @@ function getDependenciesCached(dependencyProvider, project) { } } - const dependencies = getDependencies(dependencyProvider, project.elmJson); + const dependencies = getDependencies( + dependencyProvider, + packageStrategy, + project.elmJson + ); fs.writeFileSync(cacheFile, dependencies); @@ -51,10 +58,11 @@ function getDependenciesCached(dependencyProvider, project) { /** * @param { import('./DependencyProvider').DependencyProviderType } dependencyProvider + * @param { import('./DependencyProvider').PackageStrategy } packageStrategy * @param { import('./ElmJson').ElmJson } elmJson * @returns { string } */ -function getDependencies(dependencyProvider, elmJson) { +function getDependencies(dependencyProvider, packageStrategy, elmJson) { const useTest = true; // Note: These are the dependencies listed in `elm/elm.json`, except // `elm-explorations/test`. `elm/elm.json` is only used during development of @@ -67,11 +75,20 @@ function getDependencies(dependencyProvider, elmJson) { 'elm/time': '1.0.0 <= v < 2.0.0', 'elm/random': '1.0.0 <= v < 2.0.0', }; - const elmJsonStr = JSON.stringify(elmJson); try { - return dependencyProvider.solveOffline(elmJsonStr, useTest, extra); + return dependencyProvider.solveOffline( + elmJson, + useTest, + extra, + packageStrategy + ); } catch (_) { - return dependencyProvider.solveOnline(elmJsonStr, useTest, extra); + return dependencyProvider.solveOnline( + elmJson, + useTest, + extra, + packageStrategy + ); } } diff --git a/lib/elm-test.js b/lib/elm-test.js index fd1f1982..bad215ed 100644 --- a/lib/elm-test.js +++ b/lib/elm-test.js @@ -208,6 +208,14 @@ function main() { 'Use a custom path to an Elm executable (default: elm)', undefined ) + .addOption( + new Option( + '--dependencies ', + 'Choose newest or oldest compatible dependencies for a package project (ignored for applications)' + ) + .default('newest') + .choices(['newest', 'oldest']) + ) // Ensure compatibility with editor plugins setting --output=/dev/null when // running `make`. This has caused issues with Emacs' flycheck-elm package. .addOption( @@ -280,7 +288,11 @@ function main() { const pathToElmBinary = getPathToElmBinary(options['compiler']); const project = getProject('make'); const make = async () => { - Generate.generateElmJson(dependencyProvider, project); + Generate.generateElmJson( + dependencyProvider, + options['dependencies'], + project + ); await Compile.compileSources( FindTests.resolveGlobs( testFileGlobs.length === 0 ? [project.testsDir] : testFileGlobs, diff --git a/tests/flags.js b/tests/flags.js index 4a01c48f..1290d1f7 100644 --- a/tests/flags.js +++ b/tests/flags.js @@ -42,7 +42,7 @@ function elmTestWithYes(args, callback) { * @param { Array } args * @param { string } [cwd] * @param { import('child_process').SpawnOptions } extraOpts - * @returns + * @returns { import('child_process').SpawnSyncReturns } */ function execElmTest(args, cwd = fixturesDir, extraOpts = {}) { return spawnSync( @@ -536,6 +536,34 @@ describe('flags', () => { }); }); + describe('--dependencies', () => { + /** + * @param { Array} args + * @returns { import('child_process').SpawnSyncReturns } + */ + const runTest = (args) => + execElmTest( + [...args, 'tests/DependenciesTest.elm'], + path.join(rootDir, 'example-package') + ); + + it('Should detect a too low version boundary by not compiling when using --dependencies oldest', () => { + const runResult = runTest(['--dependencies=oldest']); + assert.strictEqual(runResult.status, 1); + assert.strictEqual(runResult.stderr.includes('oneOrMore'), true); + }); + + it('Should compile when using --dependencies newest', () => { + const runResult = runTest(['--dependencies=newest']); + assert.strictEqual(runResult.status, 0); + }); + + it('Should also compile when not using the flag since it defaults to latest', () => { + const runResult = runTest(['--dependencies=newest']); + assert.strictEqual(runResult.status, 0); + }); + }); + describe('--watch', () => { it('Should fail if given a value', () => { const runResult = execElmTest([ From 7c7be22fc433d7fb9ecf35cf5d3f06954f5afd17 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 9 Aug 2026 11:24:45 +0200 Subject: [PATCH 2/7] Add make tests --- tests/flags.js | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/tests/flags.js b/tests/flags.js index 1290d1f7..3bfde1a4 100644 --- a/tests/flags.js +++ b/tests/flags.js @@ -547,20 +547,40 @@ describe('flags', () => { path.join(rootDir, 'example-package') ); - it('Should detect a too low version boundary by not compiling when using --dependencies oldest', () => { - const runResult = runTest(['--dependencies=oldest']); - assert.strictEqual(runResult.status, 1); - assert.strictEqual(runResult.stderr.includes('oneOrMore'), true); - }); + describe('run tests', () => { + it('Should detect a too low version boundary by not compiling when using --dependencies oldest', () => { + const runResult = runTest(['--dependencies=oldest']); + assert.strictEqual(runResult.status, 1); + assert.strictEqual(runResult.stderr.includes('oneOrMore'), true); + }); - it('Should compile when using --dependencies newest', () => { - const runResult = runTest(['--dependencies=newest']); - assert.strictEqual(runResult.status, 0); + it('Should compile when using --dependencies newest', () => { + const runResult = runTest(['--dependencies=newest']); + assert.strictEqual(runResult.status, 0); + }); + + it('Should also compile when not using the flag since it defaults to latest', () => { + const runResult = runTest(['--dependencies=newest']); + assert.strictEqual(runResult.status, 0); + }); }); - it('Should also compile when not using the flag since it defaults to latest', () => { - const runResult = runTest(['--dependencies=newest']); - assert.strictEqual(runResult.status, 0); + describe('make', () => { + it('Should detect a too low version boundary by not compiling when using --dependencies oldest', () => { + const runResult = runTest(['make', '--dependencies=oldest']); + assert.strictEqual(runResult.status, 1); + assert.strictEqual(runResult.stderr.includes('oneOrMore'), true); + }); + + it('Should compile when using --dependencies newest', () => { + const runResult = runTest(['make', '--dependencies=newest']); + assert.strictEqual(runResult.status, 0); + }); + + it('Should also compile when not using the flag since it defaults to latest', () => { + const runResult = runTest(['make', '--dependencies=newest']); + assert.strictEqual(runResult.status, 0); + }); }); }); From 22588bf85035a87d4d1169d4acb4909f0f404a26 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 9 Aug 2026 11:37:05 +0200 Subject: [PATCH 3/7] Add --offline flag --- lib/Generate.js | 11 +++++++++-- lib/RunTests.js | 18 ++++++++++++++++-- lib/Solve.js | 24 ++++++++++++++++++++---- lib/elm-test.js | 7 ++++++- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/lib/Generate.js b/lib/Generate.js index 40c20b0d..a0474d8a 100644 --- a/lib/Generate.js +++ b/lib/Generate.js @@ -89,9 +89,15 @@ function getGeneratedSrcDir(generatedCodeDir) { * @param { import('./DependencyProvider').DependencyProviderType } dependencyProvider * @param { import('./DependencyProvider').PackageStrategy } packageStrategy * @param { import('./Project').Project } project + * @param { boolean } offline * @returns { void } */ -function generateElmJson(dependencyProvider, packageStrategy, project) { +function generateElmJson( + dependencyProvider, + packageStrategy, + project, + offline +) { const generatedSrc = getGeneratedSrcDir(project.generatedCodeDir); fs.mkdirSync(generatedSrc, { recursive: true }); @@ -125,7 +131,8 @@ function generateElmJson(dependencyProvider, packageStrategy, project) { dependencies: Solve.getDependenciesCached( dependencyProvider, packageStrategy, - project + project, + offline ), 'test-dependencies': { direct: {}, diff --git a/lib/RunTests.js b/lib/RunTests.js index cd29ad50..360fb381 100644 --- a/lib/RunTests.js +++ b/lib/RunTests.js @@ -145,6 +145,7 @@ function watcherEventMessage(queue) { seed: number, fuzz: number, dependencies: import('./DependencyProvider').PackageStrategy, + offline: boolean, } } Options * @param { import('./DependencyProvider').DependencyProviderType } dependencyProvider @@ -161,7 +162,15 @@ function runTests( pathToElmBinary, testFileGlobs, processes, - { watch, clearConsole, report, seed, fuzz, dependencies: packageStrategy } + { + watch, + clearConsole, + report, + seed, + fuzz, + dependencies: packageStrategy, + offline, + } ) { /** @type { import('chokidar').FSWatcher | undefined } */ let watcher = undefined; @@ -243,7 +252,12 @@ function runTests( const mainModule = Generate.getMainModule(project.generatedCodeDir); const dest = path.join(project.generatedCodeDir, 'elmTestOutput.js'); - Generate.generateElmJson(dependencyProvider, packageStrategy, project); + Generate.generateElmJson( + dependencyProvider, + packageStrategy, + project, + offline + ); progressLogger.log('Compiling'); diff --git a/lib/Solve.js b/lib/Solve.js index 988bed54..8e72d742 100644 --- a/lib/Solve.js +++ b/lib/Solve.js @@ -15,9 +15,15 @@ function sha256(string) { * @param { import('./DependencyProvider').DependencyProviderType } dependencyProvider * @param { import('./DependencyProvider').PackageStrategy } packageStrategy * @param { import('./Project').Project } project + * @param { boolean } offline * @returns { import('./ElmJson').DirectAndIndirectDependencies } */ -function getDependenciesCached(dependencyProvider, packageStrategy, project) { +function getDependenciesCached( + dependencyProvider, + packageStrategy, + project, + offline +) { const hash = sha256( JSON.stringify({ packageStrategy: @@ -45,7 +51,8 @@ function getDependenciesCached(dependencyProvider, packageStrategy, project) { const dependencies = getDependencies( dependencyProvider, packageStrategy, - project.elmJson + project.elmJson, + offline ); fs.writeFileSync(cacheFile, dependencies); @@ -60,9 +67,15 @@ function getDependenciesCached(dependencyProvider, packageStrategy, project) { * @param { import('./DependencyProvider').DependencyProviderType } dependencyProvider * @param { import('./DependencyProvider').PackageStrategy } packageStrategy * @param { import('./ElmJson').ElmJson } elmJson + * @param { boolean } offline * @returns { string } */ -function getDependencies(dependencyProvider, packageStrategy, elmJson) { +function getDependencies( + dependencyProvider, + packageStrategy, + elmJson, + offline +) { const useTest = true; // Note: These are the dependencies listed in `elm/elm.json`, except // `elm-explorations/test`. `elm/elm.json` is only used during development of @@ -82,7 +95,10 @@ function getDependencies(dependencyProvider, packageStrategy, elmJson) { extra, packageStrategy ); - } catch (_) { + } catch (error) { + if (offline) { + throw error; + } return dependencyProvider.solveOnline( elmJson, useTest, diff --git a/lib/elm-test.js b/lib/elm-test.js index bad215ed..0ec67c29 100644 --- a/lib/elm-test.js +++ b/lib/elm-test.js @@ -216,6 +216,10 @@ function main() { .default('newest') .choices(['newest', 'oldest']) ) + .option( + '--offline', + 'Do not make any HTTP requests when solving dependencies' + ) // Ensure compatibility with editor plugins setting --output=/dev/null when // running `make`. This has caused issues with Emacs' flycheck-elm package. .addOption( @@ -291,7 +295,8 @@ function main() { Generate.generateElmJson( dependencyProvider, options['dependencies'], - project + project, + options['offline'] ); await Compile.compileSources( FindTests.resolveGlobs( From ccd68e2a3149a33806b4883acf64763ed123de45 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 9 Aug 2026 11:37:27 +0200 Subject: [PATCH 4/7] Improve solver error messages --- lib/DependencyProvider.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/DependencyProvider.js b/lib/DependencyProvider.js index 305bc800..a30fd44b 100644 --- a/lib/DependencyProvider.js +++ b/lib/DependencyProvider.js @@ -241,7 +241,7 @@ class DependencyProvider { lister.list(pkg, toOrderConstraints(elmJson, packageStrategy, pkg)) ); } catch (errorMessage) { - throw new Error(errorMessage); + throw new Error(`Failed to solve dependencies:\n${errorMessage}`); } } @@ -269,7 +269,7 @@ class DependencyProvider { lister.list(pkg, toOrderConstraints(elmJson, packageStrategy, pkg)) ); } catch (errorMessage) { - throw new Error(errorMessage); + throw new Error(`Failed to solve dependencies:\n${errorMessage}`); } } } From bf730049d9bee61279681cef9aa130ff72487f4e Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 9 Aug 2026 11:42:38 +0200 Subject: [PATCH 5/7] Log when solving dependencies --- lib/Generate.js | 7 +++++-- lib/RunTests.js | 5 ++++- lib/Solve.js | 6 +++++- lib/elm-test.js | 5 ++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/Generate.js b/lib/Generate.js index a0474d8a..143043f4 100644 --- a/lib/Generate.js +++ b/lib/Generate.js @@ -90,13 +90,15 @@ function getGeneratedSrcDir(generatedCodeDir) { * @param { import('./DependencyProvider').PackageStrategy } packageStrategy * @param { import('./Project').Project } project * @param { boolean } offline + * @param { () => void } onBeforeSolve * @returns { void } */ function generateElmJson( dependencyProvider, packageStrategy, project, - offline + offline, + onBeforeSolve ) { const generatedSrc = getGeneratedSrcDir(project.generatedCodeDir); @@ -132,7 +134,8 @@ function generateElmJson( dependencyProvider, packageStrategy, project, - offline + offline, + onBeforeSolve ), 'test-dependencies': { direct: {}, diff --git a/lib/RunTests.js b/lib/RunTests.js index 360fb381..fc257a4d 100644 --- a/lib/RunTests.js +++ b/lib/RunTests.js @@ -256,7 +256,10 @@ function runTests( dependencyProvider, packageStrategy, project, - offline + offline, + () => { + progressLogger.log('Solving dependencies'); + } ); progressLogger.log('Compiling'); diff --git a/lib/Solve.js b/lib/Solve.js index 8e72d742..53c54b25 100644 --- a/lib/Solve.js +++ b/lib/Solve.js @@ -16,13 +16,15 @@ function sha256(string) { * @param { import('./DependencyProvider').PackageStrategy } packageStrategy * @param { import('./Project').Project } project * @param { boolean } offline + * @param { () => void } onBeforeSolve * @returns { import('./ElmJson').DirectAndIndirectDependencies } */ function getDependenciesCached( dependencyProvider, packageStrategy, project, - offline + offline, + onBeforeSolve ) { const hash = sha256( JSON.stringify({ @@ -48,6 +50,8 @@ function getDependenciesCached( } } + onBeforeSolve(); + const dependencies = getDependencies( dependencyProvider, packageStrategy, diff --git a/lib/elm-test.js b/lib/elm-test.js index 0ec67c29..865f4dba 100644 --- a/lib/elm-test.js +++ b/lib/elm-test.js @@ -296,7 +296,10 @@ function main() { dependencyProvider, options['dependencies'], project, - options['offline'] + options['offline'], + () => { + // Don’t log that we are solving dependencies in `make` mode. + } ); await Compile.compileSources( FindTests.resolveGlobs( From 8891c8d30d97c1893abe61c174e783bc85744de3 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 9 Aug 2026 17:31:39 +0200 Subject: [PATCH 6/7] Properly choose when to cache and when to make HTTP requests --- README.md | 12 ++++++ lib/DependencyProvider.js | 67 +++-------------------------- lib/ElmJson.js | 16 +++++++ lib/Project.js | 3 ++ lib/Solve.js | 90 +++++++++++++++++++++++++++++---------- 5 files changed, 106 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index d9e0518f..8c2146c3 100644 --- a/README.md +++ b/README.md @@ -243,3 +243,15 @@ If you do start using `--dependencies oldest`, remember that your tests could fa Note: Even with `--dependencies oldest` there are still edge cases. In the example above, let’s say your package also has another dependency, and that dependency in turn also depends on `elm/json`. But it has already specified that it wants at least 1.1.0. Then `--dependencies oldest` has no choice but installing 1.1.0, even if _your_ range allows 1.0.0. So `--dependencies oldest` is no guarantee that your lower version bounds are correct, but it does make it more likely. The flag is ignored for applications (`"type": "application"` in elm.json), because for applications all dependency versions are specified exactly (no ranges). (In rare edge cases, there _can_ be situations where your pinned _indirect_ dependencies can’t be honored perfectly, due to the merge between regular dependencies, test dependencies and the test runner dependencies that elm-test has to perform. But then we let the solver pick a working version and don’t consider the `--dependencies` flag.) + +If you use this together with `--offline`, beware that “oldest” and “newest” refer to what you packages you have on disk on your computer, not what the actually oldest and newest versions available on the package site are. Going back to the example with `"elm-json": "1.0.0 <= v < 2.0.0"`, if the only `elm/json` version you have on your computer is 1.1.0 then that’s what you’re gonna get with `--dependencies oldest --offline`. Even though 1.0.0 exists on the Internet, the tests are going to use 1.1.0 and therefore _not_ fail (as they would have with 1.0.0). + +### --offline + +Tell elm-test to fail instead of making HTTP request when “solving dependencies:” + + elm-test --offline + +Before running tests, elm-test needs to merge your regular dependencies, test dependencies and dependencies of the test runner, and find a working set of versions. When doing so, elm-test needs to ask the package server for available versions of packages. The results are cached in `~/.elm` (`$ELM_HOME`). If you already have a cache that is supposed to be up-to-date cache, and want elm-test to fail instead of making HTTP requests to the package server if it isn’t, pass `--offline`. + +Note: `--offline` only controls HTTP requests that elm-test makes directly. The Elm compiler might still make HTTP requests. diff --git a/lib/DependencyProvider.js b/lib/DependencyProvider.js index a30fd44b..627f677c 100644 --- a/lib/DependencyProvider.js +++ b/lib/DependencyProvider.js @@ -119,7 +119,7 @@ class OnlineVersionsCache { } } -class OnlineAvailableVersionLister { +class AvailableVersionLister { /** * Memoization cache to avoid doing the same work twice in list. * @type { Map> } @@ -132,7 +132,6 @@ class OnlineAvailableVersionLister { * @param {OnlineVersionsCache} onlineCache */ constructor(onlineCache) { - onlineCache.update(); this.onlineCache = onlineCache; } @@ -158,31 +157,6 @@ class OnlineAvailableVersionLister { } } -class OfflineAvailableVersionLister { - /** - * Memoization cache to avoid doing the same work twice in list. - * @type { Map> } - */ - cache = new Map(); - - /** - * @param { string } pkg - * @param { OrderConstraints } orderConstraints - * @returns { Array } - */ - list(pkg, orderConstraints) { - const memoVersions = this.cache.get(pkg); - if (memoVersions !== undefined) { - return applyOrderConstraints(memoVersions, orderConstraints); - } - - const offlineVersions = readVersionsInElmHomeAndSort(pkg); - - this.cache.set(pkg, offlineVersions); - return applyOrderConstraints(offlineVersions, orderConstraints); - } -} - /** * @param { string } pkg * @returns { Array } @@ -219,51 +193,24 @@ class DependencyProvider { } /** - * Solve dependencies completely offline, without any http request. - * - * @param { import('./ElmJson').ElmJson } elmJson - * @param { boolean } useTest - * @param { Record } extra - * @param { PackageStrategy } packageStrategy - * @returns { string } - */ - solveOffline(elmJson, useTest, extra, packageStrategy) { - const lister = new OfflineAvailableVersionLister(); - - try { - return wasm.solve_deps( - JSON.stringify(elmJson), - useTest, - extra, - fetchElmJsonOffline, - /** @type { (pkg: string) => Array } */ - (pkg) => - lister.list(pkg, toOrderConstraints(elmJson, packageStrategy, pkg)) - ); - } catch (errorMessage) { - throw new Error(`Failed to solve dependencies:\n${errorMessage}`); - } - } - - /** - * Solve dependencies with http requests when required. - * + * @param { boolean } offline * @param { import('./ElmJson').ElmJson } elmJson * @param { boolean } useTest * @param { Record } extra * @param { PackageStrategy } packageStrategy * @returns { string } */ - solveOnline(elmJson, useTest, extra, packageStrategy) { - const lister = new OnlineAvailableVersionLister(this.cache); + solve(offline, elmJson, useTest, extra, packageStrategy) { + const lister = new AvailableVersionLister(this.cache); try { return wasm.solve_deps( JSON.stringify(elmJson), useTest, extra, - /** @type { (pkg: string, version: string) => string } */ - (pkg, version) => fetchElmJsonOnline(this.syncHttpGet, pkg, version), + offline + ? fetchElmJsonOffline + : fetchElmJsonOnline.bind(null, this.syncHttpGet), /** @type { (pkg: string) => Array } */ (pkg) => lister.list(pkg, toOrderConstraints(elmJson, packageStrategy, pkg)) diff --git a/lib/ElmJson.js b/lib/ElmJson.js index acf789e7..1fdca3e1 100644 --- a/lib/ElmJson.js +++ b/lib/ElmJson.js @@ -253,9 +253,25 @@ function getElmExplorationsTestPackageVersionOrRange(elmJson) { } } +/** + * @param { string } dir + * @returns { number } + */ +function mtime(dir) { + const elmJsonPath = getPath(dir); + + try { + const stats = fs.statSync(elmJsonPath); + return stats.mtimeMs; + } catch (_) { + return 0; + } +} + module.exports = { ELM_TEST_PACKAGE: ELM_TEST_PACKAGE, getPath: getPath, + mtime: mtime, parseDirectAndIndirectDependencies: parseDirectAndIndirectDependencies, read: read, requireElmTestPackage: requireElmTestPackage, diff --git a/lib/Project.js b/lib/Project.js index 2d01bf92..00c2b13f 100644 --- a/lib/Project.js +++ b/lib/Project.js @@ -9,6 +9,7 @@ const ElmJson = require('./ElmJson'); generatedCodeDir: string, testsSourceDirs: Array, elmJson: import('./ElmJson').ElmJson, + elmJsonMtime: number, } } Project */ @@ -33,6 +34,7 @@ function init(rootDir, version) { const shouldAddTestsDirAsSource = fs.existsSync(testsDir); const elmJson = ElmJson.read(rootDir); + const elmJsonMtime = ElmJson.mtime(rootDir); const projectSourceDirs = elmJson.type === 'package' ? ['src'] : elmJson['source-directories']; @@ -62,6 +64,7 @@ function init(rootDir, version) { generatedCodeDir, testsSourceDirs, elmJson, + elmJsonMtime, }; } diff --git a/lib/Solve.js b/lib/Solve.js index 53c54b25..c9a4eb51 100644 --- a/lib/Solve.js +++ b/lib/Solve.js @@ -26,27 +26,48 @@ function getDependenciesCached( offline, onBeforeSolve ) { - const hash = sha256( - JSON.stringify({ - packageStrategy: - project.elmJson.type === 'package' ? packageStrategy : undefined, - dependencies: project.elmJson.dependencies, - 'test-dependencies': project.elmJson['test-dependencies'], - }) - ); + /** @type { string | undefined } */ + let cacheFile = undefined; - const cacheFile = path.join( - project.generatedCodeDir, - `dependencies.${hash}.json` - ); + // For packages, in offline mode we get _some_ version + // that happened to be available offline and happened to + // be newest or oldest at the time. So we can’t cache that + // result. An online run between two offline runs can alter + // what the second offline run results in. + if (!(project.elmJson.type === 'package' && offline)) { + const hash = sha256( + JSON.stringify({ + // For packages, when we want the newest version available + // for each dependency’s range, we in theory can’t cache + // anything and must look for potential new versions on the + // package site at every build. But when running the tests + // a hundred times to fix a bug, that is a waste of time + // and server resources. We therefore do the same thing as + // the Elm compiler itself: We _do_ cache, and use the mtime + // of elm.json in the cache key. That is not perfect, but it + // is what the compiler does, so we following along. + mtime: + project.elmJson.type === 'package' && packageStrategy === 'newest' + ? project.elmJsonMtime + : undefined, + dependencies: project.elmJson.dependencies, + 'test-dependencies': project.elmJson['test-dependencies'], + }) + ); - try { - return JSON.parse(fs.readFileSync(cacheFile, 'utf8')); - } catch (error) { - if (error.code !== 'ENOENT') { - console.warn( - `Ignoring bad dependencies cache file:\n\n${error.message}\n\nPlease report this issue: https://github.com/rtfeldman/node-test-runner/issues/new` - ); + cacheFile = path.join( + project.generatedCodeDir, + `dependencies.${hash}.json` + ); + + try { + return JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + } catch (error) { + if (error.code !== 'ENOENT') { + console.warn( + `Ignoring bad dependencies cache file:\n\n${error.message}\n\nPlease report this issue: https://github.com/rtfeldman/node-test-runner/issues/new` + ); + } } } @@ -59,7 +80,9 @@ function getDependenciesCached( offline ); - fs.writeFileSync(cacheFile, dependencies); + if (cacheFile !== undefined) { + fs.writeFileSync(cacheFile, dependencies); + } return ElmJson.parseDirectAndIndirectDependencies( JSON.parse(dependencies), @@ -80,7 +103,20 @@ function getDependencies( elmJson, offline ) { + // Applications have exact versions for all dependencies. If we find a solution + // using local information, that is THE solution and there is no need to ask the + // package site for anything. + // Packages, on the other hand, have _ranges_ for their dependencies. We can still + // find a solution using only local information, but we can’t know that it is the + // oldest or newest version out of all versions on the package site – only out of + // the versions available offline. So for packages we need to ask the package server + // about things _before_ trying to solve anything. + if (elmJson.type === 'package' && !offline) { + dependencyProvider.cache.update(); + } + const useTest = true; + // Note: These are the dependencies listed in `elm/elm.json`, except // `elm-explorations/test`. `elm/elm.json` is only used during development of // this CLI (for editor integrations and unit tests). When running `elm-test` @@ -92,8 +128,10 @@ function getDependencies( 'elm/time': '1.0.0 <= v < 2.0.0', 'elm/random': '1.0.0 <= v < 2.0.0', }; + try { - return dependencyProvider.solveOffline( + return dependencyProvider.solve( + /* offline */ true, elmJson, useTest, extra, @@ -103,7 +141,15 @@ function getDependencies( if (offline) { throw error; } - return dependencyProvider.solveOnline( + + // As mentioned above, for applications we only need to ask the package server + // for information if the local information wasn’t enough. + if (elmJson.type === 'application') { + dependencyProvider.cache.update(); + } + + return dependencyProvider.solve( + /* offline */ false, elmJson, useTest, extra, From 3f0308b868e10d9e387e5af5c88170e99edf09a2 Mon Sep 17 00:00:00 2001 From: Simon Lydell Date: Sun, 9 Aug 2026 17:55:53 +0200 Subject: [PATCH 7/7] Add tests for --offline --- lib/Project.js | 26 ++++++++++++++++++-------- tests/flags.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/lib/Project.js b/lib/Project.js index 00c2b13f..4e49cfbc 100644 --- a/lib/Project.js +++ b/lib/Project.js @@ -49,14 +49,7 @@ function init(rootDir, version) { ? resolvedSourceDirs.concat([testsDir]) : resolvedSourceDirs; - const generatedCodeDir = path.join( - rootDir, - 'elm-stuff', - 'generated-code', - 'elm-community', - 'elm-test', - version - ); + const generatedCodeDir = getGeneratedCodeDir(rootDir, version); return { rootDir, @@ -68,6 +61,22 @@ function init(rootDir, version) { }; } +/** + * @param { string } rootDir + * @param { string } version + * @returns { string } + */ +function getGeneratedCodeDir(rootDir, version) { + return path.join( + rootDir, + 'elm-stuff', + 'generated-code', + 'elm-community', + 'elm-test', + version + ); +} + /* We do this validation ourselves to avoid the ../../../../../ in Elm’s error message: -- MISSING SOURCE DIRECTORY ------------------------------------------- elm.json @@ -126,6 +135,7 @@ ${message} } module.exports = { + getGeneratedCodeDir: getGeneratedCodeDir, getTestsDir: getTestsDir, init: init, validateTestsSourceDirs: validateTestsSourceDirs, diff --git a/tests/flags.js b/tests/flags.js index 3bfde1a4..30ee4582 100644 --- a/tests/flags.js +++ b/tests/flags.js @@ -8,6 +8,9 @@ const os = require('os'); const readline = require('readline'); const which = require('which'); const { fixturesDir, spawnOpts, dummyBinPath } = require('./util'); +const Project = require('../lib/Project'); +const packageInfo = require('../package.json'); +const elmTestVersion = packageInfo.version; const rootDir = path.join(__dirname, '..'); const elmTestPath = path.join(rootDir, 'bin', 'elm-test'); @@ -584,6 +587,46 @@ describe('flags', () => { }); }); + describe('--offline', () => { + it('Should fail if ELM_HOME is empty', () => { + const elmHome = path.join(fixturesDir, 'elm-stuff', 'elm-home'); + const generatedCodeDir = Project.getGeneratedCodeDir( + fixturesDir, + elmTestVersion + ); + for (const name of fs.readdirSync(generatedCodeDir)) { + if (name.startsWith('dependencies.') && name.endsWith('.json')) { + fs.unlinkSync(path.join(generatedCodeDir, name)); + } + } + + const runResult = execElmTest( + ['--offline', path.join('tests', 'Passing', 'One.elm')], + fixturesDir, + { + env: Object.assign({}, spawnOpts.env, { ELM_HOME: elmHome }), + } + ); + assert.strictEqual(runResult.status, 1); + assert.strictEqual( + runResult.stderr.includes('Failed to solve dependencies'), + true + ); + }); + + it('Should succeed if a previous run succeeded', () => { + const runResult1 = execElmTest([ + path.join('tests', 'Passing', 'One.elm'), + ]); + assert.strictEqual(runResult1.status, 0); + const runResult2 = execElmTest([ + '--offline', + path.join('tests', 'Passing', 'One.elm'), + ]); + assert.strictEqual(runResult2.status, 0); + }); + }); + describe('--watch', () => { it('Should fail if given a value', () => { const runResult = execElmTest([