Migrate some tests to the new htmlRunner

Fix events.get_timeStamp (was events.get_timestamp, wrong casing).

Rename `newRunner` to `htmlRunner`.

move tests to src/tests (from src/browser/tests). src/runtime and possibly other
parts might want to have html tests too.
This commit is contained in:
Karl Seguin
2025-09-02 10:38:27 +08:00
parent e486f28a41
commit 81766c8517
22 changed files with 317 additions and 300 deletions

View File

@@ -112,11 +112,5 @@ pub const Browser = struct {
const testing = @import("../testing.zig");
test "Browser" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
// this will crash if ICU isn't properly configured / ininitialized
try runner.testCases(&.{
.{ "new Intl.DateTimeFormat()", "[object Intl.DateTimeFormat]" },
}, .{});
try testing.htmlRunner("browser.html");
}

View File

@@ -66,32 +66,6 @@ const RandomValues = union(enum) {
};
const testing = @import("../../testing.zig");
test "Browser.Crypto" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "const a = crypto.randomUUID();", "undefined" },
.{ "const b = crypto.randomUUID();", "undefined" },
.{ "a.length;", "36" },
.{ "b.length;", "36" },
.{ "a == b;", "false" },
}, .{});
try runner.testCases(&.{
.{ "try { crypto.getRandomValues(new BigUint64Array(8193)) } catch(e) { e.message == 'QuotaExceededError' }", "true" },
.{ "let r1 = new Int32Array(5)", "undefined" },
.{ "let r2 = crypto.getRandomValues(r1)", "undefined" },
.{ "new Set(r1).size", "5" },
.{ "new Set(r2).size", "5" },
.{ "r1.every((v, i) => v === r2[i])", "true" },
}, .{});
try runner.testCases(&.{
.{ "var r3 = new Uint8Array(16)", null },
.{ "let r4 = crypto.getRandomValues(r3)", "undefined" },
.{ "r4[6] = 10", null },
.{ "r4[6]", "10" },
.{ "r3[6]", "10" },
}, .{});
test "Browser: Crypto" {
try testing.htmlRunner("crypto.html");
}

View File

@@ -190,12 +190,6 @@ test "parse" {
}
const testing = @import("../../testing.zig");
test "Browser.HTML.CSS" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "CSS.supports('display: flex')", "true" },
.{ "CSS.supports('text-decoration-style', 'blink')", "true" },
}, .{});
test "Browser: CSS" {
try testing.htmlRunner("css.html");
}

View File

@@ -79,29 +79,6 @@ pub fn _decode(self: *const TextDecoder, v: []const u8) ![]const u8 {
}
const testing = @import("../../testing.zig");
test "Browser.Encoding.TextDecoder" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{
.html = "",
});
defer runner.deinit();
try runner.testCases(&.{
.{ "let d1 = new TextDecoder();", null },
.{ "d1.encoding;", "utf-8" },
.{ "d1.fatal", "false" },
.{ "d1.ignoreBOM", "false" },
.{ "d1.decode(new Uint8Array([240, 160, 174, 183]))", "𠮷" },
.{ "d1.decode(new Uint8Array([0xEF, 0xBB, 0xBF, 240, 160, 174, 183]))", "𠮷" },
.{ "d1.decode(new Uint8Array([49, 50]).buffer)", "12" },
.{ "let d2 = new TextDecoder('utf8', {fatal: true})", null },
.{
\\ try {
\\ let data = new Uint8Array([240, 240, 160, 174, 183]);
\\ d2.decode(data);
\\ } catch (e) {e}
,
"Error: InvalidUtf8",
},
}, .{});
test "Browser: Encoding.TextDecoder" {
try testing.htmlRunner("encoding/decoder.html");
}

View File

