Fix expect.assertions() and done callback (#13463)

This commit is contained in:
Jarred Sumner
2024-08-22 15:26:58 -07:00
committed by GitHub
parent 1bac09488d
commit 886c31f0c5
8 changed files with 275 additions and 82 deletions

View File

@@ -42,7 +42,6 @@ const JSTypeOfMap = bun.ComptimeStringMap([]const u8, .{
pub var active_test_expectation_counter: Counter = .{};
pub var is_expecting_assertions: bool = false;
pub var is_expecting_assertions_count: bool = false;
pub var expected_assertions_number: u32 = 0;
const log = bun.Output.scoped(.expect, false);
@@ -4868,8 +4867,8 @@ pub const Expect = struct {
return .zero;
}
const expected_assertions: f64 = expected.asNumber();
if (@round(expected_assertions) != expected_assertions or std.math.isInf(expected_assertions) or std.math.isNan(expected_assertions) or expected_assertions < 0) {
const expected_assertions: f64 = expected.coerceToDouble(globalThis);
if (@round(expected_assertions) != expected_assertions or std.math.isInf(expected_assertions) or std.math.isNan(expected_assertions) or expected_assertions < 0 or expected_assertions > std.math.maxInt(u32)) {
var fmt = JSC.ConsoleObject.Formatter{ .globalThis = globalThis, .quote_strings = true };
globalThis.throw("Expected value must be a non-negative integer: {any}", .{expected.toFmt(&fmt)});
return .zero;
@@ -4878,7 +4877,7 @@ pub const Expect = struct {
const unsigned_expected_assertions: u32 = @intFromFloat(expected_assertions);
is_expecting_assertions_count = true;
expected_assertions_number = unsigned_expected_assertions;
active_test_expectation_counter.expected = unsigned_expected_assertions;
return .undefined;
}

View File

@@ -264,6 +264,8 @@ pub const TestRunner = struct {
skip,
todo,
fail_because_todo_passed,
fail_because_expected_has_assertions,
fail_because_expected_assertion_count,
};
};
};
@@ -1340,6 +1342,20 @@ pub const TestRunnerTask = struct {
}
}
pub fn checkAssertionsCounter(result: *Result) void {
if (expect.is_expecting_assertions and expect.active_test_expectation_counter.actual == 0) {
expect.is_expecting_assertions = false;
expect.is_expecting_assertions_count = false;
result.* = .{ .fail_because_expected_has_assertions = {} };
}
if (expect.is_expecting_assertions_count and expect.active_test_expectation_counter.actual != expect.active_test_expectation_counter.expected) {
expect.is_expecting_assertions = false;
expect.is_expecting_assertions_count = false;
result.* = .{ .fail_because_expected_assertion_count = expect.active_test_expectation_counter };
}
}
pub fn run(this: *TestRunnerTask) bool {
var describe = this.describe;
var globalThis = this.globalThis;
@@ -1402,40 +1418,24 @@ pub const TestRunnerTask = struct {
}
// rejected promises should fail the test
if (result != .fail)
if (!result.isFailure())
globalThis.handleRejectedPromises();
if (result == .pending and this.sync_state == .pending and (this.done_callback_state == .pending or this.promise_state == .pending)) {
this.sync_state = .fulfilled;
if (this.reported and this.promise_state != .pending) {
// An unhandled error was reported.
// Let's allow any pending work to run, and then move on to the next test.
this.continueRunningTestsAfterMicrotasksRun();
}
return true;
}
if (expect.is_expecting_assertions and expect.active_test_expectation_counter.actual == 0) {
const fmt = comptime "<d>expect.hasAssertions()<r>\n\nExpected <green>at least one assertion<r> to be called but <red>received none<r>.\n";
const error_value = if (Output.enable_ansi_colors)
globalThis.createErrorInstance(Output.prettyFmt(fmt, true), .{})
else
globalThis.createErrorInstance(Output.prettyFmt(fmt, false), .{});
this.handleResultPtr(&result, .sync);
globalThis.*.bunVM().runErrorHandler(error_value, null);
result = .{ .fail = 0 };
}
if (expect.is_expecting_assertions_count and expect.active_test_expectation_counter.actual != expect.expected_assertions_number) {
const fmt = comptime "<d>expect.assertions({})<r>\n\nExpected <green>{} assertion<r> to be called but <red>found {} assertions<r> instead.\n";
const fmt_args = .{ expect.expected_assertions_number, expect.expected_assertions_number, expect.active_test_expectation_counter.actual };
const error_value = if (Output.enable_ansi_colors)
globalThis.createErrorInstance(Output.prettyFmt(fmt, true), fmt_args)
else
globalThis.createErrorInstance(Output.prettyFmt(fmt, false), fmt_args);
globalThis.*.bunVM().runErrorHandler(error_value, null);
result = .{ .fail = expect.active_test_expectation_counter.actual };
}
this.handleResult(result, .sync);
if (result == .fail) {
if (result.isFailure()) {
globalThis.handleRejectedPromises();
}
@@ -1459,12 +1459,25 @@ pub const TestRunnerTask = struct {
};
pub fn handleResult(this: *TestRunnerTask, result: Result, from: ResultType) void {
var result_copy = result;
this.handleResultPtr(&result_copy, from);
}
fn continueRunningTestsAfterMicrotasksRun(this: *TestRunnerTask) void {
if (this.ref.has)
// Drain microtasks one more time.
// But don't hang forever.
// We report the test failure before that task is run.
this.globalThis.bunVM().enqueueTask(JSC.ManagedTask.New(@This(), deinit).init(this));
}
pub fn handleResultPtr(this: *TestRunnerTask, result: *Result, from: ResultType) void {
switch (from) {
.promise => {
if (comptime Environment.allow_assert) assert(this.promise_state == .pending);
this.promise_state = .fulfilled;
if (this.done_callback_state == .pending and result == .pass) {
if (this.done_callback_state == .pending and result.* == .pass) {
return;
}
},
@@ -1472,7 +1485,7 @@ pub const TestRunnerTask = struct {
if (comptime Environment.allow_assert) assert(this.done_callback_state == .pending);
this.done_callback_state = .fulfilled;
if (this.promise_state == .pending and result == .pass) {
if (this.promise_state == .pending and result.* == .pass) {
return;
}
},
@@ -1488,8 +1501,43 @@ pub const TestRunnerTask = struct {
this.deinit();
}
if (this.reported)
if (this.reported) {
// This covers the following scenario:
//
// test("foo", async done => {
// await Bun.sleep(42);
// throw new Error("foo");
// });
//
// The test will hang forever if we don't drain microtasks here.
//
// It is okay for this to be called multiple times, as it unrefs() the event loop once, and doesn't free memory.
if (result.* != .pass and this.promise_state != .pending and this.done_callback_state == .pending and this.sync_state == .fulfilled) {
this.continueRunningTestsAfterMicrotasksRun();
}
return;
}
// This covers the following scenario:
//
//
// test("foo", done => {
// setTimeout(() => {
// if (Math.random() > 0.5) {
// done();
// } else {
// throw new Error("boom");
// }
// }, 100);
// })
//
// It is okay for this to be called multiple times, as it unrefs() the event loop once, and doesn't free memory.
if (this.promise_state != .pending and this.sync_state != .pending and this.done_callback_state == .pending) {
// Drain microtasks one more time.
// But don't hang forever.
// We report the test failure before that task is run.
this.continueRunningTestsAfterMicrotasksRun();
}
this.reported = true;
@@ -1507,7 +1555,8 @@ pub const TestRunnerTask = struct {
_ = this.globalThis.bunVM().uncaughtException(this.globalThis, err, true);
}
processTestResult(this, this.globalThis, result, test_, test_id, describe);
checkAssertionsCounter(result);
processTestResult(this, this.globalThis, result.*, test_, test_id, describe);
}
fn processTestResult(this: *TestRunnerTask, globalThis: *JSGlobalObject, result: Result, test_: TestScope, test_id: u32, describe: *DescribeScope) void {
@@ -1528,6 +1577,33 @@ pub const TestRunnerTask = struct {
this.started_at.sinceNow(),
describe,
),
.fail_because_expected_has_assertions => {
Output.err(error.AssertionError, "received <red>0 assertions<r>, but expected <green>at least one assertion<r> to be called\n", .{});
Output.flush();
Jest.runner.?.reportFailure(
test_id,
this.source_file_path,
test_.label,
0,
this.started_at.sinceNow(),
describe,
);
},
.fail_because_expected_assertion_count => |counter| {
Output.err(error.AssertionError, "expected <green>{d} assertions<r>, but test ended with <red>{d} assertions<r>\n", .{
counter.expected,
counter.actual,
});
Output.flush();
Jest.runner.?.reportFailure(
test_id,
this.source_file_path,
test_.label,
counter.actual,
this.started_at.sinceNow(),
describe,
);
},
.skip => Jest.runner.?.reportSkip(test_id, this.source_file_path, test_.label, describe),
.todo => Jest.runner.?.reportTodo(test_id, this.source_file_path, test_.label, describe),
.fail_because_todo_passed => |count| {
@@ -1576,6 +1652,12 @@ pub const Result = union(TestRunner.Test.Status) {
skip: void,
todo: void,
fail_because_todo_passed: u32,
fail_because_expected_has_assertions: void,
fail_because_expected_assertion_count: Counter,
pub fn isFailure(this: *const Result) bool {
return this.* == .fail or this.* == .fail_because_expected_has_assertions or this.* == .fail_because_expected_assertion_count;
}
pub fn forceTODO(this: Result, is_todo: bool) Result {
if (is_todo and this == .pass)

View File

@@ -0,0 +1,30 @@
import { test, expect } from "bun:test";
import { tempDirWithFiles, bunEnv, bunExe } from "harness";
import path from "path";
test("done() causes the test to fail when it should", async () => {
const dir = tempDirWithFiles("done", {
"done.test.ts": await Bun.file(path.join(import.meta.dir, "done-infinity.fixture.ts")).text(),
"package.json": JSON.stringify({
name: "done",
version: "0.0.0",
scripts: {
test: "bun test",
},
}),
});
const $$ = new Bun.$.Shell();
$$.nothrow();
$$.cwd(dir);
$$.env(bunEnv);
const result = await $$`${bunExe()} test`;
console.log(result.stdout.toString());
console.log(result.stderr.toString());
expect(result.exitCode).toBe(1);
expect(result.stderr.toString()).toContain(" 7 fail\n");
expect(result.stderr.toString()).toContain(" 0 pass\n");
});

View File

@@ -0,0 +1,41 @@
import { test, expect } from "bun:test";
test("asynchronously failing test with a done callback does not hang", async done => {
await Bun.sleep(42);
throw new Error("Test failed successfully");
});
test("asynchronously failing test after a done callback is called does not hang", async done => {
await Bun.sleep(42);
done();
throw new Error("Test failed successfully");
});
test("synchronously failing test with an async done callback does not hang", async done => {
throw new Error("Test failed successfully");
});
test("done() with an unhandled exception ends the test", done => {
expect(true).toBe(true);
setTimeout(() => {
throw new Error("Test failed successfully");
});
});
test("exception inside setImmediate does not hang", done => {
setImmediate(() => {
throw new Error("Test failed successfully");
});
});
test("exception inside queueMicrotask does not hang", done => {
queueMicrotask(() => {
throw new Error("Test failed successfully");
});
});
test("exception inside process.nextTick does not hang", done => {
process.nextTick(() => {
throw new Error("Test failed successfully");
});
});

View File

@@ -0,0 +1,29 @@
test("expect.assertions DOES fail the test, sync", async () => {
expect.assertions(1);
});
test("expect.assertions DOES fail the test, async", async () => {
expect.assertions(1);
await new Promise(resolve => setTimeout(resolve, 1));
});
test("expect.assertions DOES fail the test, callback", done => {
expect.assertions(1);
process.nextTick(() => {
done();
});
});
test("expect.assertions DOES fail the test, setImmediate", done => {
expect.assertions(1);
setImmediate(() => {
done();
});
});
test("expect.assertions DOES fail the test, queueMicrotask", done => {
expect.assertions(1);
queueMicrotask(() => {
done();
});
});

View File

@@ -0,0 +1,30 @@
import { test, expect } from "bun:test";
import { tempDirWithFiles, bunEnv, bunExe } from "harness";
import path from "path";
test("expect.assertions causes the test to fail when it should", async () => {
const dir = tempDirWithFiles("expect-assertions", {
"expect-assertions.test.ts": await Bun.file(path.join(import.meta.dir, "expect-assertions-fixture.ts")).text(),
"package.json": JSON.stringify({
name: "expect-assertions",
version: "0.0.0",
scripts: {
test: "bun test",
},
}),
});
const $$ = new Bun.$.Shell();
$$.nothrow();
$$.cwd(dir);
$$.env(bunEnv);
const result = await $$`${bunExe()} test`;
console.log(result.stdout.toString());
console.log(result.stderr.toString());
expect(result.exitCode).toBe(1);
expect(result.stderr.toString()).toContain("5 fail\n");
expect(result.stderr.toString()).toContain("0 pass\n");
});

View File

@@ -807,7 +807,6 @@ describe("expect()", () => {
err.code = "ERR_BAZ";
throw err;
}).toThrow(expect.objectContaining({ code: "ERR_BAZ", name: "TypeError" }));
});
test("toThrow", () => {
@@ -4484,6 +4483,36 @@ describe("expect()", () => {
expect("a").toEqual("a");
});
test("expect.assertions doesn't throw when valid, async", async () => {
expect.assertions(1);
await new Promise(resolve => setTimeout(resolve, 1));
expect("a").toEqual("a");
});
test("expect.assertions doesn't throw when valid, callback", done => {
expect.assertions(1);
process.nextTick(() => {
expect("a").toEqual("a");
done();
});
});
test("expect.assertions doesn't throw when valid, setImmediate", done => {
expect.assertions(1);
setImmediate(() => {
expect("a").toEqual("a");
done();
});
});
test("expect.assertions doesn't throw when valid, queueMicrotask", done => {
expect.assertions(1);
queueMicrotask(() => {
expect("a").toEqual("a");
done();
});
});
test("expect.hasAssertions returns undefined", () => {
expect(expect.hasAssertions()).toBeUndefined();
});

View File

@@ -1,47 +0,0 @@
//#FILE: test-net-write-fully-async-hex-string.js
//#SHA1: e5b365bb794f38e7153fc41ebfaf991031f85423
//-----------------
'use strict';
// Flags: --expose-gc
// Regression test for https://github.com/nodejs/node/issues/8251.
const net = require('net');
const data = Buffer.alloc(1000000).toString('hex');
test('net write fully async hex string', (done) => {
const server = net.createServer((conn) => {
conn.resume();
}).listen(0, () => {
const conn = net.createConnection(server.address().port, () => {
let count = 0;
function writeLoop() {
if (count++ === 20) {
conn.destroy();
server.close();
done();
return;
}
while (conn.write(data, 'hex'));
global.gc({ type: 'minor' });
// The buffer allocated inside the .write() call should still be alive.
}
conn.on('drain', writeLoop);
writeLoop();
});
});
expect.assertions(2);
server.on('listening', () => {
expect(server.address().port).toBeGreaterThan(0);
});
server.on('connection', () => {
expect(true).toBe(true); // Connection established
});
});
//<#END_FILE: test-net-write-fully-async-hex-string.js