Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gleam.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
39 changes: 37 additions & 2 deletions src/gleeunit_ffi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool! Which runtimes is this API not available for?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node, Deno, and Bun all cover it, so the edge case is if someone decides to run it on a Vercel Edge runtime or some really early version of Deno/Bun or a browser or something :p i can remove it if you want

if (globalThis.process?.on) {
process.on("beforeExit", preExit);
} else {
preExit();
}
}

export function crash(message) {
Expand Down
64 changes: 64 additions & 0 deletions test/gleam_panics_test.gleam
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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()
}
}
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this test, what is it doing? Why is it reading environment variables and spawning OS processes?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It respawns gleam test but using the JavaScript runtime and with an environment variable, as the test in question requires seeing if gleeunit exits with an error on an uncaught Promise rejection. Since there are two tests (immediate unhandled rejection, delayed unhandled rejection), it's spawned two times with the environment variable set for the two test cases to run. I was considering either this or making an OS temporary directory with a brand-new Gleam project, but figured this was easier to implement.


@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
}
19 changes: 19 additions & 0 deletions test/gleeunit_test_ffi.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
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;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is a bit confusing to me, it sounds like it is being used as the timeout for tests being run, but it seems to instead be used to reject one promise in the FFI below?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intention is for the Promise's rejection to outlast the regular time of the tests, as it's testing to make sure gleeunit doesn't prematurely exit before the Promise rejects.


export function rescue(f) {
try {
return new Ok(f());
} catch (e) {
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"));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says the promise panics, but there's no panic here.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, i'll update the message!

}, MAX_TEST_TIME);
});
}