@@ -43,20 +43,6 @@ pub fn _encode(_: *const TextEncoder, v: []const u8) !Env.TypedArray(u8) {
}
const testing = @import("../../testing.zig");
test "Browser.Encoding.TextEncoder" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{
.html = "",
});
defer runner.deinit();
try runner.testCases(&.{
.{ "var encoder = new TextEncoder();", null },
.{ "encoder.encoding;", "utf-8" },
.{ "encoder.encode('€');", "226,130,172" },
// Invalid utf-8 sequence.
// Result with chrome:
// .{ "encoder.encode(new Uint8Array([0xE2,0x28,0xA1]))", "50,50,54,44,52,48,44,49,54,49" },
.{ "try {encoder.encode(new Uint8Array([0xE2,0x28,0xA1])) } catch (e) { e };", "Error: InvalidUtf8" },
}, .{});
test "Browser: Encoding.TextEncoder" {
try testing.htmlRunner("encoding/encoder.html");
}

View File

@@ -58,23 +58,6 @@ pub const CustomEvent = struct {
};
const testing = @import("../../testing.zig");
test "Browser.CustomEvent" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let capture = null", "undefined" },
.{ "const el = document.createElement('div');", "undefined" },
.{ "el.addEventListener('c1', (e) => { capture = 'c1-' + new String(e.detail)})", "undefined" },
.{ "el.addEventListener('c2', (e) => { capture = 'c2-' + new String(e.detail.over)})", "undefined" },
.{ "el.dispatchEvent(new CustomEvent('c1'));", "true" },
.{ "capture", "c1-null" },
.{ "el.dispatchEvent(new CustomEvent('c1', {detail: '123'}));", "true" },
.{ "capture", "c1-123" },
.{ "el.dispatchEvent(new CustomEvent('c2', {detail: {over: 9000}}));", "true" },
.{ "capture", "c2-9000" },
}, .{});
test "Browser: Events.Custom" {
try testing.htmlRunner("events/custom.html");
}

View File

