diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6041533..7208349 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,8 +93,78 @@ 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 harness build dependencies + run: npm ci + + - name: Install, compile and run real projects + env: + PACKAGE: ${{ needs.build.outputs.artifact_filename }} + MANAGER: ${{ matrix.manager }} + run: >- + npm run test:e2e -- + --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/AGENTS.md b/AGENTS.md index ae0f790..9585bf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,24 @@ ## 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.*`. +- 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. +- 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`. - 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. +- 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 @@ -40,6 +51,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/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 c407be1..5911574 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": "rescript && node test/e2e/E2eMain.res.mjs", "format": "rescript format", "dev": "rescript -w" }, @@ -44,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/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 893e7da..ac4834a 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 fail: string => unit = "fail" + external deepStrictEqual: ('a, 'a, string) => unit = "deepStrictEqual" + + @module("node:assert/strict") external ok: (bool, string) => unit = "ok" + + @module("node:assert/strict") + external fail: string => 'a = "fail" } module Test = { @@ -100,6 +121,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 = { @@ -109,19 +134,53 @@ module Url = { @new external makeUnsafe: string => t = "URL" @get external href: t => string = "href" - - let make = string => - try Some(makeUnsafe(string)) catch { - | Exn.Error(_exn) => None - } } 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 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" + @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/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 new file mode 100644 index 0000000..9512e11 --- /dev/null +++ b/test/E2eProcessTest.res @@ -0,0 +1,217 @@ +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 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 => { + 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 NodeSafe.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, Value(7)) + Assert.strictEqual(expected, 0) + 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"}, + () => + 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/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 new file mode 100644 index 0000000..68d1516 --- /dev/null +++ b/test/e2e/E2eLaunch.res @@ -0,0 +1,27 @@ +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) { + | (Some(directory), dict{"CRA_E2E_CLI": cliPath}) => + let args = Process.argv->Array.slice(~start=3) + + let? Ok(result) = NodeSafe.ChildProcess.spawnSync( + Process.execPath, + [cliPath, ...args], + { + cwd: directory, + stdio: "inherit", + }, + ) + + Ok(result.status->Nullable.getOr(1)) + | _ => Error(#InvalidArguments("Expected a project directory and CRA_E2E_CLI")) + } + +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 new file mode 100644 index 0000000..4b10f7d --- /dev/null +++ b/test/e2e/E2eMain.res @@ -0,0 +1,88 @@ +open Node +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} => + let? Ok(manager) = P.parseManager(value) + parseArguments(rest, {...arguments, manager: Some(manager)}) + | list{} => + switch arguments { + | {packagePath: Some(packagePath), manager: Some(manager), logs: Some(logs)} => + Ok({P.packagePath, manager, logs}) + | _ => Error(#InvalidArguments(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 = await runSuite(context, options) + + switch outcome { + | Ok() => () + | Error(_) => + if Fs.existsSync(context.log.contents) { + switch NodeSafe.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 () => { + 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 prefix = Path.join2(Os.tmpdir(), "cra e2e ") + let? Ok(workspace) = NodeSafe.Fs.mkdtempSync(prefix) + + let outcome = await runInWorkspace(options, workspace) + + let cleanup = await NodeSafe.Fs.Promises.rm(workspace, ~options={recursive: true, force: true}) + + switch (outcome, cleanup) { + | (Error(primary), Error(secondary)) => + Console.error(`Workspace cleanup also failed:\n${P.formatError(secondary)}`) + Error(primary) + | (Error(_), _) => outcome + | (Ok(), _) => cleanup + } +} + +switch await main() { +| Ok() => () +| Error(error) => + Console.error(P.formatError(error)) + Process.state.exitCode = 1 +} diff --git a/test/e2e/E2eProcess.res b/test/e2e/E2eProcess.res new file mode 100644 index 0000000..07ccf51 --- /dev/null +++ b/test/e2e/E2eProcess.res @@ -0,0 +1,354 @@ +open Node + +type managerInfo = {name: string, command: string, lockfile: string} + +type options = { + packagePath: string, + manager: PackageManagers.packageManager, + logs: string, +} + +type context = { + manager: PackageManagers.packageManager, + info: managerInfo, + workspace: string, + logs: string, + log: ref, + launcher: string, + env: Dict.t, +} + +type prompt = (string, string) +type commandMode = Piped | Terminal(array) + +type exitError = { + command: string, + actual: Nullable.t, + signal: Nullable.t, + expected: int, +} + +type promptError = {answered: int, expected: int} + +type error = [ + | #InvalidArguments(string) + | #UnsupportedPlatform(string) + | #SpawnError(JsExn.t) + | #InputError(JsExn.t) + | #SignalError(JsExn.t) + | #FileError(NodeSafe.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 { + | Value(code) => code->Int.toString + | Null | Undefined => signal->Nullable.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 appendLog = (context, contents) => NodeSafe.Fs.appendFileSync(context.log.contents, contents) + +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"} + | 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): result #InvalidArguments(string)]> => + switch name { + | "npm" => Ok(Npm) + | "pnpm" => Ok(Pnpm) + | "yarn-classic" => Ok(Yarn1) + | "yarn-berry" => Ok(YarnBerry) + | "bun" => Ok(Bun) + | _ => 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) => + NodeSafe.Fs.writeFileSync(path, `${JSON.stringify(json, ~space=2)}\n`) + +let makeContext = (options: options, workspace) => { + let logs = Path.resolve(options.logs) + let? Ok() = NodeSafe.Fs.mkdirSync(logs, {recursive: true}) + let env = 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)) + Ok({ + 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->ChildProcess.pid { + | None => Ok() + | Some(pid) => + switch NodeSafe.Process.kill(-pid, signal) { + | Error(#SignalError(cause)) if cause->errorCode === Some("ESRCH") => Ok() + | outcome => outcome + } + } + +let runCommand = async (context, command, cwd, ~expected, ~mode, ~timeoutMs): result< + string, + error, +> => { + 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? Ok((executable, args, prompts, stdinMode)) = switch mode { + | Terminal(prompts) => + Ok(( + "script", + [ + "--quiet", + "--return", + "--echo=never", + "--command", + `stty cols 160 rows 40; exec ${commandText}`, + "/dev/null", + ], + prompts, + "pipe", + )) + | Piped => + switch command->List.fromArray { + | list{executable, ...args} => Ok((executable, args->List.toArray, [], "ignore")) + | list{} => Error(#InvalidArguments("Cannot run an empty command")) + } + } + + let? Ok(child) = NodeSafe.ChildProcess.spawn( + executable, + args, + { + cwd, + env: context.env, + detached: true, + 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 { + | None => forceKill := Some(Timer.setTimeout(() => stop("SIGKILL"), 3000)) + | 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) { + | (Some((question, answer)), Value(stdin)) if pending.contents->String.includes(question) => + let? Ok() = stdin->NodeSafe.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 => { + stream->ChildProcess.setEncoding + stream->ChildProcess.onData(collect) + }) + child + ->ChildProcess.stdin + ->Nullable.forEach(stdin => + stdin->ChildProcess.onInputError(error => { + if error->errorCode !== Some("EPIPE") { + recordFailure(#InputError(error)) + terminate() + } + }) + ) + + 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->Nullable.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() + } + + 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})) + } +} + +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? 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? 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() = NodeSafe.Fs.mkdirSync(context.launcher, {recursive: true}) + + let? Ok() = writeJson( + Path.join2(context.launcher, "package.json"), + JSON.Object( + dict{ + "name": JSON.String("cra-e2e-launcher"), + "private": JSON.Boolean(true), + "scripts": JSON.Object(dict{"cra": JSON.String(shellJoin(["node", launcherPath]))}), + }, + ), + ) + + let? Ok() = switch context.manager { + | YarnBerry => + NodeSafe.Fs.writeFileSync( + Path.join2(context.launcher, ".yarnrc.yml"), + "nodeLinker: node-modules\n", + ) + | _ => Ok() + } + + 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`) + let? Ok() = NodeSafe.Fs.writeFileSync(context.log.contents, "") + let directory = Path.join2(context.workspace, name) + let? Ok() = NodeSafe.Fs.mkdirSync(directory, {recursive: true}) + 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 new file mode 100644 index 0000000..00b58ac --- /dev/null +++ b/test/e2e/E2eScenarios.res @@ -0,0 +1,439 @@ +open Node +open JSON +module P = E2eProcess + +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) + +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")) + + 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`, + ) + switch context.manager { + | Npm => () + | _ => + Assert.ok( + !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 = 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 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) + Assert.strictEqual( + output->String.trim, + "Hello, world!", + ~message="Unexpected generated program output", + ) + Ok() +} + +let verifyPreserved = (directory, preserved) => + preserved->Array.forEach(((path, contents)) => { + let actual = Fs.readFileSync(Path.join2(directory, path)) + switch path { + | ".gitignore" => Assert.ok(actual->String.startsWith(contents), `Overwrote ${path}`) + | _ => Assert.strictEqual(actual, contents, ~message=`Overwrote ${path}`) + } + }) + +let newProject = async (context, directory, ~target, ~devcontainer) => { + let currentDirectory = switch target { + | "." | "./" => 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, + ~args=[target, "--template", "basic"], + ~expected=0, + ~prompts=[], + ) + + Assert.strictEqual( + P.readJson(Path.join2(project, "package.json"))->stringField("name"), + Path.basename(project), + ~message="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, + ~typescript: option, +) => { + 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, + ], + ) + + 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) => + let (moduleName, resolutionName) = switch resolution { + | NodeNext => ("NodeNext", "nodenext") + | Bundler => ("ESNext", "bundler") + } + + Fs.writeFileSync( + 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)) => + 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, + ~args=[], + ~expected=0, + ~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 => + Assert.deepStrictEqual( + field(packageJson, name), + field(original, name), + `Changed existing package field ${name}`, + ) + ) + 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( + 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")) + 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") + switch resolution { + | NodeNext => () + | Bundler => + tsconfig->assertContains( + "\"allowImportingTsExtensions\": true", + "Did not enable TS extension imports", + ) + } + 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`, + ) + } + 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 => Ok() + | Some(_) => + Assert.ok( + 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() + } +} + +let occupiedDirectory = async (context, directory) => { + let project = Path.join2(directory, "occupied") + 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, + ~args=["occupied", "--template", "basic"], + ~expected=1, + ~prompts=[], + ) + + 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", + ) + Ok() +} + +type scenario = {name: string, perform: string => promise>} + +let scenarios = (context: P.context) => { + let common = [ + { + 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 => + [ + ...common, + { + name: "existing-esm", + perform: directory => + existingProject(context, directory, ~moduleSystem=EsModule, ~typescript=None), + }, + { + name: "existing-ts-nodenext", + perform: directory => + existingProject(context, directory, ~moduleSystem=EsModule, ~typescript=Some(NodeNext)), + }, + { + name: "existing-ts-bundler", + perform: directory => + existingProject(context, directory, ~moduleSystem=EsModule, ~typescript=Some(Bundler)), + }, + { + name: "devcontainer-named", + perform: directory => newProject(context, directory, ~target="my-app", ~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{} => Ok() + | list{scenario, ...rest} => + let? Ok() = await P.runCase(context, scenario.name, scenario.perform) + await runScenarios(context, rest) + } + +let execute = async context => { + let cases = scenarios(context) + let? Ok() = await runScenarios(context, cases->List.fromArray) + Console.log(`PASS: ${cases->Array.length->Int.toString} E2E cases for ${context.info.name}`) + Ok() +}