Compare commits

...

42 Commits

Author SHA1 Message Date
autofix-ci[bot]
6ec86dbe24 [autofix.ci] apply automated fixes 2025-10-15 21:05:03 +00:00
Marko Vejnovic
93b6ed3c1f test(ms): Add vercel/ms' set of tests 2025-10-15 14:02:33 -07:00
Michael H
dfed86e888 Merge branch 'main' into claude/add-bun-ms 2025-10-16 05:28:55 +11:00
RiskyMH
d4f678c6c7 Update install.md 2025-10-08 05:18:32 +11:00
RiskyMH
ff8d32332f Merge branch 'main' into claude/add-bun-ms 2025-10-08 05:18:18 +11:00
Michael H
8eb39f3531 Merge branch 'main' into claude/add-bun-ms 2025-10-07 14:28:16 +11:00
Alistair Smith
9451a8634e Merge branch 'main' into claude/add-bun-ms 2025-10-06 10:28:31 -07:00
RiskyMH
3336de6702 , 2025-10-06 22:28:05 +11:00
autofix-ci[bot]
63a67e91a7 [autofix.ci] apply automated fixes 2025-10-06 11:23:20 +00:00
RiskyMH
d81d16a055 docs 2025-10-06 22:21:38 +11:00
RiskyMH
2e0f9b8680 make min release age use this! 2025-10-06 21:51:26 +11:00
RiskyMH
a357f11ea1 Merge remote-tracking branch 'origin/main' into claude/add-bun-ms 2025-10-06 21:00:08 +11:00
RiskyMH
9c55050ee5 Merge branch 'main' of https://github.com/oven-sh/bun into claude/add-bun-ms 2025-10-04 09:45:17 +10:00
RiskyMH
079aa68a94 slightly better solution 2025-10-04 09:44:53 +10:00
RiskyMH
fd1e31b5bb some fixes 2025-10-03 16:17:44 +10:00
autofix-ci[bot]
2ea6a71244 [autofix.ci] apply automated fixes 2025-10-02 08:44:07 +00:00
Michael H
f15b358eee Merge branch 'main' into claude/add-bun-ms 2025-10-02 18:41:07 +10:00
RiskyMH
86802057fb . 2025-10-02 18:40:56 +10:00
RiskyMH
83815e388d allow inlining from import too (pretend to be a macro) 2025-10-02 17:59:35 +10:00
RiskyMH
bace521b69 improve perf 2025-10-02 12:57:41 +10:00
RiskyMH
f0e6a89e59 align closer to npm:ms@nightly 2025-10-02 11:01:02 +10:00
RiskyMH
086dc1c860 . 2025-10-02 10:14:41 +10:00
RiskyMH
33e8e9bd50 use canary ms to be more fair 2025-10-02 10:08:32 +10:00
RiskyMH
2d892b5eb7 fix tests 2025-10-02 10:04:00 +10:00
Dylan Conway
8b8c845708 oops 2025-10-01 16:56:18 -07:00
RiskyMH
f0be4443be , 2025-10-02 09:47:22 +10:00
RiskyMH
0abed6e575 . 2025-10-02 09:47:08 +10:00
RiskyMH
05c3a13366 coderabbit 2025-10-02 09:46:08 +10:00
Dylan Conway
1d95405da8 oops 2025-10-01 16:38:12 -07:00
Dylan Conway
548a4f7859 extern string, comptime string map 2025-10-01 16:33:08 -07:00
RiskyMH
709b8b9803 fix rounding 2025-10-02 01:45:37 +10:00
RiskyMH
30f045dd41 add bench 2025-10-02 01:09:09 +10:00
RiskyMH
6d1d14663b better 2025-10-02 00:27:07 +10:00
Claude Bot
0ba7624824 Inline number literals too! Bun.ms(1000) → "1s" at compile time
Now the bundler inlines BOTH directions:
- String → Number: Bun.ms("1s") → 1000
- Number → String: Bun.ms(1000) → "1s"
- With options: Bun.ms(60000, { long: true }) → "1 minute"

This means ZERO runtime overhead for all constant Bun.ms() calls!

Updated test comments to reflect that number inputs ARE inlined.
All 138 tests passing 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 13:14:10 +00:00
Claude Bot
f6d326d136 Inline invalid strings to NaN and add dynamic value tests
Bundler improvements:
- Invalid string literals now inline to NaN (Bun.ms("invalid") → NaN)
- Empty strings inline to NaN (Bun.ms("") → NaN)
- Number inputs correctly preserved for runtime formatting

Added runtime tests for dynamic values:
- Dynamic string concatenation
- Template literals with function calls
- Variable strings
- Dynamic number formatting

All 138 tests passing 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 13:05:57 +00:00
Claude Bot
d85fd983f9 Implement bundler compile-time inlining for Bun.ms() string literals
When minify.syntax is enabled, Bun.ms("literal") calls are now inlined
to their numeric values at build time:

- Bun.ms("1s") → 1000
- Bun.ms("1m") → 60000
- Bun.ms("2d") → 172800000

Implementation:
- Added compile-time folding in visitExpr.zig e_call visitor
- Reuses existing parse() function from ms.zig
- Only inlines string literals, preserves dynamic values
- Works with case-insensitive units, decimals, negatives

This optimization activates with --minify or when bundling with
syntax minification enabled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:55:57 +00:00
Claude Bot
339a90ac1a Add TypeScript type integration tests for Bun.ms
Verify type checking works correctly:
- String literal autocomplete for time units
- Correct return types (number for strings, string for numbers)
- Options parameter only allowed with number input
- All unit variations type-check correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:45:37 +00:00
Claude Bot
33eac619ef Add months support, return NaN for invalid inputs, comprehensive tests
- Added month/months/mo unit support (30.4375 days average)
- Return NaN instead of undefined for invalid string inputs
- Throw errors for NaN/Infinity number inputs
- Complete test coverage matching vercel/ms library (133 tests)
- Updated TypeScript types with string literal autocomplete
- All test cases from vercel/ms parse, format, and index tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:43:13 +00:00
Claude Bot
b00bf53cb4 Use std.time constants and fix bundler test
- Replace custom constants with std.time.ms_per_* directly
- Rewrite bundler test to use Bun.build API with inline snapshot
- Remove external snapshot file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:17:14 +00:00
Claude Bot
0972365627 Use std.time constants instead of hardcoded values
Replace manual time constant calculations with std.time.ns_per_*
constants from the Zig standard library.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:09:24 +00:00
Claude Bot
bbf04de639 Fix bundler test to snapshot output instead of running code
The bundler test now checks the actual bundled output with snapshots
rather than executing the bundled code. This verifies Bun.ms works
in bundled code and sets a baseline for future compile-time inlining.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:03:08 +00:00
Claude Bot
6bf431d707 Add Bun.ms for time string parsing and formatting
Implements Bun.ms as a drop-in replacement for the npm ms library.
Parses time strings like "2d", "1.5h", "5m" to milliseconds and formats
numbers back to human-readable time strings.

- Implemented in Zig for reusability with future bundler optimizations
- Supports all time units: ms, s, m, h, d, w, y with variations
- Case-insensitive parsing with whitespace handling
- Formats with short ("1m") and long ("1 minute") options
- Comprehensive test coverage with 226 runtime + 1 bundler test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:01:03 +00:00
23 changed files with 1530 additions and 72 deletions

View File

@@ -18,6 +18,7 @@
"fastify": "^5.0.0",
"fdir": "^6.1.0",
"mitata": "^1.0.25",
"ms": "^4.0.0-nightly.202508271359",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"string-width": "7.1.0",
@@ -363,7 +364,7 @@
"mitata": ["mitata@1.0.25", "", {}, "sha512-0v5qZtVW5vwj9FDvYfraR31BMDcRLkhSFWPTLaxx/Z3/EvScfVtAAWtMI2ArIbBcwh7P86dXh0lQWKiXQPlwYA=="],
"ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"ms": ["ms@4.0.0-nightly.202508271359", "", {}, "sha512-WC/Eo7NzFrOV/RRrTaI0fxKVbNCzEy76j2VqNV8SxDf9D69gSE2Lh0QwYvDlhiYmheBYExAvEAxVf5NoN0cj2A=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
@@ -497,6 +498,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"fastify/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],

81
bench/ms/ms.mjs Normal file
View File