@@ -110,8 +110,11 @@ pub const Event = struct {
return try parser.eventIsTrusted(self);
}
pub fn get_timestamp(self: *parser.Event) !u32 {
return try parser.eventTimestamp(self);
// Even though this is supposed to to provide microsecond resolution, browser
// return coarser values to protect against fingerprinting. libdom returns
// seconds, which is good enough.
pub fn get_timeStamp(self: *parser.Event) !u32 {
return parser.eventTimestamp(self);
}
// Methods
@@ -386,155 +389,6 @@ const SignalCallback = struct {
};
const testing = @import("../../testing.zig");
test "Browser.Event" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let para = document.getElementById('para')", "undefined" },
.{ "var nb = 0; var evt", "undefined" },
}, .{});
try runner.testCases(&.{
.{
\\ content.addEventListener('target', function(e) {
\\ evt = e; nb = nb + 1;
\\ e.preventDefault();
\\ })
,
"undefined",
},
.{ "content.dispatchEvent(new Event('target', {bubbles: true, cancelable: true}))", "false" },
.{ "nb", "1" },
.{ "evt.target === content", "true" },
.{ "evt.bubbles", "true" },
.{ "evt.cancelable", "true" },
.{ "evt.defaultPrevented", "true" },
.{ "evt.isTrusted", "true" },
.{ "evt.timestamp > 1704063600", "true" }, // 2024/01/01 00:00
// event.type, event.currentTarget, event.phase checked in EventTarget
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{
\\ content.addEventListener('stop',function(e) {
\\ e.stopPropagation();
\\ nb = nb + 1;
\\ }, true)
,
"undefined",
},
// the following event listener will not be invoked
.{
\\ para.addEventListener('stop',function(e) {
\\ nb = nb + 1;
\\ })
,
"undefined",
},
.{ "para.dispatchEvent(new Event('stop'))", "true" },
.{ "nb", "1" }, // will be 2 if event was not stopped at content event listener
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{
\\ content.addEventListener('immediate', function(e) {
\\ e.stopImmediatePropagation();
\\ nb = nb + 1;
\\ })
,
"undefined",
},
// the following event listener will not be invoked
.{
\\ content.addEventListener('immediate', function(e) {
\\ nb = nb + 1;
\\ })
,
"undefined",
},
.{ "content.dispatchEvent(new Event('immediate'))", "true" },
.{ "nb", "1" }, // will be 2 if event was not stopped at first content event listener
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{
\\ content.addEventListener('legacy', function(e) {
\\ evt = e; nb = nb + 1;
\\ })
,
"undefined",
},
.{ "let evtLegacy = document.createEvent('Event')", "undefined" },
.{ "evtLegacy.initEvent('legacy')", "undefined" },
.{ "content.dispatchEvent(evtLegacy)", "true" },
.{ "nb", "1" },
}, .{});
try runner.testCases(&.{
.{ "var nb = 0; var evt = null; function cbk(event) { nb ++; evt=event; }", "undefined" },
.{ "document.addEventListener('count', cbk)", "undefined" },
.{ "document.removeEventListener('count', cbk)", "undefined" },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "nb", "0" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; function cbk(event) { nb ++; }", null },
.{ "document.addEventListener('count', cbk, {once: true})", null },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "nb", "1" },
.{ "document.removeEventListener('count', cbk)", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; function cbk(event) { nb ++; }", null },
.{ "let ac = new AbortController()", null },
.{ "document.addEventListener('count', cbk, {signal: ac.signal})", null },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "ac.abort()", null },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "nb", "2" },
.{ "document.removeEventListener('count', cbk)", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "new Event('').composedPath()", "" },
.{
\\ let div1 = document.createElement('div');
\\ let sr1 = div1.attachShadow({mode: 'open'});
\\ sr1.innerHTML = "<p id=srp1></p>";
\\ document.getElementsByTagName('body')[0].appendChild(div1);
\\ let cp = null;
\\ div1.addEventListener('click', (e) => {
\\ cp = e.composedPath().map((n) => n.id || n.nodeName || n.toString());
\\ });
\\ sr1.getElementById('srp1').click();
\\ cp.join(', ');
,
"srp1, #document-fragment, DIV, BODY, HTML, #document, [object Window]",
},
.{
\\ let div2 = document.createElement('div');
\\ let sr2 = div2.attachShadow({mode: 'closed'});
\\ sr2.innerHTML = "<p id=srp2></p>";
\\ document.getElementsByTagName('body')[0].appendChild(div2);
\\ cp = null;
\\ div2.addEventListener('click', (e) => {
\\ cp = e.composedPath().map((n) => n.id || n.nodeName || n.toString());
\\ });
\\ sr2.getElementById('srp2').click();
\\ cp.join(', ');
,
"DIV, BODY, HTML, #document, [object Window]",
},
}, .{});
test "Browser: Event" {
try testing.htmlRunner("events/event.html");
}

View File

@@ -107,34 +107,6 @@ pub const MouseEvent = struct {
};
const testing = @import("../../testing.zig");
test "Browser.MouseEvent" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
// Default MouseEvent
.{ "let event = new MouseEvent('click')", "undefined" },
.{ "event.type", "click" },
.{ "event instanceof MouseEvent", "true" },
.{ "event instanceof Event", "true" },
.{ "event.clientX", "0" },
.{ "event.clientY", "0" },
.{ "event.screenX", "0" },
.{ "event.screenY", "0" },
// MouseEvent with parameters
.{ "let new_event = new MouseEvent('click', { 'button': 0, 'clientX': 10, 'clientY': 20 })", "undefined" },
.{ "new_event.button", "0" },
.{ "new_event.x", "10" },
.{ "new_event.y", "20" },
.{ "new_event.screenX", "10" },
.{ "new_event.screenY", "20" },
// MouseEvent Listener
.{ "let me = new MouseEvent('click')", "undefined" },
.{ "me instanceof Event", "true" },
.{ "var eevt = null; function ccbk(event) { eevt = event; }", "undefined" },
.{ "document.addEventListener('click', ccbk)", "undefined" },
.{ "document.dispatchEvent(me)", "true" },
.{ "eevt.type", "click" },
.{ "eevt instanceof MouseEvent", "true" },
}, .{});
test "Browser: Events.Mouse" {
try testing.htmlRunner("events/mouse.html");
}

View File

