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
1 change: 1 addition & 0 deletions docs/runtime/secrets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ let githubToken: string | null = await secrets.get({

if (!githubToken) {
githubToken = prompt("Please enter your GitHub token");
if (!githubToken) throw new Error("No GitHub token entered");

await secrets.set({
service: "my-cli-tool",
Expand Down
16 changes: 8 additions & 8 deletions src/runtime/webcore/prompt.bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import * as b from "../../codegen/bindgen-lib.ts";

export default b.module({ rustPath: "crate::webcore::prompt" });

/** `alert()` shows a message of its own. `alert(undefined)` shows "undefined". */
const message = b.string.loose.utf8.ifPassed;
/** `alert()` and `alert(message)` are overloads: `alert()` shows a message of its own, `alert(undefined)` shows "undefined". */
export const alert = b.fn({ args: { message: b.string.loose.utf8.ifPassed } });

/** `optional DOMString message = ""`: without a message, or with `undefined`, the dialog shows a message of its own. */
const message = b.string.loose.utf8.optional;

export const alert = b.fn({ args: { message } });
export const confirm = b.fn({ args: { message }, returns: b.bool });
/** An empty answer returns `defaultValue` as it is. */
/** `null` is the end of the input. An empty answer is `defaultValue`, and "" without one. */
export const prompt = b.fn({
args: { message, defaultValue: b.any.ifPassed },
returns: b.any,
capability: "js",
throws: true,
args: { message, defaultValue: b.string.loose.optional },
returns: b.string.nullable,
});
58 changes: 22 additions & 36 deletions src/runtime/webcore/prompt.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
//! Implements prompt, alert, and confirm Web API

use crate::webcore::jsc::{JSValue, JsResult};
use bun_collections::VecExt as _;
use bun_core::EncodedSlice;
use bun_core::Output;
use bun_jsc::EncodedSliceJsc as _;
use bun_jsc::bindgen::{JsLocal, JsScope};

/// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-alert
pub(crate) fn alert(message: Option<&[u8]>) {
Expand Down Expand Up @@ -214,16 +210,11 @@ fn read_until_delimiter_array_list_infinity<R: ReadByte>(
}

/// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-prompt
pub(crate) fn prompt<'a>(
cx: &mut JsScope<'a>,
pub(crate) fn prompt(
message: Option<&[u8]>,
default_value: Option<JsLocal<'a>>,
) -> JsResult<JSValue> {
let global = cx.global();
default_value: Option<bun_core::String>,
) -> Option<bun_core::String> {
let output = Output::writer();
// 4. Set default to the result of optionally truncating default.
// * We don't really need to do this.
let default = default_value.map(JsLocal::to_js);

if let Some(message) = message {
// 2. Set message to the result of normalizing newlines given message.
Expand All @@ -233,11 +224,12 @@ pub(crate) fn prompt<'a>(
// * Not necessary so we won't do it.
if output.write_all(message).is_err() {
// 1. If we cannot show simple dialogs for this, then return null.
return Ok(JSValue::NULL);
return None;
}
}

// 4. Set default to the result of optionally truncating default.
// * We don't really need to do this.

// 5. Show message to the user, treating U+000A LF as a line break,
// and ask the user to either respond with a string value or
Expand All @@ -251,24 +243,18 @@ pub(crate) fn prompt<'a>(
})
.is_err()
{
// 1. If we cannot show simple dialogs for this, then return false.
return Ok(JSValue::FALSE);
// 1. If we cannot show simple dialogs for this, then return null.
return None;
}

if let Some(default) = default {
let default_string = default.to_utf8(global)?;

if output
.print(format_args!(
"[{}] ",
bstr::BStr::new(default_string.slice())
))
.is_err()
{
// 1. If we cannot show simple dialogs for this, then return false.
return Ok(JSValue::FALSE);
if let Some(default) = &default_value {
if output.print(format_args!("[{default}] ")).is_err() {
// 1. If we cannot show simple dialogs for this, then return null.
return None;
}
}
// `optional DOMString default = ""`: the response to an empty line.
let default = default_value.unwrap_or(bun_core::String::EMPTY);

// 6. Invoke WebDriver BiDi user prompt opened with this, "prompt" and message.
// * Not relevant in a server context.
Expand All @@ -293,20 +279,20 @@ pub(crate) fn prompt<'a>(
let Ok(first_byte) = reader.read_byte() else {
// 8. Let result be null if the user aborts, or otherwise the string
// that the user responded with.
return Ok(JSValue::NULL);
return None;
};

if first_byte == b'\n' {
// 8. Let result be null if the user aborts, or otherwise the string
// that the user responded with.
return Ok(default.unwrap_or(JSValue::NULL));
return Some(default);
} else if first_byte == b'\r' {
let Ok(second) = reader.read_byte() else {
return Ok(JSValue::NULL);
return None;
};
second_byte = Some(second);
if second == b'\n' {
return Ok(default.unwrap_or(JSValue::NULL));
return Some(default);
}
}

Expand All @@ -330,7 +316,7 @@ pub(crate) fn prompt<'a>(
if !matches!(e, ReadError::StreamTooLong) {
// 8. Let result be null if the user aborts, or otherwise the string
// that the user responded with.
return Ok(JSValue::NULL);
return None;
Comment thread
robobun marked this conversation as resolved.
Resolved
}

input.ensure_total_capacity(4096);
Expand All @@ -344,13 +330,13 @@ pub(crate) fn prompt<'a>(
if !matches!(e2, ReadError::StreamTooLong) {
// 8. Let result be null if the user aborts, or otherwise the string
// that the user responded with.
return Ok(JSValue::NULL);
return None;
}

if read_until_delimiter_array_list_infinity(&mut *reader, &mut input, b'\n').is_err() {
// 8. Let result be null if the user aborts, or otherwise the string
// that the user responded with.
return Ok(JSValue::NULL);
return None;
}
}
}
Expand All @@ -363,12 +349,12 @@ pub(crate) fn prompt<'a>(

// 8. Let result be null if the user aborts, or otherwise the string
// that the user responded with.
let result = EncodedSlice::utf8(&input);
let result = bun_core::String::clone_utf8(&input);

// 9. Invoke WebDriver BiDi user prompt closed with this, false if
// result is null or true otherwise, and result.
// * Too complex for server context.

// 9. Return result.
Ok(result.to_js(global))
Some(result)
}
92 changes: 92 additions & 0 deletions test/js/web/web-globals.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,98 @@ test.concurrent.each([
});
});

// The dialog goes to stdout. The fixture writes what the call returns to stderr, as JSON.
async function runDialog(call, stdin) {
await using proc = spawn({
cmd: [bunExe(), "-e", `console.error(JSON.stringify(${call}));`],
stdio: ["pipe", "pipe", "pipe"],
env: bunEnv,
});

proc.stdin.write(stdin);
await proc.stdin.flush();
proc.stdin.end();

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

// 5001 bytes. The read of a line grows its buffer at 2048 and at 4096 bytes, here inside a two-byte character.
const longAnswer = "a" + Buffer.alloc(5000, "\u00e9").toString();

// `prompt(optional DOMString message = "", optional DOMString default = "")` returns the response, a string.
// `null` is the end of the input.
test.concurrent.each([
["an answer", `prompt("Q?")`, "x\n", "Q? ", "x"],
[
"a non-ASCII answer",
`prompt("Q?")`,
"h\u00e9llo \u20ac \u65e5\u672c\u8a9e \ud83d\ude00\n",
"Q? ",
"h\u00e9llo \u20ac \u65e5\u672c\u8a9e \ud83d\ude00",
],
[
"an answer that is not UTF-8",
`prompt("Q?")`,
Buffer.from([0x61, 0xff, 0x62, 0xe2, 0x82, 0x0a]),
"Q? ",
"a\ufffdb\ufffd",
],
["an answer longer than the read buffer", `prompt("Q?")`, longAnswer + "\n", "Q? ", longAnswer],
["end of input", `prompt("Q?")`, "", "Q? ", null],
["empty line", `prompt("Q?")`, "\n", "Q? ", ""],
["empty line, CRLF", `prompt("Q?")`, "\r\n", "Q? ", ""],
["empty line, no message", `prompt()`, "\n", "Prompt ", ""],
["undefined default is no default", `prompt("Q?", undefined)`, "\n", "Q? ", ""],
["default, empty line", `prompt("Q?", "dflt")`, "\n", "Q? [dflt] ", "dflt"],
["default, empty line, CRLF", `prompt("Q?", "dflt")`, "\r\n", "Q? [dflt] ", "dflt"],
["default, an answer", `prompt("Q?", "dflt")`, "hi\n", "Q? [dflt] ", "hi"],
["default, end of input", `prompt("Q?", "dflt")`, "", "Q? [dflt] ", null],
["empty default", `prompt("Q?", "")`, "\n", "Q? [] ", ""],
["Latin-1 default", `prompt("Q?", "caf\\u00e9")`, "\n", "Q? [caf\u00e9] ", "caf\u00e9"],
["number default is its string", `prompt("Q?", 5)`, "\n", "Q? [5] ", "5"],
["null default is the string null", `prompt("Q?", null)`, "\n", "Q? [null] ", "null"],
["object default is its string", `prompt("Q?", {})`, "\r\n", "Q? [[object Object]] ", "[object Object]"],
["default with a lone surrogate", `prompt("Q?", "a\\ud800b")`, "\n", "Q? [a\ufffdb] ", "a\ud800b"],
["empty line, then an answer", `[prompt("A"), prompt("B")]`, "\nz\n", "A B ", ["", "z"]],
[
"empty line between answers, then end of input",
`[prompt(), prompt(), prompt(), prompt()]`,
"a\n\nb\n",
"Prompt Prompt Prompt Prompt ",
["a", "", "b", null],
],
[
"each argument converts once, in order, before the message prints",
`prompt({ toString: () => (console.log("m"), "Q?") }, { toString: () => (console.log("d"), "D") })`,
"\n",
"m\nd\nQ? [D] ",
"D",
],
[
"a default that throws prints nothing and reads nothing",
`(() => { try { prompt("Q?", { toString() { throw new Error("boom"); } }); } catch (e) { return [e.message, prompt("next")]; } })()`,
"line\n",
"next ",
["boom", "line"],
],
])("prompt: %s", async (_, call, stdin, stdout, result) => {
expect(await runDialog(call, stdin)).toEqual({ stdout, stderr: JSON.stringify(result) + "\n", exitCode: 0 });
});

// `undefined` is the default message of prompt() and confirm(). alert() and alert(message) are overloads.
test.concurrent.each([
["prompt(undefined) shows its own message", `prompt(undefined)`, "hi\n", "Prompt ", "hi"],
["prompt(undefined, default) shows its own message", `prompt(undefined, "dflt")`, "\n", "Prompt [dflt] ", "dflt"],
["prompt(null) shows null", `prompt(null)`, "hi\n", "null ", "hi"],
["confirm(undefined) shows its own message", `confirm(undefined)`, "y\n", "Confirm [y/N] ", true],
["confirm(null) shows null", `confirm(null)`, "y\n", "null [y/N] ", true],
["alert() shows its own message", `alert()`, "\n", "Alert [Enter] ", undefined],
["alert(undefined) shows undefined", `alert(undefined)`, "\n", "undefined [Enter] ", undefined],
])("%s", async (_, call, stdin, stdout, result) => {
expect(await runDialog(call, stdin)).toEqual({ stdout, stderr: JSON.stringify(result) + "\n", exitCode: 0 });
});

test("globalThis.self = 123 works", () => {
expect(Object.getOwnPropertyDescriptor(globalThis, "self")).toMatchObject({
configurable: true,
Expand Down
Loading