diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 59aa530c8e58..63e305d58c58 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3047,6 +3047,16 @@ declare module "bun" { inline?: boolean; /** + * The lowest level of diagnostic that the transpiler keeps. + * + * A warning never makes the constructor, `transform`, `transformSync`, + * `scan` or `scanImports` throw. Only an error does. When a call throws, + * the thrown value also holds the warnings at this level or above, so it + * can be an `AggregateError`. At `"error"` it holds errors only. + * + * When the option is not set, the level is the log level of the process: + * `logLevel` in bunfig.toml, or `"warn"`. + * * @default "warn" */ logLevel?: "verbose" | "debug" | "info" | "warn" | "error"; diff --git a/src/ast/lib.rs b/src/ast/lib.rs index f4c86093762d..76e072dce42f 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -1616,6 +1616,15 @@ impl Log { Log::init() } + /// An empty log at the `level` of `self`, for messages that are appended to `self` later. + #[inline] + pub fn child(&self) -> Log { + Log { + level: self.level, + ..Default::default() + } + } + #[inline] pub fn add_debug_fmt(&mut self, source: Option<&Source>, l: Loc, args: fmt::Arguments<'_>) { if !Kind::Debug.should_print(self.level) { diff --git a/src/bundler/cache.rs b/src/bundler/cache.rs index fb867d899c3c..f3c4ccaaa157 100644 --- a/src/bundler/cache.rs +++ b/src/bundler/cache.rs @@ -76,8 +76,7 @@ impl JavaScript { log: &mut bun_ast::Log, source: &'a bun_ast::Source, ) -> Result>, crate::Error> { - let mut temp_log = bun_ast::Log::init(); - temp_log.level = log.level; + let mut temp_log = log.child(); let parser = match js_parser::Parser::init(opts, &mut temp_log, source, defines, bump) { Ok(p) => p, Err(_) => { @@ -125,7 +124,7 @@ impl JavaScript { return Ok(()); } - let mut temp_log = bun_ast::Log::init(); + let mut temp_log = log.child(); // scopeguard cannot capture &mut temp_log while it's used below; // explicit `append_to_maybe_recycled` calls at each exit. diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index fe0994cf72b4..706ff9182743 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -1365,6 +1365,12 @@ pub trait LogJsc { global: &JSGlobalObject, message: core::fmt::Arguments<'_>, ) -> JsResult; + /// The value to throw for this log: `to_js` if its `errors` counter is above zero, else `None`. + fn errors_to_js( + &self, + global: &JSGlobalObject, + message: core::fmt::Arguments<'_>, + ) -> JsResult>; fn to_js_array(&self, global: &JSGlobalObject) -> JsResult; /// Unlike `to_js`, always produces an `AggregateError`. fn to_js_aggregate_error( @@ -1408,6 +1414,17 @@ impl LogJsc for bun_ast::Log { } } } + #[inline] + fn errors_to_js( + &self, + global: &JSGlobalObject, + message: core::fmt::Arguments<'_>, + ) -> JsResult> { + if !self.has_errors() { + return Ok(None); + } + self.to_js(global, message).map(Some) + } fn to_js_array(&self, global: &JSGlobalObject) -> JsResult { JSValue::create_array_from_iter(global, self.msgs.iter(), |msg| msg_to_js(msg, global)) } diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index d8b2f4e161bc..c422cdd83eb5 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1116,13 +1116,16 @@ impl WebWorker { return; } let global = vm.global(); - let result: jsc::JsResult<(JSValue, BunString)> = (|| { - let err = vm_log.to_js(global, format_args!("Error in worker"))?; + let result: jsc::JsResult> = (|| { + let Some(err) = vm_log.errors_to_js(global, format_args!("Error in worker"))? else { + return Ok(None); + }; let str = err.to_bun_string(global)?; - Ok((err, str)) + Ok(Some((err, str))) })(); let (err, str) = match result { - Ok(pair) => pair, + Ok(Some(pair)) => pair, + Ok(None) => return, Err(JsError::OutOfMemory) => bun_core::out_of_memory(), Err(err) => { // The worker's start sequence is its outermost frame: building the error from the diff --git a/src/resolver/tsconfig_json.rs b/src/resolver/tsconfig_json.rs index 753c29d75067..7fcf58f23d37 100644 --- a/src/resolver/tsconfig_json.rs +++ b/src/resolver/tsconfig_json.rs @@ -48,7 +48,7 @@ impl JsonCache { &bun_alloc::Arena, ) -> Result, ) -> Result, crate::Error> { - let mut temp_log = bun_ast::Log::init(); + let mut temp_log = log.child(); let bump = self.bump.get_or_insert_with(bun_alloc::Arena::new); let result = func(source, &mut temp_log, bump).ok(); let _ = temp_log.append_to_maybe_recycled(log, source); @@ -65,7 +65,7 @@ impl JsonCache { &mut bun_ast::Log, ) -> Result, ) -> Result, crate::Error> { - let mut temp_log = bun_ast::Log::init(); + let mut temp_log = log.child(); let result = func(source, &mut temp_log).ok(); let _ = temp_log.append_to_maybe_recycled(log, source); Ok(result) diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index 5e01edc3914a..61caafad63cb 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -280,17 +280,6 @@ impl Config { break 'tsconfig; } self.tsconfig_buf = out.to_owned_slice().into(); - - // TODO: JSC -> Ast conversion - // SAFETY: VirtualMachine::get() returns the live singleton on the JS thread. - let vm = VirtualMachine::get().as_mut(); - if let Ok(Some(parsed_tsconfig)) = TSConfigJSON::parse( - &mut self.log, - &bun_ast::Source::init_path_string(b"tsconfig.json", &self.tsconfig_buf[..]), - &mut vm.transpiler.resolver.caches.json, - ) { - self.tsconfig = Some(parsed_tsconfig); - } } } @@ -325,18 +314,6 @@ impl Config { break 'macros; } self.macros_buf = out.to_owned_slice().into(); - let source = - bun_ast::Source::init_path_string(b"macros.json", &self.macros_buf[..]); - // SAFETY: VirtualMachine::get() returns the live singleton on the JS thread. - let vm = VirtualMachine::get().as_mut(); - let Ok(Some(json)) = vm.transpiler.resolver.caches.json.parse_json( - &mut self.log, - &source, - bun_resolver::tsconfig_json::JsonMode::Json, - ) else { - break 'macros; - }; - self.macro_map = PackageJSON::parse_macros_json(json, &mut self.log, &source); } } @@ -582,6 +559,31 @@ impl Config { } } + // `tsconfig` and `macro` are parsed after `logLevel` so that it covers their diagnostics. + if !self.tsconfig_buf.is_empty() { + // SAFETY: VirtualMachine::get() returns the live singleton on the JS thread. + let vm = VirtualMachine::get().as_mut(); + if let Ok(Some(parsed_tsconfig)) = TSConfigJSON::parse( + &mut self.log, + &bun_ast::Source::init_path_string(b"tsconfig.json", &self.tsconfig_buf[..]), + &mut vm.transpiler.resolver.caches.json, + ) { + self.tsconfig = Some(parsed_tsconfig); + } + } + if !self.macros_buf.is_empty() { + let source = bun_ast::Source::init_path_string(b"macros.json", &self.macros_buf[..]); + // SAFETY: VirtualMachine::get() returns the live singleton on the JS thread. + let vm = VirtualMachine::get().as_mut(); + if let Ok(Some(json)) = vm.transpiler.resolver.caches.json.parse_json( + &mut self.log, + &source, + bun_resolver::tsconfig_json::JsonMode::Json, + ) { + self.macro_map = PackageJSON::parse_macros_json(json, &mut self.log, &source); + } + } + self.tree_shaking = tree_shaking.unwrap_or(false); self.trim_unused_imports = Some(trim_unused_imports.unwrap_or(self.tree_shaking)); Ok(()) @@ -642,8 +644,7 @@ impl TransformTask { loader: Loader, ) -> JSValue { let config = transpiler.config.get(); - let mut log = bun_ast::Log::init(); - log.level = config.log.level; + let log = config.log.child(); // SAFETY: `ManuallyDrop` keeps this bitwise copy from freeing what the // wrapper owns; `run` re-aims its log and arena pointers. @@ -801,31 +802,27 @@ impl TransformTask { fn then(&mut self, promise: &mut JSPromise, global: &JSGlobalObject) -> JsResult<()> { // The job drops this `TransformTask` (running its `Drop`: transpiler // deref etc.) right after `then` returns. - if self.log.has_any() || self.err.is_some() { - let error_value: JsResult = 'brk: { - if let Some(err) = &self.err { - if !self.log.has_any() { - break 'brk bun_jsc::BuildMessage::create( - global, - bun_ast::Msg { - data: bun_ast::Data { - text: err.name().as_bytes().to_vec().into(), - ..Default::default() - }, - ..Default::default() - }, - ); - } - } - - break 'brk self.log.to_js(global, format_args!("Transform failed")); - }; - - promise.reject_with_async_stack(global, error_value)?; - return Ok(()); - } + let log_error = self + .log + .errors_to_js(global, format_args!("Transform failed")) + .transpose(); + let error_value: JsResult = match (log_error, &self.err) { + (Some(value), _) => value, + (None, Some(err)) => bun_jsc::BuildMessage::create( + global, + bun_ast::Msg { + data: bun_ast::Data { + text: err.name().as_bytes().to_vec().into(), + ..Default::default() + }, + ..Default::default() + }, + ), + (None, None) => return self.finish(promise, global), + }; - self.finish(promise, global) + promise.reject_with_async_stack(global, error_value)?; + Ok(()) } fn finish(&mut self, promise: &mut JSPromise, global: &JSGlobalObject) -> JsResult<()> { @@ -923,12 +920,11 @@ impl JSTranspiler { config.from_js(global, config_arg, arena_ref)?; - if (config.log.warnings + config.log.errors) > 0 { - return Err(global.throw_value( - config - .log - .to_js(global, format_args!("Failed to create transpiler"))?, - )); + if let Some(value) = config + .log + .errors_to_js(global, format_args!("Failed to create transpiler"))? + { + return Err(global.throw_value(value)); } // SAFETY: VirtualMachine::get() returns the live singleton on the JS thread. @@ -941,11 +937,11 @@ impl JSTranspiler { ) { Ok(t) => t, Err(err) => { - let log = &mut config.log; - if (log.warnings + log.errors) > 0 { - return Err(global.throw_value( - log.to_js(global, format_args!("Failed to create transpiler"))?, - )); + if let Some(value) = config + .log + .errors_to_js(global, format_args!("Failed to create transpiler"))? + { + return Err(global.throw_value(value)); } return Err(global.throw_error(err, "Error creating transpiler")); } @@ -979,11 +975,11 @@ impl JSTranspiler { transpiler.configure_linker_with_auto_jsx(false); transpiler.options.env.behavior = options::EnvBehavior::disable; if let Err(err) = transpiler.configure_defines() { - let log = &mut config.log; - if (log.warnings + log.errors) > 0 { - return Err( - global.throw_value(log.to_js(global, format_args!("Failed to load define"))?) - ); + if let Some(value) = config + .log + .errors_to_js(global, format_args!("Failed to load define"))? + { + return Err(global.throw_value(value)); } return Err(global.throw_error(err, "Failed to load define")); } @@ -1237,7 +1233,7 @@ impl JSTranspiler { let loader: Option = loader_from_js(global, loader_arg.to_js())?; let arena = Arena::new(); - let mut log = bun_ast::Log::init(); + let mut log = self.config.get().log.child(); // SAFETY: `arena` outlives every use through `self.transpiler` in this fn body; // `_restore` (declared after `arena`/`log`, so dropped first) restores // `prev_arena` and `&self.config.log` before either local drops. @@ -1261,17 +1257,13 @@ impl JSTranspiler { let parse_result = self.get_parse_result(arena_ref, code, loader, MacroJSCtx::ZERO); let log_ref = self.transpiler.get().log_mut(); + if let Some(value) = log_ref.errors_to_js(global, format_args!("Parse error"))? { + return Err(global.throw_value(value)); + } let Some(mut parse_result) = parse_result else { - if (log_ref.warnings + log_ref.errors) > 0 { - return Err(global.throw_value(log_ref.to_js(global, format_args!("Parse error"))?)); - } return Err(global.throw(format_args!("Failed to parse"))); }; - if (log_ref.warnings + log_ref.errors) > 0 { - return Err(global.throw_value(log_ref.to_js(global, format_args!("Parse error"))?)); - } - let exports_label = EncodedSlice::latin1(b"exports"); let imports_label = EncodedSlice::latin1(b"imports"); let named_imports_value = named_imports_to_js( @@ -1380,8 +1372,7 @@ impl JSTranspiler { // (`prev_bundler = this.transpiler`) and restores it on exit. `Transpiler` is not // bitwise-copyable in Rust, so explicitly snapshot the fields the body mutates // (`allocator`, `log`, `macro_context`) and restore them via RAII guard. - let mut log = bun_ast::Log::init(); - log.level = self.config.get().log.level; + let mut log = self.config.get().log.child(); // SAFETY: `arena` outlives every use through `self.transpiler` in this fn body; // `_restore` (declared after `arena`/`log`, so dropped first) restores // `prev_arena`, `&self.config.log`, and `prev_macro_context` before either drops. @@ -1406,17 +1397,13 @@ impl JSTranspiler { let macro_js_ctx: MacroJSCtx = MacroJSCtx(js_ctx_value.0 as i64); let parse_result = self.get_parse_result(arena_ref, code, loader, macro_js_ctx); let log_ref = self.transpiler.get().log_mut(); + if let Some(value) = log_ref.errors_to_js(global, format_args!("Parse error"))? { + return Err(global.throw_value(value)); + } let Some(parse_result) = parse_result else { - if (log_ref.warnings + log_ref.errors) > 0 { - return Err(global.throw_value(log_ref.to_js(global, format_args!("Parse error"))?)); - } return Err(global.throw(format_args!("Failed to parse code"))); }; - if (log_ref.warnings + log_ref.errors) > 0 { - return Err(global.throw_value(log_ref.to_js(global, format_args!("Parse error"))?)); - } - let mut buffer_writer = self.buffer_writer.replace(None).unwrap_or_else(|| { let mut writer = JSPrinter::BufferWriter::init(); bun_core::handle_oom(writer.buffer.grow_if_needed(code.len())); @@ -1531,7 +1518,7 @@ impl JSTranspiler { } let arena = Arena::new(); - let mut log = bun_ast::Log::init(); + let mut log = self.config.get().log.child(); // SAFETY: `arena` outlives every use through `self.transpiler` in this fn body; // `_restore` (declared after `arena`/`log`, so dropped first) restores // `prev_arena` and `&self.config.log` before either local drops. @@ -1594,20 +1581,13 @@ impl JSTranspiler { // (including the error paths). Compute the result, then reset // unconditionally before returning. let result = (|| -> JsResult { + if let Some(value) = log.errors_to_js(global, format_args!("Failed to scan imports"))? { + return Err(global.throw_value(value)); + } if let Err(err) = scan_result { - if (log.warnings + log.errors) > 0 { - return Err(global - .throw_value(log.to_js(global, format_args!("Failed to scan imports"))?)); - } return Err(global.throw_error(err, "Failed to scan imports")); } - if (log.warnings + log.errors) > 0 { - return Err( - global.throw_value(log.to_js(global, format_args!("Failed to scan imports"))?) - ); - } - named_imports_to_js( global, self.scan_pass_result.get().import_records.as_slice(), diff --git a/src/runtime/api/filesystem_router.rs b/src/runtime/api/filesystem_router.rs index c557e085c426..1589ab0c7e04 100644 --- a/src/runtime/api/filesystem_router.rs +++ b/src/runtime/api/filesystem_router.rs @@ -96,7 +96,7 @@ pub(crate) struct FileSystemRouter { #[derive(Clone, Copy, PartialEq, Eq)] enum Load { - /// From the resolver's cache of the directory. A warning is an error. + /// From the resolver's cache of the directory. An error in the log is a failure. Cached, /// From the disk. Warnings pass. FromDisk, @@ -140,10 +140,15 @@ fn load_router( &mut RouterResolver(resolver), &config_dir, ); - if loaded.is_err() || (load == Load::Cached && log.errors + log.warnings > 0) { + if loaded.is_err() { let error = log.to_js(global, format_args!("loading routes"))?; return Err(global.throw_value(error)); } + if load == Load::Cached + && let Some(error) = log.errors_to_js(global, format_args!("loading routes"))? + { + return Err(global.throw_value(error)); + } Ok(Some((router, root_dir_info))) } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 2d68d35449d2..aa7ea9714532 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2679,6 +2679,438 @@ export default <>hi ).toThrow(); }); + describe("a warning is not a failure", () => { + const methods = ["transformSync", "transform", "scan", "scanImports"]; + const optionsAt = (level, options) => (level === "default" ? options : { ...options, logLevel: level }); + const messagesOf = e => (e instanceof AggregateError ? e.errors : [e]).map(m => [m.level, m.message]); + const thrownBy = async fn => { + try { + await fn(); + } catch (e) { + return e; + } + throw new Error("expected a throw"); + }; + + const nothing = { exports: [], imports: [] }; + const defaultExport = { exports: ["default"], imports: [] }; + // The scan pass lists these two for every source that has JSX. + const jsxRuntime = [ + { kind: "require-call", path: "react/jsx-dev-runtime" }, + { kind: "require-call", path: "react" }, + ]; + + // [name, loader, source, warning, transform() output, scan() result, scanImports() result] + const parserWarnings = [ + [ + "legacy HTML comment", + "js", + "x = 1\n--> legacy html comment\ny = 2\n", + 'Treating "-->" as the start of a legacy HTML single-line comment', + "x = 1;\ny = 2;\n", + nothing, + [], + ], + [ + "JSX key after a spread", + "jsx", + 'export default
;\n', + '"key" prop after a {...spread} is deprecated in JSX. Falling back to classic runtime.', + 'export default createElement_mvmpqhxp("div", {\n ...obj,\n key: "k"\n});\n', + defaultExport, + jsxRuntime, + ], + [ + "JSX key without a value", + "jsx", + "export default
;\n", + '"key" prop ignored. Must be a string, number or symbol.', + 'export default jsxDEV_7x81h0kn("div", {}, undefined, false, undefined, this);\n', + defaultExport, + jsxRuntime, + ], + // The visit pass logs the six warnings below. scanImports() has no visit + // pass: it returned [] for these sources before, too. + [ + "write to a getter-only private name", + "js", + "class Foo { get #x() { this.#x = 1 } }", + 'Writing to getter-only property "#x" will throw', + "class Foo {\n get #x() {\n this.#x = 1;\n }\n}\n", + nothing, + [], + ], + [ + "update of a getter-only private name", + "js", + "class Foo { get #x() { this.#x += 1 } }", + 'Writing to getter-only property "#x" will throw', + "class Foo {\n get #x() {\n this.#x += 1;\n }\n}\n", + nothing, + [], + ], + [ + "read of a setter-only private name", + "js", + "class Foo { set #x(x) { this.#x } }", + 'Reading from setter-only property "#x" will throw', + "class Foo {\n set #x(x) {\n this.#x;\n }\n}\n", + nothing, + [], + ], + [ + "update of a setter-only private name", + "js", + "class Foo { set #x(x) { this.#x += 1 } }", + 'Reading from setter-only property "#x" will throw', + "class Foo {\n set #x(x) {\n this.#x += 1;\n }\n}\n", + nothing, + [], + ], + [ + "write to a private method", + "js", + "class Foo { #x() { this.#x = 1 } }", + 'Writing to read-only method "#x" will throw', + "class Foo {\n #x() {\n this.#x = 1;\n }\n}\n", + nothing, + [], + ], + [ + "update of a private method", + "js", + "class Foo { #x() { this.#x += 1 } }", + 'Writing to read-only method "#x" will throw', + "class Foo {\n #x() {\n this.#x += 1;\n }\n}\n", + nothing, + [], + ], + ]; + + describe.each(parserWarnings)("%s", (_, loader, code, warning, output, scanned, imports) => { + it.each(["default", "warn", "error"])("every method returns its result at logLevel %s", async level => { + // `autoImportJSX: false` is the default. It is here so that the two + // JSX rows keep their output if that default changes. + const t = new Bun.Transpiler(optionsAt(level, { loader, autoImportJSX: false })); + expect(t.transformSync(code)).toBe(output); + expect(await t.transform(code)).toBe(output); + expect(t.scan(code)).toEqual(scanned); + expect(t.scanImports(code)).toEqual(imports); + }); + + // Proves that the source above does log this warning. + it("the warning is thrown beside an error", async () => { + const t = new Bun.Transpiler({ loader }); + const e = await thrownBy(() => t.transformSync(code + "\nconst c = 1;\nc = 2;\n")); + expect(messagesOf(e)).toEqual([ + ["warn", warning], + ["error", 'This assignment will throw because "c" is a constant'], + ]); + }); + }); + + it("scan and scanImports return the imports of a source that has a warning", () => { + const t = new Bun.Transpiler({ loader: "js" }); + const code = 'import { a } from "a";\nx = 1\n--> legacy html comment\ny = 2\n'; + expect(t.scan(code)).toEqual({ imports: [{ kind: "import-statement", path: "a" }], exports: [] }); + expect(t.scanImports(code)).toEqual([{ kind: "import-statement", path: "a" }]); + }); + + it("a warning from the JSON loader does not throw", async () => { + const t = new Bun.Transpiler({ loader: "js" }); + const json = '{"a":1,"a":2}'; + const output = "var a = 2;\n\nexport {\n a\n};\nexport default { a, a: 2 };\n"; + expect(t.transformSync(json, "json")).toBe(output); + expect(await t.transform(json, "json")).toBe(output); + expect(t.scan(json, "json")).toEqual(nothing); + // Proves that the duplicate key does log a warning. + expect(messagesOf(await thrownBy(() => t.transformSync('{"a":1,"a":2,}', "json")))).toEqual([ + ["warn", 'Duplicate key "a" in object literal'], + ["error", "JSON does not support trailing commas"], + ]); + }); + + it("transform() rejects with the error name when a failure logs nothing", async () => { + const t = new Bun.Transpiler({ loader: "js" }); + expect(messagesOf(await thrownBy(() => t.transform("a{}", "css")))).toEqual([["error", "ParseError"]]); + }); + + describe("an error beside a warning", () => { + const code = "x = 1\n--> legacy html comment\ny = 2\nbad??!?!?!"; + const warning = ["warn", 'Treating "-->" as the start of a legacy HTML single-line comment']; + const error = ["error", "Unexpected ?"]; + + describe.each(methods)("%s", fn => { + it.each(["default", "warn"])("throws both at logLevel %s", async level => { + const t = new Bun.Transpiler(optionsAt(level, { loader: "js" })); + const e = await thrownBy(() => t[fn](code)); + expect(e).toBeInstanceOf(AggregateError); + expect(messagesOf(e)).toEqual([warning, error]); + }); + + it('throws only the error at logLevel "error"', async () => { + const t = new Bun.Transpiler({ loader: "js", logLevel: "error" }); + const e = await thrownBy(() => t[fn](code)); + expect(e).not.toBeInstanceOf(AggregateError); + expect(messagesOf(e)).toEqual([error]); + }); + }); + }); + + describe("logLevel covers the tsconfig and macro options", () => { + const unexpectedEnd = ["error", 'Expected "}" but found end of file']; + // [name, options that log one warning and one error, the messages in log order] + const configs = [ + [ + "tsconfig warning beside a macro error", + { tsconfig: { compilerOptions: { moduleSuffixes: [".ios"] } }, macro: '{"react": {' }, + [["warn", "moduleSuffixes is not supported yet"], unexpectedEnd], + ], + [ + "duplicate tsconfig key beside a macro error", + { tsconfig: '{"compilerOptions":{"jsx":"react","jsx":"react"}}', macro: '{"react": {' }, + [["warn", 'Duplicate key "jsx" in object literal'], unexpectedEnd], + ], + [ + "tsconfig error beside a duplicate macro key", + { tsconfig: '{"compilerOptions": {', macro: '{"a":{"b":"c"},"a":{"b":"c"}}' }, + [unexpectedEnd, ["warn", 'Duplicate key "a" in object literal']], + ], + ]; + + describe.each(configs)("%s", (_, config, messages) => { + it.each(["default", "warn"])("the constructor throws both at logLevel %s", async level => { + const e = await thrownBy(() => new Bun.Transpiler(optionsAt(level, config))); + expect(e).toBeInstanceOf(AggregateError); + expect(e.message).toBe("Failed to create transpiler"); + expect(messagesOf(e)).toEqual(messages); + }); + + it('the constructor throws only the error at logLevel "error"', async () => { + const e = await thrownBy(() => new Bun.Transpiler({ ...config, logLevel: "error" })); + expect(e).not.toBeInstanceOf(AggregateError); + expect(messagesOf(e)).toEqual([unexpectedEnd]); + }); + }); + + it("tsconfig and macro are still read before logLevel", () => { + const reads = []; + const options = new Proxy( + { + tsconfig: { compilerOptions: { jsx: "react" } }, + macro: { react: { bacon: "./macro.js" } }, + logLevel: "error", + }, + { + get(target, key, receiver) { + if (typeof key === "string") reads.push(key); + return Reflect.get(target, key, receiver); + }, + }, + ); + new Bun.Transpiler(options); + expect(reads.filter(key => key in options)).toEqual(["tsconfig", "macro", "logLevel"]); + }); + + it("an invalid logLevel is still reported before a tsconfig warning", () => { + expect( + () => new Bun.Transpiler({ tsconfig: { compilerOptions: { moduleSuffixes: [".ios"] } }, logLevel: "poop" }), + ).toThrow('logLevel must be one of "verbose", "debug", "info", "warn", or "error"'); + }); + + // Without the option the level is the log level of the process. The + // option wins over it in both directions. + it("the option wins over logLevel in bunfig.toml", async () => { + using dir = tempDir("transpiler-loglevel-bunfig", { + "bunfig.toml": 'logLevel = "error"\n', + "fixture.js": ` + const messagesOf = e => (e instanceof AggregateError ? e.errors : [e]).map(m => [m.level, m.message]); + const thrownBy = fn => { + try { + fn(); + } catch (e) { + return messagesOf(e); + } + }; + const code = "x = 1\\n--> legacy html comment\\ny = 2\\nbad??!?!?!"; + const config = { tsconfig: '{"compilerOptions":{"jsx":"react","jsx":"react"}}', macro: '{"react": {' }; + const out = {}; + for (const level of ["default", "warn"]) { + const logLevel = level === "default" ? undefined : level; + out[level] = { + method: thrownBy(() => new Bun.Transpiler({ loader: "js", logLevel }).transformSync(code)), + constructor: thrownBy(() => new Bun.Transpiler({ ...config, logLevel })), + }; + } + console.log(JSON.stringify(out)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ + default: { method: [["error", "Unexpected ?"]], constructor: [unexpectedEnd] }, + warn: { + method: [ + ["warn", 'Treating "-->" as the start of a legacy HTML single-line comment'], + ["error", "Unexpected ?"], + ], + constructor: [["warn", 'Duplicate key "jsx" in object literal'], unexpectedEnd], + }, + }), + stderr: "", + exitCode: 0, + }); + }); + }); + + describe("constructor", () => { + // [name, options, warning] + const configWarnings = [ + [ + "importsNotUsedAsValues", + { tsconfig: { compilerOptions: { importsNotUsedAsValues: "bogus" } } }, + 'Invalid value "bogus" for "importsNotUsedAsValues"', + ], + [ + "moduleSuffixes", + { tsconfig: { compilerOptions: { moduleSuffixes: [".ios", ""] } } }, + "moduleSuffixes is not supported yet", + ], + [ + "jsxFactory", + { tsconfig: { compilerOptions: { jsxFactory: "1abc" } } }, + 'Invalid JSX member expression: "1abc"', + ], + [ + "jsxFragmentFactory", + { tsconfig: { compilerOptions: { jsxFragmentFactory: "foo-bar" } } }, + 'Invalid JSX member expression: "foo-bar"', + ], + [ + "paths without baseUrl", + { tsconfig: { compilerOptions: { paths: { "a/*": ["b/*"] } } } }, + 'Non-relative path "b/*" is not allowed when "baseUrl" is not set (did you forget a leading "./"?)', + ], + [ + "paths pattern with two stars", + { tsconfig: { compilerOptions: { baseUrl: ".", paths: { "a/*/*": ["./b/*"] } } } }, + 'Invalid pattern "a/*/*", must have at most one "*" character', + ], + [ + "paths substitution with two stars", + { tsconfig: { compilerOptions: { baseUrl: ".", paths: { "a/*": ["./b/*/*"] } } } }, + 'Invalid pattern "./b/*/*", must have at most one "*" character', + ], + [ + "paths value that is not an array", + { tsconfig: { compilerOptions: { baseUrl: ".", paths: { "a/*": "./b/*" } } } }, + 'Substitutions for pattern "a/*" should be an array', + ], + [ + "duplicate tsconfig key", + { tsconfig: '{"compilerOptions":{"jsx":"react","jsx":"react"}}' }, + 'Duplicate key "jsx" in object literal', + ], + [ + "macro key that is not a package", + { macro: { "./rel": { x: "y" } } }, + '"./rel" is not a package path. "macros" remaps package paths to macros. Skipping.', + ], + ["duplicate macro key", { macro: '{"a":{"b":"c"},"a":{"b":"c"}}' }, 'Duplicate key "a" in object literal'], + [ + "macro value that is not an object", + { macro: { react: "nope" } }, + 'Invalid macro remapping in "react": expected object where the keys are import names and the value is a string path to replace', + ], + ]; + const code = "export const a = 1;"; + const output = "export const a = 1;\n"; + const defineError = ["error", 'Expected "}" but found end of file']; + + // In a child process, to read its stderr: the constructor writes nothing. + it.concurrent.each(["default", "warn", "error"])( + "constructs with each warning-only option at logLevel %s", + async level => { + const fixture = ` + const configs = ${JSON.stringify(configWarnings.map(([, config]) => config))}; + const level = ${JSON.stringify(optionsAt(level, {}))}; + const out = configs.map(config => + new Bun.Transpiler({ loader: "tsx", ...config, ...level }).transformSync(${JSON.stringify(code)}), + ); + console.log(JSON.stringify(out)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify(configWarnings.map(() => output)), + stderr: "", + exitCode: 0, + }); + }, + ); + + // Proves that each option above does log its warning. + it.each(configWarnings)("%s: the warning is thrown beside a define error", async (_, config, warning) => { + const e = await thrownBy(() => new Bun.Transpiler({ ...config, define: { a: "{" } })); + expect(e).toBeInstanceOf(AggregateError); + expect(e.message).toBe("Failed to load define"); + expect(messagesOf(e)).toEqual([["warn", warning], defineError]); + + const atError = await thrownBy(() => new Bun.Transpiler({ ...config, define: { a: "{" }, logLevel: "error" })); + expect(atError).not.toBeInstanceOf(AggregateError); + expect(messagesOf(atError)).toEqual([defineError]); + }); + + // The four tsconfig warnings fill the buffer of the transpiler's log. The + // twelve define errors that follow make it grow. + it("define errors after a warning-only tsconfig do not corrupt the log", async () => { + const fixture = ` + const tsconfig = { + compilerOptions: { + importsNotUsedAsValues: "bogus", + jsxFactory: "1abc", + jsxFragmentFactory: "foo-bar", + moduleSuffixes: [".ios"], + }, + }; + const define = Object.fromEntries(Array.from({ length: 12 }, (_, i) => [i + "x", "1"])); + for (let i = 0; i < 20; i++) { + const t = new Bun.Transpiler({ tsconfig, define }); + if (i % 10 === 0) t.transformSync("export const a = 1;"); + if (i % 10 === 9) Bun.gc(true); + } + Bun.gc(true); + console.log("constructed"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "constructed\n", + stderr: "", + exitCode: 0, + signalCode: null, + }); + }); + }); + }); + it("define with an empty-string key is ignored without leaving uninitialized slots", async () => { // `JSPropertyIterator` skips empty-name properties, but `names`/`values` were being // indexed by the property position instead of a dense counter, leaving garbage in the @@ -4148,15 +4580,6 @@ class Foo { expectParseError("class Foo { static { continue } }", 'Cannot use "continue" here'); expectParseError("x: { class Foo { static { break x } } }", 'There is no containing label named "x"'); expectParseError("x: { class Foo { static { continue x } } }", 'There is no containing label named "x"'); - - expectParseError("class Foo { get #x() { this.#x = 1 } }", 'Writing to getter-only property "#x" will throw'); - expectParseError("class Foo { get #x() { this.#x += 1 } }", 'Writing to getter-only property "#x" will throw'); - expectParseError("class Foo { set #x(x) { this.#x } }", 'Reading from setter-only property "#x" will throw'); - expectParseError("class Foo { set #x(x) { this.#x += 1 } }", 'Reading from setter-only property "#x" will throw'); - - // Writing to method warnings - expectParseError("class Foo { #x() { this.#x = 1 } }", 'Writing to read-only method "#x" will throw'); - expectParseError("class Foo { #x() { this.#x += 1 } }", 'Writing to read-only method "#x" will throw'); }); it("class bodies keep `this` and the class name as written", () => { diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 2e22b5eddfcd..bb57ccdec05d 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -662,6 +662,35 @@ it("assetPrefix and origin are what the router was given, or null", () => { expect(empty.origin).toBeNull(); }); +it("a warning in a tsconfig.json under the routes dir does not fail the constructor", () => { + // The resolver logs the warning of a tsconfig.json the first time it reads + // the directory. So each directory below is given to one constructor only. + const tsconfig = JSON.stringify({ compilerOptions: { jsxFactory: "1abc" } }); + using dir = tempDir("fsr-tsconfig-warning", { + "pages/tsconfig.json": tsconfig, + "pages/index.tsx": "export default 1;\n", + "control/tsconfig.json": tsconfig, + "control/[foo.tsx": "export default 1;\n", + }); + + // Proves that this tsconfig.json puts a warning in the log of the + // constructor: the route error of the control directory carries it. + let thrown: any; + try { + new FileSystemRouter({ dir: path.join(String(dir), "control"), style: "nextjs" }); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(AggregateError); + expect(thrown.errors.map((m: any) => [m.level, m.message])).toEqual([ + ["warn", 'Invalid JSX member expression: "1abc"'], + ["error", "Route is missing a closing bracket]"], + ]); + + const router = new FileSystemRouter({ dir: path.join(String(dir), "pages"), style: "nextjs" }); + expect(Object.keys(router.routes)).toEqual(["/"]); +}); + // POSIX allows arbitrary bytes (except '/' and NUL) in filenames, including 0xFF. // The route sorter's lookup table must cover the full u8 range. // Windows and macOS (APFS/HFS+) require filenames to be valid Unicode, so skip there. diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 654d54aac7c3..4eba086c8613 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -733,6 +733,34 @@ describe("error event", () => { expect(err).toBeInstanceOf(Error); expect(err.message).toMatch(/MessagePort \[EventTarget\] \{.*\}/s); }); + + test("is not fired for a warning in the tsconfig.json beside the entry", async () => { + // The parent has no 'error' listener, so an 'error' event ends it with exit code 1. + // The thread that reads a tsconfig.json first logs its warnings. main.mjs is one + // directory up, so for sub/tsconfig.json that thread is the worker. + using dir = tempDir("worker-threads-tsconfig-warning", { + "sub/tsconfig.json": JSON.stringify({ compilerOptions: { moduleSuffixes: [".ios"] } }), + "sub/worker.ts": ` + import { parentPort } from "node:worker_threads"; + parentPort.postMessage("hi"); + `, + "main.mjs": ` + import { Worker } from "node:worker_threads"; + const worker = new Worker("./sub/worker.ts"); + worker.on("message", data => console.log("message:", data)); + worker.on("exit", code => console.log("exit", code)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "message: hi\nexit 0\n", stderr: "", exitCode: 0 }); + }); }); // Debug and ASAN builds assert on `vm.hasTerminationRequest()` if startup continues to its GC after the stop. diff --git a/test/js/web/workers/worker-entry-point.test.ts b/test/js/web/workers/worker-entry-point.test.ts index 6d3fbcd9d9ce..9bc3d6162e72 100644 --- a/test/js/web/workers/worker-entry-point.test.ts +++ b/test/js/web/workers/worker-entry-point.test.ts @@ -66,3 +66,33 @@ describe.concurrent("package.json imports alias as the entry point", () => { expect(exitCode).toBe(0); }); }); + +test("a warning in the tsconfig.json beside the entry point does not fire the error event", async () => { + // The thread that reads a tsconfig.json first logs its warnings. main.js is one directory up, so + // for each tsconfig.json below that thread is a worker. + const tsconfig = JSON.stringify({ compilerOptions: { moduleSuffixes: [".ios"] } }); + const { stdout, stderr, exitCode } = await runWorkerFixture({ + "sub/tsconfig.json": tsconfig, + "sub/worker.ts": `postMessage("hi");`, + "control/tsconfig.json": tsconfig, + "main.js": ` + const run = specifier => + new Promise(resolve => { + const worker = new Worker(specifier); + worker.addEventListener("error", event => console.log("error:", event.message)); + worker.addEventListener("message", event => console.log("message:", event.data)); + worker.addEventListener("close", event => { + console.log("closed", event.code); + resolve(); + }); + }); + await run("./sub/worker.ts"); + // Proves that this tsconfig.json puts a warning in the log of a worker: + // the error of an entry that does not exist carries it. + await run("./control/missing.ts"); + `, + }); + expect(stderr).toBe(""); + expect(stdout).toBe("message: hi\nclosed 0\nerror: AggregateError: Error in worker\nclosed 1\n"); + expect(exitCode).toBe(0); +});