THRIFT-5811: Add ESM support to nodejs codegen
Client: nodejs
Patch: Cameron Martin <cameronm@graphcore.ai>

This closes #3083

This adds a flag to the JS generator to output ES modules instead of CommonJS. This is only valid when targeting node. A lot of the changes here are to test this.

The `testAll.sh` script now generates an ES module version of the services and types, and tests the client and the server with these. This has a few knock-on effects. Firstly, any module that imports a generated ES module must itself be an ES module, since CommonJS modules cannot import ES modules. ES modules also do not support `NODE_PATH`, so instead the tests directory is converted into a node package with a `file:` dependency on the root thrift package.
diff --git a/lib/nodejs/test/test_driver.mjs b/lib/nodejs/test/test_driver.mjs
new file mode 100644
index 0000000..eca56ba
--- /dev/null
+++ b/lib/nodejs/test/test_driver.mjs
@@ -0,0 +1,366 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * 'License'); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// This is the Node.js test driver for the standard Apache Thrift
+// test service. The driver invokes every function defined in the
+// Thrift Test service with a representative range of parameters.
+//
+// The ThriftTestDriver function requires a client object
+// connected to a server hosting the Thrift Test service and
+// supports an optional callback function which is called with
+// a status message when the test is complete.
+
+import test from "tape";
+
+import helpers from "./helpers.js";
+import thrift from "thrift";
+import Int64 from "node-int64";
+import * as testCases from "./test-cases.mjs";
+
+const ttypes = await import(
+  `./${helpers.genPath}/ThriftTest_types.${helpers.moduleExt}`
+);
+
+const TException = thrift.Thrift.TException;
+
+export const ThriftTestDriver = function (client, callback) {
+  test(
+    "NodeJS Style Callback Client Tests",
+    { skip: helpers.ecmaMode === "es6" },
+    function (assert) {
+      const checkRecursively = makeRecursiveCheck(assert);
+
+      function makeAsserter(assertionFn) {
+        return function (c) {
+          const fnName = c[0];
+          const expected = c[1];
+          client[fnName](expected, function (err, actual) {
+            assert.error(err, fnName + ": no callback error");
+            assertionFn(actual, expected, fnName);
+          });
+        };
+      }
+
+      testCases.simple.forEach(
+        makeAsserter(function (a, e, m) {
+          if (a instanceof Int64) {
+            const e64 = e instanceof Int64 ? e : new Int64(e);
+            assert.deepEqual(a.buffer, e64.buffer, m);
+          } else {
+            assert.equal(a, e, m);
+          }
+        }),
+      );
+      testCases.deep.forEach(makeAsserter(assert.deepEqual));
+      testCases.deepUnordered.forEach(
+        makeAsserter(makeUnorderedDeepEqual(assert)),
+      );
+
+      const arr = [];
+      for (let i = 0; i < 256; ++i) {
+        arr[i] = 255 - i;
+      }
+      let buf = new Buffer(arr);
+      client.testBinary(buf, function (err, response) {
+        assert.error(err, "testBinary: no callback error");
+        assert.equal(response.length, 256, "testBinary");
+        assert.deepEqual(response, buf, "testBinary(Buffer)");
+      });
+      buf = new Buffer(arr);
+      client.testBinary(buf.toString("binary"), function (err, response) {
+        assert.error(err, "testBinary: no callback error");
+        assert.equal(response.length, 256, "testBinary");
+        assert.deepEqual(response, buf, "testBinary(string)");
+      });
+
+      client.testMapMap(42, function (err, response) {
+        const expected = {
+          4: { 1: 1, 2: 2, 3: 3, 4: 4 },
+          "-4": { "-4": -4, "-3": -3, "-2": -2, "-1": -1 },
+        };
+        assert.error(err, "testMapMap: no callback error");
+        assert.deepEqual(expected, response, "testMapMap");
+      });
+
+      client.testStruct(testCases.out, function (err, response) {
+        assert.error(err, "testStruct: no callback error");
+        checkRecursively(testCases.out, response, "testStruct");
+      });
+
+      client.testNest(testCases.out2, function (err, response) {
+        assert.error(err, "testNest: no callback error");
+        checkRecursively(testCases.out2, response, "testNest");
+      });
+
+      client.testInsanity(testCases.crazy, function (err, response) {
+        assert.error(err, "testInsanity: no callback error");
+        checkRecursively(testCases.insanity, response, "testInsanity");
+      });
+
+      client.testInsanity(testCases.crazy2, function (err, response) {
+        assert.error(err, "testInsanity2: no callback error");
+        checkRecursively(testCases.insanity, response, "testInsanity2");
+      });
+
+      client.testException("TException", function (err, response) {
+        assert.ok(
+          err instanceof TException,
+          "testException: correct error type",
+        );
+        assert.ok(!response, "testException: no response");
+      });
+
+      client.testException("Xception", function (err, response) {
+        assert.ok(
+          err instanceof ttypes.Xception,
+          "testException: correct error type",
+        );
+        assert.ok(!response, "testException: no response");
+        assert.equal(err.errorCode, 1001, "testException: correct error code");
+        assert.equal(
+          "Xception",
+          err.message,
+          "testException: correct error message",
+        );
+      });
+
+      client.testException("no Exception", function (err, response) {
+        assert.error(err, "testException: no callback error");
+        assert.ok(!response, "testException: no response");
+      });
+
+      client.testOneway(0, function (err, response) {
+        assert.error(err, "testOneway: no callback error");
+        assert.strictEqual(response, undefined, "testOneway: void response");
+      });
+
+      checkOffByOne(function (done) {
+        client.testI32(-1, function (err, response) {
+          assert.error(err, "checkOffByOne: no callback error");
+          assert.equal(-1, response);
+          assert.end();
+          done();
+        });
+      }, callback);
+    },
+  );
+
+  // ES6 does not support callback style
+  if (helpers.ecmaMode === "es6") {
+    checkOffByOne((done) => done(), callback);
+  }
+};
+
+export const ThriftTestDriverPromise = function (client, callback) {
+  test("Promise Client Tests", function (assert) {
+    const checkRecursively = makeRecursiveCheck(assert);
+
+    function makeAsserter(assertionFn) {
+      return function (c) {
+        const fnName = c[0];
+        const expected = c[1];
+        client[fnName](expected)
+          .then(function (actual) {
+            assertionFn(actual, expected, fnName);
+          })
+          .catch(() => assert.fail("fnName"));
+      };
+    }
+
+    testCases.simple.forEach(
+      makeAsserter(function (a, e, m) {
+        if (a instanceof Int64) {
+          const e64 = e instanceof Int64 ? e : new Int64(e);
+          assert.deepEqual(a.buffer, e64.buffer, m);
+        } else {
+          assert.equal(a, e, m);
+        }
+      }),
+    );
+    testCases.deep.forEach(makeAsserter(assert.deepEqual));
+    testCases.deepUnordered.forEach(
+      makeAsserter(makeUnorderedDeepEqual(assert)),
+    );
+
+    client
+      .testStruct(testCases.out)
+      .then(function (response) {
+        checkRecursively(testCases.out, response, "testStruct");
+      })
+      .catch(() => assert.fail("testStruct"));
+
+    client
+      .testNest(testCases.out2)
+      .then(function (response) {
+        checkRecursively(testCases.out2, response, "testNest");
+      })
+      .catch(() => assert.fail("testNest"));
+
+    client
+      .testInsanity(testCases.crazy)
+      .then(function (response) {
+        checkRecursively(testCases.insanity, response, "testInsanity");
+      })
+      .catch(() => assert.fail("testInsanity"));
+
+    client
+      .testInsanity(testCases.crazy2)
+      .then(function (response) {
+        checkRecursively(testCases.insanity, response, "testInsanity2");
+      })
+      .catch(() => assert.fail("testInsanity2"));
+
+    client
+      .testException("TException")
+      .then(function () {
+        assert.fail("testException: TException");
+      })
+      .catch(function (err) {
+        assert.ok(err instanceof TException);
+      });
+
+    client
+      .testException("Xception")
+      .then(function () {
+        assert.fail("testException: Xception");
+      })
+      .catch(function (err) {
+        assert.ok(err instanceof ttypes.Xception);
+        assert.equal(err.errorCode, 1001);
+        assert.equal("Xception", err.message);
+      });
+
+    client
+      .testException("no Exception")
+      .then(function (response) {
+        assert.equal(undefined, response); //void
+      })
+      .catch(() => assert.fail("testException"));
+
+    client
+      .testOneway(0)
+      .then(function (response) {
+        assert.strictEqual(response, undefined, "testOneway: void response");
+      })
+      .catch(() => assert.fail("testOneway: should not reject"));
+
+    checkOffByOne(function (done) {
+      client
+        .testI32(-1)
+        .then(function (response) {
+          assert.equal(-1, response);
+          assert.end();
+          done();
+        })
+        .catch(() => assert.fail("checkOffByOne"));
+    }, callback);
+  });
+};
+
+// Helper Functions
+// =========================================================
+
+function makeRecursiveCheck(assert) {
+  return function (map1, map2, msg) {
+    const equal = checkRecursively(map1, map2);
+
+    assert.ok(equal, msg);
+
+    // deepEqual doesn't work with fields using node-int64
+    function checkRecursively(map1, map2) {
+      if (typeof map1 !== "function" && typeof map2 !== "function") {
+        if (!map1 || typeof map1 !== "object") {
+          //Handle int64 types (which use node-int64 in Node.js JavaScript)
+          if (
+            typeof map1 === "number" &&
+            typeof map2 === "object" &&
+            map2.buffer &&
+            map2.buffer instanceof Buffer &&
+            map2.buffer.length === 8
+          ) {
+            const n = new Int64(map2.buffer);
+            return map1 === n.toNumber();
+          } else {
+            return map1 == map2;
+          }
+        } else {
+          return Object.keys(map1).every(function (key) {
+            return checkRecursively(map1[key], map2[key]);
+          });
+        }
+      }
+    }
+  };
+}
+
+function checkOffByOne(done, callback) {
+  const retry_limit = 30;
+  const retry_interval = 100;
+  let test_complete = false;
+  let retrys = 0;
+
+  /**
+   * redo a simple test after the oneway to make sure we aren't "off by one" --
+   * if the server treated oneway void like normal void, this next test will
+   * fail since it will get the void confirmation rather than the correct
+   * result. In this circumstance, the client will throw the exception:
+   *
+   * Because this is the last test against the server, when it completes
+   * the entire suite is complete by definition (the tests run serially).
+   */
+  done(function () {
+    test_complete = true;
+  });
+
+  //We wait up to retry_limit * retry_interval for the test suite to complete
+  function TestForCompletion() {
+    if (test_complete && callback) {
+      callback("Server successfully tested!");
+    } else {
+      if (++retrys < retry_limit) {
+        setTimeout(TestForCompletion, retry_interval);
+      } else if (callback) {
+        callback(
+          "Server test failed to complete after " +
+            (retry_limit * retry_interval) / 1000 +
+            " seconds",
+        );
+      }
+    }
+  }
+
+  setTimeout(TestForCompletion, retry_interval);
+}
+
+function makeUnorderedDeepEqual(assert) {
+  return function (actual, expected, name) {
+    assert.equal(actual.length, expected.length, name);
+    for (const k in actual) {
+      let found = false;
+      for (const k2 in expected) {
+        if (actual[k] === expected[k2]) {
+          found = true;
+        }
+      }
+      if (!found) {
+        assert.fail("Unexpected value " + actual[k] + " with key " + k);
+      }
+    }
+  };
+}