From f83d5fcfd994f8efb48846e4911ded8f71388af3 Mon Sep 17 00:00:00 2001 From: acandoo <117209328+acandoo@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:28:28 +0000 Subject: [PATCH 1/2] Handle unhandled Promise rejection in JavaScript --- src/gleeunit_ffi.mjs | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/gleeunit_ffi.mjs b/src/gleeunit_ffi.mjs index 7bdc071..0150627 100644 --- a/src/gleeunit_ffi.mjs +++ b/src/gleeunit_ffi.mjs @@ -40,6 +40,21 @@ export async function main() { let packageName = await readRootPackageName(); let dist = `../${packageName}/`; + // Since we want to be able to report unhandled Promise rejections as test failures, + // we need to keep track of them as they happen. This is because they can occur at any + // time during the test run, and we want to be able to report them at the end of the test run, + // after all tests have been executed. + const rejections = []; + + // Node's approach to unhandled rejections is different + // from the Web/Deno API, in that attaching an event listener + // through `globalThis.addEventListener` or `globalThis.onunhandledrejection` + // still causes the process to prematurely exit. Instead, we have to use `process.on`, + // as it will allow us to manually handle the exiting behavior. + globalThis.process?.on("unhandledRejection", (reason) => { + rejections.push(reason); + }); + for await (let path of await gleamFiles("test")) { let js_path = path.slice("test/".length).replace(".gleam", ".mjs"); let module = await import(join_path(dist, js_path)); @@ -55,8 +70,28 @@ export async function main() { } } - const status = reporting.finished(state); - exit(status); + const preExit = () => { + // While a likely module/function can be inferred from what test is + // currently running, unhandled Promise rejections outlast the lifetime + // of ES module dynamic import or function calls, which means that they + // can reject on any module/function running. + // + // We also put this logic at the end of the test runner so that we can run all tests. + for (const entry of rejections) { + state = reporting.test_failed(state, "Unknown module", "Unknown function", entry); + } + + const status = reporting.finished(state); + exit(status); + }; + + // If we're able to subscribe to beforeExit, then we can report unhandled rejections that + // occur after the tests are finished. + if (globalThis.process?.on) { + process.on("beforeExit", preExit); + } else { + preExit(); + } } export function crash(message) { From 3a0b018227428a5da50e1a2b3d6777830c29a704 Mon Sep 17 00:00:00 2001 From: acandoo <117209328+acandoo@users.noreply.github.com> Date: Sat, 14 Mar 2026 04:51:55 +0000 Subject: [PATCH 2/2] Add test for unhandled Promise rejection --- gleam.toml | 2 ++ manifest.toml | 4 +++ test/gleam_panics_test.gleam | 64 ++++++++++++++++++++++++++++++++++++ test/gleeunit_test_ffi.mjs | 19 +++++++++++ 4 files changed, 89 insertions(+) diff --git a/gleam.toml b/gleam.toml index c7df0c5..9296023 100644 --- a/gleam.toml +++ b/gleam.toml @@ -14,3 +14,5 @@ gleam_stdlib = ">= 0.60.0 and < 1.0.0" [dev-dependencies] testhelper = { "path" = "./testhelper" } +shellout = ">= 1.8.0 and < 2.0.0" +envoy = ">= 1.1.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index f73875e..65c0d15 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,10 +2,14 @@ # You typically do not need to edit this file packages = [ + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, { name = "gleam_stdlib", version = "0.60.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "621D600BB134BC239CB2537630899817B1A42E60A1D46C5E9F3FAE39F88C800B" }, + { name = "shellout", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "C416356D45151F298108C9DB9CD1EDE0313F620B5EDBB5766CD7237659D87841" }, { name = "testhelper", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "testhelper" }, ] [requirements] +envoy = { version = ">= 1.1.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.60.0 and < 1.0.0" } +shellout = { version = ">= 1.8.0 and < 2.0.0" } testhelper = { path = "./testhelper" } diff --git a/test/gleam_panics_test.gleam b/test/gleam_panics_test.gleam index 3f5d54d..18a879b 100644 --- a/test/gleam_panics_test.gleam +++ b/test/gleam_panics_test.gleam @@ -1,9 +1,11 @@ +import envoy import gleam/dynamic import gleam/function import gleeunit/internal/gleam_panic.{ Assert, BinaryOperator, Expression, FunctionCall, LetAssert, Literal, OtherExpression, Panic, Todo, Unevaluated, } +import shellout import testhelper @external(erlang, "gleeunit_test_ffi", "rescue") @@ -206,3 +208,65 @@ pub fn assert_binary_operator_test() { assert right.end == end assert right.kind == Unevaluated } + +pub fn javascript_unhandled_promise_test() { + case envoy.get("GLEEUNIT_RUN_UNHANDLED_PROMISE_TEST") { + Error(_) -> { + // Spawn gleam test + let assert Error(#(code, _)) = + shellout.command( + run: "gleam", + with: ["test", "-t", "javascript"], + in: ".", + opt: [ + shellout.SetEnvironment([ + #("GLEEUNIT_RUN_UNHANDLED_PROMISE_TEST", "1"), + ]), + ], + ) + as "immediate rejected promise: expected error due to unhandled promise rejection" + + assert code == 1 + as "immediate rejected promise: expected exit code 1 due to unhandled promise rejection" + + // Spawn gleam test + let assert Error(#(code, _)) = + shellout.command( + run: "gleam", + with: ["test", "-t", "javascript"], + in: ".", + opt: [ + shellout.SetEnvironment([ + #("GLEEUNIT_RUN_UNHANDLED_PROMISE_TEST", "2"), + ]), + ], + ) + as "delayed rejected promise: expected error due to unhandled promise rejection" + + assert code == 1 + as "delayed rejected promise: expected exit code 1 due to unhandled promise rejection" + } + Ok("1") -> { + echo "inside spawned gleam test, testing immediate reject" + javascript_test_promise() + } + Ok(_) -> { + echo "inside spawned gleam test, testing delayed reject" + javascript_test_delayed_promise() + } + } +} + +@external(javascript, "./gleeunit_test_ffi.mjs", "promise_fail_test") +fn javascript_test_promise() -> Nil { + // Not relevant to other targets + echo "This is not relevant to other targets!" + Nil +} + +@external(javascript, "./gleeunit_test_ffi.mjs", "delayed_promise_fail_test") +fn javascript_test_delayed_promise() -> Nil { + // Not relevant to other targets + echo "This is not relevant to other targets!" + Nil +} diff --git a/test/gleeunit_test_ffi.mjs b/test/gleeunit_test_ffi.mjs index 783209d..24e1875 100644 --- a/test/gleeunit_test_ffi.mjs +++ b/test/gleeunit_test_ffi.mjs @@ -1,5 +1,10 @@ import { Ok, Error } from "./gleam.mjs"; +// The maximum amount of time running all tests is expected to take. +// This is used to set the timeout for the delayed promise test, to ensure +// it runs after all other tests have had a chance to execute. +const MAX_TEST_TIME = 4000; + export function rescue(f) { try { return new Ok(f()); @@ -7,3 +12,17 @@ export function rescue(f) { return new Error(e); } } + +export function promise_fail_test() { + new Promise(() => { + throw new Error("Promise panicked"); + }); +} + +export function delayed_promise_fail_test() { + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Promise panicked")); + }, MAX_TEST_TIME); + }); +}