Stolpersteine von Yargs 18
Ein Upgrade von Yargs 17 auf Version 18 in einem TypeScript-Projekt mit Jest entpuppte sich als kleiner Alptraum. Die Liste der inkompatiblen Änderungen in den Release-Notes von Version 18.0.0 enthalten den Punkt "yargs is now ESM first", der ordentlich Kopfschmerzen bereiten kann. Schauen wir uns das in diesem Blog-Post genauer an.

Hintergrund
Das erwähnte Projekt ist e-invoice-eu, ein TypeScript-Framework für elektronische Rechnungen. Es besteht aus einer Bibliothek, einem API-Server, einer Kommandozeilenapplikation und Doku.
Die Komponenten sind in einem Lerna-Monorepo organisiert. Yargs wird von der Kommandozeilenapplikation im Workspace apps/cli
. Als Test-Framework ist durchgehend Jest im Einsatz.
Jeder Workspace hat seine eigene tsconfig.json
und eine tsconfig.build.json
, welche tsconfig.json
erweitert, die wiederum die Top-Level tsconfig.json
erweitert, in der repository-weite Einstellungen definiert sind.
Das Repository verwendet bun
als Paket-Manager. Das spielt hier allerdings keine Rolle.
Der Namespace yargs
Der Build des Paketes klappte nach dem Upgrade auf Yargs 18 noch ohne Probleme. Der Versuch, die ausführbare Datei zu starten, schlug allerdings mit einer langen Liste von Fehlern fehl. Das sah in etwa so aus: of this form:
src/command.ts:7:14 - error TS2503: Cannot find namespace 'yargs'.
7 build(argv: yargs.Argv): yargs.Argv<object>;
~~~~~
Schuldig war der Namespace yargs
. Mein Code sah ungefähr so aus:
import yargs, { InferredOptionTypes } from 'yargs';
function build(argv: yargs.Argv): yargs.Argv<object> {
return argv.options(options);
}
async function run(argv: yargs.Arguments): Promise<number> {
// Tue irgendwas.
return 0;
}
Statt die Typen Argv
, Arguments
, Options
zu importieren, habe ich durchgehend den Namespace yargs
mit Code wie yargs.Argv
, yargs.Arguments
, yargs.Options
und so weiter verwendet. Mit Yargs 18 ist es allerdings besser diese Typen zu importieren und ohne Namespace zu verwenden:
import yargs from 'yargs';
import type { Argv, Arguments, Options, InferredOptionTypes } from 'yargs';
function build(argv: Argv): yargs.Argv<object> {
return argv.options(options);
}
async function run(argv: Arguments): Promise<number> {
// Do something.
return 0;
}
In einer größeren Code-Basis kann man einfach nach yargs.
suchen, mit einem leeren String ersetzen, und dann die Imports so lange anpassen, bis der Code wieder kompiliert.
Es ist im Allgemeinen schlauer, import type
statt einfach import
zu verwenden, wenn man Typen (die nicht im resultierenden JavaScript auftauchen) importiert. In meinem Fall war das sogar zwingend notwendig.
Ungemach mit Jest
Das Starten der Tests hielt die nächste unangenehme Überraschung bereit:
FAIL src/commands/validate.spec.ts
● Test suite failed to run
Jest encountered an unexpected token
Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.
Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.
By default "node_modules" folder is ignored by transformers.
Here's what you can do:
• If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
• If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
• To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
• If you need a custom transformation, specify a "transform" option in your config.
• If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.
You'll find more details and examples of these config options in the docs:
https://jestjs.io/docs/configuration
For information about custom transformations, see:
https://jestjs.io/docs/code-transformation
Details:
/Users/me/javascript/e-invoice-eu/node_modules/yargs/index.mjs:4
import esmPlatformShim from './lib/platform-shims/esm.mjs';
^^^^^^
SyntaxError: Cannot use import statement outside a module
> 1 | import yargs from 'yargs';
| ^
2 | import type { Arguments } from 'yargs';
3 |
4 | import { coerceOptions } from '../optspec';
at Runtime.createScriptFromCode (../../../node_modules/jest-runtime/build/index.js:1316:40)
at Object.<anonymous> (commands/validate.spec.ts:1:1)
Buhu! 😭 Was ist das denn?
Ich beschloss, die Ratschläge aus der Fehlermeldung, einen nach dem anderen auszuprobieren und fing damit an, die Jest-Dokumentation für ESM an. Das hörte sich nach einem Haufen Arbeit an, und der Hinweis "Jest wird mit experimentellem Support für for ECMAScript-Module (ESM) ausgeliefert", machte auch nicht gerade Mut, ganz abgesehen davon, dass ich es auch nicht ans Laufen bekommen habe.
Die Jest-Doku für TypeScript hilft hier nicht. Die nächsten Vorschläge raten dazu, mit den Optionen transformIgnorePatterns
und transform
zu experimentieren. Stackoverflow ist voll von Vorschlägen dazu, die aber alle nicht funktionierten.
Als letztes wird schließlich geraten, den Ärger machenden Code mit einem Stub zu ersetzen. Das hatte ich schon früher erfolgreich mit chalk, einer Bibliothek zur Erzeugung von Farbe und Styles in Terminalausgaben gemacht.
Ob ein Stub für yargs
(oder chalk
oder jede andere Bibliothek) funktioniert, hängt von der Natur der Unit-Tests ab. Meine Unit-Tests mocken yargs
komplett weg, weil ich meinen eigenen Code, und nicht yargs
testen will. Hängen die Tests von echter Funktionalität von yargs
ab, reicht ein reiner Stub nicht aus man muss die zumindeste eine minimale Implementierung für diese Funktionalität bereitstellen. In meinem Fall war der Stub jedoch sehr simpel:
Der Stub findet sich in [src/stubs/yargs.ts
]https://github.com/gflohr/e-invoice-eu/blob/main/apps/cli/src/stubs/yargs.ts:
/* eslint-disable @typescript-eslint/no-unused-vars */
// Minimal runtime stub for tests — returns a chainable object.
import type { Argv } from 'yargs';
function makeStub(): any {
const stub: any = {
command: (..._args: any[]) => stub,
option: (..._args: any[]) => stub,
options: (..._args: any[]) => stub,
help: (..._args: any[]) => stub,
parse: (..._args: any[]) => ({}),
demandOption: (..._args: any[]) => stub,
middleware: (..._args: any[]) => stub,
};
return stub;
}
export default function yargs(_argv?: string[] | null): Argv {
return makeStub() as Argv;
}
Das reicht aus, um das typische Chaining mit yargs
in der Form yargs(argv).help().parse()
und so weiter zu erlauben.
Ich brauchte auch noch einen Stub für yargs/helpers
in [src/stubs/yargs-helpers.ts
]https://github.com/gflohr/e-invoice-eu/blob/main/apps/cli/src/stubs/yargs-helpers.ts:
export const hideBin = (argv?: string[]) => {
// fake implementation: return argv || []
return argv ?? [];
};
Und Jest muss diese Stubs natürlich auch verwenden. Das geht mit der Konfigurationoption moduleNameMapper
:
{
"moduleNameMapper": {
"^yargs$": "<rootDir>/../src/stubs/yargs.ts",
"^yargs/helpers$": "<rootDir>/../src/stubs/yargs.ts"
}
}
Abhängig von der Projektstruktur und TypeScript-Konfiguration muss man die Pfade eventuell etwas anpassen.
Takeaways
- Ab Version 18 wird mit Yargs kein CommonJS mehr ausgeliefert.
- Statt den Namespace
yargs
zu verwenden, ist es besser die Yargs-Typen zu importieren. - In Jest-Unit-Tests ersetzt man Yargs durch eine Stub-Implementierung.
Kommentar hinterlassen
Die Angabe der E-Mail-Adresse ist freiwillig. Bitte bedenke aber, dass ohne gültige E-Mail-Adresse keine Benachrichtigung über eine Antwort möglich ist. Die Adresse wird nicht zusammen mit dem Kommentar angezeigt!