@@ -0,0 +1,81 @@
import { ms } from "ms";
import { bench, group, run } from "../runner.mjs";
const stringInputs = ["1s", "1m", "1h", "1d", "1w", "1y", "2 days", "10h", "2.5 hrs", "1.5h", "100ms"];
const numberInputs = [1000, 60000, 3600000, 86400000, 604800000];
if (!process.argv.includes("--full")) {
const str = ["1.5y"];
if (typeof Bun === "undefined") {
bench("ms (npm)", () => {
ms(str[0]);
});
} else {
bench("Bun.ms", () => {
Bun.ms(str[0]);
});
bench("Bun.ms (statically inlined)", () => {
Bun.ms("1.5y")
})
}
} else {
if (typeof Bun == "undefined" || process.argv.includes("--both")) {
group("ms (npm)", () => {
bench(`${stringInputs.length + numberInputs.length} inputs`, () => {
for (const input of stringInputs) {
ms(input);
}
for (const num of numberInputs) {
ms(num);
}
});
bench("string -> num", () => {
ms(stringInputs[0]);
});
bench("num -> string", () => {
ms(numberInputs[0]);
});
});
}
if (typeof Bun != "undefined") {
group("Bun.ms", () => {
bench(`${stringInputs.length + numberInputs.length} inputs`, () => {
for (const input of stringInputs) {
Bun.ms(input);
}
for (const num of numberInputs) {
Bun.ms(num);
}
});
bench("string -> num", () => {
Bun.ms(stringInputs[0]);
});
bench("num -> string", () => {
Bun.ms(numberInputs[0]);
});
bench("statically inlined", () => {
Bun.ms("1s");
Bun.ms("1m");
Bun.ms("1h");
Bun.ms("1d");
Bun.ms("1w");
Bun.ms("1y");
Bun.ms("2 days");
Bun.ms("10h");
Bun.ms("2.5 hrs");
Bun.ms("1.5h");
Bun.ms("100ms");
Bun.ms(1000);
Bun.ms(60000);
Bun.ms(3600000);
Bun.ms(86400000);
Bun.ms(604800000);
});
});
}
}
await run();

View File

@@ -15,6 +15,7 @@
"fastify": "^5.0.0",
"fdir": "^6.1.0",
"mitata": "^1.0.25",
"ms": "^4.0.0-nightly.202508271359",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"string-width": "7.1.0",

View File