@@ -439,6 +439,6 @@ const TimerCallback = struct {
const testing = @import("../../testing.zig");
test "Browser: Window" {
try testing.newRunner("window/window.html");
try testing.newRunner("window/frames.html");
try testing.htmlRunner("window/window.html");
try testing.htmlRunner("window/frames.html");
}

View File

@@ -796,7 +796,7 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
});
}
if (std.mem.startsWith(u8, path, "/src/browser/tests/")) {
if (std.mem.startsWith(u8, path, "/src/tests/")) {
// strip off leading / so that it's relative to CWD
return TestHTTPServer.sendFile(req, path[1..]);
}

View File

@@ -506,7 +506,7 @@ pub fn shutdown() void {
test_app.deinit();
}
pub fn newRunner(file: []const u8) !void {
pub fn htmlRunner(file: []const u8) !void {
defer _ = arena_instance.reset(.retain_capacity);
const page = try test_session.createPage();
defer test_session.removePage();
@@ -516,7 +516,7 @@ pub fn newRunner(file: []const u8) !void {
try_catch.init(js_context);
defer try_catch.deinit();
const url = try std.fmt.allocPrint(arena_allocator, "http://localhost:9582/src/browser/tests/{s}", .{file});
const url = try std.fmt.allocPrint(arena_allocator, "http://localhost:9582/src/tests/{s}", .{file});
try page.navigate(url, .{});
page.wait(2);

5
src/tests/browser.html Normal file
View File

@@ -0,0 +1,5 @@
<script src="testing.js"></script>
<script id=intl>
// this will crash if ICU isn't properly configured / ininitialized
testing.expectEqual("[object Intl.DateTimeFormat]", new Intl.DateTimeFormat().toString());
</script>

25
src/tests/crypto.html Normal file
View File

@@ -0,0 +1,25 @@
<script src="testing.js"></script>
<script id=crypto>
const a = crypto.randomUUID();
const b = crypto.randomUUID();
testing.expectEqual(36, a.length);
testing.expectEqual(36, b.length);
testing.expectEqual(false, a == b)
testing.expectError('Error: QuotaExceededError', () => {
crypto.getRandomValues(new BigUint64Array(8193));
});
let r1 = new Int32Array(5)
let r2 = crypto.getRandomValues(r1)
testing.expectEqual(5, new Set(r1).size);
testing.expectEqual(5, new Set(r2).size);
testing.expectEqual(true, r1.every((v, i) => v === r2[i]));
var r3 = new Uint8Array(16);
let r4 = crypto.getRandomValues(r3);
r4[6] = 10;
testing.expectEqual(10, r4[6]);
testing.expectEqual(10, r3[6]);
</script>

5
src/tests/css.html Normal file
View File

@@ -0,0 +1,5 @@
<script src="testing.js"></script>
<script id=support>
testing.expectEqual(true, CSS.supports('display: flex'));
testing.expectEqual(true, CSS.supports('text-decoration-style', 'blink'));
</script>

View File

@@ -0,0 +1,17 @@
<script src="../testing.js"></script>
<script id=decoder>
let d1 = new TextDecoder();
testing.expectEqual('utf-8', d1.encoding);
testing.expectEqual(false, d1.fatal);
testing.expectEqual(false, d1.ignoreBOM);
testing.expectEqual('𠮷', d1.decode(new Uint8Array([240, 160, 174, 183])));
testing.expectEqual('𠮷', d1.decode(new Uint8Array([0xEF, 0xBB, 0xBF, 240, 160, 174, 183])));
testing.expectEqual('<27>2', d1.decode(new Uint8Array([249, 50])));
let d2 = new TextDecoder('utf8', {fatal: true})
testing.expectError('Error: InvalidUtf8', () => {
let data = new Uint8Array([240, 240, 160, 174, 183]);
d2.decode(data);
});
</script>

View File

@@ -0,0 +1,13 @@
<script src="../testing.js"></script>
<script id=encoder>
var encoder = new TextEncoder();
testing.expectEqual('utf-8', encoder.encoding);
testing.expectEqual([226, 130, 172], Array.from(encoder.encode('€')));
// Invalid utf-8 sequence.
// Browsers give a different result for this, they decode it to:
// 50, 50, 54, 44, 52, 48, 44, 49, 54, 49
testing.expectError('Error: InvalidUtf8', () => {
encoder.encode(new Uint8Array([0xE2,0x28,0xA1]));
});
</script>

View File

@@ -0,0 +1,16 @@
<script src="../testing.js"></script>
<script id=custom>
let capture = null;
const el = document.createElement('div');
el.addEventListener('c1', (e) => { capture = 'c1-' + new String(e.detail)});
el.addEventListener('c2', (e) => { capture = 'c2-' + new String(e.detail.over)});
el.dispatchEvent(new CustomEvent('c1'));
testing.expectEqual("c1-null", capture);
el.dispatchEvent(new CustomEvent('c1', {detail: '123'}));
testing.expectEqual("c1-123", capture);
el.dispatchEvent(new CustomEvent('c2', {detail: {over: 9000}}));
testing.expectEqual("c2-9000", capture);
</script>

138
src/tests/events/event.html Normal file
View File

@@ -0,0 +1,138 @@
<script src="../testing.js"></script>
<div id=content>
<p id="para"></p>
</div>
<script id=dispatch>
const startTime = new Date().getTime();
let content = $('#content');
// let para = document.getElementById('para');
var nb = 0;
var evt = null;
const incrementCallback = function(e) {
evt = e;
nb += 1;
e.preventDefault();
}
content.addEventListener('dispatch', incrementCallback);
content.dispatchEvent(new Event('dispatch', {bubbles: true, cancelable: true}));
testing.expectEqual(1, nb);
testing.expectEqual(content, evt.target);
testing.expectEqual(true, evt.bubbles);
testing.expectEqual(true, evt.cancelable);
testing.expectEqual(true, evt.defaultPrevented);
testing.expectEqual(true, evt.isTrusted);
testing.expectEqual(true, evt.timeStamp >= Math.floor(startTime/1000));
</script>
<script id=propogate>
nb = 0;
let para = $('#para');
// the stop listener is capturing, so it propogates down
content.addEventListener('stop',function(e) {
e.stopPropagation();
nb += 1;
}, true)
para.addEventListener('stop',function(e) {
nb += 10;
});
para.dispatchEvent(new Event('stop'));
// didn't propogate down (because of capturing) to para handler
testing.expectEqual(1, nb);
</script>
<script id=immediate>
nb = 0;
content.addEventListener('immediate', function(e) {
e.stopImmediatePropagation();
nb += 1;
});
// the following event listener will not be invoked
content.addEventListener('immediate', function(e) {
nb += 10;
});
content.dispatchEvent(new Event('immediate'));
testing.expectEqual(1, nb);
</script>
<script id=legacy>
nb = 0;
content.addEventListener('legacy', incrementCallback);
let evtLegacy = document.createEvent('Event');
evtLegacy.initEvent('legacy');
content.dispatchEvent(evtLegacy);
testing.expectEqual(1, nb);
</script>
<script id=removeListener>
nb = 0;
document.addEventListener('count', incrementCallback);
document.removeEventListener('count', incrementCallback);
document.dispatchEvent(new Event('count'));
testing.expectEqual(0, nb);
</script>
<script id=once>
document.addEventListener('count', incrementCallback, {once: true});
document.dispatchEvent(new Event('count'));
document.dispatchEvent(new Event('count'));
document.dispatchEvent(new Event('count'));
testing.expectEqual(1, nb);
</script>
<script id=abortController>
nb = 0;
let ac = new AbortController()
document.addEventListener('count', incrementCallback, {signal: ac.signal})
document.dispatchEvent(new Event('count'));
document.dispatchEvent(new Event('count'));
ac.abort();
document.dispatchEvent(new Event('count'));
testing.expectEqual(2, nb);
document.removeEventListener('count', incrementCallback);
</script>
<script id=composedPath>
testing.expectEqual([], new Event('').composedPath());
let div1 = document.createElement('div');
let sr1 = div1.attachShadow({mode: 'open'});
sr1.innerHTML = "<p id=srp1></p>";
document.getElementsByTagName('body')[0].appendChild(div1);
let cp = null;
const shadowCallback = function(e) {
cp = e.composedPath().map((n) => n.id || n.nodeName || n.toString());
}
div1.addEventListener('click', shadowCallback);
sr1.getElementById('srp1').click();
testing.expectEqual(
['srp1', '#document-fragment', 'DIV', 'BODY', 'HTML', '#document', '[object Window]'],
cp
);
let div2 = document.createElement('div');
let sr2 = div2.attachShadow({mode: 'closed'});
sr2.innerHTML = "<p id=srp2></p>";
document.getElementsByTagName('body')[0].appendChild(div2);
cp = null;
div2.addEventListener('click', shadowCallback);
sr2.getElementById('srp2').click();
testing.expectEqual(
['DIV', 'BODY', 'HTML', '#document', '[object Window]'],
cp
);
</script>

View File

@@ -0,0 +1,33 @@
<script src="../testing.js"></script>
<script id=default>
let event = new MouseEvent('click');
testing.expectEqual('click', event.type);
testing.expectEqual(true, event instanceof MouseEvent);
testing.expectEqual(true, event instanceof Event);
testing.expectEqual(0, event.clientX);
testing.expectEqual(0, event.clientY);
testing.expectEqual(0, event.screenX);
testing.expectEqual(0, event.screenY);
</script>
<script id=parameters>
let new_event = new MouseEvent('click', { 'button': 0, 'clientX': 10, 'clientY': 20 });
testing.expectEqual(0, new_event.button);
testing.expectEqual(10, new_event.x);
testing.expectEqual(20, new_event.y);
testing.expectEqual(10, new_event.screenX);
testing.expectEqual(20, new_event.screenY);
</script>
<script id=listener>
let me = new MouseEvent('click');
testing.expectEqual(true, me instanceof Event);
var evt = null;
document.addEventListener('click', function (e) {
evt = e;
});
document.dispatchEvent(me);
testing.expectEqual('click', evt.type);
testing.expectEqual(true, evt instanceof MouseEvent);
</script>

View File

@@ -13,16 +13,17 @@
(() => {
function expectEqual(expected, actual) {
_recordExecution();
if (expected !== actual) {
testing._status = 'fail';
let msg = `expected: ${JSON.stringify(expected)}, got: ${JSON.stringify(actual)}`;
console.warn(
`id: ${testing._captured?.script_id || document.currentScript.id}`,
`msg: ${msg}`,
`stack: ${testing._captured?.stack || new Error().stack}`,
);
if (_equal(expected, actual)) {
return;
}
testing._status = 'fail';
let msg = `expected: ${JSON.stringify(expected)}, got: ${JSON.stringify(actual)}`;
console.warn(
`id: ${testing._captured?.script_id || document.currentScript.id}`,
`msg: ${msg}`,
`stack: ${testing._captured?.stack || new Error().stack}`,
);
}
function expectError(expected, fn) {
@@ -41,6 +42,7 @@
expectEqual('an error', null);
}
// Should only be called by the test runner
function getStatus() {
// if we're already in a fail state, return fail, nothing can recover this
if (testing._status === 'fail') return 'fail';
@@ -97,6 +99,8 @@
_registerErrorCallback();
}
// We want to attach an onError callback to each <script>, so that we can
// properly fail it.
function _registerErrorCallback() {
const script = document.currentScript;
if (!script) {
@@ -120,6 +124,33 @@
}
}
function _equal(a, b) {
if (a === b) {
return true;
}
if (a === null || b === null) {
return false;
}
if (typeof a !== 'object' || typeof b !== 'object') {
return false;
}
if (Object.keys(a).length != Object.keys(b).length) {
return false;
}
for (property in a) {
if (b.hasOwnProperty(property) === false) {
return false;
}
if (_equal(a[property], b[property]) === false) {
return false;
}
}
return true;
}
window.testing = {
_status: 'empty',
_eventually: [],