From cb7c8502b0e65c5d02439597528764a5e97f48d4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 29 Apr 2025 17:28:51 +0800 Subject: [PATCH] add console web api --- src/browser/console/console.zig | 218 +++++++++++++++++++++++++++++++- src/browser/env.zig | 5 + src/browser/html/window.zig | 6 + src/runtime/js.zig | 47 ++++++- 4 files changed, 268 insertions(+), 8 deletions(-) diff --git a/src/browser/console/console.zig b/src/browser/console/console.zig index 59f56594..55a2585e 100644 --- a/src/browser/console/console.zig +++ b/src/browser/console/console.zig @@ -17,12 +17,224 @@ // along with this program. If not, see . const std = @import("std"); -const log = std.log.scoped(.console); +const builtin = @import("builtin"); + +const JsObject = @import("../env.zig").Env.JsObject; +const SessionState = @import("../env.zig").SessionState; + +const log = if (builtin.is_test) &test_capture else std.log.scoped(.console); pub const Console = struct { // TODO: configurable writer + timers: std.StringHashMapUnmanaged(u32) = .{}, + counts: std.StringHashMapUnmanaged(u32) = .{}, - pub fn _log(_: *const Console, str: []const u8) void { - log.debug("{s}\n", .{str}); + pub fn _log(_: *const Console, values: []JsObject, state: *SessionState) !void { + if (values.len == 0) { + return; + } + log.info("{s}", .{try serializeValues(values, state)}); + } + + pub fn _info(console: *const Console, values: []JsObject, state: *SessionState) !void { + return console._log(values, state); + } + + pub fn _debug(_: *const Console, values: []JsObject, state: *SessionState) !void { + if (values.len == 0) { + return; + } + log.debug("{s}", .{try serializeValues(values, state)}); + } + + pub fn _warn(_: *const Console, values: []JsObject, state: *SessionState) !void { + if (values.len == 0) { + return; + } + log.warn("{s}", .{try serializeValues(values, state)}); + } + + pub fn _error(_: *const Console, values: []JsObject, state: *SessionState) !void { + if (values.len == 0) { + return; + } + log.err("{s}", .{try serializeValues(values, state)}); + } + + pub fn _clear(_: *const Console) void {} + + pub fn _count(self: *Console, label_: ?[]const u8, state: *SessionState) !void { + const label = label_ orelse "default"; + const gop = try self.counts.getOrPut(state.arena, label); + + var current: u32 = 0; + if (gop.found_existing) { + current = gop.value_ptr.*; + } else { + gop.key_ptr.* = try state.arena.dupe(u8, label); + } + + const count = current + 1; + gop.value_ptr.* = count; + + log.info("{s}: {d}", .{ label, count }); + } + + pub fn _countReset(self: *Console, label_: ?[]const u8) !void { + const label = label_ orelse "default"; + const kv = self.counts.fetchRemove(label) orelse { + log.warn("Counter \"{s}\" doesn't exist.", .{label}); + return; + }; + + log.info("{s}: {d}", .{ label, kv.value }); + } + + pub fn _time(self: *Console, label_: ?[]const u8, state: *SessionState) !void { + const label = label_ orelse "default"; + const gop = try self.timers.getOrPut(state.arena, label); + + if (gop.found_existing) { + log.warn("Timer \"{s}\" already exists.", .{label}); + return; + } + gop.key_ptr.* = try state.arena.dupe(u8, label); + gop.value_ptr.* = timestamp(); + } + + pub fn _timeLog(self: *Console, label_: ?[]const u8) void { + const elapsed = timestamp(); + const label = label_ orelse "default"; + const start = self.timers.get(label) orelse { + log.warn("Timer \"{s}\" doesn't exist.", .{label}); + return; + }; + + log.info("\"{s}\": {d}ms", .{ label, elapsed - start }); + } + + pub fn _timeStop(self: *Console, label_: ?[]const u8) void { + const elapsed = timestamp(); + const label = label_ orelse "default"; + const kv = self.timers.fetchRemove(label) orelse { + log.warn("Timer \"{s}\" doesn't exist.", .{label}); + return; + }; + + log.info("\"{s}\": {d}ms - timer ended", .{ label, elapsed - kv.value }); + } + + pub fn _assert(_: *Console, assertion: JsObject, values: []JsObject, state: *SessionState) !void { + if (assertion.isTruthy()) { + return; + } + var serialized_values: []const u8 = ""; + if (values.len > 0) { + serialized_values = try serializeValues(values, state); + } + log.err("Assertion failed: {s}", .{serialized_values}); + } + + fn serializeValues(values: []JsObject, state: *SessionState) ![]const u8 { + const arena = state.call_arena; + var arr: std.ArrayListUnmanaged(u8) = .{}; + try arr.appendSlice(arena, try values[0].toString()); + for (values[1..]) |value| { + try arr.append(arena, ' '); + try arr.appendSlice(arena, try value.toString()); + } + return arr.items; + } +}; + +fn timestamp() u32 { + const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch unreachable; + return @intCast(ts.sec); +} + +var test_capture = TestCapture{}; +const testing = @import("../../testing.zig"); +test "Browser.Console" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{}); + defer runner.deinit(); + + defer testing.reset(); + + { + try runner.testCases(&.{ + .{ "console.log('a')", "undefined" }, + .{ "console.warn('hello world', 23, true, new Object())", "undefined" }, + }, .{}); + + const captured = test_capture.captured.items; + try testing.expectEqual("a", captured[0]); + try testing.expectEqual("hello world 23 true [object Object]", captured[1]); + } + + { + test_capture.reset(); + try runner.testCases(&.{ + .{ "console.countReset()", "undefined" }, + .{ "console.count()", "undefined" }, + .{ "console.count('teg')", "undefined" }, + .{ "console.count('teg')", "undefined" }, + .{ "console.count('teg')", "undefined" }, + .{ "console.count()", "undefined" }, + .{ "console.countReset('teg')", "undefined" }, + .{ "console.countReset()", "undefined" }, + .{ "console.count()", "undefined" }, + }, .{}); + + const captured = test_capture.captured.items; + try testing.expectEqual("Counter \"default\" doesn't exist.", captured[0]); + try testing.expectEqual("default: 1", captured[1]); + try testing.expectEqual("teg: 1", captured[2]); + try testing.expectEqual("teg: 2", captured[3]); + try testing.expectEqual("teg: 3", captured[4]); + try testing.expectEqual("default: 2", captured[5]); + try testing.expectEqual("teg: 3", captured[6]); + try testing.expectEqual("default: 2", captured[7]); + try testing.expectEqual("default: 1", captured[8]); + } + + { + test_capture.reset(); + try runner.testCases(&.{ + .{ "console.assert(true)", "undefined" }, + .{ "console.assert('a', 2, 3, 4)", "undefined" }, + .{ "console.assert('')", "undefined" }, + .{ "console.assert('', 'x', true)", "undefined" }, + .{ "console.assert(false, 'x')", "undefined" }, + }, .{}); + + const captured = test_capture.captured.items; + try testing.expectEqual("Assertion failed: ", captured[0]); + try testing.expectEqual("Assertion failed: x true", captured[1]); + try testing.expectEqual("Assertion failed: x", captured[2]); + } +} + +const TestCapture = struct { + captured: std.ArrayListUnmanaged([]const u8) = .{}, + + fn reset(self: *TestCapture) void { + self.captured = .{}; + } + + fn debug(self: *TestCapture, comptime fmt: []const u8, args: anytype) void { + const str = std.fmt.allocPrint(testing.arena_allocator, fmt, args) catch unreachable; + self.captured.append(testing.arena_allocator, str) catch unreachable; + } + + fn info(self: *TestCapture, comptime fmt: []const u8, args: anytype) void { + self.debug(fmt, args); + } + + fn warn(self: *TestCapture, comptime fmt: []const u8, args: anytype) void { + self.debug(fmt, args); + } + + fn err(self: *TestCapture, comptime fmt: []const u8, args: anytype) void { + self.debug(fmt, args); } }; diff --git a/src/browser/env.zig b/src/browser/env.zig index 64b227fb..80e0ecbc 100644 --- a/src/browser/env.zig +++ b/src/browser/env.zig @@ -35,4 +35,9 @@ pub const SessionState = struct { http_client: *HttpClient, cookie_jar: *storage.CookieJar, document: ?*parser.DocumentHTML, + + // dangerous, but set by the JS framework + // shorter-lived than the arena above, which + // exists for the entire rendering of the page + call_arena: std.mem.Allocator = undefined, }; diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index 45aa7673..45e7d045 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -25,6 +25,7 @@ const SessionState = @import("../env.zig").SessionState; const Navigator = @import("navigator.zig").Navigator; const History = @import("history.zig").History; const Location = @import("location.zig").Location; +const Console = @import("../console/console.zig").Console; const EventTarget = @import("../dom/event_target.zig").EventTarget; const storage = @import("../storage/storage.zig"); @@ -48,6 +49,7 @@ pub const Window = struct { timeoutid: u32 = 0, timeoutids: [512]u64 = undefined, + console: Console = .{}, navigator: Navigator = .{}, pub fn create(target: ?[]const u8, navigator: ?Navigator) Window { @@ -85,6 +87,10 @@ pub const Window = struct { return &self.location; } + pub fn get_console(self: *Window) *Console { + return &self.console; + } + pub fn get_self(self: *Window) *Window { return self; } diff --git a/src/runtime/js.zig b/src/runtime/js.zig index eaa20b6a..8189c542 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -416,6 +416,16 @@ pub fn Env(comptime S: type, comptime types: anytype) type { } errdefer if (enter) handle_scope.?.deinit(); + { + // If we want to overwrite the built-in console, we have to + // delete the built-in one. + const js_obj = context.getGlobal(); + const console_key = v8.String.initUtf8(isolate, "console"); + if (js_obj.deleteValue(context, console_key) == false) { + return error.ConsoleDeleteError; + } + } + self.scope = Scope{ .state = state, .isolate = isolate, @@ -439,6 +449,16 @@ pub fn Env(comptime S: type, comptime types: anytype) type { context.setEmbedderData(1, data); } + { + // Not the prettiest but we want to make the `call_arena` + // optionally available to the WebAPIs. If `state` has a + // call_arena field, fill-it in now. + const state_type_info = @typeInfo(@TypeOf(state)); + if (state_type_info == .pointer and @hasField(state_type_info.pointer.child, "call_arena")) { + scope.state.call_arena = scope.call_arena; + } + } + // Custom exception // NOTE: there is no way in v8 to subclass the Error built-in type // TODO: this is an horrible hack @@ -875,6 +895,21 @@ pub fn Env(comptime S: type, comptime types: anytype) type { return error.FailedToSet; } } + + pub fn isTruthy(self: JsObject) bool { + const js_value = self.js_obj.toValue(); + return js_value.toBool(self.scope.isolate); + } + + pub fn toString(self: JsObject) ![]const u8 { + const scope = self.scope; + const js_value = self.js_obj.toValue(); + return valueToString(scope.call_arena, js_value, scope.isolate, scope.context); + } + + pub fn format(self: JsObject, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + return writer.writeAll(try self.toString()); + } }; // This only exists so that we know whether a function wants the opaque @@ -1990,13 +2025,15 @@ fn Caller(comptime E: type) type { is_variadic = true; if (js_parameter_count == 0) { @field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{}; - } else { + } else if (js_parameter_count >= params_to_map.len) { const arr = try self.call_arena.alloc(last_parameter_type_info.pointer.child, js_parameter_count - params_to_map.len + 1); for (arr, last_js_parameter..) |*a, i| { const js_value = info.getArg(@as(u32, @intCast(i))); a.* = try self.jsValueToZig(named_function, slice_type, js_value); } @field(args, tupleFieldName(params_to_map.len + offset - 1)) = arr; + } else { + @field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{}; } } } @@ -2168,10 +2205,6 @@ fn Caller(comptime E: type) type { }; } - if (!js_value.isObject()) { - return error.InvalidArgument; - } - const js_obj = js_value.castTo(v8.Object); if (comptime isJsObject(T)) { @@ -2183,6 +2216,10 @@ fn Caller(comptime E: type) type { }; } + if (!js_value.isObject()) { + return error.InvalidArgument; + } + const context = self.context; const isolate = self.isolate;