From 79c5f78132d2d4de3faa5b064170095db26e9cdc Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 22:48:53 +0200 Subject: [PATCH 01/19] Test packed CLI installations across package managers on Linux --- .github/workflows/ci.yml | 69 +++++++- test/e2e/README.md | 34 ++++ test/e2e/launch-cli.mjs | 12 ++ test/e2e/run.py | 343 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 test/e2e/README.md create mode 100644 test/e2e/launch-cli.mjs create mode 100644 test/e2e/run.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6041533..c0c00d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,8 +93,75 @@ jobs: npm i -g ${{ needs.build.outputs.artifact_filename }} npx create-rescript-app + e2e: + name: E2E (${{ matrix.manager }}) + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 25 + + strategy: + fail-fast: false + matrix: + include: + - manager: npm + package: "" + - manager: pnpm + package: pnpm@10.11.0 + - manager: yarn-classic + package: yarn@1.22.22 + - manager: yarn-berry + package: "@yarnpkg/cli-dist@4.9.2" + - manager: bun + package: "" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.19.0 # minimum supported version + + - name: Install package manager + if: matrix.package != '' + env: + PACKAGE: ${{ matrix.package }} + run: | + npm install --prefix "$RUNNER_TEMP/e2e-tools" "$PACKAGE" + echo "$RUNNER_TEMP/e2e-tools/node_modules/.bin" >> "$GITHUB_PATH" + + - name: Install Bun + if: matrix.manager == 'bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Download packed CLI + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.artifact_filename }} + + - name: Install, compile and run real projects + env: + PACKAGE: ${{ needs.build.outputs.artifact_filename }} + MANAGER: ${{ matrix.manager }} + run: >- + python3 test/e2e/run.py + --package "$PACKAGE" + --manager "$MANAGER" + --logs "$RUNNER_TEMP/cra-e2e-logs" + + - name: Upload failure logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-${{ matrix.manager }}-logs + path: ${{ runner.temp }}/cra-e2e-logs + if-no-files-found: ignore + publish: - needs: [build, test] + needs: [build, test, e2e] if: startsWith(github.ref, 'refs/tags/v') diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 0000000..bb34668 --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,34 @@ +# Installation E2E tests + +The `e2e` job in `.github/workflows/ci.yml` tests the CLI tarball produced by the +build job on Ubuntu with Node 20.19.0, the minimum supported version. + +Each package manager (npm, pnpm, Yarn Classic, Yarn Berry, Bun) creates a basic +project with the current ReScript 12 default and adds ReScript 11.1.4 to an +existing CommonJS project. Both projects install real dependencies, compile +ReScript and execute the generated program. Existing scripts, dependencies and +source files must survive, and the expected package manager lockfile must exist. + +The npm job also covers existing ESM projects, NodeNext and bundler TypeScript +projects (including compiling a genType consumer), devcontainer-only directories, +`.` and `./`, and rejection of an occupied target without modifying its files. + +The harness uses Python's standard-library pseudo-terminal support to answer the +real interactive prompts. It sets `CI=false` for the CLI so the old hardcoded CI +smoke-test path cannot bypass the scenario being tested. A launcher package script +supplies the chosen manager's actual environment for package-manager detection. +Each case uses an isolated temporary directory, including spaces in its path. +Commands have timeouts, and failed CI jobs upload terminal and command logs. + +To reproduce a matrix entry on a POSIX host with Python 3, Node and the chosen +package manager installed: + +```sh +npm pack +python3 test/e2e/run.py --package create-rescript-app-1.12.1.tgz --manager npm --logs /tmp/cra-e2e-logs +``` + +The suite runs on the host operating system. CI provides Linux coverage; running +it directly on macOS provides macOS coverage. The other package-manager names are +`pnpm`, `yarn-classic`, `yarn-berry` and `bun`; install the pinned version from the +workflow when reproducing a particular job. diff --git a/test/e2e/launch-cli.mjs b/test/e2e/launch-cli.mjs new file mode 100644 index 0000000..5efdd58 --- /dev/null +++ b/test/e2e/launch-cli.mjs @@ -0,0 +1,12 @@ +import { spawnSync } from "node:child_process"; + +// Running this script through the chosen package manager supplies its real +// npm_execpath/npm_config_user_agent, just as a user's package script would. +const [projectDirectory, ...args] = process.argv.slice(2); +const result = spawnSync(process.execPath, [process.env.CRA_E2E_CLI, ...args], { + cwd: projectDirectory, + stdio: "inherit", +}); + +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/test/e2e/run.py b/test/e2e/run.py new file mode 100644 index 0000000..d26a6ef --- /dev/null +++ b/test/e2e/run.py @@ -0,0 +1,343 @@ +"""Exercise the packed CLI with real package managers and terminal prompts. + +Uses only Python's standard library, available on the Ubuntu Actions runner. +""" + +import argparse +import errno +import fcntl +import json +import os +from pathlib import Path +import pty +import select +import shlex +import signal +import struct +import subprocess +import tempfile +import termios +import time + + +MANAGERS = { + "npm": ("npm", "package-lock.json"), + "pnpm": ("pnpm", "pnpm-lock.yaml"), + "yarn-classic": ("yarn", "yarn.lock"), + "yarn-berry": ("yarn", "yarn.lock"), + "bun": ("bun", "bun.lock"), +} + + +def check(condition, message): + if not condition: + raise AssertionError(message) + + +def write_json(path, value): + path.write_text(json.dumps(value, indent=2) + "\n") + + +def stop(process): + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + + +class Suite: + def __init__(self, args, workspace): + self.manager = args.manager + self.command, self.lockfile = MANAGERS[self.manager] + self.workspace = workspace + self.logs = args.logs.resolve() + self.logs.mkdir(parents=True, exist_ok=True) + self.env = { + **os.environ, + # Exercise the normal CLI, including existing-project detection. + "CI": "false", + "TERM": "xterm", + "NO_COLOR": "1", + "NPM_CONFIG_AUDIT": "false", + "NPM_CONFIG_FUND": "false", + "YARN_ENABLE_TELEMETRY": "0", + } + self.env.pop("FORCE_COLOR", None) + self.log = self.logs / "setup.log" + self.run(["node", "--version"], workspace) + self.run([self.command, "--version"], workspace) + cli_directory = workspace / "cli" + self.run([ + "npm", "install", "--prefix", str(cli_directory), str(args.package.resolve()), + ], workspace) + self.env["CRA_E2E_CLI"] = str( + cli_directory / "node_modules/create-rescript-app/out/create-rescript-app.cjs" + ) + self.launcher = workspace / "launcher" + self.launcher.mkdir() + wrapper = Path(__file__).with_name("launch-cli.mjs").resolve() + write_json(self.launcher / "package.json", { + "name": "cra-e2e-launcher", + "private": True, + "scripts": {"cra": shlex.join(["node", str(wrapper)])}, + }) + if self.manager == "yarn-berry": + (self.launcher / ".yarnrc.yml").write_text("nodeLinker: node-modules\n") + self.run([self.command, "install"], self.launcher) + self.passed = 0 + + def run(self, command, cwd, expected=0): + with self.log.open("a") as log: + log.write(f"\n$ {shlex.join(command)}\n(cwd: {cwd})\n") + log.flush() + process = subprocess.Popen( + command, cwd=cwd, env=self.env, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, start_new_session=True, + ) + try: + output, _ = process.communicate(timeout=300) + except BaseException: + stop(process) + output, _ = process.communicate() + log.write(output) + raise + log.write(output) + check(process.returncode == expected, + f"Command exited {process.returncode}, expected {expected}: {shlex.join(command)}") + return output + + def cli(self, cwd, args=(), prompts=(), expected=0): + # Every manager runs the same launcher script; package-manager detection + # and the subsequent installation commands run without mocked env values. + separator = ["--"] if self.manager == "npm" else [] + command = [self.command, "run", "cra", *separator, str(cwd), *args] + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 160, 0, 0)) + process = None + with self.log.open("ab") as log: + log.write(f"\n$ {shlex.join(command)}\n".encode()) + try: + process = subprocess.Popen( + command, cwd=self.launcher, env=self.env, stdin=slave, + stdout=slave, stderr=slave, start_new_session=True, + ) + os.close(slave) + slave = None + pending = b"" + next_prompt = 0 + deadline = time.monotonic() + 300 + while True: + check(time.monotonic() < deadline, "CLI timed out waiting for a prompt or installation") + ready, _, _ = select.select([master], [], [], 0.2) + if not ready: + if process.poll() is not None: + break + continue + try: + chunk = os.read(master, 65536) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not chunk: + break + log.write(chunk) + log.flush() + pending += chunk + if next_prompt < len(prompts): + question, answer = prompts[next_prompt] + position = pending.find(question.encode()) + if position >= 0: + os.write(master, answer.encode()) + next_prompt += 1 + pending = b"" + process.wait(timeout=10) + check(process.returncode == expected, + f"CLI exited {process.returncode}, expected {expected}") + check(next_prompt == len(prompts), + f"CLI exited before answering all prompts ({next_prompt}/{len(prompts)})") + finally: + if process is not None: + stop(process) + if slave is not None: + os.close(slave) + os.close(master) + + def case(self, name, perform): + self.log = self.logs / f"{name}.log" + directory = self.workspace / name + directory.mkdir() + print(f"RUN {self.manager}: {name}", flush=True) + try: + perform(directory) + except BaseException: + print(self.log.read_text(errors="replace"), flush=True) + print(f"FAIL {name}; log: {self.log}", flush=True) + raise + self.passed += 1 + print(f"PASS {self.manager}: {name}", flush=True) + + def verify_project(self, directory, suffix, legacy=False): + package = json.loads((directory / "package.json").read_text()) + config = json.loads((directory / "rescript.json").read_text()) + check(config["name"] == package["name"], "Project and package names differ") + check(config["suffix"] == suffix, f"Unexpected generated JS suffix: {config}") + check((directory / self.lockfile).is_file(), f"Missing {self.lockfile}: manager detection failed") + if self.manager != "npm": + check(not (directory / "package-lock.json").exists(), "npm lockfile was not removed") + if self.manager == "yarn-berry": + check("nodeLinker: node-modules" in (directory / ".yarnrc.yml").read_text(), + "Yarn Berry node-modules linker was not configured") + installed = json.loads((directory / "node_modules/rescript/package.json").read_text())["version"] + if legacy: + check(installed == "11.1.4", f"Expected ReScript 11.1.4, installed {installed}") + check("@rescript/core" in package["dependencies"], "Missing ReScript 11 core dependency") + else: + check(installed.startswith("12."), f"Expected a current ReScript 12 version, installed {installed}") + self.run([self.command, "run", "res:build"], directory) + output = self.run(["node", f"src/Demo{suffix}"], directory) + check(output.strip() == "Hello, world!", f"Unexpected generated program output: {output}") + + def new_project(self, directory, target="my-app", devcontainer=False): + project = directory if target in (".", "./") else directory / target + preserved = {} + if devcontainer: + project.mkdir(exist_ok=True) + (project / ".devcontainer").mkdir() + preserved[".devcontainer/devcontainer.json"] = '{"name":"keep me"}\n' + if target in (".", "./"): + preserved.update({"README.md": "Existing README\n", ".gitignore": "keep-this-entry\n"}) + for path, contents in preserved.items(): + (project / path).write_text(contents) + self.cli(directory, [target, "--template", "basic"]) + check(json.loads((project / "package.json").read_text())["name"] == project.name, + "Current-directory package name was not derived from the directory") + for path, contents in preserved.items(): + actual = (project / path).read_text() + check(actual == contents if path != ".gitignore" else actual.startswith(contents), + f"Scaffolding overwrote {path}") + self.verify_project(project, ".res.mjs") + + def existing_project(self, directory, module="commonjs", typescript=None): + original = { + "name": directory.name, + "version": "1.0.0", + "private": True, + "type": module, + "scripts": {"test": "node src/original.cjs"}, + "dependencies": {"is-number": "7.0.0"}, + } + if typescript: + original["devDependencies"] = {"typescript": "5.9.3"} + write_json(directory / "package.json", original) + (directory / "src").mkdir() + preserved = { + "README.md": "Existing project README\n", + ".gitignore": "existing-ignore\n", + "src/original.cjs": 'console.log(require("is-number")(42));\n', + } + if typescript: + preserved["src/original.ts"] = "export const existingValue: number = 1;\n" + preserved["tsconfig.base.json"] = json.dumps({"compilerOptions": { + "module": "NodeNext" if typescript == "nodenext" else "ESNext", + "moduleResolution": typescript, + "target": "ES2022", "strict": True, "noEmit": True, + }}, indent=2) + (directory / "tsconfig.json").write_text('''{ + // Preserve this comment and the inherited configuration. + "extends": "./tsconfig.base.json", + "compilerOptions": {"skipLibCheck": true,}, + "include": ["src"], +} +''') + for path, contents in preserved.items(): + (directory / path).write_text(contents) + self.run([self.command, "install"], directory) + prompts = [ + ("Do you want to add ReScript", "y"), + # The reversed registry list ends with the sole supported v11 + # release. Arrow-up wraps to it; assert the installed version below. + ("ReScript version?", "\r" if typescript else "\x1b[A\r"), + ("Where will you put your ReScript source files?", "\r"), + ] + if typescript: + prompts.append(("Set compilerOptions.allowJs to true", "y")) + if typescript == "bundler": + prompts.append(("Set compilerOptions.allowImportingTsExtensions to true", "y")) + else: + prompts.append(("What module system will you use?", "\r" if module == "module" else "\x1b[B\r")) + prompts.append(("Do you want to check generated", "n")) + self.cli(directory, prompts=prompts) + package = json.loads((directory / "package.json").read_text()) + for field in ("name", "version", "private", "type"): + check(package[field] == original[field], f"Changed existing package field {field}") + check(package["scripts"]["test"] == original["scripts"]["test"], "Overwrote existing script") + check(package["dependencies"]["is-number"] == "7.0.0", "Changed existing dependency") + if typescript: + check(package["devDependencies"] == original["devDependencies"], "Changed TypeScript dependency") + for path, contents in preserved.items(): + actual = (directory / path).read_text() + check(actual == contents if path != ".gitignore" else actual.startswith(contents), + f"Adding ReScript overwrote {path}") + suffix = ".res.js" if typescript or module == "commonjs" else ".res.mjs" + check(f"**/*{suffix}" in (directory / ".gitignore").read_text(), "Generated JS was not ignored") + if typescript: + config = json.loads((directory / "rescript.json").read_text()) + check(config["gentypeconfig"]["moduleResolution"] == + ("node16" if typescript == "nodenext" else "bundler"), "Incorrect genType mapping") + tsconfig = (directory / "tsconfig.json").read_text() + check("// Preserve this comment" in tsconfig, "Lost tsconfig comment") + check('"allowJs": true' in tsconfig, "Did not enable allowJs") + if typescript == "bundler": + check('"allowImportingTsExtensions": true' in tsconfig, "Did not enable TS extension imports") + (directory / "src/Interop.res").write_text("@genType\nlet answer = 42\n") + extension = "ts" if typescript == "bundler" else "js" + (directory / "src/consumer.ts").write_text( + f'import {{answer}} from "./Interop.gen.{extension}";\nconst value: number = answer;\n' + ) + self.verify_project(directory, suffix, legacy=not typescript) + check(self.run([self.command, "run", "test"], directory).strip().endswith("true"), + "Existing JavaScript program no longer runs") + if typescript: + check((directory / "src/Interop.gen.ts").is_file(), "genType did not generate TypeScript") + self.run(["node", "node_modules/typescript/bin/tsc", "--noEmit"], directory) + + def occupied_directory(self, directory): + project = directory / "occupied" + project.mkdir() + sentinel = project / "keep.txt" + sentinel.write_text("Do not overwrite\n") + self.cli(directory, ["occupied", "--template", "basic"], expected=1) + check(sentinel.read_text() == "Do not overwrite\n", "Overwrote occupied directory") + check(sorted(path.name for path in project.iterdir()) == ["keep.txt"], + "Failed scaffolding left files in the occupied directory") + + def execute(self): + self.case("new-basic", self.new_project) + self.case("existing-commonjs", self.existing_project) + if self.manager == "npm": + self.case("existing-esm", lambda path: self.existing_project(path, module="module")) + for mode in ("nodenext", "bundler"): + self.case(f"existing-ts-{mode}", lambda path, mode=mode: + self.existing_project(path, module="module", typescript=mode)) + for name, target in (("devcontainer-named", "my-app"), ("devcontainer-dot", "."), + ("devcontainer-dot-slash", "./")): + self.case(name, lambda path, target=target: + self.new_project(path, target=target, devcontainer=True)) + self.case("occupied-directory", self.occupied_directory) + print(f"PASS: {self.passed} E2E cases for {self.manager}", flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package", type=Path, required=True, help="npm pack tarball to test") + parser.add_argument("--manager", choices=MANAGERS, required=True) + parser.add_argument("--logs", type=Path, required=True) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="cra e2e ") as directory: + Suite(args, Path(directory)).execute() + + +if __name__ == "__main__": + main() From 7dfe96334c136afe956716741f0e36b1fb422539 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 22:52:19 +0200 Subject: [PATCH 02/19] Allow package manager completion output in E2E assertions --- test/e2e/run.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/e2e/run.py b/test/e2e/run.py index d26a6ef..33564b3 100644 --- a/test/e2e/run.py +++ b/test/e2e/run.py @@ -297,7 +297,8 @@ def existing_project(self, directory, module="commonjs", typescript=None): f'import {{answer}} from "./Interop.gen.{extension}";\nconst value: number = answer;\n' ) self.verify_project(directory, suffix, legacy=not typescript) - check(self.run([self.command, "run", "test"], directory).strip().endswith("true"), + output = self.run([self.command, "run", "test"], directory) + check("true" in output.splitlines(), "Existing JavaScript program no longer runs") if typescript: check((directory / "src/Interop.gen.ts").is_file(), "genType did not generate TypeScript") From 8dcf2c1d18816da31d7dbabea0f1cd6f64319ee5 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 22:56:23 +0200 Subject: [PATCH 03/19] Use ReScript 12 for existing-project E2E coverage --- test/e2e/README.md | 4 ++-- test/e2e/run.py | 16 ++++++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/test/e2e/README.md b/test/e2e/README.md index bb34668..ca982f3 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -4,8 +4,8 @@ The `e2e` job in `.github/workflows/ci.yml` tests the CLI tarball produced by th build job on Ubuntu with Node 20.19.0, the minimum supported version. Each package manager (npm, pnpm, Yarn Classic, Yarn Berry, Bun) creates a basic -project with the current ReScript 12 default and adds ReScript 11.1.4 to an -existing CommonJS project. Both projects install real dependencies, compile +project and adds ReScript to an existing CommonJS project using the current +ReScript 12 release. Both projects install real dependencies, compile ReScript and execute the generated program. Existing scripts, dependencies and source files must survive, and the expected package manager lockfile must exist. diff --git a/test/e2e/run.py b/test/e2e/run.py index 33564b3..c8f97d7 100644 --- a/test/e2e/run.py +++ b/test/e2e/run.py @@ -178,7 +178,7 @@ def case(self, name, perform): self.passed += 1 print(f"PASS {self.manager}: {name}", flush=True) - def verify_project(self, directory, suffix, legacy=False): + def verify_project(self, directory, suffix): package = json.loads((directory / "package.json").read_text()) config = json.loads((directory / "rescript.json").read_text()) check(config["name"] == package["name"], "Project and package names differ") @@ -190,11 +190,9 @@ def verify_project(self, directory, suffix, legacy=False): check("nodeLinker: node-modules" in (directory / ".yarnrc.yml").read_text(), "Yarn Berry node-modules linker was not configured") installed = json.loads((directory / "node_modules/rescript/package.json").read_text())["version"] - if legacy: - check(installed == "11.1.4", f"Expected ReScript 11.1.4, installed {installed}") - check("@rescript/core" in package["dependencies"], "Missing ReScript 11 core dependency") - else: - check(installed.startswith("12."), f"Expected a current ReScript 12 version, installed {installed}") + check(installed.startswith("12."), f"Expected a current ReScript 12 version, installed {installed}") + check("@rescript/core" not in package.get("dependencies", {}), + "ReScript 12 should use its bundled standard library") self.run([self.command, "run", "res:build"], directory) output = self.run(["node", f"src/Demo{suffix}"], directory) check(output.strip() == "Hello, world!", f"Unexpected generated program output: {output}") @@ -256,9 +254,7 @@ def existing_project(self, directory, module="commonjs", typescript=None): self.run([self.command, "install"], directory) prompts = [ ("Do you want to add ReScript", "y"), - # The reversed registry list ends with the sole supported v11 - # release. Arrow-up wraps to it; assert the installed version below. - ("ReScript version?", "\r" if typescript else "\x1b[A\r"), + ("ReScript version?", "\r"), ("Where will you put your ReScript source files?", "\r"), ] if typescript: @@ -296,7 +292,7 @@ def existing_project(self, directory, module="commonjs", typescript=None): (directory / "src/consumer.ts").write_text( f'import {{answer}} from "./Interop.gen.{extension}";\nconst value: number = answer;\n' ) - self.verify_project(directory, suffix, legacy=not typescript) + self.verify_project(directory, suffix) output = self.run([self.command, "run", "test"], directory) check("true" in output.splitlines(), "Existing JavaScript program no longer runs") From ab1393dd6d10090faf1a073c104515bb59e0a506 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 22:59:14 +0200 Subject: [PATCH 04/19] Run installation E2E tests in JavaScript --- .github/workflows/ci.yml | 2 +- package.json | 1 + test/e2e/README.md | 12 +- test/e2e/run.mjs | 342 +++++++++++++++++++++++++++++++++++++++ test/e2e/run.py | 340 -------------------------------------- 5 files changed, 350 insertions(+), 347 deletions(-) create mode 100644 test/e2e/run.mjs delete mode 100644 test/e2e/run.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0c00d7..24d8fc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,7 +147,7 @@ jobs: PACKAGE: ${{ needs.build.outputs.artifact_filename }} MANAGER: ${{ matrix.manager }} run: >- - python3 test/e2e/run.py + npm run test:e2e -- --package "$PACKAGE" --manager "$MANAGER" --logs "$RUNNER_TEMP/cra-e2e-logs" diff --git a/package.json b/package.json index c407be1..c54c105 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "start": "node src/Main.res.mjs", "prepack": "rescript && rollup -c", "test": "rescript && node --test test/*Test.res.mjs test/*Test.mjs", + "test:e2e": "node test/e2e/run.mjs", "format": "rescript format", "dev": "rescript -w" }, diff --git a/test/e2e/README.md b/test/e2e/README.md index ca982f3..41166ee 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -13,22 +13,22 @@ The npm job also covers existing ESM projects, NodeNext and bundler TypeScript projects (including compiling a genType consumer), devcontainer-only directories, `.` and `./`, and rejection of an occupied target without modifying its files. -The harness uses Python's standard-library pseudo-terminal support to answer the -real interactive prompts. It sets `CI=false` for the CLI so the old hardcoded CI +The harness uses Node.js built-ins and Ubuntu's `script` utility (from util-linux) +to answer the real interactive prompts through a pseudo-terminal. It sets +`CI=false` for the CLI so the old hardcoded CI smoke-test path cannot bypass the scenario being tested. A launcher package script supplies the chosen manager's actual environment for package-manager detection. Each case uses an isolated temporary directory, including spaces in its path. Commands have timeouts, and failed CI jobs upload terminal and command logs. -To reproduce a matrix entry on a POSIX host with Python 3, Node and the chosen +To reproduce a matrix entry on Linux with Node, util-linux and the chosen package manager installed: ```sh npm pack -python3 test/e2e/run.py --package create-rescript-app-1.12.1.tgz --manager npm --logs /tmp/cra-e2e-logs +npm run test:e2e -- --package create-rescript-app-1.12.1.tgz --manager npm --logs /tmp/cra-e2e-logs ``` -The suite runs on the host operating system. CI provides Linux coverage; running -it directly on macOS provides macOS coverage. The other package-manager names are +The terminal runner currently targets Linux, matching CI. The other package-manager names are `pnpm`, `yarn-classic`, `yarn-berry` and `bun`; install the pinned version from the workflow when reproducing a particular job. diff --git a/test/e2e/run.mjs b/test/e2e/run.mjs new file mode 100644 index 0000000..733db93 --- /dev/null +++ b/test/e2e/run.mjs @@ -0,0 +1,342 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { + appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, + readdirSync, rmSync, writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +const managers = { + npm: { command: "npm", lockfile: "package-lock.json" }, + pnpm: { command: "pnpm", lockfile: "pnpm-lock.yaml" }, + "yarn-classic": { command: "yarn", lockfile: "yarn.lock" }, + "yarn-berry": { command: "yarn", lockfile: "yarn.lock" }, + bun: { command: "bun", lockfile: "bun.lock" }, +}; + +const read = path => readFileSync(path, "utf8"); +const readJson = path => JSON.parse(read(path)); +const writeJson = (path, value) => writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +const shellQuote = value => `'${value.replaceAll("'", "'\\''")}'`; +const shellJoin = command => command.map(shellQuote).join(" "); + +class Suite { + constructor(options, workspace) { + this.manager = options.manager; + Object.assign(this, managers[this.manager]); + this.workspace = workspace; + this.logs = resolve(options.logs); + this.package = resolve(options.package); + mkdirSync(this.logs, { recursive: true }); + this.log = join(this.logs, "setup.log"); + this.env = { + ...process.env, + // Exercise normal project detection and prompts, bypassing the old CI-only path. + CI: "false", + TERM: "xterm", + NO_COLOR: "1", + NPM_CONFIG_AUDIT: "false", + NPM_CONFIG_FUND: "false", + YARN_ENABLE_TELEMETRY: "0", + }; + delete this.env.FORCE_COLOR; + this.passed = 0; + } + + async setup() { + await this.run(["node", "--version"], this.workspace); + await this.run([this.command, "--version"], this.workspace); + const cliDirectory = join(this.workspace, "cli"); + await this.run(["npm", "install", "--prefix", cliDirectory, this.package], this.workspace); + this.env.CRA_E2E_CLI = join(cliDirectory, "node_modules/create-rescript-app/out/create-rescript-app.cjs"); + this.launcher = join(this.workspace, "launcher"); + mkdirSync(this.launcher); + const wrapper = fileURLToPath(new URL("./launch-cli.mjs", import.meta.url)); + writeJson(join(this.launcher, "package.json"), { + name: "cra-e2e-launcher", + private: true, + scripts: { cra: shellJoin(["node", wrapper]) }, + }); + if (this.manager === "yarn-berry") { + writeFileSync(join(this.launcher, ".yarnrc.yml"), "nodeLinker: node-modules\n"); + } + await this.run([this.command, "install"], this.launcher); + } + + async run(command, cwd, { expected = 0, terminal = false, prompts = [] } = {}) { + appendFileSync(this.log, `\n$ ${shellJoin(command)}\n(cwd: ${cwd})\n`); + // Ubuntu's util-linux script allocates a real terminal for the interactive CLI. + // Node handles process control, prompt answers and assertions without native addons. + const invocation = terminal + ? ["script", "--quiet", "--return", "--echo=never", "--command", + `stty cols 160 rows 40; exec ${shellJoin(command)}`, "/dev/null"] + : command; + const child = spawn(invocation[0], invocation.slice(1), { + cwd, + env: this.env, + detached: true, + stdio: [terminal ? "pipe" : "ignore", "pipe", "pipe"], + }); + let output = ""; + let pending = ""; + let nextPrompt = 0; + let timedOut = false; + let inputError; + let forceKill; + const killGroup = signal => { + try { + process.kill(-child.pid, signal); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + }; + const timeout = setTimeout(() => { + timedOut = true; + killGroup("SIGTERM"); + forceKill = setTimeout(() => killGroup("SIGKILL"), 3000); + }, 300_000); + const collect = chunk => { + output += chunk; + appendFileSync(this.log, chunk); + pending += chunk; + if (nextPrompt < prompts.length) { + const [question, answer] = prompts[nextPrompt]; + if (pending.includes(question)) { + child.stdin.write(answer); + nextPrompt += 1; + pending = ""; + } + } + }; + child.stdout.setEncoding("utf8").on("data", collect); + child.stderr.setEncoding("utf8").on("data", collect); + // The terminal may close immediately after its last answer is written. + child.stdin?.on("error", error => { + if (error.code !== "EPIPE") { + inputError = error; + killGroup("SIGTERM"); + } + }); + try { + const [code, signal] = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve([code, signal])); + }); + if (inputError) throw inputError; + assert(!timedOut, `Command timed out: ${shellJoin(command)}`); + assert.equal(code, expected, `Command exited ${code ?? signal}, expected ${expected}: ${shellJoin(command)}`); + assert.equal(nextPrompt, prompts.length, "CLI exited before answering all prompts"); + return output; + } finally { + clearTimeout(timeout); + clearTimeout(forceKill); + child.stdin?.destroy(); + } + } + + async cli(cwd, args = [], options = {}) { + // Package scripts supply the manager's actual npm_execpath/user agent. + const separator = this.manager === "npm" ? ["--"] : []; + return this.run([this.command, "run", "cra", ...separator, cwd, ...args], this.launcher, { + ...options, + terminal: true, + }); + } + + async case(name, perform) { + this.log = join(this.logs, `${name}.log`); + writeFileSync(this.log, ""); + const directory = join(this.workspace, name); + mkdirSync(directory); + console.log(`RUN ${this.manager}: ${name}`); + await perform(directory); + this.passed += 1; + console.log(`PASS ${this.manager}: ${name}`); + } + + async verifyProject(directory, suffix) { + const packageJson = readJson(join(directory, "package.json")); + const config = readJson(join(directory, "rescript.json")); + assert.equal(config.name, packageJson.name, "Project and package names differ"); + assert.equal(config.suffix, suffix, "Unexpected generated JS suffix"); + assert(existsSync(join(directory, this.lockfile)), `Missing ${this.lockfile}: manager detection failed`); + if (this.manager !== "npm") { + assert(!existsSync(join(directory, "package-lock.json")), "npm lockfile was not removed"); + } + if (this.manager === "yarn-berry") { + assert(read(join(directory, ".yarnrc.yml")).includes("nodeLinker: node-modules"), + "Yarn Berry node-modules linker was not configured"); + } + const { version } = readJson(join(directory, "node_modules/rescript/package.json")); + assert(version.startsWith("12."), `Expected a current ReScript 12 version, installed ${version}`); + assert(!Object.hasOwn(packageJson.dependencies ?? {}, "@rescript/core"), + "ReScript 12 should use its bundled standard library"); + await this.run([this.command, "run", "res:build"], directory); + const output = await this.run(["node", `src/Demo${suffix}`], directory); + assert.equal(output.trim(), "Hello, world!", "Unexpected generated program output"); + } + + verifyPreserved(directory, preserved) { + for (const [path, contents] of Object.entries(preserved)) { + const actual = read(join(directory, path)); + if (path === ".gitignore") { + assert(actual.startsWith(contents), `Overwrote ${path}`); + } else { + assert.equal(actual, contents, `Overwrote ${path}`); + } + } + } + + async newProject(directory, { target = "my-app", devcontainer = false } = {}) { + const currentDirectory = target === "." || target === "./"; + const project = currentDirectory ? directory : join(directory, target); + const preserved = {}; + if (devcontainer) { + mkdirSync(join(project, ".devcontainer"), { recursive: true }); + preserved[".devcontainer/devcontainer.json"] = '{"name":"keep me"}\n'; + if (currentDirectory) { + Object.assign(preserved, { "README.md": "Existing README\n", ".gitignore": "keep-this-entry\n" }); + } + for (const [path, contents] of Object.entries(preserved)) writeFileSync(join(project, path), contents); + } + await this.cli(directory, [target, "--template", "basic"]); + assert.equal(readJson(join(project, "package.json")).name, basename(project), + "Current-directory package name was not derived from the directory"); + this.verifyPreserved(project, preserved); + await this.verifyProject(project, ".res.mjs"); + } + + async existingProject(directory, { module = "commonjs", typescript } = {}) { + const original = { + name: basename(directory), + version: "1.0.0", + private: true, + type: module, + scripts: { test: "node src/original.cjs" }, + dependencies: { "is-number": "7.0.0" }, + }; + if (typescript) original.devDependencies = { typescript: "5.9.3" }; + writeJson(join(directory, "package.json"), original); + mkdirSync(join(directory, "src")); + const preserved = { + "README.md": "Existing project README\n", + ".gitignore": "existing-ignore\n", + "src/original.cjs": 'console.log(require("is-number")(42));\n', + }; + if (typescript) { + preserved["src/original.ts"] = "export const existingValue: number = 1;\n"; + preserved["tsconfig.base.json"] = JSON.stringify({ compilerOptions: { + module: typescript === "nodenext" ? "NodeNext" : "ESNext", + moduleResolution: typescript, + target: "ES2022", strict: true, noEmit: true, + } }, null, 2); + writeFileSync(join(directory, "tsconfig.json"), `{ + // Preserve this comment and the inherited configuration. + "extends": "./tsconfig.base.json", + "compilerOptions": {"skipLibCheck": true,}, + "include": ["src"], +} +`); + } + for (const [path, contents] of Object.entries(preserved)) writeFileSync(join(directory, path), contents); + await this.run([this.command, "install"], directory); + const prompts = [ + ["Do you want to add ReScript", "y"], + ["ReScript version?", "\r"], + ["Where will you put your ReScript source files?", "\r"], + ]; + if (typescript) { + prompts.push(["Set compilerOptions.allowJs to true", "y"]); + if (typescript === "bundler") { + prompts.push(["Set compilerOptions.allowImportingTsExtensions to true", "y"]); + } + } else { + prompts.push(["What module system will you use?", module === "module" ? "\r" : "\x1b[B\r"]); + } + prompts.push(["Do you want to check generated", "n"]); + await this.cli(directory, [], { prompts }); + const packageJson = readJson(join(directory, "package.json")); + for (const field of ["name", "version", "private", "type"]) { + assert.equal(packageJson[field], original[field], `Changed existing package field ${field}`); + } + assert.equal(packageJson.scripts.test, original.scripts.test, "Overwrote existing script"); + assert.equal(packageJson.dependencies["is-number"], "7.0.0", "Changed existing dependency"); + if (typescript) assert.deepEqual(packageJson.devDependencies, original.devDependencies, "Changed TypeScript dependency"); + this.verifyPreserved(directory, preserved); + const suffix = typescript || module === "commonjs" ? ".res.js" : ".res.mjs"; + assert(read(join(directory, ".gitignore")).includes(`**/*${suffix}`), "Generated JS was not ignored"); + if (typescript) { + const config = readJson(join(directory, "rescript.json")); + assert.equal(config.gentypeconfig.moduleResolution, + typescript === "nodenext" ? "node16" : "bundler", "Incorrect genType mapping"); + const tsconfig = read(join(directory, "tsconfig.json")); + assert(tsconfig.includes("// Preserve this comment"), "Lost tsconfig comment"); + assert(tsconfig.includes('"allowJs": true'), "Did not enable allowJs"); + if (typescript === "bundler") { + assert(tsconfig.includes('"allowImportingTsExtensions": true'), "Did not enable TS extension imports"); + } + writeFileSync(join(directory, "src/Interop.res"), "@genType\nlet answer = 42\n"); + const extension = typescript === "bundler" ? "ts" : "js"; + writeFileSync(join(directory, "src/consumer.ts"), + `import {answer} from "./Interop.gen.${extension}";\nconst value: number = answer;\n`); + } + await this.verifyProject(directory, suffix); + const output = await this.run([this.command, "run", "test"], directory); + assert(output.split(/\r?\n/).includes("true"), "Existing JavaScript program no longer runs"); + if (typescript) { + assert(existsSync(join(directory, "src/Interop.gen.ts")), "genType did not generate TypeScript"); + await this.run(["node", "node_modules/typescript/bin/tsc", "--noEmit"], directory); + } + } + + async occupiedDirectory(directory) { + const project = join(directory, "occupied"); + mkdirSync(project); + const sentinel = join(project, "keep.txt"); + writeFileSync(sentinel, "Do not overwrite\n"); + await this.cli(directory, ["occupied", "--template", "basic"], { expected: 1 }); + assert.equal(read(sentinel), "Do not overwrite\n", "Overwrote occupied directory"); + assert.deepEqual(readdirSync(project), ["keep.txt"], "Failed scaffolding left files in the occupied directory"); + } + + async execute() { + await this.case("new-basic", path => this.newProject(path)); + await this.case("existing-commonjs", path => this.existingProject(path)); + if (this.manager === "npm") { + await this.case("existing-esm", path => this.existingProject(path, { module: "module" })); + for (const typescript of ["nodenext", "bundler"]) { + await this.case(`existing-ts-${typescript}`, path => this.existingProject(path, { module: "module", typescript })); + } + for (const [name, target] of [["devcontainer-named", "my-app"], ["devcontainer-dot", "."], + ["devcontainer-dot-slash", "./"]]) { + await this.case(name, path => this.newProject(path, { target, devcontainer: true })); + } + await this.case("occupied-directory", path => this.occupiedDirectory(path)); + } + console.log(`PASS: ${this.passed} E2E cases for ${this.manager}`); + } +} + +const { values } = parseArgs({ options: { + package: { type: "string" }, manager: { type: "string" }, logs: { type: "string" }, +} }); +assert.equal(process.platform, "linux", "The E2E terminal runner requires Linux with util-linux script"); +assert(values.package && values.logs && Object.hasOwn(managers, values.manager), + `Usage: npm run test:e2e -- --package --manager <${Object.keys(managers).join("|")}> --logs `); + +const workspace = mkdtempSync(join(tmpdir(), "cra e2e ")); +const suite = new Suite(values, workspace); +try { + await suite.setup(); + await suite.execute(); +} catch (error) { + if (existsSync(suite.log)) console.error(read(suite.log)); + console.error(`FAIL ${suite.manager}; log: ${suite.log}`); + console.error(error); + process.exitCode = 1; +} finally { + rmSync(workspace, { recursive: true, force: true }); +} diff --git a/test/e2e/run.py b/test/e2e/run.py deleted file mode 100644 index c8f97d7..0000000 --- a/test/e2e/run.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Exercise the packed CLI with real package managers and terminal prompts. - -Uses only Python's standard library, available on the Ubuntu Actions runner. -""" - -import argparse -import errno -import fcntl -import json -import os -from pathlib import Path -import pty -import select -import shlex -import signal -import struct -import subprocess -import tempfile -import termios -import time - - -MANAGERS = { - "npm": ("npm", "package-lock.json"), - "pnpm": ("pnpm", "pnpm-lock.yaml"), - "yarn-classic": ("yarn", "yarn.lock"), - "yarn-berry": ("yarn", "yarn.lock"), - "bun": ("bun", "bun.lock"), -} - - -def check(condition, message): - if not condition: - raise AssertionError(message) - - -def write_json(path, value): - path.write_text(json.dumps(value, indent=2) + "\n") - - -def stop(process): - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - process.wait() - - -class Suite: - def __init__(self, args, workspace): - self.manager = args.manager - self.command, self.lockfile = MANAGERS[self.manager] - self.workspace = workspace - self.logs = args.logs.resolve() - self.logs.mkdir(parents=True, exist_ok=True) - self.env = { - **os.environ, - # Exercise the normal CLI, including existing-project detection. - "CI": "false", - "TERM": "xterm", - "NO_COLOR": "1", - "NPM_CONFIG_AUDIT": "false", - "NPM_CONFIG_FUND": "false", - "YARN_ENABLE_TELEMETRY": "0", - } - self.env.pop("FORCE_COLOR", None) - self.log = self.logs / "setup.log" - self.run(["node", "--version"], workspace) - self.run([self.command, "--version"], workspace) - cli_directory = workspace / "cli" - self.run([ - "npm", "install", "--prefix", str(cli_directory), str(args.package.resolve()), - ], workspace) - self.env["CRA_E2E_CLI"] = str( - cli_directory / "node_modules/create-rescript-app/out/create-rescript-app.cjs" - ) - self.launcher = workspace / "launcher" - self.launcher.mkdir() - wrapper = Path(__file__).with_name("launch-cli.mjs").resolve() - write_json(self.launcher / "package.json", { - "name": "cra-e2e-launcher", - "private": True, - "scripts": {"cra": shlex.join(["node", str(wrapper)])}, - }) - if self.manager == "yarn-berry": - (self.launcher / ".yarnrc.yml").write_text("nodeLinker: node-modules\n") - self.run([self.command, "install"], self.launcher) - self.passed = 0 - - def run(self, command, cwd, expected=0): - with self.log.open("a") as log: - log.write(f"\n$ {shlex.join(command)}\n(cwd: {cwd})\n") - log.flush() - process = subprocess.Popen( - command, cwd=cwd, env=self.env, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, text=True, start_new_session=True, - ) - try: - output, _ = process.communicate(timeout=300) - except BaseException: - stop(process) - output, _ = process.communicate() - log.write(output) - raise - log.write(output) - check(process.returncode == expected, - f"Command exited {process.returncode}, expected {expected}: {shlex.join(command)}") - return output - - def cli(self, cwd, args=(), prompts=(), expected=0): - # Every manager runs the same launcher script; package-manager detection - # and the subsequent installation commands run without mocked env values. - separator = ["--"] if self.manager == "npm" else [] - command = [self.command, "run", "cra", *separator, str(cwd), *args] - master, slave = pty.openpty() - fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 160, 0, 0)) - process = None - with self.log.open("ab") as log: - log.write(f"\n$ {shlex.join(command)}\n".encode()) - try: - process = subprocess.Popen( - command, cwd=self.launcher, env=self.env, stdin=slave, - stdout=slave, stderr=slave, start_new_session=True, - ) - os.close(slave) - slave = None - pending = b"" - next_prompt = 0 - deadline = time.monotonic() + 300 - while True: - check(time.monotonic() < deadline, "CLI timed out waiting for a prompt or installation") - ready, _, _ = select.select([master], [], [], 0.2) - if not ready: - if process.poll() is not None: - break - continue - try: - chunk = os.read(master, 65536) - except OSError as error: - if error.errno == errno.EIO: - break - raise - if not chunk: - break - log.write(chunk) - log.flush() - pending += chunk - if next_prompt < len(prompts): - question, answer = prompts[next_prompt] - position = pending.find(question.encode()) - if position >= 0: - os.write(master, answer.encode()) - next_prompt += 1 - pending = b"" - process.wait(timeout=10) - check(process.returncode == expected, - f"CLI exited {process.returncode}, expected {expected}") - check(next_prompt == len(prompts), - f"CLI exited before answering all prompts ({next_prompt}/{len(prompts)})") - finally: - if process is not None: - stop(process) - if slave is not None: - os.close(slave) - os.close(master) - - def case(self, name, perform): - self.log = self.logs / f"{name}.log" - directory = self.workspace / name - directory.mkdir() - print(f"RUN {self.manager}: {name}", flush=True) - try: - perform(directory) - except BaseException: - print(self.log.read_text(errors="replace"), flush=True) - print(f"FAIL {name}; log: {self.log}", flush=True) - raise - self.passed += 1 - print(f"PASS {self.manager}: {name}", flush=True) - - def verify_project(self, directory, suffix): - package = json.loads((directory / "package.json").read_text()) - config = json.loads((directory / "rescript.json").read_text()) - check(config["name"] == package["name"], "Project and package names differ") - check(config["suffix"] == suffix, f"Unexpected generated JS suffix: {config}") - check((directory / self.lockfile).is_file(), f"Missing {self.lockfile}: manager detection failed") - if self.manager != "npm": - check(not (directory / "package-lock.json").exists(), "npm lockfile was not removed") - if self.manager == "yarn-berry": - check("nodeLinker: node-modules" in (directory / ".yarnrc.yml").read_text(), - "Yarn Berry node-modules linker was not configured") - installed = json.loads((directory / "node_modules/rescript/package.json").read_text())["version"] - check(installed.startswith("12."), f"Expected a current ReScript 12 version, installed {installed}") - check("@rescript/core" not in package.get("dependencies", {}), - "ReScript 12 should use its bundled standard library") - self.run([self.command, "run", "res:build"], directory) - output = self.run(["node", f"src/Demo{suffix}"], directory) - check(output.strip() == "Hello, world!", f"Unexpected generated program output: {output}") - - def new_project(self, directory, target="my-app", devcontainer=False): - project = directory if target in (".", "./") else directory / target - preserved = {} - if devcontainer: - project.mkdir(exist_ok=True) - (project / ".devcontainer").mkdir() - preserved[".devcontainer/devcontainer.json"] = '{"name":"keep me"}\n' - if target in (".", "./"): - preserved.update({"README.md": "Existing README\n", ".gitignore": "keep-this-entry\n"}) - for path, contents in preserved.items(): - (project / path).write_text(contents) - self.cli(directory, [target, "--template", "basic"]) - check(json.loads((project / "package.json").read_text())["name"] == project.name, - "Current-directory package name was not derived from the directory") - for path, contents in preserved.items(): - actual = (project / path).read_text() - check(actual == contents if path != ".gitignore" else actual.startswith(contents), - f"Scaffolding overwrote {path}") - self.verify_project(project, ".res.mjs") - - def existing_project(self, directory, module="commonjs", typescript=None): - original = { - "name": directory.name, - "version": "1.0.0", - "private": True, - "type": module, - "scripts": {"test": "node src/original.cjs"}, - "dependencies": {"is-number": "7.0.0"}, - } - if typescript: - original["devDependencies"] = {"typescript": "5.9.3"} - write_json(directory / "package.json", original) - (directory / "src").mkdir() - preserved = { - "README.md": "Existing project README\n", - ".gitignore": "existing-ignore\n", - "src/original.cjs": 'console.log(require("is-number")(42));\n', - } - if typescript: - preserved["src/original.ts"] = "export const existingValue: number = 1;\n" - preserved["tsconfig.base.json"] = json.dumps({"compilerOptions": { - "module": "NodeNext" if typescript == "nodenext" else "ESNext", - "moduleResolution": typescript, - "target": "ES2022", "strict": True, "noEmit": True, - }}, indent=2) - (directory / "tsconfig.json").write_text('''{ - // Preserve this comment and the inherited configuration. - "extends": "./tsconfig.base.json", - "compilerOptions": {"skipLibCheck": true,}, - "include": ["src"], -} -''') - for path, contents in preserved.items(): - (directory / path).write_text(contents) - self.run([self.command, "install"], directory) - prompts = [ - ("Do you want to add ReScript", "y"), - ("ReScript version?", "\r"), - ("Where will you put your ReScript source files?", "\r"), - ] - if typescript: - prompts.append(("Set compilerOptions.allowJs to true", "y")) - if typescript == "bundler": - prompts.append(("Set compilerOptions.allowImportingTsExtensions to true", "y")) - else: - prompts.append(("What module system will you use?", "\r" if module == "module" else "\x1b[B\r")) - prompts.append(("Do you want to check generated", "n")) - self.cli(directory, prompts=prompts) - package = json.loads((directory / "package.json").read_text()) - for field in ("name", "version", "private", "type"): - check(package[field] == original[field], f"Changed existing package field {field}") - check(package["scripts"]["test"] == original["scripts"]["test"], "Overwrote existing script") - check(package["dependencies"]["is-number"] == "7.0.0", "Changed existing dependency") - if typescript: - check(package["devDependencies"] == original["devDependencies"], "Changed TypeScript dependency") - for path, contents in preserved.items(): - actual = (directory / path).read_text() - check(actual == contents if path != ".gitignore" else actual.startswith(contents), - f"Adding ReScript overwrote {path}") - suffix = ".res.js" if typescript or module == "commonjs" else ".res.mjs" - check(f"**/*{suffix}" in (directory / ".gitignore").read_text(), "Generated JS was not ignored") - if typescript: - config = json.loads((directory / "rescript.json").read_text()) - check(config["gentypeconfig"]["moduleResolution"] == - ("node16" if typescript == "nodenext" else "bundler"), "Incorrect genType mapping") - tsconfig = (directory / "tsconfig.json").read_text() - check("// Preserve this comment" in tsconfig, "Lost tsconfig comment") - check('"allowJs": true' in tsconfig, "Did not enable allowJs") - if typescript == "bundler": - check('"allowImportingTsExtensions": true' in tsconfig, "Did not enable TS extension imports") - (directory / "src/Interop.res").write_text("@genType\nlet answer = 42\n") - extension = "ts" if typescript == "bundler" else "js" - (directory / "src/consumer.ts").write_text( - f'import {{answer}} from "./Interop.gen.{extension}";\nconst value: number = answer;\n' - ) - self.verify_project(directory, suffix) - output = self.run([self.command, "run", "test"], directory) - check("true" in output.splitlines(), - "Existing JavaScript program no longer runs") - if typescript: - check((directory / "src/Interop.gen.ts").is_file(), "genType did not generate TypeScript") - self.run(["node", "node_modules/typescript/bin/tsc", "--noEmit"], directory) - - def occupied_directory(self, directory): - project = directory / "occupied" - project.mkdir() - sentinel = project / "keep.txt" - sentinel.write_text("Do not overwrite\n") - self.cli(directory, ["occupied", "--template", "basic"], expected=1) - check(sentinel.read_text() == "Do not overwrite\n", "Overwrote occupied directory") - check(sorted(path.name for path in project.iterdir()) == ["keep.txt"], - "Failed scaffolding left files in the occupied directory") - - def execute(self): - self.case("new-basic", self.new_project) - self.case("existing-commonjs", self.existing_project) - if self.manager == "npm": - self.case("existing-esm", lambda path: self.existing_project(path, module="module")) - for mode in ("nodenext", "bundler"): - self.case(f"existing-ts-{mode}", lambda path, mode=mode: - self.existing_project(path, module="module", typescript=mode)) - for name, target in (("devcontainer-named", "my-app"), ("devcontainer-dot", "."), - ("devcontainer-dot-slash", "./")): - self.case(name, lambda path, target=target: - self.new_project(path, target=target, devcontainer=True)) - self.case("occupied-directory", self.occupied_directory) - print(f"PASS: {self.passed} E2E cases for {self.manager}", flush=True) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--package", type=Path, required=True, help="npm pack tarball to test") - parser.add_argument("--manager", choices=MANAGERS, required=True) - parser.add_argument("--logs", type=Path, required=True) - args = parser.parse_args() - with tempfile.TemporaryDirectory(prefix="cra e2e ") as directory: - Suite(args, Path(directory)).execute() - - -if __name__ == "__main__": - main() From b0e48f0fd8cca52341dc6f23ef5d6b5b8ec3b6fa Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 23:01:07 +0200 Subject: [PATCH 05/19] Isolate E2E launcher from the parent npm script environment --- test/e2e/run.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/e2e/run.mjs b/test/e2e/run.mjs index 733db93..3ee5666 100644 --- a/test/e2e/run.mjs +++ b/test/e2e/run.mjs @@ -43,6 +43,11 @@ class Suite { YARN_ENABLE_TELEMETRY: "0", }; delete this.env.FORCE_COLOR; + // npm run test:e2e supplies npm's own identity. Let each package manager + // populate its identity afresh when it runs the launcher package script. + for (const name of ["npm_execpath", "npm_config_user_agent", "npm_node_execpath", "INIT_CWD"]) { + delete this.env[name]; + } this.passed = 0; } From 901b33a0ad1451b5da7ebc421f20a73c7b3af3c0 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 10:46:44 +0200 Subject: [PATCH 06/19] Write installation E2E tests in ReScript --- .github/workflows/ci.yml | 3 + package.json | 2 +- test/e2e/E2eLaunch.res | 24 +++ test/e2e/E2eMain.res | 75 ++++++++ test/e2e/E2eNode.res | 67 +++++++ test/e2e/E2eProcess.res | 245 ++++++++++++++++++++++++++ test/e2e/E2eScenarios.res | 356 ++++++++++++++++++++++++++++++++++++++ test/e2e/README.md | 10 ++ test/e2e/launch-cli.mjs | 12 -- test/e2e/run.mjs | 347 ------------------------------------- 10 files changed, 781 insertions(+), 360 deletions(-) create mode 100644 test/e2e/E2eLaunch.res create mode 100644 test/e2e/E2eMain.res create mode 100644 test/e2e/E2eNode.res create mode 100644 test/e2e/E2eProcess.res create mode 100644 test/e2e/E2eScenarios.res delete mode 100644 test/e2e/launch-cli.mjs delete mode 100644 test/e2e/run.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24d8fc9..7208349 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,9 @@ jobs: with: name: ${{ needs.build.outputs.artifact_filename }} + - name: Install harness build dependencies + run: npm ci + - name: Install, compile and run real projects env: PACKAGE: ${{ needs.build.outputs.artifact_filename }} diff --git a/package.json b/package.json index c54c105..e22659c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node src/Main.res.mjs", "prepack": "rescript && rollup -c", "test": "rescript && node --test test/*Test.res.mjs test/*Test.mjs", - "test:e2e": "node test/e2e/run.mjs", + "test:e2e": "rescript && node test/e2e/E2eMain.res.mjs", "format": "rescript format", "dev": "rescript -w" }, diff --git a/test/e2e/E2eLaunch.res b/test/e2e/E2eLaunch.res new file mode 100644 index 0000000..00171df --- /dev/null +++ b/test/e2e/E2eLaunch.res @@ -0,0 +1,24 @@ +open Node +module N = E2eNode + +// A real package script supplies the selected manager's identity to the CLI. +let launch = () => + switch (Process.argv->Array.get(2), N.Process.env->Dict.get("CRA_E2E_CLI")) { + | (Some(directory), Some(cliPath)) => + let args = Process.argv->Array.slice(~start=3) + let result = N.ChildProcess.spawnSync( + N.Process.execPath, + [cliPath, ...args], + { + cwd: directory, + stdio: "inherit", + }, + ) + switch result.error { + | Some(error) => JsExn.throw(error) + | None => Process.exitWithCode(result.status->Nullable.toOption->Option.getOr(1)) + } + | _ => N.Assert.fail("Expected a project directory and CRA_E2E_CLI") + } + +launch() diff --git a/test/e2e/E2eMain.res b/test/e2e/E2eMain.res new file mode 100644 index 0000000..5a0e04d --- /dev/null +++ b/test/e2e/E2eMain.res @@ -0,0 +1,75 @@ +open Node +module N = E2eNode +module P = E2eProcess + +type arguments = {packagePath: option, manager: option, logs: option} + +let usage = "Usage: npm run test:e2e -- --package --manager --logs " + +let rec parseArguments = (remaining, arguments: arguments) => + switch remaining { + | list{"--package", value, ...rest} => + parseArguments(rest, {...arguments, packagePath: Some(value)}) + | list{"--logs", value, ...rest} => parseArguments(rest, {...arguments, logs: Some(value)}) + | list{"--manager", value, ...rest} => + switch P.parseManager(value) { + | Ok(manager) => parseArguments(rest, {...arguments, manager: Some(manager)}) + | Error(message) => Error(message) + } + | list{} => + switch arguments { + | {packagePath: Some(packagePath), manager: Some(manager), logs: Some(logs)} => + Ok({P.packagePath, manager, logs}) + | _ => Error(usage) + } + | _ => Error(usage) + } + +@scope(("import", "meta")) external importMetaUrl: Url.t = "url" + +let main = async () => { + N.Assert.equal( + N.Process.platform, + "linux", + "The E2E terminal runner requires Linux with util-linux script", + ) + let options = switch parseArguments( + Process.argv->Array.slice(~start=2)->List.fromArray, + {packagePath: None, manager: None, logs: None}, + ) { + | Ok(options) => options + | Error(message) => N.Assert.fail(message) + } + let workspace = N.Fs.mkdtemp(Path.join2(N.Os.tmpdir(), "cra e2e ")) + let context = P.makeContext(options, workspace) + let outcome = try { + let launcherPath = Path.join2( + importMetaUrl->Url.fileURLToPath->Path.dirname, + "E2eLaunch.res.mjs", + ) + await P.setup(context, ~packagePath=options.packagePath, ~launcherPath) + await E2eScenarios.execute(context) + Ok() + } catch { + | JsExn(error) => Error(error) + } + await Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) + switch outcome { + | Ok() => () + | Error(error) => + if Fs.existsSync(context.log.contents) { + Console.error(Fs.readFileSync(context.log.contents)) + } + Console.error(`FAIL ${context.info.name}; log: ${context.log.contents}`) + Console.error(error) + N.Process.state.exitCode = 1 + } +} + +main() +->Promise.catch(error => { + Console.error(error) + N.Process.state.exitCode = 1 + Promise.resolve() +}) +->Promise.ignore diff --git a/test/e2e/E2eNode.res b/test/e2e/E2eNode.res new file mode 100644 index 0000000..e6a09a0 --- /dev/null +++ b/test/e2e/E2eNode.res @@ -0,0 +1,67 @@ +// Node APIs used only by the installation E2E harness. +module Fs = { + type mkdirOptions = {recursive: bool} + @module("node:fs") external write: (string, string) => unit = "writeFileSync" + @module("node:fs") external append: (string, string) => unit = "appendFileSync" + @module("node:fs") external mkdtemp: string => string = "mkdtempSync" + @module("node:fs") external mkdir: (string, mkdirOptions) => unit = "mkdirSync" +} + +module Path = { + @module("node:path") external resolve: string => string = "resolve" +} + +module Os = { + @module("node:os") external tmpdir: unit => string = "tmpdir" +} + +module Process = { + @scope("process") external env: Dict.t = "env" + @scope("process") external platform: string = "platform" + @scope("process") external execPath: string = "execPath" + @scope("process") external kill: (int, string) => unit = "kill" + type state = {mutable exitCode: int} + @val external state: state = "process" +} + +module Assert = { + @module("node:assert/strict") external check: (bool, string) => unit = "ok" + @module("node:assert/strict") external equal: ('a, 'a, string) => unit = "strictEqual" + @module("node:assert/strict") external deepEqual: ('a, 'a, string) => unit = "deepStrictEqual" + @module("node:assert/strict") external fail: string => 'a = "fail" +} + +module Timer = { + type t + @val external start: (unit => unit, int) => t = "setTimeout" + @val external clear: t => unit = "clearTimeout" +} + +module ChildProcess = { + type readable + type writable + type t + type options = {cwd: string, env: Dict.t, detached: bool, stdio: array} + + @module("node:child_process") external spawn: (string, array, options) => t = "spawn" + @get external pid: t => option = "pid" + @get external stdout: t => readable = "stdout" + @get external stderr: t => readable = "stderr" + @get @return(nullable) external stdin: t => option = "stdin" + @send external setEncoding: (readable, @as("utf8") _) => unit = "setEncoding" + @send external onData: (readable, @as("data") _, string => unit) => unit = "on" + @send external onInputError: (writable, @as("error") _, JsExn.t => unit) => unit = "on" + @send external onError: (t, @as("error") _, JsExn.t => unit) => unit = "once" + @send + external onClose: (t, @as("close") _, (Nullable.t, Nullable.t) => unit) => unit = + "once" + @send external write: (writable, string) => unit = "write" + @send external destroyInput: writable => unit = "destroy" + + type syncOptions = {cwd: string, stdio: string} + type syncResult = {status: Nullable.t, error?: JsExn.t} + @module("node:child_process") + external spawnSync: (string, array, syncOptions) => syncResult = "spawnSync" +} + +@get external errorCode: JsExn.t => option = "code" diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res new file mode 100644 index 0000000..8a6b265 --- /dev/null +++ b/test/e2e/E2eProcess.res @@ -0,0 +1,245 @@ +open Node +module N = E2eNode + +type manager = Npm | Pnpm | YarnClassic | YarnBerry | Bun +type managerInfo = {name: string, command: string, lockfile: string} +type options = {packagePath: string, manager: manager, logs: string} +type context = { + manager: manager, + info: managerInfo, + workspace: string, + logs: string, + log: ref, + launcher: string, + env: Dict.t, +} +type prompt = (string, string) + +let managerInfo = manager => + switch manager { + | Npm => {name: "npm", command: "npm", lockfile: "package-lock.json"} + | Pnpm => {name: "pnpm", command: "pnpm", lockfile: "pnpm-lock.yaml"} + | YarnClassic => {name: "yarn-classic", command: "yarn", lockfile: "yarn.lock"} + | YarnBerry => {name: "yarn-berry", command: "yarn", lockfile: "yarn.lock"} + | Bun => {name: "bun", command: "bun", lockfile: "bun.lock"} + } + +let parseManager = name => + switch name { + | "npm" => Ok(Npm) + | "pnpm" => Ok(Pnpm) + | "yarn-classic" => Ok(YarnClassic) + | "yarn-berry" => Ok(YarnBerry) + | "bun" => Ok(Bun) + | _ => Error(`Unknown package manager: ${name}`) + } + +let quote = value => `'${value->String.replaceAll("'", "'\\''")}'` +let shellJoin = command => command->Array.map(quote)->Array.join(" ") +let readJson = path => Fs.readFileSync(path)->JSON.parseOrThrow +let writeJson = (path, json) => N.Fs.write(path, `${JSON.stringify(json, ~space=2)}\n`) + +let makeContext = (options: options, workspace) => { + let logs = N.Path.resolve(options.logs) + N.Fs.mkdir(logs, {recursive: true}) + let env = N.Process.env->Dict.copy + [ + ("CI", "false"), + ("TERM", "xterm"), + ("NO_COLOR", "1"), + ("NPM_CONFIG_AUDIT", "false"), + ("NPM_CONFIG_FUND", "false"), + ("YARN_ENABLE_TELEMETRY", "0"), + ]->Array.forEach(((name, value)) => env->Dict.set(name, value)) + // Let each manager supply its own identity, rather than inherit npm run test:e2e's. + [ + "FORCE_COLOR", + "npm_execpath", + "npm_config_user_agent", + "npm_node_execpath", + "INIT_CWD", + ]->Array.forEach(name => env->Dict.delete(name)) + { + manager: options.manager, + info: managerInfo(options.manager), + workspace, + logs, + log: ref(Path.join2(logs, "setup.log")), + launcher: Path.join2(workspace, "launcher"), + env, + } +} + +let killGroup = (child, signal) => + switch child->N.ChildProcess.pid { + | None => () + | Some(pid) => + try N.Process.kill(-pid, signal) catch { + | JsExn(error) if error->N.errorCode === Some("ESRCH") => () + } + } + +let run = async ( + context, + command, + cwd, + ~expected=0, + ~terminal=false, + ~prompts: array=[], +) => { + let commandText = shellJoin(command) + N.Fs.append(context.log.contents, `\n$ ${commandText}\n(cwd: ${cwd})\n`) + let (executable, args) = if terminal { + // util-linux script supplies a real terminal; all control and assertions are ReScript. + ( + "script", + [ + "--quiet", + "--return", + "--echo=never", + "--command", + `stty cols 160 rows 40; exec ${commandText}`, + "/dev/null", + ], + ) + } else { + switch command->List.fromArray { + | list{executable, ...args} => (executable, args->List.toArray) + | list{} => N.Assert.fail("Cannot run an empty command") + } + } + let child = N.ChildProcess.spawn( + executable, + args, + { + cwd, + env: context.env, + detached: true, + stdio: [terminal ? "pipe" : "ignore", "pipe", "pipe"], + }, + ) + // Mutable state is confined to this command's Node event callbacks. + let output = ref("") + let pending = ref("") + let nextPrompt = ref(0) + let timedOut = ref(false) + let inputError = ref(None) + let forceKill = ref(None) + let timeout = N.Timer.start(() => { + timedOut := true + killGroup(child, "SIGTERM") + forceKill := Some(N.Timer.start(() => killGroup(child, "SIGKILL"), 3000)) + }, 300_000) + let collect = chunk => { + output := output.contents ++ chunk + pending := pending.contents ++ chunk + N.Fs.append(context.log.contents, chunk) + switch (prompts->Array.get(nextPrompt.contents), child->N.ChildProcess.stdin) { + | (Some((question, answer)), Some(stdin)) if pending.contents->String.includes(question) => + stdin->N.ChildProcess.write(answer) + nextPrompt := nextPrompt.contents + 1 + pending := "" + | _ => () + } + } + [child->N.ChildProcess.stdout, child->N.ChildProcess.stderr]->Array.forEach(stream => { + stream->N.ChildProcess.setEncoding + stream->N.ChildProcess.onData(collect) + }) + switch child->N.ChildProcess.stdin { + | Some(stdin) => + stdin->N.ChildProcess.onInputError(error => { + if error->N.errorCode !== Some("EPIPE") { + inputError := Some(error) + killGroup(child, "SIGTERM") + } + }) + | None => () + } + let completion = await Promise.make((resolve, _reject) => { + child->N.ChildProcess.onError(error => resolve(Error(error))) + child->N.ChildProcess.onClose((code, signal) => resolve(Ok((code, signal)))) + }) + timeout->N.Timer.clear + forceKill.contents->Option.forEach(N.Timer.clear) + child->N.ChildProcess.stdin->Option.forEach(N.ChildProcess.destroyInput) + inputError.contents->Option.forEach(JsExn.throw) + N.Assert.check(!timedOut.contents, `Command timed out: ${commandText}`) + switch completion { + | Error(error) => JsExn.throw(error) + | Ok((code, signal)) => + let actual = code->Nullable.toOption + let description = switch actual { + | Some(code) => code->Int.toString + | None => signal->Nullable.toOption->Option.getOr("unknown signal") + } + N.Assert.equal( + actual, + Some(expected), + `Command exited ${description}, expected ${expected->Int.toString}: ${commandText}`, + ) + } + N.Assert.equal( + nextPrompt.contents, + prompts->Array.length, + "CLI exited before answering all prompts", + ) + output.contents +} + +let cli = async (context, cwd, ~args=[], ~expected=0, ~prompts=[]) => { + let separator = switch context.manager { + | Npm => ["--"] + | _ => [] + } + let _ = await run( + context, + [context.info.command, "run", "cra", ...separator, cwd, ...args], + context.launcher, + ~expected, + ~prompts, + ~terminal=true, + ) +} + +let setup = async (context, ~packagePath, ~launcherPath) => { + let _ = await run(context, ["node", "--version"], context.workspace) + let _ = await run(context, [context.info.command, "--version"], context.workspace) + let cliDirectory = Path.join2(context.workspace, "cli") + let _ = await run( + context, + ["npm", "install", "--prefix", cliDirectory, N.Path.resolve(packagePath)], + context.workspace, + ) + context.env->Dict.set( + "CRA_E2E_CLI", + Path.join2(cliDirectory, "node_modules/create-rescript-app/out/create-rescript-app.cjs"), + ) + N.Fs.mkdir(context.launcher, {recursive: true}) + writeJson( + Path.join2(context.launcher, "package.json"), + JSON.parseOrThrow( + `{ + "name": "cra-e2e-launcher", + "private": true, + "scripts": {"cra": ${JSON.stringify(JSON.String(shellJoin(["node", launcherPath])))}} + }`, + ), + ) + switch context.manager { + | YarnBerry => + N.Fs.write(Path.join2(context.launcher, ".yarnrc.yml"), "nodeLinker: node-modules\n") + | _ => () + } + let _ = await run(context, [context.info.command, "install"], context.launcher) +} + +let runCase = async (context, name, perform) => { + context.log := Path.join2(context.logs, `${name}.log`) + N.Fs.write(context.log.contents, "") + let directory = Path.join2(context.workspace, name) + N.Fs.mkdir(directory, {recursive: true}) + Console.log(`RUN ${context.info.name}: ${name}`) + await perform(directory) + Console.log(`PASS ${context.info.name}: ${name}`) +} diff --git a/test/e2e/E2eScenarios.res b/test/e2e/E2eScenarios.res new file mode 100644 index 0000000..3bacbae --- /dev/null +++ b/test/e2e/E2eScenarios.res @@ -0,0 +1,356 @@ +open Node +open JSON +module N = E2eNode +module P = E2eProcess + +type moduleSystem = CommonJs | EsModule +type tsResolution = NodeNext | Bundler + +let object = (entries: array<(string, JSON.t)>) => JSON.Object(entries->Dict.fromArray) +let field = (json: JSON.t, name) => + switch json { + | Object(fields) => + switch fields->Dict.get(name) { + | Some(value) => value + | None => N.Assert.fail(`Missing JSON field: ${name}`) + } + | _ => N.Assert.fail(`Expected a JSON object containing ${name}`) + } +let stringField = (json, name) => + switch field(json, name) { + | String(value) => value + | _ => N.Assert.fail(`Expected a string in JSON field: ${name}`) + } +let assertContains = (actual, expected, message) => + N.Assert.check(actual->String.includes(expected), message) + +let verifyProject = async (context: P.context, directory, suffix) => { + let packageJson = P.readJson(Path.join2(directory, "package.json")) + let config = P.readJson(Path.join2(directory, "rescript.json")) + N.Assert.equal( + stringField(config, "name"), + stringField(packageJson, "name"), + "Project and package names differ", + ) + N.Assert.equal(stringField(config, "suffix"), suffix, "Unexpected generated JS suffix") + N.Assert.check( + Fs.existsSync(Path.join2(directory, context.info.lockfile)), + `Missing ${context.info.lockfile}: manager detection failed`, + ) + switch context.manager { + | Npm => () + | _ => + N.Assert.check( + !Fs.existsSync(Path.join2(directory, "package-lock.json")), + "npm lockfile was not removed", + ) + } + switch context.manager { + | YarnBerry => + Fs.readFileSync(Path.join2(directory, ".yarnrc.yml"))->assertContains( + "nodeLinker: node-modules", + "Yarn Berry node-modules linker was not configured", + ) + | _ => () + } + let version = + P.readJson(Path.join2(directory, "node_modules/rescript/package.json"))->stringField("version") + N.Assert.check( + version->String.startsWith("12."), + `Expected a current ReScript 12 version, installed ${version}`, + ) + switch field(packageJson, "dependencies") { + | Object(dependencies) => + N.Assert.check( + !(dependencies->Dict.has("@rescript/core")), + "ReScript 12 should use its bundled standard library", + ) + | _ => N.Assert.fail("Expected package dependencies") + } + let _ = await P.run(context, [context.info.command, "run", "res:build"], directory) + let output = await P.run(context, ["node", `src/Demo${suffix}`], directory) + N.Assert.equal(output->String.trim, "Hello, world!", "Unexpected generated program output") +} + +let verifyPreserved = (directory, preserved) => + preserved->Array.forEach(((path, contents)) => { + let actual = Fs.readFileSync(Path.join2(directory, path)) + switch path { + | ".gitignore" => N.Assert.check(actual->String.startsWith(contents), `Overwrote ${path}`) + | _ => N.Assert.equal(actual, contents, `Overwrote ${path}`) + } + }) + +let newProject = async (context, directory, ~target="my-app", ~devcontainer=false) => { + let currentDirectory = switch target { + | "." | "./" => true + | _ => false + } + let project = currentDirectory ? directory : Path.join2(directory, target) + let preserved = if devcontainer { + N.Fs.mkdir(Path.join2(project, ".devcontainer"), {recursive: true}) + let metadata = currentDirectory + ? [("README.md", "Existing README\n"), (".gitignore", "keep-this-entry\n")] + : [] + [(".devcontainer/devcontainer.json", "{\"name\":\"keep me\"}\n"), ...metadata] + } else { + [] + } + preserved->Array.forEach(((path, contents)) => N.Fs.write(Path.join2(project, path), contents)) + await P.cli(context, directory, ~args=[target, "--template", "basic"]) + N.Assert.equal( + P.readJson(Path.join2(project, "package.json"))->stringField("name"), + Path.basename(project), + "Current-directory package name was not derived from the directory", + ) + verifyPreserved(project, preserved) + await verifyProject(context, project, ".res.mjs") +} + +let existingProject = async (context, directory, ~moduleSystem=CommonJs, ~typescript=?) => { + let moduleName = switch moduleSystem { + | CommonJs => "commonjs" + | EsModule => "module" + } + let devDependencies = switch typescript { + | Some(_) => [("devDependencies", object([("typescript", String("5.9.3"))]))] + | None => [] + } + let original = object( + [ + ("name", String(Path.basename(directory))), + ("version", String("1.0.0")), + ("private", Boolean(true)), + ("type", String(moduleName)), + ("scripts", object([("test", String("node src/original.cjs"))])), + ("dependencies", object([("is-number", String("7.0.0"))])), + ...devDependencies, + ], + ) + P.writeJson(Path.join2(directory, "package.json"), original) + N.Fs.mkdir(Path.join2(directory, "src"), {recursive: true}) + let tsPreserved = switch typescript { + | None => [] + | Some(resolution) => + let (moduleName, resolutionName) = switch resolution { + | NodeNext => ("NodeNext", "nodenext") + | Bundler => ("ESNext", "bundler") + } + N.Fs.write( + Path.join2(directory, "tsconfig.json"), + `{ + // Preserve this comment and the inherited configuration. + "extends": "./tsconfig.base.json", + "compilerOptions": {"skipLibCheck": true,}, + "include": ["src"], +} +`, + ) + [ + ("src/original.ts", "export const existingValue: number = 1;\n"), + ( + "tsconfig.base.json", + JSON.stringify( + object([ + ( + "compilerOptions", + object([ + ("module", String(moduleName)), + ("moduleResolution", String(resolutionName)), + ("target", String("ES2022")), + ("strict", Boolean(true)), + ("noEmit", Boolean(true)), + ]), + ), + ]), + ~space=2, + ), + ), + ] + } + let preserved = [ + ("README.md", "Existing project README\n"), + (".gitignore", "existing-ignore\n"), + ("src/original.cjs", "console.log(require(\"is-number\")(42));\n"), + ...tsPreserved, + ] + preserved->Array.forEach(((path, contents)) => N.Fs.write(Path.join2(directory, path), contents)) + let _ = await P.run(context, [context.info.command, "install"], directory) + let modulePrompts = switch typescript { + | Some(resolution) => + let extensionPrompts = switch resolution { + | NodeNext => [] + | Bundler => [("Set compilerOptions.allowImportingTsExtensions to true", "y")] + } + [("Set compilerOptions.allowJs to true", "y"), ...extensionPrompts] + | None => + let answer = switch moduleSystem { + | CommonJs => "\x1b[B\r" + | EsModule => "\r" + } + [("What module system will you use?", answer)] + } + await P.cli( + context, + directory, + ~prompts=[ + ("Do you want to add ReScript", "y"), + ("ReScript version?", "\r"), + ("Where will you put your ReScript source files?", "\r"), + ...modulePrompts, + ("Do you want to check generated", "n"), + ], + ) + let packageJson = P.readJson(Path.join2(directory, "package.json")) + ["name", "version", "private", "type"]->Array.forEach(name => + N.Assert.deepEqual( + field(packageJson, name), + field(original, name), + `Changed existing package field ${name}`, + ) + ) + N.Assert.deepEqual( + field(packageJson, "scripts")->field("test"), + field(original, "scripts")->field("test"), + "Overwrote existing script", + ) + N.Assert.equal( + field(packageJson, "dependencies")->stringField("is-number"), + "7.0.0", + "Changed existing dependency", + ) + switch typescript { + | Some(_) => + N.Assert.deepEqual( + field(packageJson, "devDependencies"), + field(original, "devDependencies"), + "Changed TypeScript dependency", + ) + | None => () + } + verifyPreserved(directory, preserved) + let suffix = switch (typescript, moduleSystem) { + | (None, EsModule) => ".res.mjs" + | _ => ".res.js" + } + Fs.readFileSync(Path.join2(directory, ".gitignore"))->assertContains( + `**/*${suffix}`, + "Generated JS was not ignored", + ) + switch typescript { + | None => () + | Some(resolution) => + let (gentypeResolution, extension) = switch resolution { + | NodeNext => ("node16", "js") + | Bundler => ("bundler", "ts") + } + let config = P.readJson(Path.join2(directory, "rescript.json")) + N.Assert.equal( + field(config, "gentypeconfig")->stringField("moduleResolution"), + gentypeResolution, + "Incorrect genType mapping", + ) + let tsconfig = Fs.readFileSync(Path.join2(directory, "tsconfig.json")) + tsconfig->assertContains("// Preserve this comment", "Lost tsconfig comment") + tsconfig->assertContains("\"allowJs\": true", "Did not enable allowJs") + switch resolution { + | NodeNext => () + | Bundler => + tsconfig->assertContains( + "\"allowImportingTsExtensions\": true", + "Did not enable TS extension imports", + ) + } + N.Fs.write(Path.join2(directory, "src/Interop.res"), "@genType\nlet answer = 42\n") + N.Fs.write( + Path.join2(directory, "src/consumer.ts"), + `import {answer} from "./Interop.gen.${extension}";\nconst value: number = answer;\n`, + ) + } + await verifyProject(context, directory, suffix) + let output = await P.run(context, [context.info.command, "run", "test"], directory) + N.Assert.check( + output->String.split("\n")->Array.some(line => line->String.trim === "true"), + "Existing JavaScript program no longer runs", + ) + switch typescript { + | None => () + | Some(_) => + N.Assert.check( + Fs.existsSync(Path.join2(directory, "src/Interop.gen.ts")), + "genType did not generate TypeScript", + ) + let _ = await P.run(context, ["node", "node_modules/typescript/bin/tsc", "--noEmit"], directory) + } +} + +let occupiedDirectory = async (context, directory) => { + let project = Path.join2(directory, "occupied") + N.Fs.mkdir(project, {recursive: true}) + let sentinel = Path.join2(project, "keep.txt") + N.Fs.write(sentinel, "Do not overwrite\n") + await P.cli(context, directory, ~args=["occupied", "--template", "basic"], ~expected=1) + N.Assert.equal(Fs.readFileSync(sentinel), "Do not overwrite\n", "Overwrote occupied directory") + N.Assert.deepEqual( + Fs.readdirSync(project), + ["keep.txt"], + "Failed scaffolding left files in the occupied directory", + ) +} + +type scenario = {name: string, perform: string => promise} + +let scenarios = (context: P.context) => { + let common = [ + {name: "new-basic", perform: directory => newProject(context, directory)}, + {name: "existing-commonjs", perform: directory => existingProject(context, directory)}, + ] + switch context.manager { + | Npm => + [ + ...common, + { + name: "existing-esm", + perform: directory => existingProject(context, directory, ~moduleSystem=EsModule), + }, + { + name: "existing-ts-nodenext", + perform: directory => + existingProject(context, directory, ~moduleSystem=EsModule, ~typescript=NodeNext), + }, + { + name: "existing-ts-bundler", + perform: directory => + existingProject(context, directory, ~moduleSystem=EsModule, ~typescript=Bundler), + }, + { + name: "devcontainer-named", + perform: directory => newProject(context, directory, ~devcontainer=true), + }, + { + name: "devcontainer-dot", + perform: directory => newProject(context, directory, ~target=".", ~devcontainer=true), + }, + { + name: "devcontainer-dot-slash", + perform: directory => newProject(context, directory, ~target="./", ~devcontainer=true), + }, + {name: "occupied-directory", perform: directory => occupiedDirectory(context, directory)}, + ] + | _ => common + } +} + +let rec runScenarios = async (context, remaining) => + switch remaining { + | list{} => () + | list{scenario, ...rest} => + await P.runCase(context, scenario.name, scenario.perform) + await runScenarios(context, rest) + } + +let execute = async context => { + let cases = scenarios(context) + await runScenarios(context, cases->List.fromArray) + Console.log(`PASS: ${cases->Array.length->Int.toString} E2E cases for ${context.info.name}`) +} diff --git a/test/e2e/README.md b/test/e2e/README.md index 41166ee..f957732 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -13,6 +13,12 @@ The npm job also covers existing ESM projects, NodeNext and bundler TypeScript projects (including compiling a genType consumer), devcontainer-only directories, `.` and `./`, and rejection of an occupied target without modifying its files. +The harness is written in ReScript, alongside the other tests. `E2eScenarios.res` +contains the fixtures and assertions, `E2eProcess.res` runs commands and answers +prompts, and `E2eNode.res` contains test-only Node bindings. `E2eLaunch.res` runs the +packed CLI through the selected manager's package script. The `test/` source +directory is already included in `rescript.json`. + The harness uses Node.js built-ins and Ubuntu's `script` utility (from util-linux) to answer the real interactive prompts through a pseudo-terminal. It sets `CI=false` for the CLI so the old hardcoded CI @@ -32,3 +38,7 @@ npm run test:e2e -- --package create-rescript-app-1.12.1.tgz --manager npm --log The terminal runner currently targets Linux, matching CI. The other package-manager names are `pnpm`, `yarn-classic`, `yarn-berry` and `bun`; install the pinned version from the workflow when reproducing a particular job. + +`npm run test:e2e` compiles the ReScript sources before running the harness. +Run `npm ci` first to install its build dependencies. Generated `.res.mjs` files +are not committed. diff --git a/test/e2e/launch-cli.mjs b/test/e2e/launch-cli.mjs deleted file mode 100644 index 5efdd58..0000000 --- a/test/e2e/launch-cli.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { spawnSync } from "node:child_process"; - -// Running this script through the chosen package manager supplies its real -// npm_execpath/npm_config_user_agent, just as a user's package script would. -const [projectDirectory, ...args] = process.argv.slice(2); -const result = spawnSync(process.execPath, [process.env.CRA_E2E_CLI, ...args], { - cwd: projectDirectory, - stdio: "inherit", -}); - -if (result.error) throw result.error; -process.exit(result.status ?? 1); diff --git a/test/e2e/run.mjs b/test/e2e/run.mjs deleted file mode 100644 index 3ee5666..0000000 --- a/test/e2e/run.mjs +++ /dev/null @@ -1,347 +0,0 @@ -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { - appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, - readdirSync, rmSync, writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { parseArgs } from "node:util"; - -const managers = { - npm: { command: "npm", lockfile: "package-lock.json" }, - pnpm: { command: "pnpm", lockfile: "pnpm-lock.yaml" }, - "yarn-classic": { command: "yarn", lockfile: "yarn.lock" }, - "yarn-berry": { command: "yarn", lockfile: "yarn.lock" }, - bun: { command: "bun", lockfile: "bun.lock" }, -}; - -const read = path => readFileSync(path, "utf8"); -const readJson = path => JSON.parse(read(path)); -const writeJson = (path, value) => writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); -const shellQuote = value => `'${value.replaceAll("'", "'\\''")}'`; -const shellJoin = command => command.map(shellQuote).join(" "); - -class Suite { - constructor(options, workspace) { - this.manager = options.manager; - Object.assign(this, managers[this.manager]); - this.workspace = workspace; - this.logs = resolve(options.logs); - this.package = resolve(options.package); - mkdirSync(this.logs, { recursive: true }); - this.log = join(this.logs, "setup.log"); - this.env = { - ...process.env, - // Exercise normal project detection and prompts, bypassing the old CI-only path. - CI: "false", - TERM: "xterm", - NO_COLOR: "1", - NPM_CONFIG_AUDIT: "false", - NPM_CONFIG_FUND: "false", - YARN_ENABLE_TELEMETRY: "0", - }; - delete this.env.FORCE_COLOR; - // npm run test:e2e supplies npm's own identity. Let each package manager - // populate its identity afresh when it runs the launcher package script. - for (const name of ["npm_execpath", "npm_config_user_agent", "npm_node_execpath", "INIT_CWD"]) { - delete this.env[name]; - } - this.passed = 0; - } - - async setup() { - await this.run(["node", "--version"], this.workspace); - await this.run([this.command, "--version"], this.workspace); - const cliDirectory = join(this.workspace, "cli"); - await this.run(["npm", "install", "--prefix", cliDirectory, this.package], this.workspace); - this.env.CRA_E2E_CLI = join(cliDirectory, "node_modules/create-rescript-app/out/create-rescript-app.cjs"); - this.launcher = join(this.workspace, "launcher"); - mkdirSync(this.launcher); - const wrapper = fileURLToPath(new URL("./launch-cli.mjs", import.meta.url)); - writeJson(join(this.launcher, "package.json"), { - name: "cra-e2e-launcher", - private: true, - scripts: { cra: shellJoin(["node", wrapper]) }, - }); - if (this.manager === "yarn-berry") { - writeFileSync(join(this.launcher, ".yarnrc.yml"), "nodeLinker: node-modules\n"); - } - await this.run([this.command, "install"], this.launcher); - } - - async run(command, cwd, { expected = 0, terminal = false, prompts = [] } = {}) { - appendFileSync(this.log, `\n$ ${shellJoin(command)}\n(cwd: ${cwd})\n`); - // Ubuntu's util-linux script allocates a real terminal for the interactive CLI. - // Node handles process control, prompt answers and assertions without native addons. - const invocation = terminal - ? ["script", "--quiet", "--return", "--echo=never", "--command", - `stty cols 160 rows 40; exec ${shellJoin(command)}`, "/dev/null"] - : command; - const child = spawn(invocation[0], invocation.slice(1), { - cwd, - env: this.env, - detached: true, - stdio: [terminal ? "pipe" : "ignore", "pipe", "pipe"], - }); - let output = ""; - let pending = ""; - let nextPrompt = 0; - let timedOut = false; - let inputError; - let forceKill; - const killGroup = signal => { - try { - process.kill(-child.pid, signal); - } catch (error) { - if (error.code !== "ESRCH") throw error; - } - }; - const timeout = setTimeout(() => { - timedOut = true; - killGroup("SIGTERM"); - forceKill = setTimeout(() => killGroup("SIGKILL"), 3000); - }, 300_000); - const collect = chunk => { - output += chunk; - appendFileSync(this.log, chunk); - pending += chunk; - if (nextPrompt < prompts.length) { - const [question, answer] = prompts[nextPrompt]; - if (pending.includes(question)) { - child.stdin.write(answer); - nextPrompt += 1; - pending = ""; - } - } - }; - child.stdout.setEncoding("utf8").on("data", collect); - child.stderr.setEncoding("utf8").on("data", collect); - // The terminal may close immediately after its last answer is written. - child.stdin?.on("error", error => { - if (error.code !== "EPIPE") { - inputError = error; - killGroup("SIGTERM"); - } - }); - try { - const [code, signal] = await new Promise((resolve, reject) => { - child.once("error", reject); - child.once("close", (code, signal) => resolve([code, signal])); - }); - if (inputError) throw inputError; - assert(!timedOut, `Command timed out: ${shellJoin(command)}`); - assert.equal(code, expected, `Command exited ${code ?? signal}, expected ${expected}: ${shellJoin(command)}`); - assert.equal(nextPrompt, prompts.length, "CLI exited before answering all prompts"); - return output; - } finally { - clearTimeout(timeout); - clearTimeout(forceKill); - child.stdin?.destroy(); - } - } - - async cli(cwd, args = [], options = {}) { - // Package scripts supply the manager's actual npm_execpath/user agent. - const separator = this.manager === "npm" ? ["--"] : []; - return this.run([this.command, "run", "cra", ...separator, cwd, ...args], this.launcher, { - ...options, - terminal: true, - }); - } - - async case(name, perform) { - this.log = join(this.logs, `${name}.log`); - writeFileSync(this.log, ""); - const directory = join(this.workspace, name); - mkdirSync(directory); - console.log(`RUN ${this.manager}: ${name}`); - await perform(directory); - this.passed += 1; - console.log(`PASS ${this.manager}: ${name}`); - } - - async verifyProject(directory, suffix) { - const packageJson = readJson(join(directory, "package.json")); - const config = readJson(join(directory, "rescript.json")); - assert.equal(config.name, packageJson.name, "Project and package names differ"); - assert.equal(config.suffix, suffix, "Unexpected generated JS suffix"); - assert(existsSync(join(directory, this.lockfile)), `Missing ${this.lockfile}: manager detection failed`); - if (this.manager !== "npm") { - assert(!existsSync(join(directory, "package-lock.json")), "npm lockfile was not removed"); - } - if (this.manager === "yarn-berry") { - assert(read(join(directory, ".yarnrc.yml")).includes("nodeLinker: node-modules"), - "Yarn Berry node-modules linker was not configured"); - } - const { version } = readJson(join(directory, "node_modules/rescript/package.json")); - assert(version.startsWith("12."), `Expected a current ReScript 12 version, installed ${version}`); - assert(!Object.hasOwn(packageJson.dependencies ?? {}, "@rescript/core"), - "ReScript 12 should use its bundled standard library"); - await this.run([this.command, "run", "res:build"], directory); - const output = await this.run(["node", `src/Demo${suffix}`], directory); - assert.equal(output.trim(), "Hello, world!", "Unexpected generated program output"); - } - - verifyPreserved(directory, preserved) { - for (const [path, contents] of Object.entries(preserved)) { - const actual = read(join(directory, path)); - if (path === ".gitignore") { - assert(actual.startsWith(contents), `Overwrote ${path}`); - } else { - assert.equal(actual, contents, `Overwrote ${path}`); - } - } - } - - async newProject(directory, { target = "my-app", devcontainer = false } = {}) { - const currentDirectory = target === "." || target === "./"; - const project = currentDirectory ? directory : join(directory, target); - const preserved = {}; - if (devcontainer) { - mkdirSync(join(project, ".devcontainer"), { recursive: true }); - preserved[".devcontainer/devcontainer.json"] = '{"name":"keep me"}\n'; - if (currentDirectory) { - Object.assign(preserved, { "README.md": "Existing README\n", ".gitignore": "keep-this-entry\n" }); - } - for (const [path, contents] of Object.entries(preserved)) writeFileSync(join(project, path), contents); - } - await this.cli(directory, [target, "--template", "basic"]); - assert.equal(readJson(join(project, "package.json")).name, basename(project), - "Current-directory package name was not derived from the directory"); - this.verifyPreserved(project, preserved); - await this.verifyProject(project, ".res.mjs"); - } - - async existingProject(directory, { module = "commonjs", typescript } = {}) { - const original = { - name: basename(directory), - version: "1.0.0", - private: true, - type: module, - scripts: { test: "node src/original.cjs" }, - dependencies: { "is-number": "7.0.0" }, - }; - if (typescript) original.devDependencies = { typescript: "5.9.3" }; - writeJson(join(directory, "package.json"), original); - mkdirSync(join(directory, "src")); - const preserved = { - "README.md": "Existing project README\n", - ".gitignore": "existing-ignore\n", - "src/original.cjs": 'console.log(require("is-number")(42));\n', - }; - if (typescript) { - preserved["src/original.ts"] = "export const existingValue: number = 1;\n"; - preserved["tsconfig.base.json"] = JSON.stringify({ compilerOptions: { - module: typescript === "nodenext" ? "NodeNext" : "ESNext", - moduleResolution: typescript, - target: "ES2022", strict: true, noEmit: true, - } }, null, 2); - writeFileSync(join(directory, "tsconfig.json"), `{ - // Preserve this comment and the inherited configuration. - "extends": "./tsconfig.base.json", - "compilerOptions": {"skipLibCheck": true,}, - "include": ["src"], -} -`); - } - for (const [path, contents] of Object.entries(preserved)) writeFileSync(join(directory, path), contents); - await this.run([this.command, "install"], directory); - const prompts = [ - ["Do you want to add ReScript", "y"], - ["ReScript version?", "\r"], - ["Where will you put your ReScript source files?", "\r"], - ]; - if (typescript) { - prompts.push(["Set compilerOptions.allowJs to true", "y"]); - if (typescript === "bundler") { - prompts.push(["Set compilerOptions.allowImportingTsExtensions to true", "y"]); - } - } else { - prompts.push(["What module system will you use?", module === "module" ? "\r" : "\x1b[B\r"]); - } - prompts.push(["Do you want to check generated", "n"]); - await this.cli(directory, [], { prompts }); - const packageJson = readJson(join(directory, "package.json")); - for (const field of ["name", "version", "private", "type"]) { - assert.equal(packageJson[field], original[field], `Changed existing package field ${field}`); - } - assert.equal(packageJson.scripts.test, original.scripts.test, "Overwrote existing script"); - assert.equal(packageJson.dependencies["is-number"], "7.0.0", "Changed existing dependency"); - if (typescript) assert.deepEqual(packageJson.devDependencies, original.devDependencies, "Changed TypeScript dependency"); - this.verifyPreserved(directory, preserved); - const suffix = typescript || module === "commonjs" ? ".res.js" : ".res.mjs"; - assert(read(join(directory, ".gitignore")).includes(`**/*${suffix}`), "Generated JS was not ignored"); - if (typescript) { - const config = readJson(join(directory, "rescript.json")); - assert.equal(config.gentypeconfig.moduleResolution, - typescript === "nodenext" ? "node16" : "bundler", "Incorrect genType mapping"); - const tsconfig = read(join(directory, "tsconfig.json")); - assert(tsconfig.includes("// Preserve this comment"), "Lost tsconfig comment"); - assert(tsconfig.includes('"allowJs": true'), "Did not enable allowJs"); - if (typescript === "bundler") { - assert(tsconfig.includes('"allowImportingTsExtensions": true'), "Did not enable TS extension imports"); - } - writeFileSync(join(directory, "src/Interop.res"), "@genType\nlet answer = 42\n"); - const extension = typescript === "bundler" ? "ts" : "js"; - writeFileSync(join(directory, "src/consumer.ts"), - `import {answer} from "./Interop.gen.${extension}";\nconst value: number = answer;\n`); - } - await this.verifyProject(directory, suffix); - const output = await this.run([this.command, "run", "test"], directory); - assert(output.split(/\r?\n/).includes("true"), "Existing JavaScript program no longer runs"); - if (typescript) { - assert(existsSync(join(directory, "src/Interop.gen.ts")), "genType did not generate TypeScript"); - await this.run(["node", "node_modules/typescript/bin/tsc", "--noEmit"], directory); - } - } - - async occupiedDirectory(directory) { - const project = join(directory, "occupied"); - mkdirSync(project); - const sentinel = join(project, "keep.txt"); - writeFileSync(sentinel, "Do not overwrite\n"); - await this.cli(directory, ["occupied", "--template", "basic"], { expected: 1 }); - assert.equal(read(sentinel), "Do not overwrite\n", "Overwrote occupied directory"); - assert.deepEqual(readdirSync(project), ["keep.txt"], "Failed scaffolding left files in the occupied directory"); - } - - async execute() { - await this.case("new-basic", path => this.newProject(path)); - await this.case("existing-commonjs", path => this.existingProject(path)); - if (this.manager === "npm") { - await this.case("existing-esm", path => this.existingProject(path, { module: "module" })); - for (const typescript of ["nodenext", "bundler"]) { - await this.case(`existing-ts-${typescript}`, path => this.existingProject(path, { module: "module", typescript })); - } - for (const [name, target] of [["devcontainer-named", "my-app"], ["devcontainer-dot", "."], - ["devcontainer-dot-slash", "./"]]) { - await this.case(name, path => this.newProject(path, { target, devcontainer: true })); - } - await this.case("occupied-directory", path => this.occupiedDirectory(path)); - } - console.log(`PASS: ${this.passed} E2E cases for ${this.manager}`); - } -} - -const { values } = parseArgs({ options: { - package: { type: "string" }, manager: { type: "string" }, logs: { type: "string" }, -} }); -assert.equal(process.platform, "linux", "The E2E terminal runner requires Linux with util-linux script"); -assert(values.package && values.logs && Object.hasOwn(managers, values.manager), - `Usage: npm run test:e2e -- --package --manager <${Object.keys(managers).join("|")}> --logs `); - -const workspace = mkdtempSync(join(tmpdir(), "cra e2e ")); -const suite = new Suite(values, workspace); -try { - await suite.setup(); - await suite.execute(); -} catch (error) { - if (existsSync(suite.log)) console.error(read(suite.log)); - console.error(`FAIL ${suite.manager}; log: ${suite.log}`); - console.error(error); - process.exitCode = 1; -} finally { - rmSync(workspace, { recursive: true, force: true }); -} From e67ea5b0ccf900968dbc0d50b3c65c92361ae9cd Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 11:00:25 +0200 Subject: [PATCH 07/19] Clarify ReScript 12 conventions and require explicit E2E parameters --- AGENTS.md | 9 +++++++-- test/e2e/E2eProcess.res | 33 +++++++++++++++---------------- test/e2e/E2eScenarios.res | 41 +++++++++++++++++++++++++++++---------- 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae0f790..365fde5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,17 @@ ## Coding Style & Naming Conventions -- Make sure to use modern ReScript and not Reason syntax! Read https://rescript-lang.org/llms/manual/llm-small.txt to learn the language syntax. +- Use ReScript 12 syntax and the built-in standard library, not Reason syntax. Read https://rescript-lang.org/llms/manual/llm-small.txt to learn the language syntax. +- Prefer `Option`, `Result`, `Array`, `List`, `Dict`, `Set`, `Map`, and `JSON` over `Js.*` or `Belt.*`. +- Match local style; avoid old BuckleScript/ReScript-era patterns. +- Write new tests and test helpers in ReScript under `test/`. Prefer records and functions over classes. +- Keep parameters mandatory unless callers need to omit them. +- Do not add redundant parentheses around switch expressions: use `switch value { ... }`. Parentheses required by tuple syntax are fine. - Formatting is enforced by `rescript format`; keep 2-space indentation and prefer pattern matching over chained conditionals. - Prefer `result` values over exceptions for expected failure paths; only raise or throw at clear integration boundaries where the surrounding API requires it. - Prefer pattern matching over `if`/`else` chains when branching on the shape or state of the same value; plain comparisons across different values are fine with `if`/`else`. - Do not run `rescript`, `npm test`, or `npm run prepack` in parallel; ReScript compiler artifacts are not safe for concurrent builds in this repo. -- Module files are PascalCase (`Templates.res`), values/functions camelCase, types/variants PascalCase, and records snake_case fields only when matching external JSON. +- Module files and variant constructors are PascalCase (`Templates.res`), values/functions and type names camelCase, and records use snake_case fields only when matching external JSON. - Keep `.resi` signatures accurate and minimal; avoid exposing helpers that are template-specific. - When touching templates, mirror upstream defaults and keep package scripts consistent with the chosen toolchain. diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res index 8a6b265..858dadf 100644 --- a/test/e2e/E2eProcess.res +++ b/test/e2e/E2eProcess.res @@ -14,6 +14,7 @@ type context = { env: Dict.t, } type prompt = (string, string) +type commandMode = Piped | Terminal(array) let managerInfo = manager => switch manager { @@ -79,18 +80,13 @@ let killGroup = (child, signal) => } } -let run = async ( - context, - command, - cwd, - ~expected=0, - ~terminal=false, - ~prompts: array=[], -) => { +let runCommand = async (context, command, cwd, ~expected, ~mode) => { let commandText = shellJoin(command) N.Fs.append(context.log.contents, `\n$ ${commandText}\n(cwd: ${cwd})\n`) - let (executable, args) = if terminal { - // util-linux script supplies a real terminal; all control and assertions are ReScript. + let (executable, args, prompts, stdinMode) = switch mode { + | Terminal( + prompts, + ) => // util-linux script supplies a real terminal; all control and assertions are ReScript. ( "script", [ @@ -101,10 +97,12 @@ let run = async ( `stty cols 160 rows 40; exec ${commandText}`, "/dev/null", ], + prompts, + "pipe", ) - } else { + | Piped => switch command->List.fromArray { - | list{executable, ...args} => (executable, args->List.toArray) + | list{executable, ...args} => (executable, args->List.toArray, [], "ignore") | list{} => N.Assert.fail("Cannot run an empty command") } } @@ -115,7 +113,7 @@ let run = async ( cwd, env: context.env, detached: true, - stdio: [terminal ? "pipe" : "ignore", "pipe", "pipe"], + stdio: [stdinMode, "pipe", "pipe"], }, ) // Mutable state is confined to this command's Node event callbacks. @@ -187,18 +185,19 @@ let run = async ( output.contents } -let cli = async (context, cwd, ~args=[], ~expected=0, ~prompts=[]) => { +let run = (context, command, cwd) => runCommand(context, command, cwd, ~expected=0, ~mode=Piped) + +let cli = async (context, cwd, ~args, ~expected, ~prompts) => { let separator = switch context.manager { | Npm => ["--"] | _ => [] } - let _ = await run( + let _ = await runCommand( context, [context.info.command, "run", "cra", ...separator, cwd, ...args], context.launcher, ~expected, - ~prompts, - ~terminal=true, + ~mode=Terminal(prompts), ) } diff --git a/test/e2e/E2eScenarios.res b/test/e2e/E2eScenarios.res index 3bacbae..18b7d03 100644 --- a/test/e2e/E2eScenarios.res +++ b/test/e2e/E2eScenarios.res @@ -81,7 +81,7 @@ let verifyPreserved = (directory, preserved) => } }) -let newProject = async (context, directory, ~target="my-app", ~devcontainer=false) => { +let newProject = async (context, directory, ~target, ~devcontainer) => { let currentDirectory = switch target { | "." | "./" => true | _ => false @@ -97,7 +97,7 @@ let newProject = async (context, directory, ~target="my-app", ~devcontainer=fals [] } preserved->Array.forEach(((path, contents)) => N.Fs.write(Path.join2(project, path), contents)) - await P.cli(context, directory, ~args=[target, "--template", "basic"]) + await P.cli(context, directory, ~args=[target, "--template", "basic"], ~expected=0, ~prompts=[]) N.Assert.equal( P.readJson(Path.join2(project, "package.json"))->stringField("name"), Path.basename(project), @@ -107,7 +107,12 @@ let newProject = async (context, directory, ~target="my-app", ~devcontainer=fals await verifyProject(context, project, ".res.mjs") } -let existingProject = async (context, directory, ~moduleSystem=CommonJs, ~typescript=?) => { +let existingProject = async ( + context, + directory, + ~moduleSystem, + ~typescript: option, +) => { let moduleName = switch moduleSystem { | CommonJs => "commonjs" | EsModule => "module" @@ -193,6 +198,8 @@ let existingProject = async (context, directory, ~moduleSystem=CommonJs, ~typesc await P.cli( context, directory, + ~args=[], + ~expected=0, ~prompts=[ ("Do you want to add ReScript", "y"), ("ReScript version?", "\r"), @@ -289,7 +296,13 @@ let occupiedDirectory = async (context, directory) => { N.Fs.mkdir(project, {recursive: true}) let sentinel = Path.join2(project, "keep.txt") N.Fs.write(sentinel, "Do not overwrite\n") - await P.cli(context, directory, ~args=["occupied", "--template", "basic"], ~expected=1) + await P.cli( + context, + directory, + ~args=["occupied", "--template", "basic"], + ~expected=1, + ~prompts=[], + ) N.Assert.equal(Fs.readFileSync(sentinel), "Do not overwrite\n", "Overwrote occupied directory") N.Assert.deepEqual( Fs.readdirSync(project), @@ -302,8 +315,15 @@ type scenario = {name: string, perform: string => promise} let scenarios = (context: P.context) => { let common = [ - {name: "new-basic", perform: directory => newProject(context, directory)}, - {name: "existing-commonjs", perform: directory => existingProject(context, directory)}, + { + name: "new-basic", + perform: directory => newProject(context, directory, ~target="my-app", ~devcontainer=false), + }, + { + name: "existing-commonjs", + perform: directory => + existingProject(context, directory, ~moduleSystem=CommonJs, ~typescript=None), + }, ] switch context.manager { | Npm => @@ -311,21 +331,22 @@ let scenarios = (context: P.context) => { ...common, { name: "existing-esm", - perform: directory => existingProject(context, directory, ~moduleSystem=EsModule), + perform: directory => + existingProject(context, directory, ~moduleSystem=EsModule, ~typescript=None), }, { name: "existing-ts-nodenext", perform: directory => - existingProject(context, directory, ~moduleSystem=EsModule, ~typescript=NodeNext), + existingProject(context, directory, ~moduleSystem=EsModule, ~typescript=Some(NodeNext)), }, { name: "existing-ts-bundler", perform: directory => - existingProject(context, directory, ~moduleSystem=EsModule, ~typescript=Bundler), + existingProject(context, directory, ~moduleSystem=EsModule, ~typescript=Some(Bundler)), }, { name: "devcontainer-named", - perform: directory => newProject(context, directory, ~devcontainer=true), + perform: directory => newProject(context, directory, ~target="my-app", ~devcontainer=true), }, { name: "devcontainer-dot", From 73dceefc031d4c1f0f8af74beb9a44cc21796b2d Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 11:01:01 +0200 Subject: [PATCH 08/19] Keep the terminal-mode pattern easy to read --- test/e2e/E2eProcess.res | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res index 858dadf..50ef083 100644 --- a/test/e2e/E2eProcess.res +++ b/test/e2e/E2eProcess.res @@ -83,11 +83,9 @@ let killGroup = (child, signal) => let runCommand = async (context, command, cwd, ~expected, ~mode) => { let commandText = shellJoin(command) N.Fs.append(context.log.contents, `\n$ ${commandText}\n(cwd: ${cwd})\n`) + // util-linux script supplies a real terminal; all control and assertions are ReScript. let (executable, args, prompts, stdinMode) = switch mode { - | Terminal( - prompts, - ) => // util-linux script supplies a real terminal; all control and assertions are ReScript. - ( + | Terminal(prompts) => ( "script", [ "--quiet", From 4526b396246047fc1af0b1058453d7df37d88f08 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 11:06:16 +0200 Subject: [PATCH 09/19] Consolidate E2E Node bindings in the existing Node module --- AGENTS.md | 1 + src/bindings/Node.res | 61 ++++++++++++++++++- test/ViteTemplateFastRefreshTest.res | 8 +-- test/e2e/E2eLaunch.res | 9 ++- test/e2e/E2eMain.res | 15 +++-- test/e2e/E2eNode.res | 67 -------------------- test/e2e/E2eProcess.res | 73 +++++++++++----------- test/e2e/E2eScenarios.res | 91 ++++++++++++++++------------ test/e2e/README.md | 2 +- 9 files changed, 163 insertions(+), 164 deletions(-) delete mode 100644 test/e2e/E2eNode.res diff --git a/AGENTS.md b/AGENTS.md index 365fde5..f5e9915 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ - Do not run `rescript`, `npm test`, or `npm run prepack` in parallel; ReScript compiler artifacts are not safe for concurrent builds in this repo. - Module files and variant constructors are PascalCase (`Templates.res`), values/functions and type names camelCase, and records use snake_case fields only when matching external JSON. - Keep `.resi` signatures accurate and minimal; avoid exposing helpers that are template-specific. +- Keep Node.js bindings in `src/bindings/Node.res` and reuse existing bindings. - When touching templates, mirror upstream defaults and keep package scripts consistent with the chosen toolchain. ## Package Manager Support diff --git a/src/bindings/Node.res b/src/bindings/Node.res index 893e7da..6280322 100644 --- a/src/bindings/Node.res +++ b/src/bindings/Node.res @@ -6,6 +6,13 @@ module Fs = { @module("node:fs") external readFileSync: (string, @as(json`"utf8"`) _) => string = "readFileSync" + @module("node:fs") external writeFileSync: (string, string) => unit = "writeFileSync" + @module("node:fs") external appendFileSync: (string, string) => unit = "appendFileSync" + @module("node:fs") external mkdtempSync: string => string = "mkdtempSync" + + type mkdirOptions = {recursive: bool} + @module("node:fs") external mkdirSync: (string, mkdirOptions) => unit = "mkdirSync" + type stats @module("node:fs") external statSync: string => stats = "statSync" @@ -57,6 +64,8 @@ module Path = { @module("node:path") external dirname: string => string = "dirname" + @module("node:path") external resolve: string => string = "resolve" + @module("node:path") @variadic external join: array => string = "join" @@ -75,6 +84,13 @@ module Path = { module Process = { @scope("process") external argv: array = "argv" + @scope("process") external env: Dict.t = "env" + @scope("process") external platform: string = "platform" + @scope("process") external execPath: string = "execPath" + @scope("process") external kill: (int, string) => unit = "kill" + + type state = {mutable exitCode: int} + @val external state: state = "process" @scope("process") external chdir: string => unit = "chdir" @scope("process") external cwd: unit => string = "cwd" @@ -85,10 +101,15 @@ module Process = { module Assert = { @module("node:assert/strict") - external strictEqual: ('a, 'a) => unit = "strictEqual" + external strictEqual: ('a, 'a, ~message: string=?) => unit = "strictEqual" + + @module("node:assert/strict") + external deepStrictEqual: ('a, 'a, string) => unit = "deepStrictEqual" + + @module("node:assert/strict") external ok: (bool, string) => unit = "ok" @module("node:assert/strict") - external fail: string => unit = "fail" + external fail: string => 'a = "fail" } module Test = { @@ -120,8 +141,44 @@ module Os = { type t @module("node:os") external eol: string = "EOL" + @module("node:os") external tmpdir: unit => string = "tmpdir" } +module Timer = { + type t + @val external setTimeout: (unit => unit, int) => t = "setTimeout" + @val external clearTimeout: t => unit = "clearTimeout" +} + +module ChildProcess = { + type readable + type writable + type t + type options = {cwd: string, env: Dict.t, detached: bool, stdio: array} + + @module("node:child_process") external spawn: (string, array, options) => t = "spawn" + @get external pid: t => option = "pid" + @get external stdout: t => readable = "stdout" + @get external stderr: t => readable = "stderr" + @get @return(nullable) external stdin: t => option = "stdin" + @send external setEncoding: (readable, @as("utf8") _) => unit = "setEncoding" + @send external onData: (readable, @as("data") _, string => unit) => unit = "on" + @send external onInputError: (writable, @as("error") _, JsExn.t => unit) => unit = "on" + @send external onError: (t, @as("error") _, JsExn.t => unit) => unit = "once" + @send + external onClose: (t, @as("close") _, (Nullable.t, Nullable.t) => unit) => unit = + "once" + @send external write: (writable, string) => unit = "write" + @send external destroyInput: writable => unit = "destroy" + + type syncOptions = {cwd: string, stdio: string} + type syncResult = {status: Nullable.t, error?: JsExn.t} + @module("node:child_process") + external spawnSync: (string, array, syncOptions) => syncResult = "spawnSync" +} + +@get external errorCode: JsExn.t => option = "code" + module Promisified = { module ChildProcess = { type execResult = {stdout: string, stderr: string} diff --git a/test/ViteTemplateFastRefreshTest.res b/test/ViteTemplateFastRefreshTest.res index a300733..667366f 100644 --- a/test/ViteTemplateFastRefreshTest.res +++ b/test/ViteTemplateFastRefreshTest.res @@ -87,9 +87,7 @@ let assertDoesNotInclude = (actual, substring, message) => let assertJsonObject = (json: JSON.t, message) => switch json { | Object(object) => object - | _ => - Assert.fail(message) - Dict.make() + | _ => Assert.fail(message) } let assertJsonMissing = (json: option, message) => @@ -264,9 +262,7 @@ Test.describe("Vite template Fast Refresh", () => { ->assertJsonObject("Expected the Vite template rescript.json to be a JSON object.") let suffix = switch rescriptConfig->Dict.get("suffix") { | Some(String(suffix)) => suffix - | _ => - Assert.fail("Expected the Vite template rescript.json to configure a suffix.") - ".res.mjs" + | _ => Assert.fail("Expected the Vite template rescript.json to configure a suffix.") } assertIncludes( diff --git a/test/e2e/E2eLaunch.res b/test/e2e/E2eLaunch.res index 00171df..c4e14f4 100644 --- a/test/e2e/E2eLaunch.res +++ b/test/e2e/E2eLaunch.res @@ -1,13 +1,12 @@ open Node -module N = E2eNode // A real package script supplies the selected manager's identity to the CLI. let launch = () => - switch (Process.argv->Array.get(2), N.Process.env->Dict.get("CRA_E2E_CLI")) { + switch (Process.argv->Array.get(2), Process.env->Dict.get("CRA_E2E_CLI")) { | (Some(directory), Some(cliPath)) => let args = Process.argv->Array.slice(~start=3) - let result = N.ChildProcess.spawnSync( - N.Process.execPath, + let result = ChildProcess.spawnSync( + Process.execPath, [cliPath, ...args], { cwd: directory, @@ -18,7 +17,7 @@ let launch = () => | Some(error) => JsExn.throw(error) | None => Process.exitWithCode(result.status->Nullable.toOption->Option.getOr(1)) } - | _ => N.Assert.fail("Expected a project directory and CRA_E2E_CLI") + | _ => Assert.fail("Expected a project directory and CRA_E2E_CLI") } launch() diff --git a/test/e2e/E2eMain.res b/test/e2e/E2eMain.res index 5a0e04d..ea8aca6 100644 --- a/test/e2e/E2eMain.res +++ b/test/e2e/E2eMain.res @@ -1,5 +1,4 @@ open Node -module N = E2eNode module P = E2eProcess type arguments = {packagePath: option, manager: option, logs: option} @@ -28,19 +27,19 @@ let rec parseArguments = (remaining, arguments: arguments) => @scope(("import", "meta")) external importMetaUrl: Url.t = "url" let main = async () => { - N.Assert.equal( - N.Process.platform, + Assert.strictEqual( + Process.platform, "linux", - "The E2E terminal runner requires Linux with util-linux script", + ~message="The E2E terminal runner requires Linux with util-linux script", ) let options = switch parseArguments( Process.argv->Array.slice(~start=2)->List.fromArray, {packagePath: None, manager: None, logs: None}, ) { | Ok(options) => options - | Error(message) => N.Assert.fail(message) + | Error(message) => Assert.fail(message) } - let workspace = N.Fs.mkdtemp(Path.join2(N.Os.tmpdir(), "cra e2e ")) + let workspace = Fs.mkdtempSync(Path.join2(Os.tmpdir(), "cra e2e ")) let context = P.makeContext(options, workspace) let outcome = try { let launcherPath = Path.join2( @@ -62,14 +61,14 @@ let main = async () => { } Console.error(`FAIL ${context.info.name}; log: ${context.log.contents}`) Console.error(error) - N.Process.state.exitCode = 1 + Process.state.exitCode = 1 } } main() ->Promise.catch(error => { Console.error(error) - N.Process.state.exitCode = 1 + Process.state.exitCode = 1 Promise.resolve() }) ->Promise.ignore diff --git a/test/e2e/E2eNode.res b/test/e2e/E2eNode.res deleted file mode 100644 index e6a09a0..0000000 --- a/test/e2e/E2eNode.res +++ /dev/null @@ -1,67 +0,0 @@ -// Node APIs used only by the installation E2E harness. -module Fs = { - type mkdirOptions = {recursive: bool} - @module("node:fs") external write: (string, string) => unit = "writeFileSync" - @module("node:fs") external append: (string, string) => unit = "appendFileSync" - @module("node:fs") external mkdtemp: string => string = "mkdtempSync" - @module("node:fs") external mkdir: (string, mkdirOptions) => unit = "mkdirSync" -} - -module Path = { - @module("node:path") external resolve: string => string = "resolve" -} - -module Os = { - @module("node:os") external tmpdir: unit => string = "tmpdir" -} - -module Process = { - @scope("process") external env: Dict.t = "env" - @scope("process") external platform: string = "platform" - @scope("process") external execPath: string = "execPath" - @scope("process") external kill: (int, string) => unit = "kill" - type state = {mutable exitCode: int} - @val external state: state = "process" -} - -module Assert = { - @module("node:assert/strict") external check: (bool, string) => unit = "ok" - @module("node:assert/strict") external equal: ('a, 'a, string) => unit = "strictEqual" - @module("node:assert/strict") external deepEqual: ('a, 'a, string) => unit = "deepStrictEqual" - @module("node:assert/strict") external fail: string => 'a = "fail" -} - -module Timer = { - type t - @val external start: (unit => unit, int) => t = "setTimeout" - @val external clear: t => unit = "clearTimeout" -} - -module ChildProcess = { - type readable - type writable - type t - type options = {cwd: string, env: Dict.t, detached: bool, stdio: array} - - @module("node:child_process") external spawn: (string, array, options) => t = "spawn" - @get external pid: t => option = "pid" - @get external stdout: t => readable = "stdout" - @get external stderr: t => readable = "stderr" - @get @return(nullable) external stdin: t => option = "stdin" - @send external setEncoding: (readable, @as("utf8") _) => unit = "setEncoding" - @send external onData: (readable, @as("data") _, string => unit) => unit = "on" - @send external onInputError: (writable, @as("error") _, JsExn.t => unit) => unit = "on" - @send external onError: (t, @as("error") _, JsExn.t => unit) => unit = "once" - @send - external onClose: (t, @as("close") _, (Nullable.t, Nullable.t) => unit) => unit = - "once" - @send external write: (writable, string) => unit = "write" - @send external destroyInput: writable => unit = "destroy" - - type syncOptions = {cwd: string, stdio: string} - type syncResult = {status: Nullable.t, error?: JsExn.t} - @module("node:child_process") - external spawnSync: (string, array, syncOptions) => syncResult = "spawnSync" -} - -@get external errorCode: JsExn.t => option = "code" diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res index 50ef083..eb671d5 100644 --- a/test/e2e/E2eProcess.res +++ b/test/e2e/E2eProcess.res @@ -1,5 +1,4 @@ open Node -module N = E2eNode type manager = Npm | Pnpm | YarnClassic | YarnBerry | Bun type managerInfo = {name: string, command: string, lockfile: string} @@ -38,12 +37,12 @@ let parseManager = name => let quote = value => `'${value->String.replaceAll("'", "'\\''")}'` let shellJoin = command => command->Array.map(quote)->Array.join(" ") let readJson = path => Fs.readFileSync(path)->JSON.parseOrThrow -let writeJson = (path, json) => N.Fs.write(path, `${JSON.stringify(json, ~space=2)}\n`) +let writeJson = (path, json) => Fs.writeFileSync(path, `${JSON.stringify(json, ~space=2)}\n`) let makeContext = (options: options, workspace) => { - let logs = N.Path.resolve(options.logs) - N.Fs.mkdir(logs, {recursive: true}) - let env = N.Process.env->Dict.copy + let logs = Path.resolve(options.logs) + Fs.mkdirSync(logs, {recursive: true}) + let env = Process.env->Dict.copy [ ("CI", "false"), ("TERM", "xterm"), @@ -72,17 +71,17 @@ let makeContext = (options: options, workspace) => { } let killGroup = (child, signal) => - switch child->N.ChildProcess.pid { + switch child->ChildProcess.pid { | None => () | Some(pid) => - try N.Process.kill(-pid, signal) catch { - | JsExn(error) if error->N.errorCode === Some("ESRCH") => () + try Process.kill(-pid, signal) catch { + | JsExn(error) if error->errorCode === Some("ESRCH") => () } } let runCommand = async (context, command, cwd, ~expected, ~mode) => { let commandText = shellJoin(command) - N.Fs.append(context.log.contents, `\n$ ${commandText}\n(cwd: ${cwd})\n`) + Fs.appendFileSync(context.log.contents, `\n$ ${commandText}\n(cwd: ${cwd})\n`) // util-linux script supplies a real terminal; all control and assertions are ReScript. let (executable, args, prompts, stdinMode) = switch mode { | Terminal(prompts) => ( @@ -101,10 +100,10 @@ let runCommand = async (context, command, cwd, ~expected, ~mode) => { | Piped => switch command->List.fromArray { | list{executable, ...args} => (executable, args->List.toArray, [], "ignore") - | list{} => N.Assert.fail("Cannot run an empty command") + | list{} => Assert.fail("Cannot run an empty command") } } - let child = N.ChildProcess.spawn( + let child = ChildProcess.spawn( executable, args, { @@ -121,31 +120,31 @@ let runCommand = async (context, command, cwd, ~expected, ~mode) => { let timedOut = ref(false) let inputError = ref(None) let forceKill = ref(None) - let timeout = N.Timer.start(() => { + let timeout = Timer.setTimeout(() => { timedOut := true killGroup(child, "SIGTERM") - forceKill := Some(N.Timer.start(() => killGroup(child, "SIGKILL"), 3000)) + forceKill := Some(Timer.setTimeout(() => killGroup(child, "SIGKILL"), 3000)) }, 300_000) let collect = chunk => { output := output.contents ++ chunk pending := pending.contents ++ chunk - N.Fs.append(context.log.contents, chunk) - switch (prompts->Array.get(nextPrompt.contents), child->N.ChildProcess.stdin) { + Fs.appendFileSync(context.log.contents, chunk) + switch (prompts->Array.get(nextPrompt.contents), child->ChildProcess.stdin) { | (Some((question, answer)), Some(stdin)) if pending.contents->String.includes(question) => - stdin->N.ChildProcess.write(answer) + stdin->ChildProcess.write(answer) nextPrompt := nextPrompt.contents + 1 pending := "" | _ => () } } - [child->N.ChildProcess.stdout, child->N.ChildProcess.stderr]->Array.forEach(stream => { - stream->N.ChildProcess.setEncoding - stream->N.ChildProcess.onData(collect) + [child->ChildProcess.stdout, child->ChildProcess.stderr]->Array.forEach(stream => { + stream->ChildProcess.setEncoding + stream->ChildProcess.onData(collect) }) - switch child->N.ChildProcess.stdin { + switch child->ChildProcess.stdin { | Some(stdin) => - stdin->N.ChildProcess.onInputError(error => { - if error->N.errorCode !== Some("EPIPE") { + stdin->ChildProcess.onInputError(error => { + if error->errorCode !== Some("EPIPE") { inputError := Some(error) killGroup(child, "SIGTERM") } @@ -153,14 +152,14 @@ let runCommand = async (context, command, cwd, ~expected, ~mode) => { | None => () } let completion = await Promise.make((resolve, _reject) => { - child->N.ChildProcess.onError(error => resolve(Error(error))) - child->N.ChildProcess.onClose((code, signal) => resolve(Ok((code, signal)))) + child->ChildProcess.onError(error => resolve(Error(error))) + child->ChildProcess.onClose((code, signal) => resolve(Ok((code, signal)))) }) - timeout->N.Timer.clear - forceKill.contents->Option.forEach(N.Timer.clear) - child->N.ChildProcess.stdin->Option.forEach(N.ChildProcess.destroyInput) + timeout->Timer.clearTimeout + forceKill.contents->Option.forEach(Timer.clearTimeout) + child->ChildProcess.stdin->Option.forEach(ChildProcess.destroyInput) inputError.contents->Option.forEach(JsExn.throw) - N.Assert.check(!timedOut.contents, `Command timed out: ${commandText}`) + Assert.ok(!timedOut.contents, `Command timed out: ${commandText}`) switch completion { | Error(error) => JsExn.throw(error) | Ok((code, signal)) => @@ -169,16 +168,16 @@ let runCommand = async (context, command, cwd, ~expected, ~mode) => { | Some(code) => code->Int.toString | None => signal->Nullable.toOption->Option.getOr("unknown signal") } - N.Assert.equal( + Assert.strictEqual( actual, Some(expected), - `Command exited ${description}, expected ${expected->Int.toString}: ${commandText}`, + ~message=`Command exited ${description}, expected ${expected->Int.toString}: ${commandText}`, ) } - N.Assert.equal( + Assert.strictEqual( nextPrompt.contents, prompts->Array.length, - "CLI exited before answering all prompts", + ~message="CLI exited before answering all prompts", ) output.contents } @@ -205,14 +204,14 @@ let setup = async (context, ~packagePath, ~launcherPath) => { let cliDirectory = Path.join2(context.workspace, "cli") let _ = await run( context, - ["npm", "install", "--prefix", cliDirectory, N.Path.resolve(packagePath)], + ["npm", "install", "--prefix", cliDirectory, Path.resolve(packagePath)], context.workspace, ) context.env->Dict.set( "CRA_E2E_CLI", Path.join2(cliDirectory, "node_modules/create-rescript-app/out/create-rescript-app.cjs"), ) - N.Fs.mkdir(context.launcher, {recursive: true}) + Fs.mkdirSync(context.launcher, {recursive: true}) writeJson( Path.join2(context.launcher, "package.json"), JSON.parseOrThrow( @@ -225,7 +224,7 @@ let setup = async (context, ~packagePath, ~launcherPath) => { ) switch context.manager { | YarnBerry => - N.Fs.write(Path.join2(context.launcher, ".yarnrc.yml"), "nodeLinker: node-modules\n") + Fs.writeFileSync(Path.join2(context.launcher, ".yarnrc.yml"), "nodeLinker: node-modules\n") | _ => () } let _ = await run(context, [context.info.command, "install"], context.launcher) @@ -233,9 +232,9 @@ let setup = async (context, ~packagePath, ~launcherPath) => { let runCase = async (context, name, perform) => { context.log := Path.join2(context.logs, `${name}.log`) - N.Fs.write(context.log.contents, "") + Fs.writeFileSync(context.log.contents, "") let directory = Path.join2(context.workspace, name) - N.Fs.mkdir(directory, {recursive: true}) + Fs.mkdirSync(directory, {recursive: true}) Console.log(`RUN ${context.info.name}: ${name}`) await perform(directory) Console.log(`PASS ${context.info.name}: ${name}`) diff --git a/test/e2e/E2eScenarios.res b/test/e2e/E2eScenarios.res index 18b7d03..c9dfc97 100644 --- a/test/e2e/E2eScenarios.res +++ b/test/e2e/E2eScenarios.res @@ -1,6 +1,5 @@ open Node open JSON -module N = E2eNode module P = E2eProcess type moduleSystem = CommonJs | EsModule @@ -12,35 +11,39 @@ let field = (json: JSON.t, name) => | Object(fields) => switch fields->Dict.get(name) { | Some(value) => value - | None => N.Assert.fail(`Missing JSON field: ${name}`) + | None => Assert.fail(`Missing JSON field: ${name}`) } - | _ => N.Assert.fail(`Expected a JSON object containing ${name}`) + | _ => Assert.fail(`Expected a JSON object containing ${name}`) } let stringField = (json, name) => switch field(json, name) { | String(value) => value - | _ => N.Assert.fail(`Expected a string in JSON field: ${name}`) + | _ => Assert.fail(`Expected a string in JSON field: ${name}`) } let assertContains = (actual, expected, message) => - N.Assert.check(actual->String.includes(expected), message) + Assert.ok(actual->String.includes(expected), message) let verifyProject = async (context: P.context, directory, suffix) => { let packageJson = P.readJson(Path.join2(directory, "package.json")) let config = P.readJson(Path.join2(directory, "rescript.json")) - N.Assert.equal( + Assert.strictEqual( stringField(config, "name"), stringField(packageJson, "name"), - "Project and package names differ", + ~message="Project and package names differ", ) - N.Assert.equal(stringField(config, "suffix"), suffix, "Unexpected generated JS suffix") - N.Assert.check( + Assert.strictEqual( + stringField(config, "suffix"), + suffix, + ~message="Unexpected generated JS suffix", + ) + Assert.ok( Fs.existsSync(Path.join2(directory, context.info.lockfile)), `Missing ${context.info.lockfile}: manager detection failed`, ) switch context.manager { | Npm => () | _ => - N.Assert.check( + Assert.ok( !Fs.existsSync(Path.join2(directory, "package-lock.json")), "npm lockfile was not removed", ) @@ -55,29 +58,33 @@ let verifyProject = async (context: P.context, directory, suffix) => { } let version = P.readJson(Path.join2(directory, "node_modules/rescript/package.json"))->stringField("version") - N.Assert.check( + Assert.ok( version->String.startsWith("12."), `Expected a current ReScript 12 version, installed ${version}`, ) switch field(packageJson, "dependencies") { | Object(dependencies) => - N.Assert.check( + Assert.ok( !(dependencies->Dict.has("@rescript/core")), "ReScript 12 should use its bundled standard library", ) - | _ => N.Assert.fail("Expected package dependencies") + | _ => Assert.fail("Expected package dependencies") } let _ = await P.run(context, [context.info.command, "run", "res:build"], directory) let output = await P.run(context, ["node", `src/Demo${suffix}`], directory) - N.Assert.equal(output->String.trim, "Hello, world!", "Unexpected generated program output") + Assert.strictEqual( + output->String.trim, + "Hello, world!", + ~message="Unexpected generated program output", + ) } let verifyPreserved = (directory, preserved) => preserved->Array.forEach(((path, contents)) => { let actual = Fs.readFileSync(Path.join2(directory, path)) switch path { - | ".gitignore" => N.Assert.check(actual->String.startsWith(contents), `Overwrote ${path}`) - | _ => N.Assert.equal(actual, contents, `Overwrote ${path}`) + | ".gitignore" => Assert.ok(actual->String.startsWith(contents), `Overwrote ${path}`) + | _ => Assert.strictEqual(actual, contents, ~message=`Overwrote ${path}`) } }) @@ -88,7 +95,7 @@ let newProject = async (context, directory, ~target, ~devcontainer) => { } let project = currentDirectory ? directory : Path.join2(directory, target) let preserved = if devcontainer { - N.Fs.mkdir(Path.join2(project, ".devcontainer"), {recursive: true}) + Fs.mkdirSync(Path.join2(project, ".devcontainer"), {recursive: true}) let metadata = currentDirectory ? [("README.md", "Existing README\n"), (".gitignore", "keep-this-entry\n")] : [] @@ -96,12 +103,14 @@ let newProject = async (context, directory, ~target, ~devcontainer) => { } else { [] } - preserved->Array.forEach(((path, contents)) => N.Fs.write(Path.join2(project, path), contents)) + preserved->Array.forEach(((path, contents)) => + Fs.writeFileSync(Path.join2(project, path), contents) + ) await P.cli(context, directory, ~args=[target, "--template", "basic"], ~expected=0, ~prompts=[]) - N.Assert.equal( + Assert.strictEqual( P.readJson(Path.join2(project, "package.json"))->stringField("name"), Path.basename(project), - "Current-directory package name was not derived from the directory", + ~message="Current-directory package name was not derived from the directory", ) verifyPreserved(project, preserved) await verifyProject(context, project, ".res.mjs") @@ -133,7 +142,7 @@ let existingProject = async ( ], ) P.writeJson(Path.join2(directory, "package.json"), original) - N.Fs.mkdir(Path.join2(directory, "src"), {recursive: true}) + Fs.mkdirSync(Path.join2(directory, "src"), {recursive: true}) let tsPreserved = switch typescript { | None => [] | Some(resolution) => @@ -141,7 +150,7 @@ let existingProject = async ( | NodeNext => ("NodeNext", "nodenext") | Bundler => ("ESNext", "bundler") } - N.Fs.write( + Fs.writeFileSync( Path.join2(directory, "tsconfig.json"), `{ // Preserve this comment and the inherited configuration. @@ -179,7 +188,9 @@ let existingProject = async ( ("src/original.cjs", "console.log(require(\"is-number\")(42));\n"), ...tsPreserved, ] - preserved->Array.forEach(((path, contents)) => N.Fs.write(Path.join2(directory, path), contents)) + preserved->Array.forEach(((path, contents)) => + Fs.writeFileSync(Path.join2(directory, path), contents) + ) let _ = await P.run(context, [context.info.command, "install"], directory) let modulePrompts = switch typescript { | Some(resolution) => @@ -210,25 +221,25 @@ let existingProject = async ( ) let packageJson = P.readJson(Path.join2(directory, "package.json")) ["name", "version", "private", "type"]->Array.forEach(name => - N.Assert.deepEqual( + Assert.deepStrictEqual( field(packageJson, name), field(original, name), `Changed existing package field ${name}`, ) ) - N.Assert.deepEqual( + Assert.deepStrictEqual( field(packageJson, "scripts")->field("test"), field(original, "scripts")->field("test"), "Overwrote existing script", ) - N.Assert.equal( + Assert.strictEqual( field(packageJson, "dependencies")->stringField("is-number"), "7.0.0", - "Changed existing dependency", + ~message="Changed existing dependency", ) switch typescript { | Some(_) => - N.Assert.deepEqual( + Assert.deepStrictEqual( field(packageJson, "devDependencies"), field(original, "devDependencies"), "Changed TypeScript dependency", @@ -252,10 +263,10 @@ let existingProject = async ( | Bundler => ("bundler", "ts") } let config = P.readJson(Path.join2(directory, "rescript.json")) - N.Assert.equal( + Assert.strictEqual( field(config, "gentypeconfig")->stringField("moduleResolution"), gentypeResolution, - "Incorrect genType mapping", + ~message="Incorrect genType mapping", ) let tsconfig = Fs.readFileSync(Path.join2(directory, "tsconfig.json")) tsconfig->assertContains("// Preserve this comment", "Lost tsconfig comment") @@ -268,22 +279,22 @@ let existingProject = async ( "Did not enable TS extension imports", ) } - N.Fs.write(Path.join2(directory, "src/Interop.res"), "@genType\nlet answer = 42\n") - N.Fs.write( + Fs.writeFileSync(Path.join2(directory, "src/Interop.res"), "@genType\nlet answer = 42\n") + Fs.writeFileSync( Path.join2(directory, "src/consumer.ts"), `import {answer} from "./Interop.gen.${extension}";\nconst value: number = answer;\n`, ) } await verifyProject(context, directory, suffix) let output = await P.run(context, [context.info.command, "run", "test"], directory) - N.Assert.check( + Assert.ok( output->String.split("\n")->Array.some(line => line->String.trim === "true"), "Existing JavaScript program no longer runs", ) switch typescript { | None => () | Some(_) => - N.Assert.check( + Assert.ok( Fs.existsSync(Path.join2(directory, "src/Interop.gen.ts")), "genType did not generate TypeScript", ) @@ -293,9 +304,9 @@ let existingProject = async ( let occupiedDirectory = async (context, directory) => { let project = Path.join2(directory, "occupied") - N.Fs.mkdir(project, {recursive: true}) + Fs.mkdirSync(project, {recursive: true}) let sentinel = Path.join2(project, "keep.txt") - N.Fs.write(sentinel, "Do not overwrite\n") + Fs.writeFileSync(sentinel, "Do not overwrite\n") await P.cli( context, directory, @@ -303,8 +314,12 @@ let occupiedDirectory = async (context, directory) => { ~expected=1, ~prompts=[], ) - N.Assert.equal(Fs.readFileSync(sentinel), "Do not overwrite\n", "Overwrote occupied directory") - N.Assert.deepEqual( + Assert.strictEqual( + Fs.readFileSync(sentinel), + "Do not overwrite\n", + ~message="Overwrote occupied directory", + ) + Assert.deepStrictEqual( Fs.readdirSync(project), ["keep.txt"], "Failed scaffolding left files in the occupied directory", diff --git a/test/e2e/README.md b/test/e2e/README.md index f957732..c5409bc 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -15,7 +15,7 @@ projects (including compiling a genType consumer), devcontainer-only directories The harness is written in ReScript, alongside the other tests. `E2eScenarios.res` contains the fixtures and assertions, `E2eProcess.res` runs commands and answers -prompts, and `E2eNode.res` contains test-only Node bindings. `E2eLaunch.res` runs the +prompts, and Node bindings live in `src/bindings/Node.res`. `E2eLaunch.res` runs the packed CLI through the selected manager's package script. The `test/` source directory is already included in `rescript.json`. From 8cf24bd31136117daa613d20aef8c67000d613db Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 11:31:33 +0200 Subject: [PATCH 10/19] Update ReScript to 12.3.1 and use let? in E2E tests --- AGENTS.md | 1 + package-lock.json | 67 +++++++++++++++++++++----------------- package.json | 2 +- rescript.json | 5 ++- src/NewProjectLocation.res | 4 +-- src/bindings/Node.res | 2 +- test/e2e/E2eMain.res | 33 ++++++++++--------- test/e2e/E2eProcess.res | 16 ++++----- test/e2e/E2eScenarios.res | 22 +++++-------- test/e2e/README.md | 4 +++ 10 files changed, 84 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f5e9915..ccc017b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ - Do not add redundant parentheses around switch expressions: use `switch value { ... }`. Parentheses required by tuple syntax are fine. - Formatting is enforced by `rescript format`; keep 2-space indentation and prefer pattern matching over chained conditionals. - Prefer `result` values over exceptions for expected failure paths; only raise or throw at clear integration boundaries where the surrounding API requires it. +- Use `let?` to unwrap and propagate `Option`/`Result` failures where it simplifies the code. `LetUnwrap` is enabled in `rescript.json`. - Prefer pattern matching over `if`/`else` chains when branching on the shape or state of the same value; plain comparisons across different values are fine with `if`/`else`. - Do not run `rescript`, `npm test`, or `npm run prepack` in parallel; ReScript compiler artifacts are not safe for concurrent builds in this repo. - Module files and variant constructors are PascalCase (`Templates.res`), values/functions and type names camelCase, and records use snake_case fields only when matching external JSON. diff --git a/package-lock.json b/package-lock.json index 541ab3e..cddcbc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@vitejs/plugin-react": "^6.0.1", "compare-versions": "^6.1.0", "jsonc-parser": "^3.3.1", - "rescript": "^12.0.0", + "rescript": "^12.3.1", "rollup": "^4.53.3", "typescript": "^5.9.3", "vite": "^8.0.10" @@ -158,13 +158,14 @@ } }, "node_modules/@rescript/darwin-arm64": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@rescript/darwin-arm64/-/darwin-arm64-12.0.0.tgz", - "integrity": "sha512-N2zNj/Uh0zrg8BJGWxdQjYTDf0d2ZV8D+Vmct8xORaUcEZvAkvZb+xA66UxP2QGhWmATcHmBth7oJ48xpWz/Vg==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@rescript/darwin-arm64/-/darwin-arm64-12.3.1.tgz", + "integrity": "sha512-pwdnHhuIMH4Y5nuEwBLAMOYvGIcAVgnSZq/DWUNZJWoUr7YZpNkABJp0TFGiyYePT5ld+nvjQ40oI0rKjEWK8w==", "cpu": [ "arm64" ], "dev": true, + "license": "(LGPL-3.0-or-later AND MIT)", "optional": true, "os": [ "darwin" @@ -174,13 +175,14 @@ } }, "node_modules/@rescript/darwin-x64": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@rescript/darwin-x64/-/darwin-x64-12.0.0.tgz", - "integrity": "sha512-Qx/ao3Fl2lx3rF8mbRsyRK5HGAx8pmzyCdpSIAaUun5wLBnsJbNqgvqUH7VeI2TwaczHvcIljrYmnB7T5LJdFQ==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@rescript/darwin-x64/-/darwin-x64-12.3.1.tgz", + "integrity": "sha512-Eu39cHkzoHyC18BCRqnmZnLgP42c7JhR74UBl6XhaFOzuf63tddOssgTyX9mEihzok2xFBGLpETrCvZN8Qqtkg==", "cpu": [ "x64" ], "dev": true, + "license": "(LGPL-3.0-or-later AND MIT)", "optional": true, "os": [ "darwin" @@ -190,13 +192,14 @@ } }, "node_modules/@rescript/linux-arm64": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@rescript/linux-arm64/-/linux-arm64-12.0.0.tgz", - "integrity": "sha512-H65csj/0uqqjYy235m0h5CFxbrsas6RoVfjy5Xjf91Vyg/r5R/QIGQvhMtFvl4QojYsa1wXzXGcujwXh+S15xQ==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@rescript/linux-arm64/-/linux-arm64-12.3.1.tgz", + "integrity": "sha512-Nsk3qfDRrp9b6RI85t0xzrr8LoxwXAI678V7zpQMaRfhltYl+N5fsK66MWx9rSrv6SIWPVSyU8FrJG0MskONwQ==", "cpu": [ "arm64" ], "dev": true, + "license": "(LGPL-3.0-or-later AND MIT)", "optional": true, "os": [ "linux" @@ -206,13 +209,14 @@ } }, "node_modules/@rescript/linux-x64": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@rescript/linux-x64/-/linux-x64-12.0.0.tgz", - "integrity": "sha512-WizQwyfMadxF096foZYU3I4zxcnNIc9XP7TP4fqdfe0v6OvQ5yHMI/uIfOsJ1GfNALce2UxvgS0pn67uxc55uA==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@rescript/linux-x64/-/linux-x64-12.3.1.tgz", + "integrity": "sha512-CfAJ3q/y/GSwrSJCLdCdyLWWdb7kTZkl2vjC+mSAw3pMqknEyCbt4ukS64Xn8PrqxVGHIs+kdefEphU7dt77xA==", "cpu": [ "x64" ], "dev": true, + "license": "(LGPL-3.0-or-later AND MIT)", "optional": true, "os": [ "linux" @@ -222,19 +226,21 @@ } }, "node_modules/@rescript/runtime": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@rescript/runtime/-/runtime-12.0.0.tgz", - "integrity": "sha512-STRbYHT5rnW61vWn2+Bdtj620XaprnoBSbPbkvOYZ+Zs8nfqtpBjhNw+JNpGQLXf73crk6MhxFGPxL2o5vb8TQ==", - "dev": true + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@rescript/runtime/-/runtime-12.3.1.tgz", + "integrity": "sha512-yTM0iTl6owyA37UEtPAZR0A3G1kFffJWb+UE6+rcG/N2kxYQnuSlNDfVvsry7l8O+o/ME6QE7Zt7efjCDGM3xg==", + "dev": true, + "license": "MIT" }, "node_modules/@rescript/win32-x64": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@rescript/win32-x64/-/win32-x64-12.0.0.tgz", - "integrity": "sha512-kkqj4kQGbni5YglWoPm76M6hUuzKJHDEiLce5EZxW2Q4FrkS9dNRgm02c2Ck1cz/uKCS3m+ZoKQGIMMXTvo5Jw==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@rescript/win32-x64/-/win32-x64-12.3.1.tgz", + "integrity": "sha512-1ffFbw9IJnJmGTNshWzRcG6wQ7GkzOeWxLLrarvxI38p2aYPQKTbVovUraXpw1Jfg2bbWkm8hPMMrFIezI/pkA==", "cpu": [ "x64" ], "dev": true, + "license": "(LGPL-3.0-or-later AND MIT)", "optional": true, "os": [ "win32" @@ -1494,11 +1500,11 @@ } }, "node_modules/rescript": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/rescript/-/rescript-12.0.0.tgz", - "integrity": "sha512-DGcZI2L5W0c6FuEnspLE0MIe1UtTt1VsW/vQfzBFCEBxSsQtoA6YRHUB8Puwnb30PHqZiFK1ADhn6UgA8LWK0A==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/rescript/-/rescript-12.3.1.tgz", + "integrity": "sha512-Q4y0SQ7EUShiX1ymEG5iOCGzbVSOh7CCLEnQUkq19FX2i64eQ2uEGeMCKFl0AsL7TcK5pS+66QTSP6jf6LSgaA==", "dev": true, - "license": "SEE LICENSE IN LICENSE", + "license": "(LGPL-3.0-or-later AND MIT)", "workspaces": [ "packages/playground", "packages/@rescript/*", @@ -1507,10 +1513,11 @@ "tests/docstring_tests", "tests/gentype_tests/**", "tests/tools_tests", + "tests/commonjs_tests", "scripts/res" ], "dependencies": { - "@rescript/runtime": "12.0.0" + "@rescript/runtime": "12.3.1" }, "bin": { "bsc": "cli/bsc.js", @@ -1523,11 +1530,11 @@ "node": ">=20.11.0" }, "optionalDependencies": { - "@rescript/darwin-arm64": "12.0.0", - "@rescript/darwin-x64": "12.0.0", - "@rescript/linux-arm64": "12.0.0", - "@rescript/linux-x64": "12.0.0", - "@rescript/win32-x64": "12.0.0" + "@rescript/darwin-arm64": "12.3.1", + "@rescript/darwin-x64": "12.3.1", + "@rescript/linux-arm64": "12.3.1", + "@rescript/linux-x64": "12.3.1", + "@rescript/win32-x64": "12.3.1" } }, "node_modules/resolve": { diff --git a/package.json b/package.json index e22659c..5911574 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@vitejs/plugin-react": "^6.0.1", "compare-versions": "^6.1.0", "jsonc-parser": "^3.3.1", - "rescript": "^12.0.0", + "rescript": "^12.3.1", "rollup": "^4.53.3", "typescript": "^5.9.3", "vite": "^8.0.10" diff --git a/rescript.json b/rescript.json index a2c9135..95c273d 100644 --- a/rescript.json +++ b/rescript.json @@ -15,5 +15,8 @@ "module": "esmodule", "in-source": true }, - "suffix": ".res.mjs" + "suffix": ".res.mjs", + "experimental-features": { + "LetUnwrap": true + } } diff --git a/src/NewProjectLocation.res b/src/NewProjectLocation.res index fe2f0a5..c7b2dc2 100644 --- a/src/NewProjectLocation.res +++ b/src/NewProjectLocation.res @@ -32,7 +32,7 @@ let hasDevcontainerDirectory = projectPath => try { Path.join2(projectPath, devcontainerDirectoryName)->Fs.statSync->Fs.Stats.isDirectory } catch { - | Exn.Error(_) => false + | JsExn(_) => false } let containsOnlyDevcontainerDirectory = projectPath => @@ -42,7 +42,7 @@ let containsOnlyDevcontainerDirectory = projectPath => | _ => false } } catch { - | Exn.Error(_) => false + | JsExn(_) => false } let isAllowedCurrentDirectoryEntry = (~cwd, entry) => { diff --git a/src/bindings/Node.res b/src/bindings/Node.res index 6280322..9618675 100644 --- a/src/bindings/Node.res +++ b/src/bindings/Node.res @@ -133,7 +133,7 @@ module Url = { let make = string => try Some(makeUnsafe(string)) catch { - | Exn.Error(_exn) => None + | JsExn(_) => None } } diff --git a/test/e2e/E2eMain.res b/test/e2e/E2eMain.res index ea8aca6..f3701c3 100644 --- a/test/e2e/E2eMain.res +++ b/test/e2e/E2eMain.res @@ -11,10 +11,8 @@ let rec parseArguments = (remaining, arguments: arguments) => parseArguments(rest, {...arguments, packagePath: Some(value)}) | list{"--logs", value, ...rest} => parseArguments(rest, {...arguments, logs: Some(value)}) | list{"--manager", value, ...rest} => - switch P.parseManager(value) { - | Ok(manager) => parseArguments(rest, {...arguments, manager: Some(manager)}) - | Error(message) => Error(message) - } + let? Ok(manager) = P.parseManager(value) + parseArguments(rest, {...arguments, manager: Some(manager)}) | list{} => switch arguments { | {packagePath: Some(packagePath), manager: Some(manager), logs: Some(logs)} => @@ -32,13 +30,10 @@ let main = async () => { "linux", ~message="The E2E terminal runner requires Linux with util-linux script", ) - let options = switch parseArguments( + let? Ok(options) = parseArguments( Process.argv->Array.slice(~start=2)->List.fromArray, {packagePath: None, manager: None, logs: None}, - ) { - | Ok(options) => options - | Error(message) => Assert.fail(message) - } + ) let workspace = Fs.mkdtempSync(Path.join2(Os.tmpdir(), "cra e2e ")) let context = P.makeContext(options, workspace) let outcome = try { @@ -53,19 +48,25 @@ let main = async () => { | JsExn(error) => Error(error) } await Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) - switch outcome { - | Ok() => () - | Error(error) => + outcome->Result.mapError(error => { if Fs.existsSync(context.log.contents) { Console.error(Fs.readFileSync(context.log.contents)) } - Console.error(`FAIL ${context.info.name}; log: ${context.log.contents}`) - Console.error(error) + let detail = + JsExn.stack(error)->Option.orElse(JsExn.message(error))->Option.getOr("Unknown error") + `FAIL ${context.info.name}; log: ${context.log.contents}\n${detail}` + }) +} + +let run = async () => + switch await main() { + | Ok() => () + | Error(message) => + Console.error(message) Process.state.exitCode = 1 } -} -main() +run() ->Promise.catch(error => { Console.error(error) Process.state.exitCode = 1 diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res index eb671d5..8d118af 100644 --- a/test/e2e/E2eProcess.res +++ b/test/e2e/E2eProcess.res @@ -71,13 +71,13 @@ let makeContext = (options: options, workspace) => { } let killGroup = (child, signal) => - switch child->ChildProcess.pid { - | None => () - | Some(pid) => + child + ->ChildProcess.pid + ->Option.forEach(pid => { try Process.kill(-pid, signal) catch { | JsExn(error) if error->errorCode === Some("ESRCH") => () } - } + }) let runCommand = async (context, command, cwd, ~expected, ~mode) => { let commandText = shellJoin(command) @@ -141,16 +141,16 @@ let runCommand = async (context, command, cwd, ~expected, ~mode) => { stream->ChildProcess.setEncoding stream->ChildProcess.onData(collect) }) - switch child->ChildProcess.stdin { - | Some(stdin) => + child + ->ChildProcess.stdin + ->Option.forEach(stdin => stdin->ChildProcess.onInputError(error => { if error->errorCode !== Some("EPIPE") { inputError := Some(error) killGroup(child, "SIGTERM") } }) - | None => () - } + ) let completion = await Promise.make((resolve, _reject) => { child->ChildProcess.onError(error => resolve(Error(error))) child->ChildProcess.onClose((code, signal) => resolve(Ok((code, signal)))) diff --git a/test/e2e/E2eScenarios.res b/test/e2e/E2eScenarios.res index c9dfc97..cd7574e 100644 --- a/test/e2e/E2eScenarios.res +++ b/test/e2e/E2eScenarios.res @@ -6,20 +6,16 @@ type moduleSystem = CommonJs | EsModule type tsResolution = NodeNext | Bundler let object = (entries: array<(string, JSON.t)>) => JSON.Object(entries->Dict.fromArray) -let field = (json: JSON.t, name) => - switch json { - | Object(fields) => - switch fields->Dict.get(name) { - | Some(value) => value - | None => Assert.fail(`Missing JSON field: ${name}`) - } - | _ => Assert.fail(`Expected a JSON object containing ${name}`) - } +let findField = (json, name) => { + let? Some(fields) = json->JSON.Decode.object + fields->Dict.get(name) +} +let field = (json, name) => + findField(json, name)->Option.getOrThrow(~message=`Expected JSON object field: ${name}`) let stringField = (json, name) => - switch field(json, name) { - | String(value) => value - | _ => Assert.fail(`Expected a string in JSON field: ${name}`) - } + field(json, name) + ->JSON.Decode.string + ->Option.getOrThrow(~message=`Expected a string in JSON field: ${name}`) let assertContains = (actual, expected, message) => Assert.ok(actual->String.includes(expected), message) diff --git a/test/e2e/README.md b/test/e2e/README.md index c5409bc..d73af89 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -19,6 +19,10 @@ prompts, and Node bindings live in `src/bindings/Node.res`. `E2eLaunch.res` runs packed CLI through the selected manager's package script. The `test/` source directory is already included in `rescript.json`. +The harness uses ReScript 12.3's `let?` syntax for option/result propagation, with +`LetUnwrap` enabled in `rescript.json`, and the built-in standard library for JSON +decoding and collection helpers. + The harness uses Node.js built-ins and Ubuntu's `script` utility (from util-linux) to answer the real interactive prompts through a pseudo-terminal. It sets `CI=false` for the CLI so the old hardcoded CI From f9e73b0654c78cdabad75c5d7440e8a57cb027d1 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 11:39:19 +0200 Subject: [PATCH 11/19] Keep minimal E2E instructions in AGENTS.md --- AGENTS.md | 1 + test/e2e/README.md | 48 ---------------------------------------------- 2 files changed, 1 insertion(+), 48 deletions(-) delete mode 100644 test/e2e/README.md diff --git a/AGENTS.md b/AGENTS.md index ccc017b..b0636a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ ## Testing and Validation - **Automated Tests**: Run `npm test` for automated coverage of CLI parsing and related helpers +- **Installation E2E**: Run `npm pack`, then `npm run test:e2e -- --package --manager --logs `. Requires Linux, util-linux `script`, and the selected package manager; use the versions in `.github/workflows/ci.yml`. - **Manual Testing**: Perform smoke tests by running the CLI into a temp directory - **CI Preview Testing**: To test a pkg.pr.new build from CI, run `npm exec --yes --package=https://pkg.pr.new/rescript-lang/create-rescript-app/create-rescript-app@ -- create-rescript-app my-app --template vite` and replace `` with the SHA from the pkg.pr.new CI comment. - **Template Validation**: After changes, test each template type (basic/Next.js/Vite) to ensure templates bootstrap cleanly diff --git a/test/e2e/README.md b/test/e2e/README.md deleted file mode 100644 index d73af89..0000000 --- a/test/e2e/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Installation E2E tests - -The `e2e` job in `.github/workflows/ci.yml` tests the CLI tarball produced by the -build job on Ubuntu with Node 20.19.0, the minimum supported version. - -Each package manager (npm, pnpm, Yarn Classic, Yarn Berry, Bun) creates a basic -project and adds ReScript to an existing CommonJS project using the current -ReScript 12 release. Both projects install real dependencies, compile -ReScript and execute the generated program. Existing scripts, dependencies and -source files must survive, and the expected package manager lockfile must exist. - -The npm job also covers existing ESM projects, NodeNext and bundler TypeScript -projects (including compiling a genType consumer), devcontainer-only directories, -`.` and `./`, and rejection of an occupied target without modifying its files. - -The harness is written in ReScript, alongside the other tests. `E2eScenarios.res` -contains the fixtures and assertions, `E2eProcess.res` runs commands and answers -prompts, and Node bindings live in `src/bindings/Node.res`. `E2eLaunch.res` runs the -packed CLI through the selected manager's package script. The `test/` source -directory is already included in `rescript.json`. - -The harness uses ReScript 12.3's `let?` syntax for option/result propagation, with -`LetUnwrap` enabled in `rescript.json`, and the built-in standard library for JSON -decoding and collection helpers. - -The harness uses Node.js built-ins and Ubuntu's `script` utility (from util-linux) -to answer the real interactive prompts through a pseudo-terminal. It sets -`CI=false` for the CLI so the old hardcoded CI -smoke-test path cannot bypass the scenario being tested. A launcher package script -supplies the chosen manager's actual environment for package-manager detection. -Each case uses an isolated temporary directory, including spaces in its path. -Commands have timeouts, and failed CI jobs upload terminal and command logs. - -To reproduce a matrix entry on Linux with Node, util-linux and the chosen -package manager installed: - -```sh -npm pack -npm run test:e2e -- --package create-rescript-app-1.12.1.tgz --manager npm --logs /tmp/cra-e2e-logs -``` - -The terminal runner currently targets Linux, matching CI. The other package-manager names are -`pnpm`, `yarn-classic`, `yarn-berry` and `bun`; install the pinned version from the -workflow when reproducing a particular job. - -`npm run test:e2e` compiles the ReScript sources before running the harness. -Run `npm ci` first to install its build dependencies. Generated `.res.mjs` files -are not committed. From 6e3ae44057e7c8cb443f4610a22c0776956ab8e4 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 12:00:15 +0200 Subject: [PATCH 12/19] Propagate typed E2E failures with let? and wrap unsafe Node calls --- src/bindings/Node.res | 42 +++++++- test/E2eProcessTest.res | 176 ++++++++++++++++++++++++++++++++ test/e2e/E2eLaunch.res | 16 +-- test/e2e/E2eMain.res | 78 +++++++++----- test/e2e/E2eProcess.res | 209 ++++++++++++++++++++++++++------------ test/e2e/E2eScenarios.res | 44 +++++--- 6 files changed, 445 insertions(+), 120 deletions(-) create mode 100644 test/E2eProcessTest.res diff --git a/src/bindings/Node.res b/src/bindings/Node.res index 9618675..ac17e20 100644 --- a/src/bindings/Node.res +++ b/src/bindings/Node.res @@ -87,7 +87,15 @@ module Process = { @scope("process") external env: Dict.t = "env" @scope("process") external platform: string = "platform" @scope("process") external execPath: string = "execPath" - @scope("process") external kill: (int, string) => unit = "kill" + @scope("process") external killUnsafe: (int, string) => unit = "kill" + + let kill = (pid, signal) => + try { + killUnsafe(pid, signal) + Ok() + } catch { + | JsExn(error) => Error(#SignalError(error)) + } type state = {mutable exitCode: int} @val external state: state = "process" @@ -121,6 +129,10 @@ module Test = { @module("node:test") external testAsync: (string, unit => promise) => unit = "test" + + type options = {skip: bool} + @module("node:test") + external testAsyncWithOptions: (string, options, unit => promise) => unit = "test" } module Url = { @@ -156,7 +168,12 @@ module ChildProcess = { type t type options = {cwd: string, env: Dict.t, detached: bool, stdio: array} - @module("node:child_process") external spawn: (string, array, options) => t = "spawn" + @module("node:child_process") + external spawnUnsafe: (string, array, options) => t = "spawn" + let spawn = (command, args, options) => + try Ok(spawnUnsafe(command, args, options)) catch { + | JsExn(error) => Error(#SpawnError(error)) + } @get external pid: t => option = "pid" @get external stdout: t => readable = "stdout" @get external stderr: t => readable = "stderr" @@ -168,13 +185,30 @@ module ChildProcess = { @send external onClose: (t, @as("close") _, (Nullable.t, Nullable.t) => unit) => unit = "once" - @send external write: (writable, string) => unit = "write" + @send external writeUnsafe: (writable, string) => unit = "write" + let write = (stream, text) => + try { + writeUnsafe(stream, text) + Ok() + } catch { + | JsExn(error) => Error(#InputError(error)) + } @send external destroyInput: writable => unit = "destroy" type syncOptions = {cwd: string, stdio: string} type syncResult = {status: Nullable.t, error?: JsExn.t} @module("node:child_process") - external spawnSync: (string, array, syncOptions) => syncResult = "spawnSync" + external spawnSyncUnsafe: (string, array, syncOptions) => syncResult = "spawnSync" + let spawnSync = (command, args, options) => + try { + let result = spawnSyncUnsafe(command, args, options) + switch result.error { + | Some(error) => Error(#SpawnError(error)) + | None => Ok(result) + } + } catch { + | JsExn(error) => Error(#SpawnError(error)) + } } @get external errorCode: JsExn.t => option = "code" diff --git a/test/E2eProcessTest.res b/test/E2eProcessTest.res new file mode 100644 index 0000000..febb7f7 --- /dev/null +++ b/test/E2eProcessTest.res @@ -0,0 +1,176 @@ +open Node +module P = E2eProcess + +let withContext = async perform => { + let workspace = Fs.mkdtempSync(Path.join2(Os.tmpdir(), "cra command test ")) + let context = switch P.makeContext( + {packagePath: "unused.tgz", manager: Npm, logs: Path.join2(workspace, "logs")}, + workspace, + ) { + | Ok(context) => context + | Error(error) => Assert.fail(P.formatError(error)) + } + let outcome = try { + await perform(context) + Ok() + } catch { + | JsExn(cause) => Error(cause) + } + await Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) + switch outcome { + | Ok() => () + | Error(cause) => Assert.fail(P.describeCause(cause)) + } +} + +Test.describe("E2E command failures", () => { + Test.testAsync("returns asynchronous spawn failures as values", () => + withContext( + async context => { + switch await P.run( + context, + [Path.join2(context.workspace, "missing-command")], + context.workspace, + ) { + | Error(#SpawnError(cause)) => Assert.strictEqual(cause->errorCode, Some("ENOENT")) + | _ => Assert.fail("Expected an ENOENT spawn error") + } + }, + ) + ) + + Test.testAsync("wraps synchronous spawn exceptions", () => + withContext( + async context => { + switch await P.run(context, [""], context.workspace) { + | Error(#SpawnError(cause)) => + Assert.strictEqual(cause->errorCode, Some("ERR_INVALID_ARG_VALUE")) + | _ => Assert.fail("Expected an invalid-command spawn error") + } + }, + ) + ) + + Test.testAsync("wraps spawnSync error properties for the launcher", () => + withContext( + async context => { + switch ChildProcess.spawnSync( + Path.join2(context.workspace, "missing-command"), + [], + {cwd: context.workspace, stdio: "pipe"}, + ) { + | Error(#SpawnError(cause)) => Assert.strictEqual(cause->errorCode, Some("ENOENT")) + | _ => Assert.fail("Expected a launcher spawn error") + } + }, + ) + ) + + Test.testAsync("accepts the expected nonzero exit status", () => + withContext( + async context => { + let outcome = await P.runCommand( + context, + [Process.execPath, "-e", "console.log('expected failure'); process.exitCode = 1"], + context.workspace, + ~expected=1, + ~mode=Piped, + ~timeoutMs=10_000, + ) + switch outcome { + | Ok(output) => Assert.strictEqual(output->String.trim, "expected failure") + | Error(error) => Assert.fail(P.formatError(error)) + } + }, + ) + ) + + Test.testAsync("returns unexpected exit details", () => + withContext( + async context => { + switch await P.run( + context, + [Process.execPath, "-e", "process.exitCode = 7"], + context.workspace, + ) { + | Error(#UnexpectedExit({actual, expected, signal})) => + Assert.strictEqual(actual, Some(7)) + Assert.strictEqual(expected, 0) + Assert.strictEqual(signal, None) + | _ => Assert.fail("Expected an unexpected-exit error") + } + }, + ) + ) + + Test.testAsyncWithOptions( + "stops a timed-out process and returns its failure", + {skip: Process.platform === "win32"}, + () => + withContext( + async context => { + switch await P.runCommand( + context, + [Process.execPath, "-e", "setInterval(() => {}, 1000)"], + context.workspace, + ~expected=0, + ~mode=Piped, + ~timeoutMs=100, + ) { + | Error(#CommandTimedOut(_)) => () + | _ => Assert.fail("Expected a timeout error") + } + }, + ), + ) + + Test.testAsyncWithOptions( + "captures callback log failures and stops the child", + {skip: Process.platform === "win32"}, + () => + withContext( + async context => { + switch await P.runCommand( + context, + [ + Process.execPath, + "-e", + "require('node:fs').rmSync(process.argv[1], {recursive: true}); console.log('trigger log write'); setInterval(() => {}, 1000)", + context.logs, + ], + context.workspace, + ~expected=0, + ~mode=Piped, + ~timeoutMs=10_000, + ) { + | Error(#FileError({path, cause})) => + Assert.strictEqual(path, context.log.contents) + Assert.strictEqual(cause->errorCode, Some("ENOENT")) + | _ => Assert.fail("Expected the callback's file error") + } + }, + ), + ) + + Test.testAsync("captures assertion failures at the scenario boundary", () => + withContext( + async context => { + switch await P.runCase( + context, + "assertion-failure", + async _directory => { + Assert.strictEqual(1, 2, ~message="Intentional assertion failure") + Ok() + }, + ) { + | Error(#TestFailure(cause)) => + Assert.ok( + P.describeCause(cause)->String.includes("Intentional assertion failure"), + "Lost the original assertion failure", + ) + | _ => Assert.fail("Expected a test failure") + } + }, + ) + ) +}) diff --git a/test/e2e/E2eLaunch.res b/test/e2e/E2eLaunch.res index c4e14f4..4dda658 100644 --- a/test/e2e/E2eLaunch.res +++ b/test/e2e/E2eLaunch.res @@ -5,7 +5,7 @@ let launch = () => switch (Process.argv->Array.get(2), Process.env->Dict.get("CRA_E2E_CLI")) { | (Some(directory), Some(cliPath)) => let args = Process.argv->Array.slice(~start=3) - let result = ChildProcess.spawnSync( + let? Ok(result) = ChildProcess.spawnSync( Process.execPath, [cliPath, ...args], { @@ -13,11 +13,13 @@ let launch = () => stdio: "inherit", }, ) - switch result.error { - | Some(error) => JsExn.throw(error) - | None => Process.exitWithCode(result.status->Nullable.toOption->Option.getOr(1)) - } - | _ => Assert.fail("Expected a project directory and CRA_E2E_CLI") + Ok(result.status->Nullable.toOption->Option.getOr(1)) + | _ => Error(#InvalidArguments("Expected a project directory and CRA_E2E_CLI")) } -launch() +switch launch() { +| Ok(code) => Process.state.exitCode = code +| Error(error) => + Console.error(E2eProcess.formatError(error)) + Process.state.exitCode = 1 +} diff --git a/test/e2e/E2eMain.res b/test/e2e/E2eMain.res index f3701c3..5583a5b 100644 --- a/test/e2e/E2eMain.res +++ b/test/e2e/E2eMain.res @@ -17,52 +17,74 @@ let rec parseArguments = (remaining, arguments: arguments) => switch arguments { | {packagePath: Some(packagePath), manager: Some(manager), logs: Some(logs)} => Ok({P.packagePath, manager, logs}) - | _ => Error(usage) + | _ => Error(#InvalidArguments(usage)) } - | _ => Error(usage) + | _ => Error(#InvalidArguments(usage)) } @scope(("import", "meta")) external importMetaUrl: Url.t = "url" +let runSuite = async (context, options: P.options) => { + let launcherPath = Path.join2(importMetaUrl->Url.fileURLToPath->Path.dirname, "E2eLaunch.res.mjs") + let? Ok() = await P.setup(context, ~packagePath=options.packagePath, ~launcherPath) + await E2eScenarios.execute(context) +} + +let runInWorkspace = async (options, workspace) => { + let? Ok(context) = P.makeContext(options, workspace) + let outcome = try { + await runSuite(context, options) + } catch { + | JsExn(cause) => Error(#TestFailure(cause)) + } + switch outcome { + | Ok() => () + | Error(_) => + if Fs.existsSync(context.log.contents) { + switch P.fileOperation(context.log.contents, () => Fs.readFileSync(context.log.contents)) { + | Ok(log) => Console.error(log) + | Error(error) => Console.error(P.formatError(error)) + } + } + Console.error(`FAIL ${context.info.name}; log: ${context.log.contents}`) + } + outcome +} + let main = async () => { - Assert.strictEqual( - Process.platform, - "linux", - ~message="The E2E terminal runner requires Linux with util-linux script", - ) + let? Ok() = switch Process.platform { + | "linux" => Ok() + | platform => Error(#UnsupportedPlatform(platform)) + } let? Ok(options) = parseArguments( Process.argv->Array.slice(~start=2)->List.fromArray, {packagePath: None, manager: None, logs: None}, ) - let workspace = Fs.mkdtempSync(Path.join2(Os.tmpdir(), "cra e2e ")) - let context = P.makeContext(options, workspace) - let outcome = try { - let launcherPath = Path.join2( - importMetaUrl->Url.fileURLToPath->Path.dirname, - "E2eLaunch.res.mjs", - ) - await P.setup(context, ~packagePath=options.packagePath, ~launcherPath) - await E2eScenarios.execute(context) + let prefix = Path.join2(Os.tmpdir(), "cra e2e ") + let? Ok(workspace) = P.fileOperation(prefix, () => Fs.mkdtempSync(prefix)) + let outcome = try await runInWorkspace(options, workspace) catch { + | JsExn(cause) => Error(#TestFailure(cause)) + } + let cleanup = try { + await Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) Ok() } catch { - | JsExn(error) => Error(error) + | JsExn(cause) => Error(#FileError({P.path: workspace, cause})) + } + switch (outcome, cleanup) { + | (Error(primary), Error(secondary)) => + Console.error(`Workspace cleanup also failed:\n${P.formatError(secondary)}`) + Error(primary) + | (Error(_), _) => outcome + | (Ok(), _) => cleanup } - await Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) - outcome->Result.mapError(error => { - if Fs.existsSync(context.log.contents) { - Console.error(Fs.readFileSync(context.log.contents)) - } - let detail = - JsExn.stack(error)->Option.orElse(JsExn.message(error))->Option.getOr("Unknown error") - `FAIL ${context.info.name}; log: ${context.log.contents}\n${detail}` - }) } let run = async () => switch await main() { | Ok() => () - | Error(message) => - Console.error(message) + | Error(error) => + Console.error(P.formatError(error)) Process.state.exitCode = 1 } diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res index 8d118af..6f63ab7 100644 --- a/test/e2e/E2eProcess.res +++ b/test/e2e/E2eProcess.res @@ -14,6 +14,55 @@ type context = { } type prompt = (string, string) type commandMode = Piped | Terminal(array) +type fileError = {path: string, cause: JsExn.t} +type exitError = {command: string, actual: option, signal: option, expected: int} +type promptError = {answered: int, expected: int} +type error = [ + | #InvalidArguments(string) + | #UnsupportedPlatform(string) + | #SpawnError(JsExn.t) + | #InputError(JsExn.t) + | #SignalError(JsExn.t) + | #FileError(fileError) + | #CommandTimedOut(string) + | #UnexpectedExit(exitError) + | #UnansweredPrompts(promptError) + | #TestFailure(JsExn.t) +] + +let describeCause = cause => + JsExn.stack(cause)->Option.orElse(JsExn.message(cause))->Option.getOr("Unknown error") + +let formatError = (error: error) => + switch error { + | #InvalidArguments(message) => message + | #UnsupportedPlatform(platform) => + `The E2E terminal runner requires Linux with util-linux script; got ${platform}` + | #SpawnError(cause) => `Could not start command:\n${describeCause(cause)}` + | #InputError(cause) => `Could not answer CLI prompt:\n${describeCause(cause)}` + | #SignalError(cause) => `Could not stop process group:\n${describeCause(cause)}` + | #FileError({path, cause}) => `File operation failed: ${path}\n${describeCause(cause)}` + | #CommandTimedOut(command) => `Command timed out: ${command}` + | #UnexpectedExit({command, actual, signal, expected}) => + let status = switch actual { + | Some(code) => code->Int.toString + | None => signal->Option.getOr("unknown signal") + } + `Command exited ${status}, expected ${expected->Int.toString}: ${command}` + | #UnansweredPrompts({answered, expected}) => + `CLI exited after answering ${answered->Int.toString} of ${expected->Int.toString} prompts` + | #TestFailure(cause) => describeCause(cause) + } + +let fileOperation = (path, perform) => + try Ok(perform()) catch { + | JsExn(cause) => Error(#FileError({path, cause})) + } + +let writeFile = (path, contents) => fileOperation(path, () => Fs.writeFileSync(path, contents)) +let makeDirectory = path => fileOperation(path, () => Fs.mkdirSync(path, {recursive: true})) +let appendLog = (context, contents) => + fileOperation(context.log.contents, () => Fs.appendFileSync(context.log.contents, contents)) let managerInfo = manager => switch manager { @@ -31,17 +80,17 @@ let parseManager = name => | "yarn-classic" => Ok(YarnClassic) | "yarn-berry" => Ok(YarnBerry) | "bun" => Ok(Bun) - | _ => Error(`Unknown package manager: ${name}`) + | _ => Error(#InvalidArguments(`Unknown package manager: ${name}`)) } let quote = value => `'${value->String.replaceAll("'", "'\\''")}'` let shellJoin = command => command->Array.map(quote)->Array.join(" ") let readJson = path => Fs.readFileSync(path)->JSON.parseOrThrow -let writeJson = (path, json) => Fs.writeFileSync(path, `${JSON.stringify(json, ~space=2)}\n`) +let writeJson = (path, json) => writeFile(path, `${JSON.stringify(json, ~space=2)}\n`) let makeContext = (options: options, workspace) => { let logs = Path.resolve(options.logs) - Fs.mkdirSync(logs, {recursive: true}) + let? Ok() = makeDirectory(logs) let env = Process.env->Dict.copy [ ("CI", "false"), @@ -59,7 +108,7 @@ let makeContext = (options: options, workspace) => { "npm_node_execpath", "INIT_CWD", ]->Array.forEach(name => env->Dict.delete(name)) - { + Ok({ manager: options.manager, info: managerInfo(options.manager), workspace, @@ -67,21 +116,25 @@ let makeContext = (options: options, workspace) => { log: ref(Path.join2(logs, "setup.log")), launcher: Path.join2(workspace, "launcher"), env, - } + }) } let killGroup = (child, signal) => - child - ->ChildProcess.pid - ->Option.forEach(pid => { - try Process.kill(-pid, signal) catch { - | JsExn(error) if error->errorCode === Some("ESRCH") => () + switch child->ChildProcess.pid { + | None => Ok() + | Some(pid) => + switch Process.kill(-pid, signal) { + | Error(#SignalError(cause)) if cause->errorCode === Some("ESRCH") => Ok() + | outcome => outcome } - }) + } -let runCommand = async (context, command, cwd, ~expected, ~mode) => { +let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): result< + string, + error, +> => { let commandText = shellJoin(command) - Fs.appendFileSync(context.log.contents, `\n$ ${commandText}\n(cwd: ${cwd})\n`) + let? Ok() = appendLog(context, `\n$ ${commandText}\n(cwd: ${cwd})\n`) // util-linux script supplies a real terminal; all control and assertions are ReScript. let (executable, args, prompts, stdinMode) = switch mode { | Terminal(prompts) => ( @@ -103,7 +156,7 @@ let runCommand = async (context, command, cwd, ~expected, ~mode) => { | list{} => Assert.fail("Cannot run an empty command") } } - let child = ChildProcess.spawn( + let? Ok(child) = ChildProcess.spawn( executable, args, { @@ -117,24 +170,48 @@ let runCommand = async (context, command, cwd, ~expected, ~mode) => { let output = ref("") let pending = ref("") let nextPrompt = ref(0) - let timedOut = ref(false) - let inputError = ref(None) + let failure = ref(None) let forceKill = ref(None) + let recordFailure = error => + switch failure.contents { + | None => failure := Some(error) + | Some(_) => () + } + let stop = signal => + switch killGroup(child, signal) { + | Ok() => () + | Error(error) => recordFailure(error) + } + let terminate = () => { + stop("SIGTERM") + switch forceKill.contents { + | None => forceKill := Some(Timer.setTimeout(() => stop("SIGKILL"), 3000)) + | Some(_) => () + } + } let timeout = Timer.setTimeout(() => { - timedOut := true - killGroup(child, "SIGTERM") - forceKill := Some(Timer.setTimeout(() => killGroup(child, "SIGKILL"), 3000)) - }, 300_000) + recordFailure(#CommandTimedOut(commandText)) + terminate() + }, timeoutMs) let collect = chunk => { output := output.contents ++ chunk pending := pending.contents ++ chunk - Fs.appendFileSync(context.log.contents, chunk) - switch (prompts->Array.get(nextPrompt.contents), child->ChildProcess.stdin) { - | (Some((question, answer)), Some(stdin)) if pending.contents->String.includes(question) => - stdin->ChildProcess.write(answer) - nextPrompt := nextPrompt.contents + 1 - pending := "" - | _ => () + let outcome = () => { + let? Ok() = appendLog(context, chunk) + switch (prompts->Array.get(nextPrompt.contents), child->ChildProcess.stdin) { + | (Some((question, answer)), Some(stdin)) if pending.contents->String.includes(question) => + let? Ok() = stdin->ChildProcess.write(answer) + nextPrompt := nextPrompt.contents + 1 + pending := "" + Ok() + | _ => Ok() + } + } + switch outcome() { + | Ok() => () + | Error(error) => + recordFailure(error) + terminate() } } [child->ChildProcess.stdout, child->ChildProcess.stderr]->Array.forEach(stream => { @@ -146,63 +223,59 @@ let runCommand = async (context, command, cwd, ~expected, ~mode) => { ->Option.forEach(stdin => stdin->ChildProcess.onInputError(error => { if error->errorCode !== Some("EPIPE") { - inputError := Some(error) - killGroup(child, "SIGTERM") + recordFailure(#InputError(error)) + terminate() } }) ) - let completion = await Promise.make((resolve, _reject) => { - child->ChildProcess.onError(error => resolve(Error(error))) - child->ChildProcess.onClose((code, signal) => resolve(Ok((code, signal)))) + let (code, signal) = await Promise.make((resolve, _reject) => { + child->ChildProcess.onError(error => recordFailure(#SpawnError(error))) + child->ChildProcess.onClose((code, signal) => resolve((code, signal))) }) timeout->Timer.clearTimeout forceKill.contents->Option.forEach(Timer.clearTimeout) child->ChildProcess.stdin->Option.forEach(ChildProcess.destroyInput) - inputError.contents->Option.forEach(JsExn.throw) - Assert.ok(!timedOut.contents, `Command timed out: ${commandText}`) - switch completion { - | Error(error) => JsExn.throw(error) - | Ok((code, signal)) => - let actual = code->Nullable.toOption - let description = switch actual { - | Some(code) => code->Int.toString - | None => signal->Nullable.toOption->Option.getOr("unknown signal") - } - Assert.strictEqual( - actual, - Some(expected), - ~message=`Command exited ${description}, expected ${expected->Int.toString}: ${commandText}`, + // No early propagation until timers and streams have been cleaned up. + let? Ok() = switch failure.contents { + | Some(error) => Error(error) + | None => Ok() + } + let actual = code->Nullable.toOption + switch (actual === Some(expected), nextPrompt.contents === prompts->Array.length) { + | (false, _) => + Error( + #UnexpectedExit({command: commandText, actual, signal: signal->Nullable.toOption, expected}), ) + | (_, false) => + Error(#UnansweredPrompts({answered: nextPrompt.contents, expected: prompts->Array.length})) + | _ => Ok(output.contents) } - Assert.strictEqual( - nextPrompt.contents, - prompts->Array.length, - ~message="CLI exited before answering all prompts", - ) - output.contents } -let run = (context, command, cwd) => runCommand(context, command, cwd, ~expected=0, ~mode=Piped) +let run = (context, command, cwd) => + runCommand(context, command, cwd, ~expected=0, ~mode=Piped, ~timeoutMs=300_000) let cli = async (context, cwd, ~args, ~expected, ~prompts) => { let separator = switch context.manager { | Npm => ["--"] | _ => [] } - let _ = await runCommand( + let? Ok(_) = await runCommand( context, [context.info.command, "run", "cra", ...separator, cwd, ...args], context.launcher, ~expected, ~mode=Terminal(prompts), + ~timeoutMs=300_000, ) + Ok() } let setup = async (context, ~packagePath, ~launcherPath) => { - let _ = await run(context, ["node", "--version"], context.workspace) - let _ = await run(context, [context.info.command, "--version"], context.workspace) + let? Ok(_) = await run(context, ["node", "--version"], context.workspace) + let? Ok(_) = await run(context, [context.info.command, "--version"], context.workspace) let cliDirectory = Path.join2(context.workspace, "cli") - let _ = await run( + let? Ok(_) = await run( context, ["npm", "install", "--prefix", cliDirectory, Path.resolve(packagePath)], context.workspace, @@ -211,8 +284,8 @@ let setup = async (context, ~packagePath, ~launcherPath) => { "CRA_E2E_CLI", Path.join2(cliDirectory, "node_modules/create-rescript-app/out/create-rescript-app.cjs"), ) - Fs.mkdirSync(context.launcher, {recursive: true}) - writeJson( + let? Ok() = makeDirectory(context.launcher) + let? Ok() = writeJson( Path.join2(context.launcher, "package.json"), JSON.parseOrThrow( `{ @@ -222,20 +295,24 @@ let setup = async (context, ~packagePath, ~launcherPath) => { }`, ), ) - switch context.manager { + let? Ok() = switch context.manager { | YarnBerry => - Fs.writeFileSync(Path.join2(context.launcher, ".yarnrc.yml"), "nodeLinker: node-modules\n") - | _ => () + writeFile(Path.join2(context.launcher, ".yarnrc.yml"), "nodeLinker: node-modules\n") + | _ => Ok() } - let _ = await run(context, [context.info.command, "install"], context.launcher) + let? Ok(_) = await run(context, [context.info.command, "install"], context.launcher) + Ok() } let runCase = async (context, name, perform) => { context.log := Path.join2(context.logs, `${name}.log`) - Fs.writeFileSync(context.log.contents, "") + let? Ok() = writeFile(context.log.contents, "") let directory = Path.join2(context.workspace, name) - Fs.mkdirSync(directory, {recursive: true}) + let? Ok() = makeDirectory(directory) Console.log(`RUN ${context.info.name}: ${name}`) - await perform(directory) + let? Ok() = try await perform(directory) catch { + | JsExn(cause) => Error(#TestFailure(cause)) + } Console.log(`PASS ${context.info.name}: ${name}`) + Ok() } diff --git a/test/e2e/E2eScenarios.res b/test/e2e/E2eScenarios.res index cd7574e..9079ac7 100644 --- a/test/e2e/E2eScenarios.res +++ b/test/e2e/E2eScenarios.res @@ -66,13 +66,14 @@ let verifyProject = async (context: P.context, directory, suffix) => { ) | _ => Assert.fail("Expected package dependencies") } - let _ = await P.run(context, [context.info.command, "run", "res:build"], directory) - let output = await P.run(context, ["node", `src/Demo${suffix}`], directory) + let? Ok(_) = await P.run(context, [context.info.command, "run", "res:build"], directory) + let? Ok(output) = await P.run(context, ["node", `src/Demo${suffix}`], directory) Assert.strictEqual( output->String.trim, "Hello, world!", ~message="Unexpected generated program output", ) + Ok() } let verifyPreserved = (directory, preserved) => @@ -102,7 +103,13 @@ let newProject = async (context, directory, ~target, ~devcontainer) => { preserved->Array.forEach(((path, contents)) => Fs.writeFileSync(Path.join2(project, path), contents) ) - await P.cli(context, directory, ~args=[target, "--template", "basic"], ~expected=0, ~prompts=[]) + let? Ok() = await P.cli( + context, + directory, + ~args=[target, "--template", "basic"], + ~expected=0, + ~prompts=[], + ) Assert.strictEqual( P.readJson(Path.join2(project, "package.json"))->stringField("name"), Path.basename(project), @@ -137,7 +144,7 @@ let existingProject = async ( ...devDependencies, ], ) - P.writeJson(Path.join2(directory, "package.json"), original) + let? Ok() = P.writeJson(Path.join2(directory, "package.json"), original) Fs.mkdirSync(Path.join2(directory, "src"), {recursive: true}) let tsPreserved = switch typescript { | None => [] @@ -187,7 +194,7 @@ let existingProject = async ( preserved->Array.forEach(((path, contents)) => Fs.writeFileSync(Path.join2(directory, path), contents) ) - let _ = await P.run(context, [context.info.command, "install"], directory) + let? Ok(_) = await P.run(context, [context.info.command, "install"], directory) let modulePrompts = switch typescript { | Some(resolution) => let extensionPrompts = switch resolution { @@ -202,7 +209,7 @@ let existingProject = async ( } [("What module system will you use?", answer)] } - await P.cli( + let? Ok() = await P.cli( context, directory, ~args=[], @@ -281,20 +288,25 @@ let existingProject = async ( `import {answer} from "./Interop.gen.${extension}";\nconst value: number = answer;\n`, ) } - await verifyProject(context, directory, suffix) - let output = await P.run(context, [context.info.command, "run", "test"], directory) + let? Ok() = await verifyProject(context, directory, suffix) + let? Ok(output) = await P.run(context, [context.info.command, "run", "test"], directory) Assert.ok( output->String.split("\n")->Array.some(line => line->String.trim === "true"), "Existing JavaScript program no longer runs", ) switch typescript { - | None => () + | None => Ok() | Some(_) => Assert.ok( Fs.existsSync(Path.join2(directory, "src/Interop.gen.ts")), "genType did not generate TypeScript", ) - let _ = await P.run(context, ["node", "node_modules/typescript/bin/tsc", "--noEmit"], directory) + let? Ok(_) = await P.run( + context, + ["node", "node_modules/typescript/bin/tsc", "--noEmit"], + directory, + ) + Ok() } } @@ -303,7 +315,7 @@ let occupiedDirectory = async (context, directory) => { Fs.mkdirSync(project, {recursive: true}) let sentinel = Path.join2(project, "keep.txt") Fs.writeFileSync(sentinel, "Do not overwrite\n") - await P.cli( + let? Ok() = await P.cli( context, directory, ~args=["occupied", "--template", "basic"], @@ -320,9 +332,10 @@ let occupiedDirectory = async (context, directory) => { ["keep.txt"], "Failed scaffolding left files in the occupied directory", ) + Ok() } -type scenario = {name: string, perform: string => promise} +type scenario = {name: string, perform: string => promise>} let scenarios = (context: P.context) => { let common = [ @@ -375,14 +388,15 @@ let scenarios = (context: P.context) => { let rec runScenarios = async (context, remaining) => switch remaining { - | list{} => () + | list{} => Ok() | list{scenario, ...rest} => - await P.runCase(context, scenario.name, scenario.perform) + let? Ok() = await P.runCase(context, scenario.name, scenario.perform) await runScenarios(context, rest) } let execute = async context => { let cases = scenarios(context) - await runScenarios(context, cases->List.fromArray) + let? Ok() = await runScenarios(context, cases->List.fromArray) Console.log(`PASS: ${cases->Array.length->Int.toString} E2E cases for ${context.info.name}`) + Ok() } From fd339fc16f1cbdb6e550d182370fa2de4e31458a Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 12:40:11 +0200 Subject: [PATCH 13/19] Separate multiline bindings and types with blank lines --- AGENTS.md | 1 + src/bindings/Node.res | 5 +++++ test/E2eProcessTest.res | 4 ++++ test/e2e/E2eLaunch.res | 2 ++ test/e2e/E2eMain.res | 7 +++++++ test/e2e/E2eProcess.res | 29 +++++++++++++++++++++++++++++ test/e2e/E2eScenarios.res | 33 +++++++++++++++++++++++++++++++++ 7 files changed, 81 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b0636a5..c2f38dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ - Keep parameters mandatory unless callers need to omit them. - Do not add redundant parentheses around switch expressions: use `switch value { ... }`. Parentheses required by tuple syntax are fine. - Formatting is enforced by `rescript format`; keep 2-space indentation and prefer pattern matching over chained conditionals. +- Separate multiline `let` bindings (including `let?`) and type definitions from surrounding statements or declarations with a blank line before and after. Keep attached comments and attributes together with the declaration. - Prefer `result` values over exceptions for expected failure paths; only raise or throw at clear integration boundaries where the surrounding API requires it. - Use `let?` to unwrap and propagate `Option`/`Result` failures where it simplifies the code. `LetUnwrap` is enabled in `rescript.json`. - Prefer pattern matching over `if`/`else` chains when branching on the shape or state of the same value; plain comparisons across different values are fine with `if`/`else`. diff --git a/src/bindings/Node.res b/src/bindings/Node.res index ac17e20..b719de0 100644 --- a/src/bindings/Node.res +++ b/src/bindings/Node.res @@ -170,10 +170,12 @@ module ChildProcess = { @module("node:child_process") external spawnUnsafe: (string, array, options) => t = "spawn" + let spawn = (command, args, options) => try Ok(spawnUnsafe(command, args, options)) catch { | JsExn(error) => Error(#SpawnError(error)) } + @get external pid: t => option = "pid" @get external stdout: t => readable = "stdout" @get external stderr: t => readable = "stderr" @@ -186,6 +188,7 @@ module ChildProcess = { external onClose: (t, @as("close") _, (Nullable.t, Nullable.t) => unit) => unit = "once" @send external writeUnsafe: (writable, string) => unit = "write" + let write = (stream, text) => try { writeUnsafe(stream, text) @@ -193,12 +196,14 @@ module ChildProcess = { } catch { | JsExn(error) => Error(#InputError(error)) } + @send external destroyInput: writable => unit = "destroy" type syncOptions = {cwd: string, stdio: string} type syncResult = {status: Nullable.t, error?: JsExn.t} @module("node:child_process") external spawnSyncUnsafe: (string, array, syncOptions) => syncResult = "spawnSync" + let spawnSync = (command, args, options) => try { let result = spawnSyncUnsafe(command, args, options) diff --git a/test/E2eProcessTest.res b/test/E2eProcessTest.res index febb7f7..7ad76c4 100644 --- a/test/E2eProcessTest.res +++ b/test/E2eProcessTest.res @@ -3,6 +3,7 @@ module P = E2eProcess let withContext = async perform => { let workspace = Fs.mkdtempSync(Path.join2(Os.tmpdir(), "cra command test ")) + let context = switch P.makeContext( {packagePath: "unused.tgz", manager: Npm, logs: Path.join2(workspace, "logs")}, workspace, @@ -10,12 +11,14 @@ let withContext = async perform => { | Ok(context) => context | Error(error) => Assert.fail(P.formatError(error)) } + let outcome = try { await perform(context) Ok() } catch { | JsExn(cause) => Error(cause) } + await Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) switch outcome { | Ok() => () @@ -77,6 +80,7 @@ Test.describe("E2E command failures", () => { ~mode=Piped, ~timeoutMs=10_000, ) + switch outcome { | Ok(output) => Assert.strictEqual(output->String.trim, "expected failure") | Error(error) => Assert.fail(P.formatError(error)) diff --git a/test/e2e/E2eLaunch.res b/test/e2e/E2eLaunch.res index 4dda658..c9263c5 100644 --- a/test/e2e/E2eLaunch.res +++ b/test/e2e/E2eLaunch.res @@ -5,6 +5,7 @@ let launch = () => switch (Process.argv->Array.get(2), Process.env->Dict.get("CRA_E2E_CLI")) { | (Some(directory), Some(cliPath)) => let args = Process.argv->Array.slice(~start=3) + let? Ok(result) = ChildProcess.spawnSync( Process.execPath, [cliPath, ...args], @@ -13,6 +14,7 @@ let launch = () => stdio: "inherit", }, ) + Ok(result.status->Nullable.toOption->Option.getOr(1)) | _ => Error(#InvalidArguments("Expected a project directory and CRA_E2E_CLI")) } diff --git a/test/e2e/E2eMain.res b/test/e2e/E2eMain.res index 5583a5b..9363da9 100644 --- a/test/e2e/E2eMain.res +++ b/test/e2e/E2eMain.res @@ -32,11 +32,13 @@ let runSuite = async (context, options: P.options) => { let runInWorkspace = async (options, workspace) => { let? Ok(context) = P.makeContext(options, workspace) + let outcome = try { await runSuite(context, options) } catch { | JsExn(cause) => Error(#TestFailure(cause)) } + switch outcome { | Ok() => () | Error(_) => @@ -56,21 +58,26 @@ let main = async () => { | "linux" => Ok() | platform => Error(#UnsupportedPlatform(platform)) } + let? Ok(options) = parseArguments( Process.argv->Array.slice(~start=2)->List.fromArray, {packagePath: None, manager: None, logs: None}, ) + let prefix = Path.join2(Os.tmpdir(), "cra e2e ") let? Ok(workspace) = P.fileOperation(prefix, () => Fs.mkdtempSync(prefix)) + let outcome = try await runInWorkspace(options, workspace) catch { | JsExn(cause) => Error(#TestFailure(cause)) } + let cleanup = try { await Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) Ok() } catch { | JsExn(cause) => Error(#FileError({P.path: workspace, cause})) } + switch (outcome, cleanup) { | (Error(primary), Error(secondary)) => Console.error(`Workspace cleanup also failed:\n${P.formatError(secondary)}`) diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res index 6f63ab7..4847592 100644 --- a/test/e2e/E2eProcess.res +++ b/test/e2e/E2eProcess.res @@ -3,6 +3,7 @@ open Node type manager = Npm | Pnpm | YarnClassic | YarnBerry | Bun type managerInfo = {name: string, command: string, lockfile: string} type options = {packagePath: string, manager: manager, logs: string} + type context = { manager: manager, info: managerInfo, @@ -12,11 +13,13 @@ type context = { launcher: string, env: Dict.t, } + type prompt = (string, string) type commandMode = Piped | Terminal(array) type fileError = {path: string, cause: JsExn.t} type exitError = {command: string, actual: option, signal: option, expected: int} type promptError = {answered: int, expected: int} + type error = [ | #InvalidArguments(string) | #UnsupportedPlatform(string) @@ -48,6 +51,7 @@ let formatError = (error: error) => | Some(code) => code->Int.toString | None => signal->Option.getOr("unknown signal") } + `Command exited ${status}, expected ${expected->Int.toString}: ${command}` | #UnansweredPrompts({answered, expected}) => `CLI exited after answering ${answered->Int.toString} of ${expected->Int.toString} prompts` @@ -61,6 +65,7 @@ let fileOperation = (path, perform) => let writeFile = (path, contents) => fileOperation(path, () => Fs.writeFileSync(path, contents)) let makeDirectory = path => fileOperation(path, () => Fs.mkdirSync(path, {recursive: true})) + let appendLog = (context, contents) => fileOperation(context.log.contents, () => Fs.appendFileSync(context.log.contents, contents)) @@ -135,6 +140,7 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re > => { let commandText = shellJoin(command) let? Ok() = appendLog(context, `\n$ ${commandText}\n(cwd: ${cwd})\n`) + // util-linux script supplies a real terminal; all control and assertions are ReScript. let (executable, args, prompts, stdinMode) = switch mode { | Terminal(prompts) => ( @@ -156,6 +162,7 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re | list{} => Assert.fail("Cannot run an empty command") } } + let? Ok(child) = ChildProcess.spawn( executable, args, @@ -166,22 +173,26 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re stdio: [stdinMode, "pipe", "pipe"], }, ) + // Mutable state is confined to this command's Node event callbacks. let output = ref("") let pending = ref("") let nextPrompt = ref(0) let failure = ref(None) let forceKill = ref(None) + let recordFailure = error => switch failure.contents { | None => failure := Some(error) | Some(_) => () } + let stop = signal => switch killGroup(child, signal) { | Ok() => () | Error(error) => recordFailure(error) } + let terminate = () => { stop("SIGTERM") switch forceKill.contents { @@ -189,13 +200,16 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re | Some(_) => () } } + let timeout = Timer.setTimeout(() => { recordFailure(#CommandTimedOut(commandText)) terminate() }, timeoutMs) + let collect = chunk => { output := output.contents ++ chunk pending := pending.contents ++ chunk + let outcome = () => { let? Ok() = appendLog(context, chunk) switch (prompts->Array.get(nextPrompt.contents), child->ChildProcess.stdin) { @@ -207,6 +221,7 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re | _ => Ok() } } + switch outcome() { | Ok() => () | Error(error) => @@ -214,6 +229,7 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re terminate() } } + [child->ChildProcess.stdout, child->ChildProcess.stderr]->Array.forEach(stream => { stream->ChildProcess.setEncoding stream->ChildProcess.onData(collect) @@ -228,18 +244,22 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re } }) ) + let (code, signal) = await Promise.make((resolve, _reject) => { child->ChildProcess.onError(error => recordFailure(#SpawnError(error))) child->ChildProcess.onClose((code, signal) => resolve((code, signal))) }) + timeout->Timer.clearTimeout forceKill.contents->Option.forEach(Timer.clearTimeout) child->ChildProcess.stdin->Option.forEach(ChildProcess.destroyInput) + // No early propagation until timers and streams have been cleaned up. let? Ok() = switch failure.contents { | Some(error) => Error(error) | None => Ok() } + let actual = code->Nullable.toOption switch (actual === Some(expected), nextPrompt.contents === prompts->Array.length) { | (false, _) => @@ -260,6 +280,7 @@ let cli = async (context, cwd, ~args, ~expected, ~prompts) => { | Npm => ["--"] | _ => [] } + let? Ok(_) = await runCommand( context, [context.info.command, "run", "cra", ...separator, cwd, ...args], @@ -268,6 +289,7 @@ let cli = async (context, cwd, ~args, ~expected, ~prompts) => { ~mode=Terminal(prompts), ~timeoutMs=300_000, ) + Ok() } @@ -275,16 +297,19 @@ let setup = async (context, ~packagePath, ~launcherPath) => { let? Ok(_) = await run(context, ["node", "--version"], context.workspace) let? Ok(_) = await run(context, [context.info.command, "--version"], context.workspace) let cliDirectory = Path.join2(context.workspace, "cli") + let? Ok(_) = await run( context, ["npm", "install", "--prefix", cliDirectory, Path.resolve(packagePath)], context.workspace, ) + context.env->Dict.set( "CRA_E2E_CLI", Path.join2(cliDirectory, "node_modules/create-rescript-app/out/create-rescript-app.cjs"), ) let? Ok() = makeDirectory(context.launcher) + let? Ok() = writeJson( Path.join2(context.launcher, "package.json"), JSON.parseOrThrow( @@ -295,11 +320,13 @@ let setup = async (context, ~packagePath, ~launcherPath) => { }`, ), ) + let? Ok() = switch context.manager { | YarnBerry => writeFile(Path.join2(context.launcher, ".yarnrc.yml"), "nodeLinker: node-modules\n") | _ => Ok() } + let? Ok(_) = await run(context, [context.info.command, "install"], context.launcher) Ok() } @@ -310,9 +337,11 @@ let runCase = async (context, name, perform) => { let directory = Path.join2(context.workspace, name) let? Ok() = makeDirectory(directory) Console.log(`RUN ${context.info.name}: ${name}`) + let? Ok() = try await perform(directory) catch { | JsExn(cause) => Error(#TestFailure(cause)) } + Console.log(`PASS ${context.info.name}: ${name}`) Ok() } diff --git a/test/e2e/E2eScenarios.res b/test/e2e/E2eScenarios.res index 9079ac7..19ef85b 100644 --- a/test/e2e/E2eScenarios.res +++ b/test/e2e/E2eScenarios.res @@ -6,16 +6,20 @@ type moduleSystem = CommonJs | EsModule type tsResolution = NodeNext | Bundler let object = (entries: array<(string, JSON.t)>) => JSON.Object(entries->Dict.fromArray) + let findField = (json, name) => { let? Some(fields) = json->JSON.Decode.object fields->Dict.get(name) } + let field = (json, name) => findField(json, name)->Option.getOrThrow(~message=`Expected JSON object field: ${name}`) + let stringField = (json, name) => field(json, name) ->JSON.Decode.string ->Option.getOrThrow(~message=`Expected a string in JSON field: ${name}`) + let assertContains = (actual, expected, message) => Assert.ok(actual->String.includes(expected), message) @@ -52,8 +56,10 @@ let verifyProject = async (context: P.context, directory, suffix) => { ) | _ => () } + let version = P.readJson(Path.join2(directory, "node_modules/rescript/package.json"))->stringField("version") + Assert.ok( version->String.startsWith("12."), `Expected a current ReScript 12 version, installed ${version}`, @@ -90,19 +96,25 @@ let newProject = async (context, directory, ~target, ~devcontainer) => { | "." | "./" => true | _ => false } + let project = currentDirectory ? directory : Path.join2(directory, target) + let preserved = if devcontainer { Fs.mkdirSync(Path.join2(project, ".devcontainer"), {recursive: true}) + let metadata = currentDirectory ? [("README.md", "Existing README\n"), (".gitignore", "keep-this-entry\n")] : [] + [(".devcontainer/devcontainer.json", "{\"name\":\"keep me\"}\n"), ...metadata] } else { [] } + preserved->Array.forEach(((path, contents)) => Fs.writeFileSync(Path.join2(project, path), contents) ) + let? Ok() = await P.cli( context, directory, @@ -110,6 +122,7 @@ let newProject = async (context, directory, ~target, ~devcontainer) => { ~expected=0, ~prompts=[], ) + Assert.strictEqual( P.readJson(Path.join2(project, "package.json"))->stringField("name"), Path.basename(project), @@ -129,10 +142,12 @@ let existingProject = async ( | CommonJs => "commonjs" | EsModule => "module" } + let devDependencies = switch typescript { | Some(_) => [("devDependencies", object([("typescript", String("5.9.3"))]))] | None => [] } + let original = object( [ ("name", String(Path.basename(directory))), @@ -144,8 +159,10 @@ let existingProject = async ( ...devDependencies, ], ) + let? Ok() = P.writeJson(Path.join2(directory, "package.json"), original) Fs.mkdirSync(Path.join2(directory, "src"), {recursive: true}) + let tsPreserved = switch typescript { | None => [] | Some(resolution) => @@ -153,6 +170,7 @@ let existingProject = async ( | NodeNext => ("NodeNext", "nodenext") | Bundler => ("ESNext", "bundler") } + Fs.writeFileSync( Path.join2(directory, "tsconfig.json"), `{ @@ -185,30 +203,36 @@ let existingProject = async ( ), ] } + let preserved = [ ("README.md", "Existing project README\n"), (".gitignore", "existing-ignore\n"), ("src/original.cjs", "console.log(require(\"is-number\")(42));\n"), ...tsPreserved, ] + preserved->Array.forEach(((path, contents)) => Fs.writeFileSync(Path.join2(directory, path), contents) ) let? Ok(_) = await P.run(context, [context.info.command, "install"], directory) + let modulePrompts = switch typescript { | Some(resolution) => let extensionPrompts = switch resolution { | NodeNext => [] | Bundler => [("Set compilerOptions.allowImportingTsExtensions to true", "y")] } + [("Set compilerOptions.allowJs to true", "y"), ...extensionPrompts] | None => let answer = switch moduleSystem { | CommonJs => "\x1b[B\r" | EsModule => "\r" } + [("What module system will you use?", answer)] } + let? Ok() = await P.cli( context, directory, @@ -222,6 +246,7 @@ let existingProject = async ( ("Do you want to check generated", "n"), ], ) + let packageJson = P.readJson(Path.join2(directory, "package.json")) ["name", "version", "private", "type"]->Array.forEach(name => Assert.deepStrictEqual( @@ -250,10 +275,12 @@ let existingProject = async ( | None => () } verifyPreserved(directory, preserved) + let suffix = switch (typescript, moduleSystem) { | (None, EsModule) => ".res.mjs" | _ => ".res.js" } + Fs.readFileSync(Path.join2(directory, ".gitignore"))->assertContains( `**/*${suffix}`, "Generated JS was not ignored", @@ -265,6 +292,7 @@ let existingProject = async ( | NodeNext => ("node16", "js") | Bundler => ("bundler", "ts") } + let config = P.readJson(Path.join2(directory, "rescript.json")) Assert.strictEqual( field(config, "gentypeconfig")->stringField("moduleResolution"), @@ -301,11 +329,13 @@ let existingProject = async ( Fs.existsSync(Path.join2(directory, "src/Interop.gen.ts")), "genType did not generate TypeScript", ) + let? Ok(_) = await P.run( context, ["node", "node_modules/typescript/bin/tsc", "--noEmit"], directory, ) + Ok() } } @@ -315,6 +345,7 @@ let occupiedDirectory = async (context, directory) => { Fs.mkdirSync(project, {recursive: true}) let sentinel = Path.join2(project, "keep.txt") Fs.writeFileSync(sentinel, "Do not overwrite\n") + let? Ok() = await P.cli( context, directory, @@ -322,6 +353,7 @@ let occupiedDirectory = async (context, directory) => { ~expected=1, ~prompts=[], ) + Assert.strictEqual( Fs.readFileSync(sentinel), "Do not overwrite\n", @@ -349,6 +381,7 @@ let scenarios = (context: P.context) => { existingProject(context, directory, ~moduleSystem=CommonJs, ~typescript=None), }, ] + switch context.manager { | Npm => [ From 550a317ab756c029409e4c368b24d9289ef5ee45 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 12:46:50 +0200 Subject: [PATCH 14/19] Reuse production package manager type in E2E tests --- AGENTS.md | 1 + test/e2e/E2eMain.res | 6 +++++- test/e2e/E2eProcess.res | 18 +++++++++++------- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c2f38dd..3ab41bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ - Prefer `Option`, `Result`, `Array`, `List`, `Dict`, `Set`, `Map`, and `JSON` over `Js.*` or `Belt.*`. - Match local style; avoid old BuckleScript/ReScript-era patterns. - Write new tests and test helpers in ReScript under `test/`. Prefer records and functions over classes. +- Reuse existing application types in tests instead of redefining them. - Keep parameters mandatory unless callers need to omit them. - Do not add redundant parentheses around switch expressions: use `switch value { ... }`. Parentheses required by tuple syntax are fine. - Formatting is enforced by `rescript format`; keep 2-space indentation and prefer pattern matching over chained conditionals. diff --git a/test/e2e/E2eMain.res b/test/e2e/E2eMain.res index 9363da9..052c235 100644 --- a/test/e2e/E2eMain.res +++ b/test/e2e/E2eMain.res @@ -1,7 +1,11 @@ open Node module P = E2eProcess -type arguments = {packagePath: option, manager: option, logs: option} +type arguments = { + packagePath: option, + manager: option, + logs: option, +} let usage = "Usage: npm run test:e2e -- --package --manager --logs " diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res index 4847592..8eba005 100644 --- a/test/e2e/E2eProcess.res +++ b/test/e2e/E2eProcess.res @@ -1,11 +1,15 @@ open Node -type manager = Npm | Pnpm | YarnClassic | YarnBerry | Bun type managerInfo = {name: string, command: string, lockfile: string} -type options = {packagePath: string, manager: manager, logs: string} + +type options = { + packagePath: string, + manager: PackageManagers.packageManager, + logs: string, +} type context = { - manager: manager, + manager: PackageManagers.packageManager, info: managerInfo, workspace: string, logs: string, @@ -69,20 +73,20 @@ let makeDirectory = path => fileOperation(path, () => Fs.mkdirSync(path, {recurs let appendLog = (context, contents) => fileOperation(context.log.contents, () => Fs.appendFileSync(context.log.contents, contents)) -let managerInfo = manager => +let managerInfo = (manager: PackageManagers.packageManager) => switch manager { | Npm => {name: "npm", command: "npm", lockfile: "package-lock.json"} | Pnpm => {name: "pnpm", command: "pnpm", lockfile: "pnpm-lock.yaml"} - | YarnClassic => {name: "yarn-classic", command: "yarn", lockfile: "yarn.lock"} + | Yarn1 => {name: "yarn-classic", command: "yarn", lockfile: "yarn.lock"} | YarnBerry => {name: "yarn-berry", command: "yarn", lockfile: "yarn.lock"} | Bun => {name: "bun", command: "bun", lockfile: "bun.lock"} } -let parseManager = name => +let parseManager = (name): result #InvalidArguments(string)]> => switch name { | "npm" => Ok(Npm) | "pnpm" => Ok(Pnpm) - | "yarn-classic" => Ok(YarnClassic) + | "yarn-classic" => Ok(Yarn1) | "yarn-berry" => Ok(YarnBerry) | "bun" => Ok(Bun) | _ => Error(#InvalidArguments(`Unknown package manager: ${name}`)) From 880dc395da0647b945ac23be6ca98cfe3e63abdc Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 12:54:39 +0200 Subject: [PATCH 15/19] Use dict patterns for E2E environment and JSON assertions --- AGENTS.md | 1 + test/e2e/E2eLaunch.res | 4 +-- test/e2e/E2eScenarios.res | 72 +++++++++++++++++++++------------------ 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ab41bc..f364687 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ - Use ReScript 12 syntax and the built-in standard library, not Reason syntax. Read https://rescript-lang.org/llms/manual/llm-small.txt to learn the language syntax. - Prefer `Option`, `Result`, `Array`, `List`, `Dict`, `Set`, `Map`, and `JSON` over `Js.*` or `Belt.*`. +- Prefer `dict{...}` patterns for known dictionary keys and nested JSON shapes; use `Dict.get` for dynamic keys. - Match local style; avoid old BuckleScript/ReScript-era patterns. - Write new tests and test helpers in ReScript under `test/`. Prefer records and functions over classes. - Reuse existing application types in tests instead of redefining them. diff --git a/test/e2e/E2eLaunch.res b/test/e2e/E2eLaunch.res index c9263c5..dfee69f 100644 --- a/test/e2e/E2eLaunch.res +++ b/test/e2e/E2eLaunch.res @@ -2,8 +2,8 @@ open Node // A real package script supplies the selected manager's identity to the CLI. let launch = () => - switch (Process.argv->Array.get(2), Process.env->Dict.get("CRA_E2E_CLI")) { - | (Some(directory), Some(cliPath)) => + switch (Process.argv->Array.get(2), Process.env) { + | (Some(directory), dict{"CRA_E2E_CLI": cliPath}) => let args = Process.argv->Array.slice(~start=3) let? Ok(result) = ChildProcess.spawnSync( diff --git a/test/e2e/E2eScenarios.res b/test/e2e/E2eScenarios.res index 19ef85b..00b58ac 100644 --- a/test/e2e/E2eScenarios.res +++ b/test/e2e/E2eScenarios.res @@ -26,16 +26,19 @@ let assertContains = (actual, expected, message) => let verifyProject = async (context: P.context, directory, suffix) => { let packageJson = P.readJson(Path.join2(directory, "package.json")) let config = P.readJson(Path.join2(directory, "rescript.json")) - Assert.strictEqual( - stringField(config, "name"), - stringField(packageJson, "name"), - ~message="Project and package names differ", - ) - Assert.strictEqual( - stringField(config, "suffix"), - suffix, - ~message="Unexpected generated JS suffix", - ) + + let (projectName, actualSuffix) = switch config { + | Object(dict{"name": String(name), "suffix": String(suffix)}) => (name, suffix) + | _ => Assert.fail("Expected project name and generated JS suffix") + } + + let (packageName, dependencies) = switch packageJson { + | Object(dict{"name": String(name), "dependencies": Object(dependencies)}) => (name, dependencies) + | _ => Assert.fail("Expected package name and dependencies") + } + + Assert.strictEqual(projectName, packageName, ~message="Project and package names differ") + Assert.strictEqual(actualSuffix, suffix, ~message="Unexpected generated JS suffix") Assert.ok( Fs.existsSync(Path.join2(directory, context.info.lockfile)), `Missing ${context.info.lockfile}: manager detection failed`, @@ -57,20 +60,18 @@ let verifyProject = async (context: P.context, directory, suffix) => { | _ => () } - let version = - P.readJson(Path.join2(directory, "node_modules/rescript/package.json"))->stringField("version") + let version = switch P.readJson(Path.join2(directory, "node_modules/rescript/package.json")) { + | Object(dict{"version": String(version)}) => version + | _ => Assert.fail("Expected installed ReScript version") + } Assert.ok( version->String.startsWith("12."), `Expected a current ReScript 12 version, installed ${version}`, ) - switch field(packageJson, "dependencies") { - | Object(dependencies) => - Assert.ok( - !(dependencies->Dict.has("@rescript/core")), - "ReScript 12 should use its bundled standard library", - ) - | _ => Assert.fail("Expected package dependencies") + switch dependencies { + | dict{"@rescript/core": ?None} => () + | _ => Assert.fail("ReScript 12 should use its bundled standard library") } let? Ok(_) = await P.run(context, [context.info.command, "run", "res:build"], directory) let? Ok(output) = await P.run(context, ["node", `src/Demo${suffix}`], directory) @@ -255,16 +256,19 @@ let existingProject = async ( `Changed existing package field ${name}`, ) ) - Assert.deepStrictEqual( - field(packageJson, "scripts")->field("test"), - field(original, "scripts")->field("test"), - "Overwrote existing script", - ) - Assert.strictEqual( - field(packageJson, "dependencies")->stringField("is-number"), - "7.0.0", - ~message="Changed existing dependency", - ) + switch packageJson { + | Object(dict{ + "scripts": Object(dict{"test": testScript}), + "dependencies": Object(dict{"is-number": String(version)}), + }) => + Assert.deepStrictEqual( + testScript, + field(original, "scripts")->field("test"), + "Overwrote existing script", + ) + Assert.strictEqual(version, "7.0.0", ~message="Changed existing dependency") + | _ => Assert.fail("Expected existing package script and dependency") + } switch typescript { | Some(_) => Assert.deepStrictEqual( @@ -294,11 +298,11 @@ let existingProject = async ( } let config = P.readJson(Path.join2(directory, "rescript.json")) - Assert.strictEqual( - field(config, "gentypeconfig")->stringField("moduleResolution"), - gentypeResolution, - ~message="Incorrect genType mapping", - ) + switch config { + | Object(dict{"gentypeconfig": Object(dict{"moduleResolution": String(actual)})}) => + Assert.strictEqual(actual, gentypeResolution, ~message="Incorrect genType mapping") + | _ => Assert.fail("Expected genType module resolution") + } let tsconfig = Fs.readFileSync(Path.join2(directory, "tsconfig.json")) tsconfig->assertContains("// Preserve this comment", "Lost tsconfig comment") tsconfig->assertContains("\"allowJs\": true", "Did not enable allowJs") From a37be2fc4ac3f5bfe5925749c5bae40d86d1103f Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 12:58:11 +0200 Subject: [PATCH 16/19] Handle E2E results with top-level await --- test/e2e/E2eMain.res | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/test/e2e/E2eMain.res b/test/e2e/E2eMain.res index 052c235..771c259 100644 --- a/test/e2e/E2eMain.res +++ b/test/e2e/E2eMain.res @@ -91,18 +91,9 @@ let main = async () => { } } -let run = async () => - switch await main() { - | Ok() => () - | Error(error) => - Console.error(P.formatError(error)) - Process.state.exitCode = 1 - } - -run() -->Promise.catch(error => { - Console.error(error) +switch await main() { +| Ok() => () +| Error(error) => + Console.error(P.formatError(error)) Process.state.exitCode = 1 - Promise.resolve() -}) -->Promise.ignore +} From 42f25dcb99417e4a84e22b078422f70982127107 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 13:18:44 +0200 Subject: [PATCH 17/19] Keep Node bindings raw and collect safe adapters in NodeSafe --- AGENTS.md | 2 +- src/NpmRegistry.res | 2 +- src/bindings/Node.res | 45 +++--------------------- src/bindings/NodeSafe.res | 72 +++++++++++++++++++++++++++++++++++++++ test/E2eProcessTest.res | 2 +- test/e2e/E2eLaunch.res | 2 +- test/e2e/E2eMain.res | 11 ++---- test/e2e/E2eProcess.res | 37 +++++++++----------- 8 files changed, 99 insertions(+), 74 deletions(-) create mode 100644 src/bindings/NodeSafe.res diff --git a/AGENTS.md b/AGENTS.md index f364687..d691dbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ - Do not run `rescript`, `npm test`, or `npm run prepack` in parallel; ReScript compiler artifacts are not safe for concurrent builds in this repo. - Module files and variant constructors are PascalCase (`Templates.res`), values/functions and type names camelCase, and records use snake_case fields only when matching external JSON. - Keep `.resi` signatures accurate and minimal; avoid exposing helpers that are template-specific. -- Keep Node.js bindings in `src/bindings/Node.res` and reuse existing bindings. +- Keep raw Node.js bindings in `src/bindings/Node.res`. Put exception-to-result/option wrappers in `src/bindings/NodeSafe.res`, using small concrete helpers. - When touching templates, mirror upstream defaults and keep package scripts consistent with the chosen toolchain. ## Package Manager Support diff --git a/src/NpmRegistry.res b/src/NpmRegistry.res index f9ee6ab..93515e1 100644 --- a/src/NpmRegistry.res +++ b/src/NpmRegistry.res @@ -14,7 +14,7 @@ let defaultRegistryUrl = "https://registry.npmjs.org" let getNpmRegistry = () => npm_config_registry - ->Option.flatMap(registry => registry->Node.Url.make) + ->Option.flatMap(registry => registry->NodeSafe.Url.make) ->Option.mapOr(defaultRegistryUrl, url => url->Node.Url.href) type fetchError = diff --git a/src/bindings/Node.res b/src/bindings/Node.res index b719de0..0a9e5fd 100644 --- a/src/bindings/Node.res +++ b/src/bindings/Node.res @@ -87,15 +87,7 @@ module Process = { @scope("process") external env: Dict.t = "env" @scope("process") external platform: string = "platform" @scope("process") external execPath: string = "execPath" - @scope("process") external killUnsafe: (int, string) => unit = "kill" - - let kill = (pid, signal) => - try { - killUnsafe(pid, signal) - Ok() - } catch { - | JsExn(error) => Error(#SignalError(error)) - } + @scope("process") external kill: (int, string) => unit = "kill" type state = {mutable exitCode: int} @val external state: state = "process" @@ -142,11 +134,6 @@ module Url = { @new external makeUnsafe: string => t = "URL" @get external href: t => string = "href" - - let make = string => - try Some(makeUnsafe(string)) catch { - | JsExn(_) => None - } } module Os = { @@ -169,12 +156,7 @@ module ChildProcess = { type options = {cwd: string, env: Dict.t, detached: bool, stdio: array} @module("node:child_process") - external spawnUnsafe: (string, array, options) => t = "spawn" - - let spawn = (command, args, options) => - try Ok(spawnUnsafe(command, args, options)) catch { - | JsExn(error) => Error(#SpawnError(error)) - } + external spawn: (string, array, options) => t = "spawn" @get external pid: t => option = "pid" @get external stdout: t => readable = "stdout" @@ -187,33 +169,14 @@ module ChildProcess = { @send external onClose: (t, @as("close") _, (Nullable.t, Nullable.t) => unit) => unit = "once" - @send external writeUnsafe: (writable, string) => unit = "write" - - let write = (stream, text) => - try { - writeUnsafe(stream, text) - Ok() - } catch { - | JsExn(error) => Error(#InputError(error)) - } + @send external write: (writable, string) => unit = "write" @send external destroyInput: writable => unit = "destroy" type syncOptions = {cwd: string, stdio: string} type syncResult = {status: Nullable.t, error?: JsExn.t} @module("node:child_process") - external spawnSyncUnsafe: (string, array, syncOptions) => syncResult = "spawnSync" - - let spawnSync = (command, args, options) => - try { - let result = spawnSyncUnsafe(command, args, options) - switch result.error { - | Some(error) => Error(#SpawnError(error)) - | None => Ok(result) - } - } catch { - | JsExn(error) => Error(#SpawnError(error)) - } + external spawnSync: (string, array, syncOptions) => syncResult = "spawnSync" } @get external errorCode: JsExn.t => option = "code" diff --git a/src/bindings/NodeSafe.res b/src/bindings/NodeSafe.res new file mode 100644 index 0000000..ea8df33 --- /dev/null +++ b/src/bindings/NodeSafe.res @@ -0,0 +1,72 @@ +type fileError = {path: string, cause: JsExn.t} + +module Fs = { + let withPath = (path, perform) => + try Ok(perform()) catch { + | JsExn(cause) => Error(#FileError({path, cause})) + } + + let readFileSync = path => withPath(path, () => Node.Fs.readFileSync(path)) + + let writeFileSync = (path, contents) => + withPath(path, () => Node.Fs.writeFileSync(path, contents)) + + let appendFileSync = (path, contents) => + withPath(path, () => Node.Fs.appendFileSync(path, contents)) + + let mkdtempSync = prefix => withPath(prefix, () => Node.Fs.mkdtempSync(prefix)) + let mkdirSync = (path, options) => withPath(path, () => Node.Fs.mkdirSync(path, options)) + + module Promises = { + let rm = async (path, ~options) => + try { + await Node.Fs.Promises.rm(path, ~options) + Ok() + } catch { + | JsExn(cause) => Error(#FileError({path, cause})) + } + } +} + +module Process = { + let kill = (pid, signal) => + try { + Node.Process.kill(pid, signal) + Ok() + } catch { + | JsExn(error) => Error(#SignalError(error)) + } +} + +module ChildProcess = { + let spawn = (command, args, options) => + try Ok(Node.ChildProcess.spawn(command, args, options)) catch { + | JsExn(error) => Error(#SpawnError(error)) + } + + let write = (stream, text) => + try { + Node.ChildProcess.write(stream, text) + Ok() + } catch { + | JsExn(error) => Error(#InputError(error)) + } + + let spawnSync = (command, args, options) => + try { + let result = Node.ChildProcess.spawnSync(command, args, options) + switch result.error { + | Some(error) => Error(#SpawnError(error)) + | None => Ok(result) + } + } catch { + | JsExn(error) => Error(#SpawnError(error)) + } +} + +module Url = { + let make = string => + try Some(Node.Url.makeUnsafe(string)) catch { + | JsExn(_) => None + } +} diff --git a/test/E2eProcessTest.res b/test/E2eProcessTest.res index 7ad76c4..27df470 100644 --- a/test/E2eProcessTest.res +++ b/test/E2eProcessTest.res @@ -57,7 +57,7 @@ Test.describe("E2E command failures", () => { Test.testAsync("wraps spawnSync error properties for the launcher", () => withContext( async context => { - switch ChildProcess.spawnSync( + switch NodeSafe.ChildProcess.spawnSync( Path.join2(context.workspace, "missing-command"), [], {cwd: context.workspace, stdio: "pipe"}, diff --git a/test/e2e/E2eLaunch.res b/test/e2e/E2eLaunch.res index dfee69f..ecd758b 100644 --- a/test/e2e/E2eLaunch.res +++ b/test/e2e/E2eLaunch.res @@ -6,7 +6,7 @@ let launch = () => | (Some(directory), dict{"CRA_E2E_CLI": cliPath}) => let args = Process.argv->Array.slice(~start=3) - let? Ok(result) = ChildProcess.spawnSync( + let? Ok(result) = NodeSafe.ChildProcess.spawnSync( Process.execPath, [cliPath, ...args], { diff --git a/test/e2e/E2eMain.res b/test/e2e/E2eMain.res index 771c259..347ffce 100644 --- a/test/e2e/E2eMain.res +++ b/test/e2e/E2eMain.res @@ -47,7 +47,7 @@ let runInWorkspace = async (options, workspace) => { | Ok() => () | Error(_) => if Fs.existsSync(context.log.contents) { - switch P.fileOperation(context.log.contents, () => Fs.readFileSync(context.log.contents)) { + switch NodeSafe.Fs.readFileSync(context.log.contents) { | Ok(log) => Console.error(log) | Error(error) => Console.error(P.formatError(error)) } @@ -69,18 +69,13 @@ let main = async () => { ) let prefix = Path.join2(Os.tmpdir(), "cra e2e ") - let? Ok(workspace) = P.fileOperation(prefix, () => Fs.mkdtempSync(prefix)) + let? Ok(workspace) = NodeSafe.Fs.mkdtempSync(prefix) let outcome = try await runInWorkspace(options, workspace) catch { | JsExn(cause) => Error(#TestFailure(cause)) } - let cleanup = try { - await Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) - Ok() - } catch { - | JsExn(cause) => Error(#FileError({P.path: workspace, cause})) - } + let cleanup = await NodeSafe.Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) switch (outcome, cleanup) { | (Error(primary), Error(secondary)) => diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res index 8eba005..773f347 100644 --- a/test/e2e/E2eProcess.res +++ b/test/e2e/E2eProcess.res @@ -20,7 +20,6 @@ type context = { type prompt = (string, string) type commandMode = Piped | Terminal(array) -type fileError = {path: string, cause: JsExn.t} type exitError = {command: string, actual: option, signal: option, expected: int} type promptError = {answered: int, expected: int} @@ -30,7 +29,7 @@ type error = [ | #SpawnError(JsExn.t) | #InputError(JsExn.t) | #SignalError(JsExn.t) - | #FileError(fileError) + | #FileError(NodeSafe.fileError) | #CommandTimedOut(string) | #UnexpectedExit(exitError) | #UnansweredPrompts(promptError) @@ -62,16 +61,7 @@ let formatError = (error: error) => | #TestFailure(cause) => describeCause(cause) } -let fileOperation = (path, perform) => - try Ok(perform()) catch { - | JsExn(cause) => Error(#FileError({path, cause})) - } - -let writeFile = (path, contents) => fileOperation(path, () => Fs.writeFileSync(path, contents)) -let makeDirectory = path => fileOperation(path, () => Fs.mkdirSync(path, {recursive: true})) - -let appendLog = (context, contents) => - fileOperation(context.log.contents, () => Fs.appendFileSync(context.log.contents, contents)) +let appendLog = (context, contents) => NodeSafe.Fs.appendFileSync(context.log.contents, contents) let managerInfo = (manager: PackageManagers.packageManager) => switch manager { @@ -95,11 +85,13 @@ let parseManager = (name): result #InvalidArg let quote = value => `'${value->String.replaceAll("'", "'\\''")}'` let shellJoin = command => command->Array.map(quote)->Array.join(" ") let readJson = path => Fs.readFileSync(path)->JSON.parseOrThrow -let writeJson = (path, json) => writeFile(path, `${JSON.stringify(json, ~space=2)}\n`) + +let writeJson = (path, json) => + NodeSafe.Fs.writeFileSync(path, `${JSON.stringify(json, ~space=2)}\n`) let makeContext = (options: options, workspace) => { let logs = Path.resolve(options.logs) - let? Ok() = makeDirectory(logs) + let? Ok() = NodeSafe.Fs.mkdirSync(logs, {recursive: true}) let env = Process.env->Dict.copy [ ("CI", "false"), @@ -132,7 +124,7 @@ let killGroup = (child, signal) => switch child->ChildProcess.pid { | None => Ok() | Some(pid) => - switch Process.kill(-pid, signal) { + switch NodeSafe.Process.kill(-pid, signal) { | Error(#SignalError(cause)) if cause->errorCode === Some("ESRCH") => Ok() | outcome => outcome } @@ -167,7 +159,7 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re } } - let? Ok(child) = ChildProcess.spawn( + let? Ok(child) = NodeSafe.ChildProcess.spawn( executable, args, { @@ -218,7 +210,7 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re let? Ok() = appendLog(context, chunk) switch (prompts->Array.get(nextPrompt.contents), child->ChildProcess.stdin) { | (Some((question, answer)), Some(stdin)) if pending.contents->String.includes(question) => - let? Ok() = stdin->ChildProcess.write(answer) + let? Ok() = stdin->NodeSafe.ChildProcess.write(answer) nextPrompt := nextPrompt.contents + 1 pending := "" Ok() @@ -312,7 +304,7 @@ let setup = async (context, ~packagePath, ~launcherPath) => { "CRA_E2E_CLI", Path.join2(cliDirectory, "node_modules/create-rescript-app/out/create-rescript-app.cjs"), ) - let? Ok() = makeDirectory(context.launcher) + let? Ok() = NodeSafe.Fs.mkdirSync(context.launcher, {recursive: true}) let? Ok() = writeJson( Path.join2(context.launcher, "package.json"), @@ -327,7 +319,10 @@ let setup = async (context, ~packagePath, ~launcherPath) => { let? Ok() = switch context.manager { | YarnBerry => - writeFile(Path.join2(context.launcher, ".yarnrc.yml"), "nodeLinker: node-modules\n") + NodeSafe.Fs.writeFileSync( + Path.join2(context.launcher, ".yarnrc.yml"), + "nodeLinker: node-modules\n", + ) | _ => Ok() } @@ -337,9 +332,9 @@ let setup = async (context, ~packagePath, ~launcherPath) => { let runCase = async (context, name, perform) => { context.log := Path.join2(context.logs, `${name}.log`) - let? Ok() = writeFile(context.log.contents, "") + let? Ok() = NodeSafe.Fs.writeFileSync(context.log.contents, "") let directory = Path.join2(context.workspace, name) - let? Ok() = makeDirectory(directory) + let? Ok() = NodeSafe.Fs.mkdirSync(directory, {recursive: true}) Console.log(`RUN ${context.info.name}: ${name}`) let? Ok() = try await perform(directory) catch { From aa77354f993055c65640ac1df757b1b3b3880bcd Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 13:49:58 +0200 Subject: [PATCH 18/19] Propagate E2E results without catches in main --- test/E2eProcessTest.res | 12 ++++++++++++ test/e2e/E2eMain.res | 10 ++-------- test/e2e/E2eProcess.res | 23 ++++++++++++----------- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/test/E2eProcessTest.res b/test/E2eProcessTest.res index 27df470..61677db 100644 --- a/test/E2eProcessTest.res +++ b/test/E2eProcessTest.res @@ -27,6 +27,18 @@ let withContext = async perform => { } Test.describe("E2E command failures", () => { + Test.testAsync("returns an empty command as a validation error", () => + withContext( + async context => { + switch await P.run(context, [], context.workspace) { + | Error(#InvalidArguments(message)) => + Assert.strictEqual(message, "Cannot run an empty command") + | _ => Assert.fail("Expected an empty-command validation error") + } + }, + ) + ) + Test.testAsync("returns asynchronous spawn failures as values", () => withContext( async context => { diff --git a/test/e2e/E2eMain.res b/test/e2e/E2eMain.res index 347ffce..4b10f7d 100644 --- a/test/e2e/E2eMain.res +++ b/test/e2e/E2eMain.res @@ -37,11 +37,7 @@ let runSuite = async (context, options: P.options) => { let runInWorkspace = async (options, workspace) => { let? Ok(context) = P.makeContext(options, workspace) - let outcome = try { - await runSuite(context, options) - } catch { - | JsExn(cause) => Error(#TestFailure(cause)) - } + let outcome = await runSuite(context, options) switch outcome { | Ok() => () @@ -71,9 +67,7 @@ let main = async () => { let prefix = Path.join2(Os.tmpdir(), "cra e2e ") let? Ok(workspace) = NodeSafe.Fs.mkdtempSync(prefix) - let outcome = try await runInWorkspace(options, workspace) catch { - | JsExn(cause) => Error(#TestFailure(cause)) - } + let outcome = await runInWorkspace(options, workspace) let cleanup = await NodeSafe.Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res index 773f347..a386cf1 100644 --- a/test/e2e/E2eProcess.res +++ b/test/e2e/E2eProcess.res @@ -138,8 +138,9 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re let? Ok() = appendLog(context, `\n$ ${commandText}\n(cwd: ${cwd})\n`) // util-linux script supplies a real terminal; all control and assertions are ReScript. - let (executable, args, prompts, stdinMode) = switch mode { - | Terminal(prompts) => ( + let? Ok((executable, args, prompts, stdinMode)) = switch mode { + | Terminal(prompts) => + Ok(( "script", [ "--quiet", @@ -151,11 +152,11 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re ], prompts, "pipe", - ) + )) | Piped => switch command->List.fromArray { - | list{executable, ...args} => (executable, args->List.toArray, [], "ignore") - | list{} => Assert.fail("Cannot run an empty command") + | list{executable, ...args} => Ok((executable, args->List.toArray, [], "ignore")) + | list{} => Error(#InvalidArguments("Cannot run an empty command")) } } @@ -308,12 +309,12 @@ let setup = async (context, ~packagePath, ~launcherPath) => { let? Ok() = writeJson( Path.join2(context.launcher, "package.json"), - JSON.parseOrThrow( - `{ - "name": "cra-e2e-launcher", - "private": true, - "scripts": {"cra": ${JSON.stringify(JSON.String(shellJoin(["node", launcherPath])))}} - }`, + JSON.Object( + dict{ + "name": JSON.String("cra-e2e-launcher"), + "private": JSON.Boolean(true), + "scripts": JSON.Object(dict{"cra": JSON.String(shellJoin(["node", launcherPath]))}), + }, ), ) From 88c7831a9b272faf09eaf2a76f6289eef1d38482 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Tue, 13 Oct 2026 14:02:02 +0200 Subject: [PATCH 19/19] Handle nullable Node values directly in E2E tests --- AGENTS.md | 1 + src/bindings/Node.res | 2 +- test/E2eProcessTest.res | 29 +++++++++++++++++++++++++++-- test/e2e/E2eLaunch.res | 2 +- test/e2e/E2eProcess.res | 37 ++++++++++++++++++++++--------------- 5 files changed, 52 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d691dbc..9585bf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ - Use ReScript 12 syntax and the built-in standard library, not Reason syntax. Read https://rescript-lang.org/llms/manual/llm-small.txt to learn the language syntax. - Prefer `Option`, `Result`, `Array`, `List`, `Dict`, `Set`, `Map`, and `JSON` over `Js.*` or `Belt.*`. - Prefer `dict{...}` patterns for known dictionary keys and nested JSON shapes; use `Dict.get` for dynamic keys. +- Match `Nullable.t` directly with `Value`, `Null`, and `Undefined`, and use `Nullable.getOr` for defaults instead of converting to `option`. - Match local style; avoid old BuckleScript/ReScript-era patterns. - Write new tests and test helpers in ReScript under `test/`. Prefer records and functions over classes. - Reuse existing application types in tests instead of redefining them. diff --git a/src/bindings/Node.res b/src/bindings/Node.res index 0a9e5fd..ac4834a 100644 --- a/src/bindings/Node.res +++ b/src/bindings/Node.res @@ -161,7 +161,7 @@ module ChildProcess = { @get external pid: t => option = "pid" @get external stdout: t => readable = "stdout" @get external stderr: t => readable = "stderr" - @get @return(nullable) external stdin: t => option = "stdin" + @get external stdin: t => Nullable.t = "stdin" @send external setEncoding: (readable, @as("utf8") _) => unit = "setEncoding" @send external onData: (readable, @as("data") _, string => unit) => unit = "on" @send external onInputError: (writable, @as("error") _, JsExn.t => unit) => unit = "on" diff --git a/test/E2eProcessTest.res b/test/E2eProcessTest.res index 61677db..9512e11 100644 --- a/test/E2eProcessTest.res +++ b/test/E2eProcessTest.res @@ -110,15 +110,40 @@ Test.describe("E2E command failures", () => { context.workspace, ) { | Error(#UnexpectedExit({actual, expected, signal})) => - Assert.strictEqual(actual, Some(7)) + Assert.strictEqual(actual, Value(7)) Assert.strictEqual(expected, 0) - Assert.strictEqual(signal, None) + Assert.strictEqual(signal, Null) | _ => Assert.fail("Expected an unexpected-exit error") } }, ) ) + Test.testAsyncWithOptions( + "returns signal termination details", + {skip: Process.platform === "win32"}, + () => + withContext( + async context => { + switch await P.run( + context, + [Process.execPath, "-e", "process.kill(process.pid, 'SIGTERM')"], + context.workspace, + ) { + | Error(#UnexpectedExit({actual, expected, signal}) as error) => + Assert.strictEqual(actual, Null) + Assert.strictEqual(expected, 0) + Assert.strictEqual(signal, Value("SIGTERM")) + Assert.ok( + P.formatError(error)->String.includes("Command exited SIGTERM"), + "Lost the termination signal in the error message", + ) + | _ => Assert.fail("Expected a signal termination error") + } + }, + ), + ) + Test.testAsyncWithOptions( "stops a timed-out process and returns its failure", {skip: Process.platform === "win32"}, diff --git a/test/e2e/E2eLaunch.res b/test/e2e/E2eLaunch.res index ecd758b..68d1516 100644 --- a/test/e2e/E2eLaunch.res +++ b/test/e2e/E2eLaunch.res @@ -15,7 +15,7 @@ let launch = () => }, ) - Ok(result.status->Nullable.toOption->Option.getOr(1)) + Ok(result.status->Nullable.getOr(1)) | _ => Error(#InvalidArguments("Expected a project directory and CRA_E2E_CLI")) } diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res index a386cf1..07ccf51 100644 --- a/test/e2e/E2eProcess.res +++ b/test/e2e/E2eProcess.res @@ -20,7 +20,14 @@ type context = { type prompt = (string, string) type commandMode = Piped | Terminal(array) -type exitError = {command: string, actual: option, signal: option, expected: int} + +type exitError = { + command: string, + actual: Nullable.t, + signal: Nullable.t, + expected: int, +} + type promptError = {answered: int, expected: int} type error = [ @@ -51,8 +58,8 @@ let formatError = (error: error) => | #CommandTimedOut(command) => `Command timed out: ${command}` | #UnexpectedExit({command, actual, signal, expected}) => let status = switch actual { - | Some(code) => code->Int.toString - | None => signal->Option.getOr("unknown signal") + | Value(code) => code->Int.toString + | Null | Undefined => signal->Nullable.getOr("unknown signal") } `Command exited ${status}, expected ${expected->Int.toString}: ${command}` @@ -210,7 +217,7 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re let outcome = () => { let? Ok() = appendLog(context, chunk) switch (prompts->Array.get(nextPrompt.contents), child->ChildProcess.stdin) { - | (Some((question, answer)), Some(stdin)) if pending.contents->String.includes(question) => + | (Some((question, answer)), Value(stdin)) if pending.contents->String.includes(question) => let? Ok() = stdin->NodeSafe.ChildProcess.write(answer) nextPrompt := nextPrompt.contents + 1 pending := "" @@ -233,7 +240,7 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re }) child ->ChildProcess.stdin - ->Option.forEach(stdin => + ->Nullable.forEach(stdin => stdin->ChildProcess.onInputError(error => { if error->errorCode !== Some("EPIPE") { recordFailure(#InputError(error)) @@ -249,7 +256,7 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re timeout->Timer.clearTimeout forceKill.contents->Option.forEach(Timer.clearTimeout) - child->ChildProcess.stdin->Option.forEach(ChildProcess.destroyInput) + child->ChildProcess.stdin->Nullable.forEach(ChildProcess.destroyInput) // No early propagation until timers and streams have been cleaned up. let? Ok() = switch failure.contents { @@ -257,15 +264,15 @@ let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): re | None => Ok() } - let actual = code->Nullable.toOption - switch (actual === Some(expected), nextPrompt.contents === prompts->Array.length) { - | (false, _) => - Error( - #UnexpectedExit({command: commandText, actual, signal: signal->Nullable.toOption, expected}), - ) - | (_, false) => - Error(#UnansweredPrompts({answered: nextPrompt.contents, expected: prompts->Array.length})) - | _ => Ok(output.contents) + switch code { + | Value(actual) if actual === expected => + switch nextPrompt.contents === prompts->Array.length { + | true => Ok(output.contents) + | false => + Error(#UnansweredPrompts({answered: nextPrompt.contents, expected: prompts->Array.length})) + } + | Null | Undefined | Value(_) => + Error(#UnexpectedExit({command: commandText, actual: code, signal, expected})) } }