@@ -14,6 +14,4 @@ export function run(opts = {}) {
export const bench = Mitata.bench;
export function group(_name, fn) {
return Mitata.group(fn);
}
export const group = Mitata.group;

View File

@@ -1,5 +1,5 @@
[test]
# Large monorepos (like Bun) may want to specify the test directory more specifically
# Large monorepos (like Bun) may want to specify the test directory more specifically
# By default, `bun test` scans every single folder recursively which, if you
# have a gigantic submodule (like WebKit), requires lots of directory
# traversals
@@ -10,4 +10,4 @@ preload = "./test/preload.ts"
[install]
linker = "isolated"
minimumReleaseAge = 1
minimumReleaseAge = 86400

View File

@@ -449,6 +449,51 @@ namespace Bun {
}
```
## `Bun.ms`
Built-in alternative to the [`ms`](https://npmjs.com/package/ms) package. Convert between human-readable time strings and milliseconds. Automatically optimized at compile-time when bundling.
**Convert to Milliseconds**
```ts
Bun.ms("2 days"); // 172800000
Bun.ms("1d"); // 86400000
Bun.ms("10h"); // 36000000
Bun.ms("2.5 hrs"); // 9000000
Bun.ms("2h"); // 7200000
Bun.ms("1m"); // 60000
Bun.ms("5s"); // 5000
Bun.ms("1y"); // 31557600000
Bun.ms("100"); // 100
Bun.ms("-3 days"); // -259200000
Bun.ms("-1h"); // -3600000
Bun.ms("-200"); // -200
```
**Convert from Milliseconds**
```ts
Bun.ms(60000); // "1m"
Bun.ms(2 * 60000); // "2m"
Bun.ms(-3 * 60000); // "-3m"
Bun.ms(Bun.ms("10 hours")); // "10h"
Bun.ms(60000, { long: true }); // "1 minute"
Bun.ms(2 * 60000, { long: true }); // "2 minutes"
Bun.ms(-3 * 60000, { long: true }); // "-3 minutes"
Bun.ms(Bun.ms("10 hours"), { long: true }); // "10 hours"
```
**Bundler Support**
```sh
$ cat ./example.ts
console.log(Bun.ms("2d"));
$ bun build ./example.ts --minify-syntax
console.log(172800000);
```
<!-- ## `Bun.enableANSIColors()` -->
## `Bun.fileURLToPath()`

View File

@@ -223,19 +223,19 @@ For complete documentation refer to [Package manager > Global cache](https://bun
## Minimum release age
To protect against supply chain attacks where malicious packages are quickly published, you can configure a minimum age requirement for npm packages. Package versions published more recently than the specified threshold (in seconds) will be filtered out during installation.
To protect against supply chain attacks where malicious packages are quickly published, you can configure a minimum age requirement for npm packages. Package versions published more recently than the specified threshold will be filtered out during installation.
```bash
# Only install package versions published at least 3 days ago
$ bun add @types/bun --minimum-release-age 259200 # seconds
$ bun add @types/bun --minimum-release-age 3d
```
You can also configure this in `bunfig.toml`:
```toml
```toml#bunfig.toml
[install]
# Only install package versions published at least 3 days ago
minimumReleaseAge = 259200 # seconds
minimumReleaseAge = "3d"
# Exclude trusted packages from the age gate
minimumReleaseAgeExcludes = ["@types/node", "typescript"]
@@ -257,7 +257,7 @@ For more advanced security scanning, including integration with services & custo
The default behavior of `bun install` can be configured in `bunfig.toml`. The default values are shown below.
```toml
```toml#bunfig.toml
[install]
# whether to install optionalDependencies
@@ -289,7 +289,7 @@ concurrentScripts = 16 # (cpu count or GOMAXPROCS) x2
linker = "hoisted"
# minimum age config
minimumReleaseAge = 259200 # seconds
minimumReleaseAge = "3d"
minimumReleaseAgeExcludes = ["@types/node", "typescript"]
```

View File

@@ -612,12 +612,12 @@ Valid values are:
### `install.minimumReleaseAge`
Configure a minimum age (in seconds) for npm package versions. Package versions published more recently than this threshold will be filtered out during installation. Default is `null` (disabled).
Configure a minimum age for npm package versions. Package versions published more recently than this threshold will be filtered out during installation. Default is `null` (disabled).
```toml
[install]
# Only install package versions published at least 3 days ago
minimumReleaseAge = 259200
minimumReleaseAge = "3d"
# These packages will bypass the 3-day minimum age requirement
minimumReleaseAgeExcludes = ["@types/bun", "typescript"]
```

View File

@@ -3753,6 +3753,43 @@ declare module "bun" {
*/
function sleepSync(ms: number): void;
/**
* Parse a time string and return milliseconds, or format milliseconds as a string.
*
* Drop-in replacement for the `ms` npm package with compile-time inlining support.
*
* @example
* ```ts
* Bun.ms("2d") // 172800000
* Bun.ms("1.5h") // 5400000
* Bun.ms("1m") // 60000
* Bun.ms("5s") // 5000
* Bun.ms("100ms") // 100
* Bun.ms("1mo") // 2629800000
* Bun.ms("1y") // 31557600000
* Bun.ms("100") // 100
* Bun.ms(60000) // "1m"
* Bun.ms(60000, { long: true }) // "1 minute"
* Bun.ms("invalid") // NaN
* ```
*
* Supports these units:
* - `ms`, `millisecond`, `milliseconds`
* - `s`, `sec`, `second`, `seconds`
* - `m`, `min`, `minute`, `minutes`
* - `h`, `hr`, `hour`, `hours`
* - `d`, `day`, `days`
* - `w`, `week`, `weeks`
* - `mo`, `month`, `months`
* - `y`, `yr`, `year`, `years`
*
* @param value - Time string to parse or milliseconds to format
* @param options - Formatting options (when value is a number)
* @returns Milliseconds (for string) or formatted string (for number). Returns NaN for invalid strings.
*/
function ms(value: string): number;
function ms(value: number, options?: { long?: boolean }): string;
/**
* Hash `input` using [SHA-2 512/256](https://en.wikipedia.org/wiki/SHA-2#Comparison_of_SHA_functions)
*

View File

@@ -2771,6 +2771,21 @@ pub fn NewParser_(
}
}
if (comptime allow_macros) {
// Track {ms} from "bun" as it will 99% of the time be statically known
// so might as well make it a macro automatically
if (strings.eqlComptime(path.text, "bun") and strings.eqlComptime(item.alias, "ms")) {
try p.macro.refs.put(ref, .{
.import_record_id = stmt.import_record_index,
.name = item.alias,
});
const import_record = &p.import_records.items[stmt.import_record_index];
if (import_record.tag == .none) {
import_record.tag = .bun;
}
}
}
if (macro_remap) |*remap| {
if (remap.get(item.alias)) |remapped_path| {
const new_import_id = p.addImportRecord(.stmt, path.loc, remapped_path);

View File

@@ -1387,6 +1387,13 @@ pub fn VisitExpr(
const name = macro_ref_data.name orelse e_.target.data.e_dot.name;
const record = &p.import_records.items[macro_ref_data.import_record_id];
// Special case: import { ms } from "bun" - inline instead of executing as macro
if (record.tag == .bun and strings.eqlComptime(name, "ms")) {
const res = bun.handleOom(bun.api.ms.astFunction(p, e_, expr.loc));
return res orelse expr;
}
const copied = Expr{ .loc = expr.loc, .data = .{ .e_call = e_ } };
const start_error_count = p.log.msgs.items.len;
p.macro_call_count += 1;
@@ -1497,6 +1504,19 @@ pub fn VisitExpr(
}
};
// Constant folding for Bun.ms("1s") -> 1000 and Bun.ms(1000) -> "1s"
if (p.should_fold_typescript_constant_expressions or p.options.features.inlining) {
if (e_.target.data.as(.e_dot)) |dot| {
if (dot.target.data == .e_identifier and strings.eqlComptime(dot.name, "ms")) {
const symbol = &p.symbols.items[dot.target.data.e_identifier.ref.innerIndex()];
if (symbol.kind == .unbound and strings.eqlComptime(symbol.original_name, "Bun")) {
const res = bun.handleOom(bun.api.ms.astFunction(p, e_, expr.loc));
if (res) |r| return r;
}
}
}
}
return expr;
}
pub fn e_new(p: *P, expr: Expr, _: ExprIn) Expr {

View File

@@ -51,6 +51,7 @@ pub const UDPSocket = @import("./api/bun/udp_socket.zig").UDPSocket;
pub const Valkey = @import("../valkey/js_valkey.zig").JSValkeyClient;
pub const BlockList = @import("./node/net/BlockList.zig");
pub const NativeZstd = @import("./node/zlib/NativeZstd.zig");
pub const ms = @import("./api/bun/ms.zig");
pub const napi = @import("../napi/napi.zig");
pub const node = @import("./node.zig");

View File

@@ -25,6 +25,7 @@ pub const BunObject = struct {
pub const jest = toJSCallback(@import("../test/jest.zig").Jest.call);
pub const listen = toJSCallback(host_fn.wrapStaticMethod(api.Listener, "listen", false));
pub const mmap = toJSCallback(Bun.mmapFile);
pub const ms = toJSCallback(api.ms.jsFunction);
pub const nanoseconds = toJSCallback(Bun.nanoseconds);
pub const openInEditor = toJSCallback(Bun.openInEditor);
pub const registerMacro = toJSCallback(Bun.registerMacro);
@@ -161,6 +162,7 @@ pub const BunObject = struct {
@export(&BunObject.jest, .{ .name = callbackName("jest") });
@export(&BunObject.listen, .{ .name = callbackName("listen") });
@export(&BunObject.mmap, .{ .name = callbackName("mmap") });
@export(&BunObject.ms, .{ .name = callbackName("ms") });
@export(&BunObject.nanoseconds, .{ .name = callbackName("nanoseconds") });
@export(&BunObject.openInEditor, .{ .name = callbackName("openInEditor") });
@export(&BunObject.registerMacro, .{ .name = callbackName("registerMacro") });

308
src/bun.js/api/bun/ms.zig Normal file
View File

@@ -0,0 +1,308 @@
/// Parse a time string like "2d", "1.5h", "5m" to milliseconds
pub fn parse(input: []const u8) ?f64 {
if (input.len == 0 or input.len > 100) return null;
var i: usize = 0;
next: switch (input[i]) {
'-', '.', '0'...'9' => {
i += 1;
if (i < input.len) continue :next input[i];
break :next;
},
' ', 'a'...'z', 'A'...'Z' => break :next,
else => return null,
}
const value = std.fmt.parseFloat(f64, input[0..i]) catch return null;
const unit = strings.trimLeadingChar(input[i..], ' ');
if (unit.len == 0) return value;
if (MultiplierMap.getASCIIICaseInsensitive(unit)) |m| {
return value * m;
}
return null;
}
// Years (365.25 days to account for leap years)
// (matching the `ms` package implementation)
const ms_per_year = std.time.ms_per_day * 365.25;
const ms_per_month = std.time.ms_per_day * (365.25 / 12.0);
const MultiplierMap = bun.ComptimeStringMap(f64, .{
// Years (365.25 days to account for leap years)
.{ "y", ms_per_year },
.{ "yr", ms_per_year },
.{ "yrs", ms_per_year },
.{ "year", ms_per_year },
.{ "years", ms_per_year },
// Months (30.4375 days average)
.{ "mo", ms_per_month },
.{ "month", ms_per_month },
.{ "months", ms_per_month },
// Weeks
.{ "w", std.time.ms_per_week },
.{ "week", std.time.ms_per_week },
.{ "weeks", std.time.ms_per_week },
// Days
.{ "d", std.time.ms_per_day },
.{ "day", std.time.ms_per_day },
.{ "days", std.time.ms_per_day },
// Hours
.{ "h", std.time.ms_per_hour },
.{ "hr", std.time.ms_per_hour },
.{ "hrs", std.time.ms_per_hour },
.{ "hour", std.time.ms_per_hour },
.{ "hours", std.time.ms_per_hour },
// Minutes
.{ "m", std.time.ms_per_min },
.{ "min", std.time.ms_per_min },
.{ "mins", std.time.ms_per_min },
.{ "minute", std.time.ms_per_min },
.{ "minutes", std.time.ms_per_min },
// Seconds
.{ "s", std.time.ms_per_s },
.{ "sec", std.time.ms_per_s },
.{ "secs", std.time.ms_per_s },
.{ "second", std.time.ms_per_s },
.{ "seconds", std.time.ms_per_s },
// Milliseconds
.{ "ms", 1 },
.{ "msec", 1 },
.{ "msecs", 1 },
.{ "millisecond", 1 },
.{ "milliseconds", 1 },
});
// To keep the behavior consistent with JavaScript, we can't use @round
// Zig's @round uses "round half away from zero": ties round away from zero (2.5→3, -2.5→-3)
// JavaScript's Math.round uses "round half toward +∞": ties round toward positive infinity (2.5→3, -2.5→-2)
fn jsMathRound(x: f64) i64 {
const i: f64 = @ceil(x);
if ((i - 0.5) > x) return @intFromFloat(i - 1.0);
return @intFromFloat(i);
}
/// Format milliseconds to a human-readable string
pub fn format(allocator: std.mem.Allocator, ms: f64, long: bool) ![]u8 {
const abs_ms = @abs(ms);
// Years
if (abs_ms >= ms_per_year) {
const years = jsMathRound(ms / ms_per_year);
if (long) {
const plural = abs_ms >= ms_per_year * 1.5;
return std.fmt.allocPrint(allocator, "{d} year{s}", .{ years, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}y", .{years});
}
// Months
if (abs_ms >= ms_per_month) {
const months = jsMathRound(ms / ms_per_month);
if (long) {
const plural = abs_ms >= ms_per_month * 1.5;
return std.fmt.allocPrint(allocator, "{d} month{s}", .{ months, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}mo", .{months});
}
// Weeks
if (abs_ms >= std.time.ms_per_week) {
const weeks = jsMathRound(ms / std.time.ms_per_week);
if (long) {
const plural = abs_ms >= std.time.ms_per_week * 1.5;
return std.fmt.allocPrint(allocator, "{d} week{s}", .{ weeks, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}w", .{weeks});
}
// Days
if (abs_ms >= std.time.ms_per_day) {
const days = jsMathRound(ms / std.time.ms_per_day);
if (long) {
const plural = abs_ms >= std.time.ms_per_day * 1.5;
return std.fmt.allocPrint(allocator, "{d} day{s}", .{ days, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}d", .{days});
}
// Hours
if (abs_ms >= std.time.ms_per_hour) {
const hours = jsMathRound(ms / std.time.ms_per_hour);
if (long) {
const plural = abs_ms >= std.time.ms_per_hour * 1.5;
return std.fmt.allocPrint(allocator, "{d} hour{s}", .{ hours, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}h", .{hours});
}
// Minutes
if (abs_ms >= std.time.ms_per_min) {
const minutes = jsMathRound(ms / std.time.ms_per_min);
if (long) {
const plural = abs_ms >= std.time.ms_per_min * 1.5;
return std.fmt.allocPrint(allocator, "{d} minute{s}", .{ minutes, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}m", .{minutes});
}
// Seconds
if (abs_ms >= std.time.ms_per_s) {
const seconds = jsMathRound(ms / std.time.ms_per_s);
if (long) {
const plural = abs_ms >= std.time.ms_per_s * 1.5;
return std.fmt.allocPrint(allocator, "{d} second{s}", .{ seconds, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}s", .{seconds});
}
// Milliseconds
const ms_int: i64 = @intFromFloat(ms);
if (long) {
return std.fmt.allocPrint(allocator, "{d} ms", .{ms_int});
}
return std.fmt.allocPrint(allocator, "{d}ms", .{ms_int});
}
// Same as other format ms, but this is long and doesn't round
// `ms` package doesn't have this, but it's more useful for some internal bun things
pub fn formatLong(allocator: std.mem.Allocator, ms: f64) ![]u8 {
const abs_ms = @abs(ms);
// Years
if (abs_ms >= ms_per_year) {
const years = abs_ms / ms_per_year;
return std.fmt.allocPrint(allocator, "{d} year{s}", .{ years, if (years != 1) "s" else "" });
}
// Months
if (abs_ms >= ms_per_month) {
const months = abs_ms / ms_per_month;
return std.fmt.allocPrint(allocator, "{d} month{s}", .{ months, if (months != 1) "s" else "" });
}
// Weeks
if (abs_ms >= std.time.ms_per_week) {
const weeks = abs_ms / std.time.ms_per_week;
return std.fmt.allocPrint(allocator, "{d} week{s}", .{ weeks, if (weeks != 1) "s" else "" });
}
// Days
if (abs_ms >= std.time.ms_per_day) {
const days = abs_ms / std.time.ms_per_day;
return std.fmt.allocPrint(allocator, "{d} day{s}", .{ days, if (days != 1) "s" else "" });
}
// Hours
if (abs_ms >= std.time.ms_per_hour) {
const hours = abs_ms / std.time.ms_per_hour;
return std.fmt.allocPrint(allocator, "{d} hour{s}", .{ hours, if (hours != 1) "s" else "" });
}
// Minutes
if (abs_ms >= std.time.ms_per_min) {
const minutes = abs_ms / std.time.ms_per_min;
return std.fmt.allocPrint(allocator, "{d} minute{s}", .{ minutes, if (minutes != 1) "s" else "" });
}
// Seconds
if (abs_ms >= std.time.ms_per_s) {
const seconds = abs_ms / std.time.ms_per_s;
return std.fmt.allocPrint(allocator, "{d} second{s}", .{ seconds, if (seconds != 1) "s" else "" });
}
// Milliseconds
return std.fmt.allocPrint(allocator, "{d} ms", .{abs_ms});
}
/// JavaScript function: Bun.ms(value, options?)
pub fn jsFunction(
globalThis: *JSGlobalObject,
callframe: *jsc.CallFrame,
) JSError!jsc.JSValue {
const input, const options = callframe.argumentsAsArray(2);
// If input is a number, format it to a string
if (input.isNumber()) {
const ms_value = input.asNumber();
if (std.math.isNan(ms_value) or std.math.isInf(ms_value)) {
return globalThis.throwInvalidArguments("Value must be a finite number", .{});
}
var long = false;
if (options.isObject()) {
if (try options.get(globalThis, "long")) |long_value| {
long = long_value.toBoolean();
}
}
const result = try format(bun.default_allocator, ms_value, long);
var str = String.createExternalGloballyAllocated(.latin1, result);
return str.transferToJS(globalThis);
}
// If input is a string, parse it to milliseconds
if (input.isString()) {
const str = try input.toSlice(globalThis, bun.default_allocator);
defer str.deinit();
const result = parse(str.slice()) orelse std.math.nan(f64);
return JSValue.jsNumber(result);
}
return globalThis.throwInvalidArguments("Bun.ms() expects a string or number", .{});
}
// Bundler macro inlining for Bun.ms
pub fn astFunction(p: anytype, e_: *const E.Call, loc: logger.Loc) !?Expr {
if (e_.args.len == 0) return null;
const arg = e_.args.at(0).unwrapInlined();
if (arg.asString(p.allocator)) |str| {
const ms_value = parse(str) orelse std.math.nan(f64);
return p.newExpr(E.Number{ .value = ms_value }, loc);
}
if (arg.asNumber()) |num| {
if (std.math.isNan(num) or std.math.isInf(num)) return null;
var long = false;
if (e_.args.len >= 2) {
const opts = e_.args.at(1).unwrapInlined();
if (opts.getBoolean("long")) |b| {
long = b;
}
}
const formatted = try format(p.allocator, num, long);
return p.newExpr(E.String.init(formatted), loc);
}
return null;
}
const std = @import("std");
const bun = @import("bun");
const JSError = bun.JSError;
const String = bun.String;
const logger = bun.logger;
const strings = bun.strings;
const E = bun.ast.E;
const Expr = bun.ast.Expr;
const jsc = bun.jsc;
const JSGlobalObject = jsc.JSGlobalObject;
const JSValue = jsc.JSValue;

View File

@@ -55,6 +55,7 @@
macro(jest) \
macro(listen) \
macro(mmap) \
macro(ms) \
macro(nanoseconds) \
macro(openInEditor) \
macro(registerMacro) \

View File

@@ -759,6 +759,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
udpSocket BunObject_callback_udpSocket DontDelete|Function 1
main bunObjectMain DontDelete|CustomAccessor
mmap BunObject_callback_mmap DontDelete|Function 1
ms BunObject_callback_ms DontDelete|Function 2
nanoseconds functionBunNanoseconds DontDelete|Function 0
openInEditor BunObject_callback_openInEditor DontDelete|Function 1
origin BunObject_lazyPropCb_wrap_origin DontEnum|ReadOnly|DontDelete|PropertyCallback

View File

@@ -702,8 +702,23 @@ pub const Bunfig = struct {
}
install.minimum_release_age_ms = days.value * std.time.ms_per_s;
},
.e_string => |str| {
const str_value = str.string(allocator) catch {
try this.addError(min_age.loc, "Failed to parse minimumReleaseAge");
return;
};
if (bun.api.ms.parse(str_value)) |ms| {
if (ms < 0) {
try this.addError(min_age.loc, "Expected positive age (e.g 3d, 24h) for minimumReleaseAge");
return;
}
install.minimum_release_age_ms = ms;
} else {
try this.addError(min_age.loc, "Expected positive age (e.g 3d, 24h) for minimumReleaseAge");
}
},
else => {
try this.addError(min_age.loc, "Expected number of seconds for minimumReleaseAge");
try this.addError(min_age.loc, "Expected positive age (e.g 3d, 24h) for minimumReleaseAge");
},
}
}

View File

@@ -50,7 +50,7 @@ const shared_params = [_]ParamType{
clap.parseParam("--omit <dev|optional|peer>... Exclude 'dev', 'optional', or 'peer' dependencies from install") catch unreachable,
clap.parseParam("--lockfile-only Generate a lockfile without installing dependencies") catch unreachable,
clap.parseParam("--linker <STR> Linker strategy (one of \"isolated\" or \"hoisted\")") catch unreachable,
clap.parseParam("--minimum-release-age <NUM> Only install packages published at least N seconds ago (security feature)") catch unreachable,
clap.parseParam("--minimum-release-age <STR> Only resolve package versions that are at least this old (e.g., 3d, 24h)") catch unreachable,
clap.parseParam("--cpu <STR>... Override CPU architecture for optional dependencies (e.g., x64, arm64, * for all)") catch unreachable,
clap.parseParam("--os <STR>... Override operating system for optional dependencies (e.g., linux, darwin, * for all)") catch unreachable,
clap.parseParam("-h, --help Print this help menu") catch unreachable,
@@ -836,13 +836,22 @@ pub fn parse(allocator: std.mem.Allocator, comptime subcommand: Subcommand) !Com
cli.save_text_lockfile = true;
}
if (args.option("--minimum-release-age")) |min_age_secs| {
if (args.option("--minimum-release-age")) |min_age_secs| brk: {
const secs = std.fmt.parseFloat(f64, min_age_secs) catch {
Output.errGeneric("Expected --minimum-release-age to be a positive number: {s}", .{min_age_secs});
Global.crash();
if (bun.api.ms.parse(min_age_secs)) |ms| {
if (ms < 0) {
Output.errGeneric("Expected --minimum-release-age to be a positive age (e.g 3d, 24h): {s}", .{min_age_secs});
Global.crash();
}
cli.minimum_release_age_ms = ms;
break :brk;
} else {
Output.errGeneric("Expected --minimum-release-age to be a positive number of seconds: {s}", .{min_age_secs});
Global.crash();
}
};
if (secs < 0) {
Output.errGeneric("Expected --minimum-release-age to be a positive number: {s}", .{min_age_secs});
Output.errGeneric("Expected --minimum-release-age to be a positive age (e.g 3d, 24h): {s}", .{min_age_secs});
Global.crash();
}
cli.minimum_release_age_ms = secs * std.time.ms_per_s;

View File

@@ -591,17 +591,19 @@ pub fn enqueueDependencyWithMainAndSuccessFn(
err,
);
} else {
const age_gate_ms = this.options.minimum_release_age_ms orelse 0;
const age_gate = bun.handleOom(bun.api.ms.formatLong(this.allocator, this.options.minimum_release_age_ms orelse 0));
defer this.allocator.free(age_gate);
if (version.tag == .dist_tag) {
this.log.addErrorFmt(
null,
logger.Loc.Empty,
this.allocator,
"Package \"{s}\" with tag \"{s}\" not found<r> <d>(all versions blocked by minimum-release-age: {d} seconds)<r>",
"Package \"{s}\" with tag \"{s}\" not found<r> <d>(all versions blocked by minimum-release-age: {s})<r>",
.{
this.lockfile.str(&name),
this.lockfile.str(&version.value.dist_tag.tag),
age_gate_ms / std.time.ms_per_s,
age_gate,
},
) catch unreachable;
} else {
@@ -609,11 +611,11 @@ pub fn enqueueDependencyWithMainAndSuccessFn(
null,
logger.Loc.Empty,
this.allocator,
"No version matching \"{s}\" found for specifier \"{s}\"<r> <d>(blocked by minimum-release-age: {d} seconds)<r>",
"No version matching \"{s}\" found for specifier \"{s}\"<r> <d>(blocked by minimum-release-age: {s})<r>",
.{
this.lockfile.str(&name),
this.lockfile.str(&version.literal),
age_gate_ms / std.time.ms_per_s,
age_gate,
},
) catch unreachable;
}
@@ -1637,26 +1639,28 @@ fn getOrPutResolvedPackage(
const package_name = this.lockfile.str(&name);
if (this.options.log_level.isVerbose()) {
if (filtered.newest_filtered) |newest| {
const min_age_seconds = (this.options.minimum_release_age_ms orelse 0) / std.time.ms_per_s;
const min_age_gate = bun.handleOom(bun.api.ms.formatLong(this.allocator, this.options.minimum_release_age_ms orelse 0));
defer this.allocator.free(min_age_gate);
switch (version.tag) {
.dist_tag => {
const tag_str = this.lockfile.str(&version.value.dist_tag.tag);
Output.prettyErrorln("<d>[minimum-release-age]<r> <b>{s}@{s}<r> selected <green>{s}<r> instead of <yellow>{s}<r> due to {d}-second filter", .{
Output.prettyErrorln("<d>[minimum-release-age]<r> <b>{s}@{s}<r> selected <green>{s}<r> instead of <yellow>{s}<r> due to {s} filter", .{
package_name,
tag_str,
filtered.result.version.fmt(manifest.string_buf),
newest.fmt(manifest.string_buf),
min_age_seconds,
min_age_gate,
});
},
.npm => {
const version_str = version.value.npm.version.fmt(manifest.string_buf);
Output.prettyErrorln("<d>[minimum-release-age]<r> <b>{s}<r>@{s}<r> selected <green>{s}<r> instead of <yellow>{s}<r> due to {d}-second filter", .{
Output.prettyErrorln("<d>[minimum-release-age]<r> <b>{s}<r>@{s}<r> selected <green>{s}<r> instead of <yellow>{s}<r> due to {s} filter", .{
package_name,
version_str,
filtered.result.version.fmt(manifest.string_buf),
newest.fmt(manifest.string_buf),
min_age_seconds,
min_age_gate,
});
},
else => unreachable,

View File

@@ -828,7 +828,30 @@ describe("minimum-release-age", () => {
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
const lockfile = await Bun.file(`${dir}/bun.lock`).text();
expect(lockfile).toContain("regular-package@2.1.0");
expect(lockfile).not.toContain("regular-package@3.0.0");
});
test("filters packages by minimum release age (numeric seconds form)", async () => {
using dir = tempDir("basic-filter-seconds", {
"package.json": JSON.stringify({
dependencies: { "regular-package": "*" },
}),
".npmrc": `registry=${mockRegistryUrl}`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", "432000" /* 5d */, "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -851,7 +874,7 @@ describe("minimum-release-age", () => {
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${9.5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "9.5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -877,7 +900,7 @@ describe("minimum-release-age", () => {
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -905,7 +928,7 @@ describe("minimum-release-age", () => {
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${1.8 * SECONDS_PER_DAY}`, "--verbose"],
cmd: [bunExe(), "install", "--minimum-release-age", "1.8d", "--verbose"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -945,7 +968,7 @@ describe("minimum-release-age", () => {
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${1.8 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "1.8d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -984,7 +1007,7 @@ describe("minimum-release-age", () => {
//
// Result: Selects 1.0.6 (gave up finding stable version)
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1011,7 +1034,7 @@ describe("minimum-release-age", () => {
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${3 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "3d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1039,7 +1062,7 @@ describe("minimum-release-age", () => {
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${3 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "3d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1064,7 +1087,7 @@ describe("minimum-release-age", () => {
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${10 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "10d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1095,7 +1118,7 @@ describe("minimum-release-age", () => {
});
await using proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${3 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "3d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1127,7 +1150,7 @@ describe("minimum-release-age", () => {
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${3 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "3d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1180,7 +1203,7 @@ describe("minimum-release-age", () => {
// - 1.1.0 (15 days): PASSES age gate, but beyond search window (3 + 7 = 10 days)
// - Should return 1.1.0 as best_version before breaking, not error!
await using proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${3 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "3d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1209,7 +1232,7 @@ describe("minimum-release-age", () => {
},
}),
"bunfig.toml": `[install]
minimumReleaseAge = ${5 * SECONDS_PER_DAY}
minimumReleaseAge = "5d"
minimumReleaseAgeExcludes = ["excluded-package"]
registry = "${mockRegistryUrl}"`,
});
@@ -1242,7 +1265,33 @@ registry = "${mockRegistryUrl}"`,
dependencies: { "regular-package": "*" },
}),
"bunfig.toml": `[install]
minimumReleaseAge = ${5 * SECONDS_PER_DAY}
minimumReleaseAge = "5d"
registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
const lockfile = await Bun.file(`${dir}/bun.lock`).text();
expect(lockfile).toContain("regular-package@2.1.0");
expect(lockfile).not.toContain("regular-package@3.0.0");
});
test("bunfig.toml configuration works (numeric seconds form)", async () => {
using dir = tempDir("bunfig-config-seconds", {
"package.json": JSON.stringify({
dependencies: { "regular-package": "*" },
}),
"bunfig.toml": `[install]
minimumReleaseAge = 432000 # 5d
registry = "${mockRegistryUrl}"`,
});
@@ -1268,13 +1317,13 @@ registry = "${mockRegistryUrl}"`,
dependencies: { "regular-package": "*" },
}),
"bunfig.toml": `[install]
minimumReleaseAge = ${10 * SECONDS_PER_DAY}
minimumReleaseAge = "10d"
registry = "${mockRegistryUrl}"`,
});
// CLI says 5 days, bunfig says 10 days
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1297,7 +1346,7 @@ registry = "${mockRegistryUrl}"`,
dependencies: { "regular-package": "*" },
}),
"bunfig.toml": `[install]
minimumReleaseAge = ${10 * SECONDS_PER_DAY}
minimumReleaseAge = "10d"
registry = "${mockRegistryUrl}"`,
});
@@ -1321,7 +1370,7 @@ registry = "${mockRegistryUrl}"`,
// Create a fake home directory with global bunfig
using globalConfigDir = tempDir("global-config", {
".bunfig.toml": `[install]
minimumReleaseAge = ${5 * SECONDS_PER_DAY}
minimumReleaseAge = "5d"
registry = "${mockRegistryUrl}"`,
});
@@ -1357,7 +1406,7 @@ registry = "${mockRegistryUrl}"`,
// Create a fake home directory with global bunfig
using globalConfigDir = tempDir("global-config-override", {
".bunfig.toml": `[install]
minimumReleaseAge = ${10 * SECONDS_PER_DAY}
minimumReleaseAge = "10d"
registry = "${mockRegistryUrl}"`,
});
@@ -1367,7 +1416,7 @@ registry = "${mockRegistryUrl}"`,
dependencies: { "regular-package": "*" },
}),
"bunfig.toml": `[install]
minimumReleaseAge = ${5 * SECONDS_PER_DAY}
minimumReleaseAge = "5d"
registry = "${mockRegistryUrl}"`,
});
@@ -1407,7 +1456,7 @@ registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--verbose"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--verbose"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1440,7 +1489,7 @@ registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1471,7 +1520,7 @@ registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1497,12 +1546,12 @@ registry = "${mockRegistryUrl}"`,
dependencies: { "regular-package": "*" },
}),
"bunfig.toml": `[install]
minimumReleaseAge = ${5 * SECONDS_PER_DAY}
minimumReleaseAge = "5d"
registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1531,7 +1580,7 @@ registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1561,7 +1610,7 @@ registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--dry-run"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--dry-run"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1601,7 +1650,7 @@ registry = "${mockRegistryUrl}"`,
// Now update with minimum-release-age
proc = Bun.spawn({
cmd: [bunExe(), "update", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "update", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1625,7 +1674,7 @@ registry = "${mockRegistryUrl}"`,
}),
".npmrc": `registry=${mockRegistryUrl}`,
"bunfig.toml": `[install]
minimumReleaseAge = ${5 * SECONDS_PER_DAY}
minimumReleaseAge = "5d"
registry = "${mockRegistryUrl}"`,
});
@@ -1679,7 +1728,7 @@ registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1710,7 +1759,7 @@ registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1745,7 +1794,7 @@ registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1775,7 +1824,7 @@ registry = "${mockRegistryUrl}"`,
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -1942,7 +1991,7 @@ linker = "${linker}"
// - stable-package (latest): 3.2.0 is 30 days old → select 3.2.0 (passes gate, is latest)
// - stable-package (3.0.0): pinned to 3.0.0 (legacy workspace - no age check on exact versions)
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -2094,7 +2143,7 @@ linker = "${linker}"
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -2119,7 +2168,7 @@ linker = "${linker}"
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -2146,7 +2195,7 @@ linker = "${linker}"
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -2175,7 +2224,7 @@ linker = "${linker}"
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -2202,7 +2251,7 @@ linker = "${linker}"
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -2235,7 +2284,7 @@ linker = "${linker}"
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -2263,7 +2312,7 @@ linker = "${linker}"
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
@@ -2289,7 +2338,7 @@ linker = "${linker}"
});
const proc = Bun.spawn({
cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"],
cmd: [bunExe(), "install", "--minimum-release-age", "5d", "--no-verify"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",

View File

@@ -0,0 +1,65 @@
import { expectType } from "./utilities";
// Test string literal autocomplete and return types
expectType(Bun.ms("1s")).is<number>();
expectType(Bun.ms("1m")).is<number>();
expectType(Bun.ms("1h")).is<number>();
expectType(Bun.ms("1d")).is<number>();
expectType(Bun.ms("1w")).is<number>();
expectType(Bun.ms("1mo")).is<number>();
expectType(Bun.ms("1y")).is<number>();
// Test with all unit variations
expectType(Bun.ms("1ms")).is<number>();
expectType(Bun.ms("1millisecond")).is<number>();
expectType(Bun.ms("1milliseconds")).is<number>();
expectType(Bun.ms("1second")).is<number>();
expectType(Bun.ms("1 second")).is<number>();
expectType(Bun.ms("1 seconds")).is<number>();
expectType(Bun.ms("1minute")).is<number>();
expectType(Bun.ms("1 minute")).is<number>();
expectType(Bun.ms("1hour")).is<number>();
expectType(Bun.ms("1 hour")).is<number>();
expectType(Bun.ms("1day")).is<number>();
expectType(Bun.ms("1 day")).is<number>();
expectType(Bun.ms("1week")).is<number>();
expectType(Bun.ms("1 week")).is<number>();
expectType(Bun.ms("1month")).is<number>();
expectType(Bun.ms("1 month")).is<number>();
expectType(Bun.ms("1year")).is<number>();
expectType(Bun.ms("1 year")).is<number>();
// Test with decimals and negatives
expectType(Bun.ms("1.5h")).is<number>();
expectType(Bun.ms("-1s")).is<number>();
expectType(Bun.ms(".5m")).is<number>();
expectType(Bun.ms("-.5h")).is<number>();
// Test number input (formatting)
expectType(Bun.ms(1000)).is<string>();
expectType(Bun.ms(60000)).is<string>();
expectType(Bun.ms(3600000)).is<string>();
// Test with options
expectType(Bun.ms(1000, { long: true })).is<string>();
expectType(Bun.ms(60000, { long: false })).is<string>();
// Test generic string input (for dynamic values)
const dynamicString: string = "1s";
expectType(Bun.ms(dynamicString)).is<number>();
// Should NOT accept options with string input
// @ts-expect-error - options only valid with number input
Bun.ms("1s", { long: true });
// Number with options should work
const formatted = Bun.ms(1000, { long: true });
expectType(formatted).is<string>();
// Options should be optional
const shortFormat = Bun.ms(1000);
expectType(shortFormat).is<string>();
// Test that invalid inputs still return number (NaN is a number)
expectType(Bun.ms("invalid")).is<number>();
expectType(Bun.ms("")).is<number>();

436
test/js/bun/util/ms.test.ts Normal file
View File

@@ -0,0 +1,436 @@
import { describe, expect, test } from "bun:test";
import { tempDirWithFiles } from "harness";
import { join } from "path";
describe("Bun.ms - parse (string to number)", () => {
test("short strings", () => {
const cases = [
["100", 100],
["1m", 60000],
["1h", 3600000],
["2d", 172800000],
["3w", 1814400000],
["1s", 1000],
["100ms", 100],
["1y", 31557600000],
["1.5h", 5400000],
["1 s", 1000],
["-.5h", -1800000],
["-1h", -3600000],
["-200", -200],
[".5ms", 0.5],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("long strings", () => {
const cases = [
["53 milliseconds", 53],
["17 msecs", 17],
["1 sec", 1000],
["1 min", 60000],
["1 hr", 3600000],
["2 days", 172800000],
["1 week", 604800000],
["1 month", 2629800000],
["1 year", 31557600000],
["1.5 hours", 5400000],
["-100 milliseconds", -100],
["-1.5 hours", -5400000],
["-10 minutes", -600000],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("case insensitive", () => {
const cases = [
["1M", 60000],
["1H", 3600000],
["2D", 172800000],
["3W", 1814400000],
["1S", 1000],
["1MS", 1],
["1Y", 31557600000],
["1 HOUR", 3600000],
["1 DAY", 86400000],
["1 WEEK", 604800000],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("invalid inputs", () => {
const cases = [
["", "empty string"],
[" ", "whitespace only"],
["foo", "invalid unit"],
["1x", "unknown unit"],
["1.2.3s", "multiple dots"],
] as const;
for (const [input] of cases) {
expect(Bun.ms(input)).toBeNaN();
}
});
});
describe("Bun.ms - format (number to string)", () => {
test("short format", () => {
const cases = [
[0, "0ms"],
[500, "500ms"],
[-500, "-500ms"],
[1000, "1s"],
[10000, "10s"],
[60000, "1m"],
[600000, "10m"],
[3600000, "1h"],
[86400000, "1d"],
[604800000, "1w"],
[2629800000, "1mo"],
[31557600001, "1y"],
[234234234, "3d"],
[-234234234, "-3d"],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("long format", () => {
const cases = [
[0, "0 ms"],
[500, "500 ms"],
[-500, "-500 ms"],
[1000, "1 second"],
[1001, "1 second"],
[1499, "1 second"],
[1500, "2 seconds"],
[10000, "10 seconds"],
[60000, "1 minute"],
[600000, "10 minutes"],
[3600000, "1 hour"],
[86400000, "1 day"],
[172800000, "2 days"],
[604800000, "1 week"],
[2629800000, "1 month"],
[31557600001, "1 year"],
[234234234, "3 days"],
[-234234234, "-3 days"],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input, { long: true })).toBe(expected);
}
});
test("rounding behavior matches JavaScript Math.round and npm ms", () => {
// JavaScript Math.round uses "round half toward +∞"
// Positive ties (X.5) round up (away from zero): 2.5 → 3
// Negative ties (X.5) round up toward zero: -2.5 → -2
// This is different from Zig's @round which rounds away from zero
// (so we made our own jsMathRound function)
const cases = [
// Positive ties - should round up
[1000, "1s", "1 second"],
[1500, "2s", "2 seconds"],
[2500, "3s", "3 seconds"],
[3500, "4s", "4 seconds"],
[4500, "5s", "5 seconds"],
// Negative ties - should round toward zero (toward +∞)
[-1000, "-1s", "-1 second"],
[-1500, "-1s", "-1 seconds"],
[-2500, "-2s", "-2 seconds"],
[-3500, "-3s", "-3 seconds"],
[-4500, "-4s", "-4 seconds"],
[9000000, "3h", "3 hours"],
[-9000000, "-2h", "-2 hours"],
[216000000, "3d", "3 days"],
[-216000000, "-2d", "-2 days"],
] as const;
for (const [input, expectedShort, expectedLong] of cases) {
expect(Bun.ms(input)).toBe(expectedShort);
expect(Bun.ms(input, { long: true })).toBe(expectedLong);
}
});
test("invalid number inputs", () => {
expect(() => Bun.ms(NaN)).toThrow();
expect(() => Bun.ms(Infinity)).toThrow();
expect(() => Bun.ms(-Infinity)).toThrow();
});
});
describe("Bun.ms - comprehensive coverage", () => {
test("all time units", () => {
const cases = [
// Milliseconds
["1ms", 1],
["1millisecond", 1],
["1milliseconds", 1],
["1msec", 1],
["1msecs", 1],
// Seconds
["1s", 1000],
["1sec", 1000],
["1secs", 1000],
["1second", 1000],
["1seconds", 1000],
["2seconds", 2000],
// Minutes
["1m", 60000],
["1min", 60000],
["1mins", 60000],
["1minute", 60000],
["1minutes", 60000],
["2minutes", 120000],
// Hours
["1h", 3600000],
["1hr", 3600000],
["1hrs", 3600000],
["1hour", 3600000],
["1hours", 3600000],
["2hours", 7200000],
// Days
["1d", 86400000],
["1day", 86400000],
["1days", 86400000],
["2days", 172800000],
// Weeks
["1w", 604800000],
["1week", 604800000],
["1weeks", 604800000],
["2weeks", 1209600000],
// Months
["1mo", 2629800000],
["1month", 2629800000],
["1months", 2629800000],
["2months", 5259600000],
// Years
["1y", 31557600000],
["1yr", 31557600000],
["1yrs", 31557600000],
["1year", 31557600000],
["1years", 31557600000],
["2years", 63115200000],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("decimals and negatives", () => {
const cases = [
["1.5s", 1500],
["1.5h", 5400000],
["0.5d", 43200000],
["-1s", -1000],
["-1.5h", -5400000],
["-0.5d", -43200000],
[".5s", 500],
["-.5s", -500],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("whitespace handling", () => {
const cases = [
["1 s", 1000],
["1 s", 1000],
["1 s", 1000],
[" 1s", NaN],
["1s ", NaN],
[" 1s ", NaN],
["1 second", 1000],
["1 seconds", 1000],
[" 1 second ", NaN],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
});
test("Bun.ms - dynamic values at runtime", () => {
{
function getNumber() {
return Math.random() > 0.5 ? 1 : 2;
}
const days = getNumber();
const result = Bun.ms(days + "days");
// Should be either 1 day or 2 days
expect(result === 86400000 || result === 172800000).toBe(true);
}
{
function getHours() {
return 5;
}
const result = Bun.ms(String(getHours()) + "h");
expect(result).toBe(18000000); // 5 hours
}
{
const timeStr = "10m";
const result = Bun.ms(timeStr);
expect(result).toBe(600000);
}
{
function getMs() {
return 60000;
}
const result = Bun.ms(getMs());
expect(result).toBe("1m");
}
});
test("Bun.ms - static string formatting", () => {
expect(Bun.ms("5s")).toBe(5000);
expect(Bun.ms(5000, { long: true })).toBe("5 seconds");
expect(Bun.ms(5000, { long: false })).toBe("5s");
});
test("Bun.ms - bundler output", async () => {
const dir = tempDirWithFiles("ms-bundler", {
"entry.ts": `
const dynamic = () => Math.random() > 0.5 ? 1 : 2;
export const values = {
// Valid strings - should inline to numbers
oneSecond: Bun.ms("1s"),
oneMinute: Bun.ms("1m"),
oneHour: Bun.ms("1h"),
oneDay: Bun.ms("1d"),
twoWeeks: Bun.ms("2w"),
halfYear: Bun.ms("0.5y"),
withSpaces: Bun.ms("5 minutes"),
negative: Bun.ms("-10s"),
decimal: Bun.ms("1.5h"),
justNumber: Bun.ms("100"),
caseInsensitive: Bun.ms("2D"),
// Invalid strings - should inline to NaN
invalid: Bun.ms("invalid"),
empty: Bun.ms(""),
// Number inputs - should inline to strings
formatShort: Bun.ms(1000),
formatLong: Bun.ms(60000, { long: true }),
// dynamic should not inline
dynamic: Bun.ms(\`\$\{dynamic()\}s\`),
// test
dontBeWeird: abc.ms("1s"),
};
`,
"bun.ts": `
import { ms, sleep } from "bun";
const dynamic = () => Math.random() > 0.5 ? 1 : 2;
export const values = {
import: ms("1s"),
importLong: ms(1000, { long: true }),
ms: Bun.ms(),
mss: ms,
sleep: sleep,
dynamic: ms(\`\${dynamic()}s\`),
dontBeWeird: abc.ms("1s"),
};
`,
});
const result = await Bun.build({
entrypoints: [join(dir, "entry.ts")],
minify: {
syntax: true,
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
let output = await result.outputs[0].text();
output = output.replace(/\/\/.*?\/entry\.ts/, "// entry.ts");
expect(output).toMatchInlineSnapshot(`
"// entry.ts
var dynamic = () => Math.random() > 0.5 ? 1 : 2, values = {
oneSecond: 1000,
oneMinute: 60000,
oneHour: 3600000,
oneDay: 86400000,
twoWeeks: 1209600000,
halfYear: 15778800000,
withSpaces: 300000,
negative: -1e4,
decimal: 5400000,
justNumber: 100,
caseInsensitive: 172800000,
invalid: NaN,
empty: NaN,
formatShort: "1s",
formatLong: "1 minute",
dynamic: Bun.ms(\`\${dynamic()}s\`),
dontBeWeird: abc.ms("1s")
};
export {
values
};
"
`);
const bunResult = await Bun.build({
entrypoints: [join(dir, "bun.ts")],
minify: {
syntax: true,
},
target: "bun",
});
expect(bunResult.success).toBe(true);
expect(bunResult.outputs).toHaveLength(1);
let bunOutput = await bunResult.outputs[0].text();
bunOutput = bunOutput.replace(/\/\/.*?\/bun\.ts/, "// bun.ts");
expect(bunOutput).toMatchInlineSnapshot(`
"// @bun
// bun.ts
var {ms, sleep } = globalThis.Bun;
var dynamic = () => Math.random() > 0.5 ? 1 : 2, values = {
import: 1000,
importLong: "1 second",
ms: Bun.ms(),
mss: ms,
sleep,
dynamic: ms(\`\${dynamic()}s\`),
dontBeWeird: abc.ms("1s")
};
export {
values
};
"
`);
});

View File

@@ -0,0 +1,367 @@
import { ms } from "bun";
import { describe, expect, it } from "bun:test";
describe("ms(string)", () => {
it("should not throw an error", () => {
expect(() => {
ms("1m");
}).not.toThrow();
});
it("should preserve ms", () => {
expect(ms("100")).toBe(100);
});
it("should convert from m to ms", () => {
expect(ms("1m")).toBe(60000);
});
it("should convert from h to ms", () => {
expect(ms("1h")).toBe(3600000);
});
it("should convert d to ms", () => {
expect(ms("2d")).toBe(172800000);
});
it("should convert w to ms", () => {
expect(ms("3w")).toBe(1814400000);
});
it("should convert s to ms", () => {
expect(ms("1s")).toBe(1000);
});
it("should convert ms to ms", () => {
expect(ms("100ms")).toBe(100);
});
it("should convert y to ms", () => {
expect(ms("1y")).toBe(31557600000);
});
it("should work with decimals", () => {
expect(ms("1.5h")).toBe(5400000);
});
it("should work with multiple spaces", () => {
expect(ms("1 s")).toBe(1000);
});
it("should return NaN if invalid", () => {
// @ts-expect-error - We expect this to fail.
expect(Number.isNaN(ms("☃"))).toBe(true);
// @ts-expect-error - We expect this to fail.
expect(Number.isNaN(ms("10-.5"))).toBe(true);
// @ts-expect-error - We expect this to fail.
expect(Number.isNaN(ms("ms"))).toBe(true);
});
it("should be case-insensitive", () => {
expect(ms("1.5H")).toBe(5400000);
});
it("should work with numbers starting with .", () => {
expect(ms(".5ms")).toBe(0.5);
});
it("should work with negative integers", () => {
expect(ms("-100ms")).toBe(-100);
});
it("should work with negative decimals", () => {
expect(ms("-1.5h")).toBe(-5400000);
expect(ms("-10.5h")).toBe(-37800000);
});
it('should work with negative decimals starting with "."', () => {
expect(ms("-.5h")).toBe(-1800000);
});
});
// long strings
describe("ms(long string)", () => {
it("should not throw an error", () => {
expect(() => {
ms("53 milliseconds");
}).not.toThrow();
});
it("should convert milliseconds to ms", () => {
expect(ms("53 milliseconds")).toBe(53);
});
it("should convert msecs to ms", () => {
expect(ms("17 msecs")).toBe(17);
});
it("should convert sec to ms", () => {
expect(ms("1 sec")).toBe(1000);
});
it("should convert from min to ms", () => {
expect(ms("1 min")).toBe(60000);
});
it("should convert from hr to ms", () => {
expect(ms("1 hr")).toBe(3600000);
});
it("should convert days to ms", () => {
expect(ms("2 days")).toBe(172800000);
});
it("should convert weeks to ms", () => {
expect(ms("1 week")).toBe(604800000);
});
it("should convert years to ms", () => {
expect(ms("1 year")).toBe(31557600000);
});
it("should work with decimals", () => {
expect(ms("1.5 hours")).toBe(5400000);
});
it("should work with negative integers", () => {
expect(ms("-100 milliseconds")).toBe(-100);
});
it("should work with negative decimals", () => {
expect(ms("-1.5 hours")).toBe(-5400000);
});
it('should work with negative decimals starting with "."', () => {
expect(ms("-.5 hr")).toBe(-1800000);
});
});
// numbers
describe("ms(number, { long: true })", () => {
it("should not throw an error", () => {
expect(() => {
ms(500, { long: true });
}).not.toThrow();
});
it("should support milliseconds", () => {
expect(ms(500, { long: true })).toBe("500 ms");
expect(ms(-500, { long: true })).toBe("-500 ms");
});
it("should support seconds", () => {
expect(ms(1000, { long: true })).toBe("1 second");
expect(ms(1200, { long: true })).toBe("1 second");
expect(ms(10000, { long: true })).toBe("10 seconds");
expect(ms(-1000, { long: true })).toBe("-1 second");
expect(ms(-1200, { long: true })).toBe("-1 second");
expect(ms(-10000, { long: true })).toBe("-10 seconds");
});
it("should support minutes", () => {
expect(ms(60 * 1000, { long: true })).toBe("1 minute");
expect(ms(60 * 1200, { long: true })).toBe("1 minute");
expect(ms(60 * 10000, { long: true })).toBe("10 minutes");
expect(ms(-1 * 60 * 1000, { long: true })).toBe("-1 minute");
expect(ms(-1 * 60 * 1200, { long: true })).toBe("-1 minute");
expect(ms(-1 * 60 * 10000, { long: true })).toBe("-10 minutes");
});
it("should support hours", () => {
expect(ms(60 * 60 * 1000, { long: true })).toBe("1 hour");
expect(ms(60 * 60 * 1200, { long: true })).toBe("1 hour");
expect(ms(60 * 60 * 10000, { long: true })).toBe("10 hours");
expect(ms(-1 * 60 * 60 * 1000, { long: true })).toBe("-1 hour");
expect(ms(-1 * 60 * 60 * 1200, { long: true })).toBe("-1 hour");
expect(ms(-1 * 60 * 60 * 10000, { long: true })).toBe("-10 hours");
});
it("should support days", () => {
expect(ms(1 * 24 * 60 * 60 * 1000, { long: true })).toBe("1 day");
expect(ms(1 * 24 * 60 * 60 * 1200, { long: true })).toBe("1 day");
expect(ms(6 * 24 * 60 * 60 * 1000, { long: true })).toBe("6 days");
expect(ms(-1 * 1 * 24 * 60 * 60 * 1000, { long: true })).toBe("-1 day");
expect(ms(-1 * 1 * 24 * 60 * 60 * 1200, { long: true })).toBe("-1 day");
expect(ms(-1 * 6 * 24 * 60 * 60 * 1000, { long: true })).toBe("-6 days");
});
it("should support weeks", () => {
expect(ms(1 * 7 * 24 * 60 * 60 * 1000, { long: true })).toBe("1 week");
expect(ms(2 * 7 * 24 * 60 * 60 * 1000, { long: true })).toBe("2 weeks");
expect(ms(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { long: true })).toBe("-1 week");
expect(ms(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { long: true })).toBe("-2 weeks");
});
it("should support months", () => {
expect(ms(30.4375 * 24 * 60 * 60 * 1000, { long: true })).toBe("1 month");
expect(ms(30.4375 * 24 * 60 * 60 * 1200, { long: true })).toBe("1 month");
expect(ms(30.4375 * 24 * 60 * 60 * 10000, { long: true })).toBe("10 months");
expect(ms(-1 * 30.4375 * 24 * 60 * 60 * 1000, { long: true })).toBe("-1 month");
expect(ms(-1 * 30.4375 * 24 * 60 * 60 * 1200, { long: true })).toBe("-1 month");
expect(ms(-1 * 30.4375 * 24 * 60 * 60 * 10000, { long: true })).toBe("-10 months");
});
it("should support years", () => {
expect(ms(365.25 * 24 * 60 * 60 * 1000 + 1, { long: true })).toBe("1 year");
expect(ms(365.25 * 24 * 60 * 60 * 1200 + 1, { long: true })).toBe("1 year");
expect(ms(365.25 * 24 * 60 * 60 * 10000 + 1, { long: true })).toBe("10 years");
expect(ms(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { long: true })).toBe("-1 year");
expect(ms(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { long: true })).toBe("-1 year");
expect(ms(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { long: true })).toBe("-10 years");
});
it("should round", () => {
expect(ms(234234234, { long: true })).toBe("3 days");
expect(ms(-234234234, { long: true })).toBe("-3 days");
});
});
// numbers
describe("ms(number)", () => {
it("should not throw an error", () => {
expect(() => {
ms(500);
}).not.toThrow();
});
it("should support milliseconds", () => {
expect(ms(500)).toBe("500ms");
expect(ms(-500)).toBe("-500ms");
});
it("should support seconds", () => {
expect(ms(1000)).toBe("1s");
expect(ms(10000)).toBe("10s");
expect(ms(-1000)).toBe("-1s");
expect(ms(-10000)).toBe("-10s");
});
it("should support minutes", () => {
expect(ms(60 * 1000)).toBe("1m");
expect(ms(60 * 10000)).toBe("10m");
expect(ms(-1 * 60 * 1000)).toBe("-1m");
expect(ms(-1 * 60 * 10000)).toBe("-10m");
});
it("should support hours", () => {
expect(ms(60 * 60 * 1000)).toBe("1h");
expect(ms(60 * 60 * 10000)).toBe("10h");
expect(ms(-1 * 60 * 60 * 1000)).toBe("-1h");
expect(ms(-1 * 60 * 60 * 10000)).toBe("-10h");
});
it("should support days", () => {
expect(ms(24 * 60 * 60 * 1000)).toBe("1d");
expect(ms(24 * 60 * 60 * 6000)).toBe("6d");
expect(ms(-1 * 24 * 60 * 60 * 1000)).toBe("-1d");
expect(ms(-1 * 24 * 60 * 60 * 6000)).toBe("-6d");
});
it("should support weeks", () => {
expect(ms(1 * 7 * 24 * 60 * 60 * 1000)).toBe("1w");
expect(ms(2 * 7 * 24 * 60 * 60 * 1000)).toBe("2w");
expect(ms(-1 * 1 * 7 * 24 * 60 * 60 * 1000)).toBe("-1w");
expect(ms(-1 * 2 * 7 * 24 * 60 * 60 * 1000)).toBe("-2w");
});
it("should support months", () => {
expect(ms(30.4375 * 24 * 60 * 60 * 1000)).toBe("1mo");
expect(ms(30.4375 * 24 * 60 * 60 * 1200)).toBe("1mo");
expect(ms(30.4375 * 24 * 60 * 60 * 10000)).toBe("10mo");
expect(ms(-1 * 30.4375 * 24 * 60 * 60 * 1000)).toBe("-1mo");
expect(ms(-1 * 30.4375 * 24 * 60 * 60 * 1200)).toBe("-1mo");
expect(ms(-1 * 30.4375 * 24 * 60 * 60 * 10000)).toBe("-10mo");
});
it("should support years", () => {
expect(ms(365.25 * 24 * 60 * 60 * 1000 + 1)).toBe("1y");
expect(ms(365.25 * 24 * 60 * 60 * 1200 + 1)).toBe("1y");
expect(ms(365.25 * 24 * 60 * 60 * 10000 + 1)).toBe("10y");
expect(ms(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1)).toBe("-1y");
expect(ms(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1)).toBe("-1y");
expect(ms(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1)).toBe("-10y");
});
it("should round", () => {
expect(ms(234234234)).toBe("3d");
expect(ms(-234234234)).toBe("-3d");
});
});
// invalid inputs
describe("ms(invalid inputs)", () => {
it('should throw an error, when ms("")', () => {
expect(() => {
// @ts-expect-error - We expect this to throw.
ms("");
}).toThrow();
});
it("should throw an error, when ms(undefined)", () => {
expect(() => {
// @ts-expect-error - We expect this to throw.
ms(undefined);
}).toThrow();
});
it("should throw an error, when ms(null)", () => {
expect(() => {
// @ts-expect-error - We expect this to throw.
ms(null);
}).toThrow();
});
it("should throw an error, when ms([])", () => {
expect(() => {
// @ts-expect-error - We expect this to throw.
ms([]);
}).toThrow();
});
it("should throw an error, when ms({})", () => {
expect(() => {
// @ts-expect-error - We expect this to throw.
ms({});
}).toThrow();
});
it("should throw an error, when ms(NaN)", () => {
expect(() => {
ms(NaN);
}).toThrow();
});
it("should throw an error, when ms(Infinity)", () => {
expect(() => {
ms(Infinity);
}).toThrow();
});
it("should throw an error, when ms(-Infinity)", () => {
expect(() => {
ms(-Infinity);
}).toThrow();
});
});