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.

Piratenschiff
Foto von Elena Theodoridou auf Unsplash

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!

Stolpersteine von Yargs 18

Die einfachste Methode den Zauberwürfel zu lösen

JSON.stringify() missbrauchen

Tücken von JavaScript `for...in`-Schleifen

Elektronische Rechnungen mit freier und quelloffener Software erzeugen

Dynamische Angular-Konfiguration

Diese Website verwendet Cookies und ähnliche Technologien, um gewisse Funktionalität zu ermöglichen, die Benutzbarkeit zu erhöhen und Inhalt entsprechend ihren Interessen zu liefern. Über die technisch notwendigen Cookies hinaus können abhängig von ihrem Zweck Analyse- und Marketing-Cookies zum Einsatz kommen. Sie können ihre Zustimmung zu den vorher erwähnten Cookies erklären, indem sie auf "Zustimmen und weiter" klicken. Hier können sie Detaileinstellungen vornehmen oder ihre Zustimmung - auch teilweise - mit Wirkung für die Zukunft zurücknehmen. Für weitere Informationen lesen sie bitte unsere Datenschutzerklärung.