From a358473f3cc2dc2a7a471f965e814f676298eef7 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 22:58:48 -0300 Subject: [PATCH 1/4] test: add WHATWG Performance API suite (opt-in via runPerformanceTests) (#25) --- Performance/index.js | 827 +++++++++++++++++++++++++++++++++++++++++++ index.js | 5 + 2 files changed, 832 insertions(+) create mode 100644 Performance/index.js diff --git a/Performance/index.js b/Performance/index.js new file mode 100644 index 0000000..4eea7ae --- /dev/null +++ b/Performance/index.js @@ -0,0 +1,827 @@ +/** + * WHATWG Performance: hr-time (performance.now/timeOrigin), User Timing + * (mark/measure), the Performance Timeline queries and PerformanceObserver, + * plus the same surface inside Worker isolates. + * + * Two deliberate deviations from the specifications are asserted here: + * - `detail` is retained by reference instead of being structured-cloned, so + * a caller reads back the very object it passed in. + * - Observer callbacks are only guaranteed to run asynchronously; nothing + * pins them to a microtask or a macrotask turn. + */ + +var globalObject = typeof globalThis !== "undefined" ? globalThis : global; + +// The suite gates itself on the API being implemented so it can run from +// runAllTests() on every runtime: one that has not shipped the Performance API +// reports a single pending spec instead of failures. Runtimes that do ship it +// must keep an unguarded canary in their own test suite asserting the globals +// exist, so this gate cannot silently hide a regression there. +if (typeof globalObject.performance === "undefined" || typeof globalObject.PerformanceObserver !== "function") { + describe("Performance API", function () { + it("is skipped: this runtime does not implement the Performance API", function () { + pending(); + }); + }); + return; +} + +var PERFORMANCE_CONSTRUCTORS = [ + "Performance", + "PerformanceEntry", + "PerformanceMark", + "PerformanceMeasure", + "PerformanceObserver", + "PerformanceObserverEntryList" +]; + +function captureThrown(fn) { + try { + fn(); + } catch (e) { + return e; + } + return null; +} + +function expectThrowsNamed(fn, name) { + var thrown = captureThrown(fn); + expect(thrown).not.toBeNull(); + expect(thrown && thrown.name).toBe(name); + return thrown; +} + +function expectThrowsTypeError(fn) { + var thrown = expectThrowsNamed(fn, "TypeError"); + expect(thrown instanceof TypeError).toBe(true); + return thrown; +} + +function clearTimeline() { + performance.clearMarks(); + performance.clearMeasures(); +} + +function entryNames(entries) { + var names = []; + for (var i = 0; i < entries.length; i++) { + names.push(entries[i].name); + } + return names; +} + +// A callback that throws is routed to reportError, which surfaces as a global +// `error` event. preventDefault() keeps it from reaching the runner's uncaught +// handler for the one test that exercises that path. +function suppressGlobalErrors() { + if (typeof globalObject.addEventListener !== "function") { + return function () {}; + } + var listener = function (event) { + if (event && typeof event.preventDefault === "function") { + event.preventDefault(); + } + }; + globalObject.addEventListener("error", listener); + return function () { + if (typeof globalObject.removeEventListener === "function") { + globalObject.removeEventListener("error", listener); + } + }; +} + +describe("Performance globals", function () { + beforeEach(clearTimeline); + + it("Should expose performance and every performance constructor", function () { + expect(typeof performance).toBe("object"); + var types = []; + for (var i = 0; i < PERFORMANCE_CONSTRUCTORS.length; i++) { + types.push(PERFORMANCE_CONSTRUCTORS[i] + ": " + typeof globalObject[PERFORMANCE_CONSTRUCTORS[i]]); + } + expect(types).toEqual(PERFORMANCE_CONSTRUCTORS.map(function (name) { + return name + ": function"; + })); + }); + + it("Should install them as own writable, enumerable, configurable properties", function () { + var names = PERFORMANCE_CONSTRUCTORS.concat(["performance"]); + var actual = names.map(function (name) { + var descriptor = Object.getOwnPropertyDescriptor(globalObject, name); + if (!descriptor) { + return name + ": missing"; + } + return name + ": " + [descriptor.writable, descriptor.enumerable, descriptor.configurable].join("/"); + }); + expect(actual).toEqual(names.map(function (name) { + return name + ": true/true/true"; + })); + }); + + it("Should make Performance an EventTarget subclass", function () { + expect(performance instanceof Performance).toBe(true); + expect(performance instanceof EventTarget).toBe(true); + expect(Object.getPrototypeOf(Performance.prototype)).toBe(EventTarget.prototype); + }); + + it("Should reject illegal constructors", function () { + var illegal = [Performance, PerformanceEntry, PerformanceMeasure, PerformanceObserverEntryList]; + for (var i = 0; i < illegal.length; i++) { + var thrown = expectThrowsTypeError((function (ctor) { + return function () { new ctor(); }; + })(illegal[i])); + expect(thrown && thrown.message).toContain("Illegal constructor"); + } + }); + + it("Should let PerformanceMark be constructed directly", function () { + var mark = new PerformanceMark("constructed"); + expect(mark instanceof PerformanceMark).toBe(true); + expect(mark instanceof PerformanceEntry).toBe(true); + expect(mark.name).toBe("constructed"); + expect(mark.entryType).toBe("mark"); + expect(mark.duration).toBe(0); + }); + + it("Should brand instances with Symbol.toStringTag", function () { + var mark = performance.mark("tagged"); + var measure = performance.measure("tagged", { start: 0, end: 1 }); + expect(Object.prototype.toString.call(performance)).toBe("[object Performance]"); + expect(Object.prototype.toString.call(mark)).toBe("[object PerformanceMark]"); + expect(Object.prototype.toString.call(measure)).toBe("[object PerformanceMeasure]"); + }); + + it("Should keep Symbol.toStringTag configurable and non writable", function () { + var prototypes = [Performance, PerformanceMark, PerformanceMeasure, PerformanceObserverEntryList]; + var actual = prototypes.map(function (ctor) { + var descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, Symbol.toStringTag); + if (!descriptor) { + return "missing"; + } + return descriptor.value + ": " + descriptor.writable + "/" + descriptor.configurable; + }); + expect(actual).toEqual([ + "Performance: false/true", + "PerformanceMark: false/true", + "PerformanceMeasure: false/true", + "PerformanceObserverEntryList: false/true" + ]); + }); + + it("Should hide the EventTarget bookkeeping from Object.keys", function () { + expect(Object.keys(performance).indexOf("_listeners")).toBe(-1); + }); + + it("Should inherit working EventTarget methods", function () { + expect(typeof performance.addEventListener).toBe("function"); + expect(typeof performance.removeEventListener).toBe("function"); + expect(typeof performance.dispatchEvent).toBe("function"); + + var received = null; + var listener = function (event) { received = event; }; + performance.addEventListener("nstest", listener); + performance.dispatchEvent(new Event("nstest")); + performance.removeEventListener("nstest", listener); + expect(received).not.toBeNull(); + expect(received && received.type).toBe("nstest"); + + received = null; + performance.dispatchEvent(new Event("nstest")); + expect(received).toBeNull(); + }); +}); + +describe("Performance high resolution time", function () { + it("Should return a finite number of milliseconds that never goes backwards", function () { + var previous = performance.now(); + expect(typeof previous).toBe("number"); + expect(isFinite(previous)).toBe(true); + expect(previous).toBeGreaterThan(0); + + var regressions = 0; + for (var i = 0; i < 200; i++) { + var current = performance.now(); + if (current < previous) { + regressions++; + } + previous = current; + } + expect(regressions).toBe(0); + }); + + it("Should not be coarsened to whole milliseconds", function () { + var fractional = 0; + for (var i = 0; i < 50; i++) { + var sample = performance.now(); + if (sample !== Math.floor(sample)) { + fractional++; + } + } + expect(fractional).toBeGreaterThan(0); + }); + + it("Should expose timeOrigin as wall clock milliseconds since the epoch", function () { + expect(typeof performance.timeOrigin).toBe("number"); + expect(performance.timeOrigin).toBeGreaterThan(0); + expect(Math.abs(Date.now() - (performance.timeOrigin + performance.now()))).toBeLessThan(10); + }); + + it("Should keep timeOrigin a readonly accessor on the prototype", function () { + var descriptor = Object.getOwnPropertyDescriptor(Performance.prototype, "timeOrigin"); + expect(descriptor).not.toBeUndefined(); + expect(typeof (descriptor && descriptor.get)).toBe("function"); + expect(descriptor && descriptor.set).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(performance, "timeOrigin")).toBe(false); + + var before = performance.timeOrigin; + performance.timeOrigin = 0; + expect(performance.timeOrigin).toBe(before); + expect(Object.prototype.hasOwnProperty.call(performance, "timeOrigin")).toBe(false); + }); + + it("Should serialize to the time origin alone", function () { + var json = performance.toJSON(); + expect(Object.keys(json)).toEqual(["timeOrigin"]); + expect(json.timeOrigin).toBe(performance.timeOrigin); + }); +}); + +describe("Performance mark", function () { + beforeEach(clearTimeline); + + it("Should return a buffered mark stamped with the current time", function () { + var before = performance.now(); + var mark = performance.mark("m1"); + var after = performance.now(); + + expect(mark instanceof PerformanceMark).toBe(true); + expect(mark instanceof PerformanceEntry).toBe(true); + expect(mark.name).toBe("m1"); + expect(mark.entryType).toBe("mark"); + expect(mark.duration).toBe(0); + expect(mark.startTime >= before && mark.startTime <= after).toBe(true); + expect(performance.getEntriesByName("m1")[0]).toBe(mark); + expect(performance.getEntriesByType("mark")[0]).toBe(mark); + expect(performance.getEntries().length).toBe(1); + }); + + it("Should honour an explicit startTime", function () { + var mark = performance.mark("m2", { startTime: 12.5 }); + expect(mark.startTime).toBe(12.5); + expect(performance.getEntriesByName("m2")[0].startTime).toBe(12.5); + }); + + it("Should default detail to null and keep a supplied detail by reference", function () { + expect(performance.mark("m3").detail).toBeNull(); + + var detail = { nested: {} }; + var mark = performance.mark("m4", { detail: detail }); + expect(mark.detail).toBe(detail); + expect(mark.detail.nested).toBe(detail.nested); + expect(performance.getEntriesByName("m4")[0].detail).toBe(detail); + }); + + it("Should reject a negative or non finite startTime", function () { + var invalid = [-1, -0.5, NaN, Infinity, -Infinity]; + for (var i = 0; i < invalid.length; i++) { + expectThrowsTypeError((function (startTime) { + return function () { performance.mark("bad", { startTime: startTime }); }; + })(invalid[i])); + } + expect(performance.getEntries().length).toBe(0); + }); + + it("Should require a name", function () { + expectThrowsTypeError(function () { performance.mark(); }); + expect(performance.getEntries().length).toBe(0); + }); + + it("Should not buffer marks built with the PerformanceMark constructor", function () { + var detail = { standalone: true }; + var mark = new PerformanceMark("standalone", { startTime: 7, detail: detail }); + expect(mark.startTime).toBe(7); + expect(mark.detail).toBe(detail); + expect(performance.getEntriesByName("standalone")).toEqual([]); + expect(performance.getEntries()).toEqual([]); + }); + + it("Should serialize the entry fields with toJSON", function () { + var detail = { any: "value" }; + var mark = performance.mark("m5", { startTime: 3, detail: detail }); + var json = mark.toJSON(); + expect(json.name).toBe("m5"); + expect(json.entryType).toBe("mark"); + expect(json.startTime).toBe(3); + expect(json.duration).toBe(0); + expect(json.detail).toBe(detail); + }); +}); + +describe("Performance measure", function () { + beforeEach(clearTimeline); + + it("Should span the time origin to now when given only a name", function () { + var measure = performance.measure("m"); + expect(measure instanceof PerformanceMeasure).toBe(true); + expect(measure instanceof PerformanceEntry).toBe(true); + expect(measure.name).toBe("m"); + expect(measure.entryType).toBe("measure"); + expect(measure.startTime).toBe(0); + expect(measure.duration).toBeGreaterThan(0); + expect(measure.duration).not.toBeGreaterThan(performance.now()); + expect(measure.detail).toBeNull(); + expect(performance.getEntriesByName("m")[0]).toBe(measure); + }); + + it("Should span two marks", function () { + var start = performance.mark("a", { startTime: 10 }); + var end = performance.mark("b", { startTime: 40 }); + var measure = performance.measure("ab", "a", "b"); + expect(measure.startTime).toBe(start.startTime); + expect(measure.duration).toBe(end.startTime - start.startTime); + }); + + it("Should use the most recent mark of a repeated name", function () { + performance.mark("dup", { startTime: 10 }); + performance.mark("dup", { startTime: 50 }); + performance.mark("end", { startTime: 80 }); + var measure = performance.measure("m", "dup", "end"); + expect(measure.startTime).toBe(50); + expect(measure.duration).toBe(30); + }); + + it("Should measure up to now when only a start mark is given", function () { + performance.mark("start", { startTime: 5 }); + var measure = performance.measure("m", "start"); + expect(measure.startTime).toBe(5); + expect(measure.duration).toBeGreaterThan(0); + }); + + it("Should throw a SyntaxError named error for an unknown mark", function () { + performance.mark("known", { startTime: 1 }); + expectThrowsNamed(function () { performance.measure("m", "missing"); }, "SyntaxError"); + expectThrowsNamed(function () { performance.measure("m", "known", "missing"); }, "SyntaxError"); + expectThrowsNamed(function () { performance.measure("m", { start: "missing" }); }, "SyntaxError"); + expect(performance.getEntriesByType("measure")).toEqual([]); + }); + + it("Should accept the numeric options form", function () { + var startEnd = performance.measure("start-end", { start: 10, end: 40 }); + expect(startEnd.startTime).toBe(10); + expect(startEnd.duration).toBe(30); + + var startDuration = performance.measure("start-duration", { start: 10, duration: 5 }); + expect(startDuration.startTime).toBe(10); + expect(startDuration.duration).toBe(5); + + var durationEnd = performance.measure("duration-end", { duration: 5, end: 40 }); + expect(durationEnd.startTime).toBe(35); + expect(durationEnd.duration).toBe(5); + }); + + it("Should fill in the missing endpoint when only start or only end is given", function () { + var onlyStart = performance.measure("only-start", { start: 10 }); + expect(onlyStart.startTime).toBe(10); + expect(onlyStart.duration).toBeGreaterThan(0); + + var onlyEnd = performance.measure("only-end", { end: 40 }); + expect(onlyEnd.startTime).toBe(0); + expect(onlyEnd.duration).toBe(40); + }); + + it("Should accept mark names inside the options bag", function () { + performance.mark("a", { startTime: 10 }); + performance.mark("b", { startTime: 40 }); + var measure = performance.measure("m", { start: "a", end: "b" }); + expect(measure.startTime).toBe(10); + expect(measure.duration).toBe(30); + }); + + it("Should default detail to null and keep a supplied detail by reference", function () { + var detail = { nested: {} }; + var measure = performance.measure("detailed", { start: 0, end: 1, detail: detail }); + expect(measure.detail).toBe(detail); + expect(performance.getEntriesByName("detailed")[0].detail).toBe(detail); + expect(performance.measure("plain", { start: 0, end: 1 }).detail).toBeNull(); + }); + + it("Should treat an options bag without members like no options at all", function () { + // The options branch of measure() only engages when start, end, + // duration or detail is present; a bare {} is a boundless measure. + var boundless = performance.measure("boundless", {}); + expect(boundless.startTime).toBe(0); + expect(boundless.duration).toBeGreaterThan(0); + }); + + it("Should reject invalid option combinations", function () { + expectThrowsTypeError(function () { performance.measure("m", { start: 1, end: 2, duration: 1 }); }); + expectThrowsTypeError(function () { performance.measure("m", { detail: { onlyDetail: true } }); }); + expectThrowsTypeError(function () { performance.measure("m", { start: 1 }, "someMark"); }); + expect(performance.getEntriesByType("measure")).toEqual([]); + }); + + it("Should reject negative or non finite numeric endpoints", function () { + // duration is not converted through "convert a mark to a timestamp", + // so a negative duration is legal (it yields an end before the start); + // only non-finite values are rejected by the double conversion. + var invalid = [ + { start: -1 }, + { end: -1 }, + { start: NaN }, + { end: Infinity }, + { start: 0, duration: NaN } + ]; + for (var i = 0; i < invalid.length; i++) { + expectThrowsTypeError((function (options) { + return function () { performance.measure("m", options); }; + })(invalid[i])); + } + expect(performance.getEntriesByType("measure")).toEqual([]); + }); +}); + +describe("Performance timeline queries", function () { + beforeEach(clearTimeline); + + it("Should return a fresh array from every query", function () { + performance.mark("a"); + var first = performance.getEntries(); + var second = performance.getEntries(); + expect(first).not.toBe(second); + expect(first).toEqual(second); + + first.length = 0; + expect(performance.getEntries().length).toBe(1); + expect(performance.getEntriesByType("mark")).not.toBe(performance.getEntriesByType("mark")); + expect(performance.getEntriesByName("a")).not.toBe(performance.getEntriesByName("a")); + }); + + it("Should sort by startTime and keep insertion order for ties", function () { + var late = performance.mark("late", { startTime: 30 }); + var early = performance.mark("early", { startTime: 10 }); + var tieFirst = performance.mark("tie-first", { startTime: 20 }); + var tieSecond = performance.mark("tie-second", { startTime: 20 }); + expect(performance.getEntries()).toEqual([early, tieFirst, tieSecond, late]); + }); + + it("Should place a measure that starts earlier ahead of already buffered marks", function () { + var mark = performance.mark("later-mark", { startTime: 50 }); + var measure = performance.measure("earlier-measure", { start: 10, end: 20 }); + var entries = performance.getEntries(); + expect(entries.length).toBe(2); + expect(entries[0]).toBe(measure); + expect(entries[1]).toBe(mark); + }); + + it("Should filter by type and by name", function () { + var mark = performance.mark("shared", { startTime: 10 }); + var measure = performance.measure("shared", { start: 0, end: 5 }); + performance.mark("other", { startTime: 1 }); + + expect(entryNames(performance.getEntriesByType("mark"))).toEqual(["other", "shared"]); + expect(performance.getEntriesByType("measure")).toEqual([measure]); + expect(performance.getEntriesByType("navigation")).toEqual([]); + expect(performance.getEntriesByName("shared")).toEqual([measure, mark]); + expect(performance.getEntriesByName("shared", "mark")).toEqual([mark]); + expect(performance.getEntriesByName("shared", "measure")).toEqual([measure]); + expect(performance.getEntriesByName("nothing")).toEqual([]); + }); + + it("Should clear marks and measures independently", function () { + performance.mark("a"); + performance.mark("b"); + performance.measure("m", { start: 0, end: 1 }); + + performance.clearMarks(); + expect(performance.getEntriesByType("mark")).toEqual([]); + expect(performance.getEntriesByType("measure").length).toBe(1); + + performance.clearMeasures(); + expect(performance.getEntries()).toEqual([]); + }); + + it("Should clear only the named entries of that type when a name is given", function () { + performance.mark("keep", { startTime: 1 }); + performance.mark("drop", { startTime: 2 }); + performance.measure("keep", { start: 0, end: 1 }); + performance.measure("drop", { start: 0, end: 2 }); + + performance.clearMarks("drop"); + expect(entryNames(performance.getEntriesByType("mark"))).toEqual(["keep"]); + expect(entryNames(performance.getEntriesByType("measure"))).toEqual(["keep", "drop"]); + + performance.clearMeasures("drop"); + expect(entryNames(performance.getEntriesByType("measure"))).toEqual(["keep"]); + expect(entryNames(performance.getEntriesByType("mark"))).toEqual(["keep"]); + }); +}); + +describe("PerformanceObserver", function () { + var originalTimeout; + var observers; + + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 8000; + observers = []; + clearTimeline(); + }); + + afterEach(function () { + for (var i = 0; i < observers.length; i++) { + observers[i].disconnect(); + } + observers = []; + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + function observing(callback) { + var observer = new PerformanceObserver(callback); + observers.push(observer); + return observer; + } + + it("Should require a callable callback", function () { + expectThrowsTypeError(function () { new PerformanceObserver(); }); + expectThrowsTypeError(function () { new PerformanceObserver(null); }); + expectThrowsTypeError(function () { new PerformanceObserver({}); }); + expectThrowsTypeError(function () { new PerformanceObserver("mark"); }); + }); + + it("Should advertise the supported entry types as a frozen list", function () { + expect(PerformanceObserver.supportedEntryTypes).toEqual(["mark", "measure"]); + expect(Object.isFrozen(PerformanceObserver.supportedEntryTypes)).toBe(true); + }); + + it("Should reject malformed observe options", function () { + var observer = observing(function () {}); + expectThrowsTypeError(function () { observer.observe({ entryTypes: ["mark"], type: "mark" }); }); + expectThrowsTypeError(function () { observer.observe({ entryTypes: ["mark"], buffered: true }); }); + expectThrowsTypeError(function () { observer.observe({}); }); + expectThrowsTypeError(function () { observer.observe(); }); + }); + + it("Should convert entryTypes as a WebIDL sequence", function (done) { + // Non-iterables (and string primitives, which fail the object check) + // must throw rather than silently observe nothing. + var observer = observing(function () {}); + expectThrowsTypeError(function () { observer.observe({ entryTypes: 5 }); }); + expectThrowsTypeError(function () { observer.observe({ entryTypes: "mark" }); }); + expectThrowsTypeError(function () { observer.observe({ entryTypes: { length: 1, 0: "mark" } }); }); + + // Any iterable converts, not just arrays. + var fromSet = observing(function (list) { + expect(entryNames(list.getEntries())).toEqual(["set-observed"]); + done(); + }); + fromSet.observe({ entryTypes: new Set(["mark"]) }); + performance.mark("set-observed"); + }); + + it("Should refuse to switch an observer between the entryTypes and type forms", function () { + var byList = observing(function () {}); + byList.observe({ entryTypes: ["mark"] }); + expectThrowsNamed(function () { byList.observe({ type: "measure" }); }, "InvalidModificationError"); + + var bySingle = observing(function () {}); + bySingle.observe({ type: "mark" }); + expectThrowsNamed(function () { bySingle.observe({ entryTypes: ["measure"] }); }, "InvalidModificationError"); + }); + + it("Should deliver entries asynchronously, after mark() has returned", function (done) { + var markReturned = false; + var observer = observing(function (list, self) { + expect(markReturned).toBe(true); + expect(this).toBe(observer); + expect(self).toBe(observer); + expect(list instanceof PerformanceObserverEntryList).toBe(true); + expect(Object.prototype.toString.call(list)).toBe("[object PerformanceObserverEntryList]"); + + var entries = list.getEntries(); + expect(entries.length).toBe(1); + expect(entries[0].name).toBe("async"); + expect(entries[0].entryType).toBe("mark"); + expect(entryNames(list.getEntriesByType("mark"))).toEqual(["async"]); + expect(list.getEntriesByType("measure")).toEqual([]); + expect(entryNames(list.getEntriesByName("async"))).toEqual(["async"]); + expect(list.getEntriesByName("async", "mark").length).toBe(1); + expect(list.getEntriesByName("async", "measure")).toEqual([]); + expect(list.getEntriesByName("nothing")).toEqual([]); + done(); + }); + + observer.observe({ entryTypes: ["mark"] }); + performance.mark("async"); + markReturned = true; + }); + + it("Should observe a single entry type with the type form", function (done) { + var finished = false; + var observer = observing(function (list) { + if (finished) { + return; + } + finished = true; + var entries = list.getEntries(); + expect(entries.length).toBe(1); + expect(entries[0].name).toBe("only-measure"); + expect(entries[0].entryType).toBe("measure"); + done(); + }); + + observer.observe({ type: "measure" }); + performance.mark("ignored"); + performance.measure("only-measure", { start: 0, end: 1 }); + }); + + it("Should replay already buffered entries when buffered is true", function (done) { + performance.mark("before-observe", { startTime: 1 }); + var observer = observing(function (list) { + expect(entryNames(list.getEntries())).toContain("before-observe"); + done(); + }); + + observer.observe({ type: "mark", buffered: true }); + }); + + it("Should ignore unsupported entry types and keep the supported ones", function (done) { + var observer = observing(function (list) { + expect(entryNames(list.getEntries())).toEqual(["filtered"]); + done(); + }); + + observer.observe({ entryTypes: ["mark", "resource", "navigation"] }); + performance.mark("filtered"); + }); + + it("Should stay silent when every requested entry type is unsupported", function (done) { + var calls = 0; + var observer = observing(function () { calls++; }); + + observer.observe({ entryTypes: ["resource", "navigation"] }); + performance.mark("unobserved"); + performance.measure("unobserved", { start: 0, end: 1 }); + + setTimeout(function () { + expect(calls).toBe(0); + done(); + }, 500); + }); + + it("Should drain pending entries synchronously with takeRecords", function (done) { + var calls = 0; + var observer = observing(function () { calls++; }); + observer.observe({ entryTypes: ["mark"] }); + performance.mark("taken"); + + var records = observer.takeRecords(); + expect(records.length).toBe(1); + expect(records[0].name).toBe("taken"); + expect(observer.takeRecords()).toEqual([]); + + setTimeout(function () { + expect(calls).toBe(0); + done(); + }, 500); + }); + + it("Should stop delivering after disconnect", function (done) { + var calls = 0; + var observer = observing(function () { calls++; }); + observer.observe({ entryTypes: ["mark", "measure"] }); + observer.disconnect(); + + performance.mark("after-disconnect"); + performance.measure("after-disconnect", { start: 0, end: 1 }); + + setTimeout(function () { + expect(calls).toBe(0); + expect(observer.takeRecords()).toEqual([]); + done(); + }, 500); + }); + + it("Should run every observer in registration order even when one throws", function (done) { + var order = []; + var restoreErrorHandling = suppressGlobalErrors(); + + observing(function () { order.push("first"); }).observe({ entryTypes: ["mark"] }); + observing(function () { + order.push("second"); + throw new Error("observer callback failure"); + }).observe({ entryTypes: ["mark"] }); + observing(function () { order.push("third"); }).observe({ entryTypes: ["mark"] }); + + performance.mark("fan-out"); + + setTimeout(function () { + expect(order).toEqual(["first", "second", "third"]); + restoreErrorHandling(); + done(); + }, 500); + }); +}); + +describe("Performance in workers", function () { + // Worker paths are resolved against the requiring module's directory. + var EVAL_WORKER = "../Workers/EvalWorker.js"; + var originalTimeout; + + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 8000; + clearTimeline(); + }); + + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + it("Should expose the same performance surface", function (done) { + var worker = new Worker(EVAL_WORKER); + + worker.postMessage({ + eval: "postMessage({" + + "performance: typeof performance," + + "now: typeof performance.now()," + + "timeOrigin: typeof performance.timeOrigin," + + "isPerformance: performance instanceof Performance," + + "isEventTarget: performance instanceof EventTarget," + + "tag: Object.prototype.toString.call(performance)," + + "mark: typeof performance.mark," + + "measure: typeof performance.measure," + + "getEntries: typeof performance.getEntries," + + "clearMarks: typeof performance.clearMarks," + + "observer: typeof PerformanceObserver," + + "entry: typeof PerformanceEntry," + + "markCtor: typeof PerformanceMark," + + "measureCtor: typeof PerformanceMeasure," + + "entryList: typeof PerformanceObserverEntryList" + + "});" + }); + + worker.onmessage = function (msg) { + expect(msg.data.performance).toBe("object"); + expect(msg.data.now).toBe("number"); + expect(msg.data.timeOrigin).toBe("number"); + expect(msg.data.isPerformance).toBe(true); + expect(msg.data.isEventTarget).toBe(true); + expect(msg.data.tag).toBe("[object Performance]"); + expect(msg.data.mark).toBe("function"); + expect(msg.data.measure).toBe("function"); + expect(msg.data.getEntries).toBe("function"); + expect(msg.data.clearMarks).toBe("function"); + expect(msg.data.observer).toBe("function"); + expect(msg.data.entry).toBe("function"); + expect(msg.data.markCtor).toBe("function"); + expect(msg.data.measureCtor).toBe("function"); + expect(msg.data.entryList).toBe("function"); + worker.terminate(); + done(); + }; + }); + + it("Should capture its own time origin when the worker thread starts", function (done) { + var mainTimeOrigin = performance.timeOrigin; + var mainNowBeforeWorker = performance.now(); + var worker = new Worker(EVAL_WORKER); + + worker.postMessage({ + eval: "postMessage({ timeOrigin: performance.timeOrigin, now: performance.now(), date: Date.now() });" + }); + + worker.onmessage = function (msg) { + var mainDate = Date.now(); + expect(msg.data.timeOrigin).not.toBeLessThan(mainTimeOrigin); + // The worker clock only starts running with its thread, so the time it + // has accumulated stays below what the main isolate had already logged + // before the worker existed. + expect(msg.data.now).toBeLessThan(mainNowBeforeWorker); + expect(Math.abs(msg.data.date - (msg.data.timeOrigin + msg.data.now))).toBeLessThan(10); + expect(Math.abs(mainDate - (msg.data.timeOrigin + msg.data.now))).toBeLessThan(500); + worker.terminate(); + done(); + }; + }); + + it("Should keep a timeline buffer independent from the main isolate", function (done) { + performance.mark("main-only"); + expect(entryNames(performance.getEntries())).toEqual(["main-only"]); + + var worker = new Worker(EVAL_WORKER); + + worker.postMessage({ + eval: "var initial = performance.getEntries().length;" + + "performance.mark('worker-only');" + + "postMessage({ initial: initial, names: performance.getEntries().map(function (entry) { return entry.name; }) });" + }); + + worker.onmessage = function (msg) { + expect(msg.data.initial).toBe(0); + expect(msg.data.names).toEqual(["worker-only"]); + expect(entryNames(performance.getEntries())).toEqual(["main-only"]); + worker.terminate(); + done(); + }; + }); +}); diff --git a/index.js b/index.js index 0724d36..a2280b7 100644 --- a/index.js +++ b/index.js @@ -18,10 +18,15 @@ exports.runWorkerTests = function() { require("./Workers"); } +exports.runPerformanceTests = function() { + require("./Performance"); +} + exports.runAllTests = function() { exports.runImportTests(); exports.runRequireTests(); exports.runWeakRefTests(); exports.runRuntimeTests(); exports.runWorkerTests(); + exports.runPerformanceTests(); } From 2eee85b4ad4863b59bc22a356246d2cbe5cb62c4 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 23:02:39 -0300 Subject: [PATCH 2/4] test: add structuredClone suite (opt-in via runStructuredCloneTests) (#26) * test: add structuredClone suite (opt-in via runStructuredCloneTests) Covers the WHATWG structuredClone global: primitives (including -0, NaN and BigInt), plain objects and arrays, Map/Set/Date/RegExp, the wrapper objects, Error, ArrayBuffer, every typed array and DataView with their offsets, graph identity and cycles, getter invocation, and the uncloneable cases. Transfer coverage asserts both halves of the hand-off: the clone is usable and the source is detached, for a bare buffer, a buffer reached through a typed array, and buffers not present in the cloned value. Rejections cover duplicate entries, non-transferable entries, already detached buffers, and non-iterable transfer lists. Failures are asserted by `.name === "DataCloneError"` rather than `instanceof DOMException`, so the suite runs on runtimes that have no DOMException. The one runtime-specific expectation (a native object is not cloneable) is behind the existing isV8iOS guard. Deliberately kept out of runAllTests(): Android consumes master and does not ship the global yet, so each runtime opts in once it does. * test: pin single capture of the transfer iterator next method WebIDL builds the iterator record once, capturing `next` at creation. The spec drives iteration with an iterator whose `next` is an accessor that yields a working function on the first read and throws on any later one, so an implementation that re-reads `next` per step fails instead of silently diverging. * test: cover SharedArrayBuffer sharing and worker message transfer structuredClone and worker postMessage run on one serialization core, so the transfer rules are asserted from both entry points: a buffer posted with a transfer list arrives usable in the worker while the sender's copy is detached, and duplicate, non-ArrayBuffer and already-detached entries are rejected with the same DataCloneError name as on the structuredClone side. postMessage takes a plain array only, so a non-array transfer list is a TypeError. The SharedArrayBuffer specs feature-detect the constructor and assert sharing in both directions -- written through the clone, read through the original, and back -- plus that one shared buffer stays shared across two references and that it cannot be transferred. The worker specs live here rather than in Workers/index.js because this suite is opt-in: runAllTests() runs on runtimes that have not implemented postMessage transfer yet. Also pins the deliberate asymmetry in host-object handling: postMessage delivers a posted native object as an empty object where structuredClone rejects it. Workers/index.js already pins that posting one does not throw; this adds what the receiver actually sees. * test: gate the shared suite on structuredClone presence The suite was kept out of runAllTests() and wired in per runtime, which meant every runtime that shipped the API had to remember to opt in, and master could not carry the suite at all. It now checks for the global itself: where structuredClone is missing it registers one pending spec and returns, so the suite can live in runAllTests() everywhere and a runtime without the API reports a visible skip instead of a wall of failures. The named export stays, since it is still the way to run just this suite. A gate like this can hide the very regression the suite exists to catch, so the header points implementing runtimes at the unguarded canary they are expected to keep in their own tests. --- StructuredClone/index.js | 741 +++++++++++++++++++++++++++++++++++++++ index.js | 5 + 2 files changed, 746 insertions(+) create mode 100644 StructuredClone/index.js diff --git a/StructuredClone/index.js b/StructuredClone/index.js new file mode 100644 index 0000000..e6dca7a --- /dev/null +++ b/StructuredClone/index.js @@ -0,0 +1,741 @@ +// Suite for the WHATWG structuredClone() global. +// +// The suite gates itself on the API being present, so it can sit in +// runAllTests() on every runtime and report a visible pending spec where +// structuredClone does not exist yet, rather than being wired in per-runtime. +// +// A runtime that DOES implement structuredClone must keep an unguarded canary +// in its own suite asserting the global is there (on iOS: +// TestRunner/app/tests/RuntimeImplementedAPIs.js). Without one, this gate would +// quietly turn a regression that removed the API into a skipped suite. +// +// Clone failures are asserted by `.name === "DataCloneError"` rather than by +// `instanceof DOMException`: runtimes without a DOMException throw a plain +// Error carrying that name. + +if (typeof global.structuredClone !== "function") { + describe("structuredClone", function () { + it("is skipped: this runtime does not implement structuredClone", function () { + pending(); + }); + }); + return; +} + +// The V8-based iOS runtime (@nativescript/ios); the legacy JSC runtime exposes TNSRuntime +var isV8iOS = !!global.NSObject && !global.TNSRuntime; + +// Not every runtime exposes shared memory; the sharing specs feature-detect. +var hasSharedArrayBuffer = typeof SharedArrayBuffer === "function"; + +function expectThrowsNamed(name, fn) { + var thrown = null; + try { + fn(); + } catch (e) { + thrown = e; + } + expect(thrown).not.toBeNull(); + expect(thrown && thrown.name).toBe(name); +} + +function expectThrowsTypeError(fn) { + var thrown = null; + try { + fn(); + } catch (e) { + thrown = e; + } + expect(thrown).not.toBeNull(); + expect(thrown instanceof TypeError).toBe(true); +} + +describe(module.id, function () { + it("is a function on the global", function () { + expect(typeof structuredClone).toBe("function"); + expect(structuredClone.length).toBe(1); + expect(structuredClone.name).toBe("structuredClone"); + }); + + describe("primitives", function () { + it("round-trips numbers, including the special values", function () { + expect(structuredClone(0)).toBe(0); + expect(structuredClone(42)).toBe(42); + expect(structuredClone(-1.5)).toBe(-1.5); + expect(Object.is(structuredClone(-0), -0)).toBe(true); + expect(isNaN(structuredClone(NaN))).toBe(true); + expect(structuredClone(Infinity)).toBe(Infinity); + expect(structuredClone(-Infinity)).toBe(-Infinity); + }); + + it("round-trips strings, booleans, null and undefined", function () { + expect(structuredClone("")).toBe(""); + expect(structuredClone("héllo \u{1F600}")).toBe("héllo \u{1F600}"); + expect(structuredClone(true)).toBe(true); + expect(structuredClone(false)).toBe(false); + expect(structuredClone(null)).toBeNull(); + expect(structuredClone(undefined)).toBeUndefined(); + }); + + it("round-trips BigInt", function () { + var big = BigInt("9007199254740993"); + var cloned = structuredClone(big); + expect(typeof cloned).toBe("bigint"); + expect(cloned === big).toBe(true); + expect(structuredClone(BigInt(-7)) === BigInt(-7)).toBe(true); + }); + }); + + describe("plain objects and arrays", function () { + it("clones a deeply nested structure by value", function () { + var source = { a: 1, b: { c: [1, 2, { d: "deep" }], e: null }, f: [[["nested"]]] }; + var cloned = structuredClone(source); + + expect(cloned).not.toBe(source); + expect(cloned.b).not.toBe(source.b); + expect(cloned.b.c[2]).not.toBe(source.b.c[2]); + expect(cloned.b.c[2].d).toBe("deep"); + expect(cloned.f[0][0][0]).toBe("nested"); + }); + + it("preserves property order", function () { + var source = { z: 1, a: 2, m: 3, "0": 4 }; + expect(Object.keys(structuredClone(source)).join(",")).toBe(Object.keys(source).join(",")); + }); + + it("clones arrays, including holes and extra properties", function () { + var source = [1, , 3]; + source.extra = "x"; + var cloned = structuredClone(source); + + expect(Array.isArray(cloned)).toBe(true); + expect(cloned.length).toBe(3); + expect(cloned.hasOwnProperty(1)).toBe(false); + expect(cloned.extra).toBe("x"); + }); + + it("is deep: mutating either side does not affect the other", function () { + var source = { list: [1, 2, 3], nested: { n: 1 } }; + var cloned = structuredClone(source); + + cloned.list.push(4); + cloned.nested.n = 99; + expect(source.list.length).toBe(3); + expect(source.nested.n).toBe(1); + + source.nested.n = 7; + expect(cloned.nested.n).toBe(99); + }); + + it("drops the prototype of a class instance", function () { + function Thing(v) { + this.v = v; + } + Thing.prototype.method = function () { }; + + var cloned = structuredClone(new Thing(5)); + expect(cloned.v).toBe(5); + expect(cloned instanceof Thing).toBe(false); + expect(Object.getPrototypeOf(cloned)).toBe(Object.prototype); + }); + + it("invokes getters and stores their value as a data property", function () { + var calls = 0; + var source = { + get computed() { + calls++; + return { inner: 1 }; + } + }; + + var cloned = structuredClone(source); + expect(calls).toBe(1); + expect(cloned.computed.inner).toBe(1); + expect(Object.getOwnPropertyDescriptor(cloned, "computed").get).toBeUndefined(); + }); + }); + + describe("built-in object types", function () { + it("clones Date", function () { + var source = new Date(1234567890123); + var cloned = structuredClone(source); + + expect(cloned instanceof Date).toBe(true); + expect(cloned).not.toBe(source); + expect(cloned.getTime()).toBe(source.getTime()); + }); + + it("clones RegExp with its flags and source", function () { + var source = /a(b)c/gimy; + var cloned = structuredClone(source); + + expect(cloned instanceof RegExp).toBe(true); + expect(cloned.source).toBe("a(b)c"); + expect(cloned.flags).toBe(source.flags); + expect(cloned.test("abc")).toBe(true); + }); + + it("clones Map, keeping entry order and cloning keys and values", function () { + var key = { k: 1 }; + var source = new Map(); + source.set("first", 1); + source.set(key, { v: 2 }); + source.set(3, "third"); + + var cloned = structuredClone(source); + expect(cloned instanceof Map).toBe(true); + expect(cloned.size).toBe(3); + expect(cloned.get("first")).toBe(1); + expect(cloned.get(3)).toBe("third"); + expect(cloned.get(key)).toBeUndefined(); + + var keys = []; + cloned.forEach(function (value, k) { keys.push(k); }); + expect(keys[0]).toBe("first"); + expect(typeof keys[1]).toBe("object"); + expect(keys[1].k).toBe(1); + expect(keys[2]).toBe(3); + }); + + it("clones Set, keeping insertion order", function () { + var source = new Set(["a", 2, "a"]); + var cloned = structuredClone(source); + + expect(cloned instanceof Set).toBe(true); + expect(cloned.size).toBe(2); + expect(cloned.has("a")).toBe(true); + expect(cloned.has(2)).toBe(true); + + var values = []; + cloned.forEach(function (v) { values.push(v); }); + expect(values.join(",")).toBe("a,2"); + }); + + it("clones Boolean, String and Number wrapper objects", function () { + var boolean = structuredClone(new Boolean(true)); + expect(typeof boolean).toBe("object"); + expect(boolean instanceof Boolean).toBe(true); + expect(boolean.valueOf()).toBe(true); + + var string = structuredClone(new String("wrapped")); + expect(string instanceof String).toBe(true); + expect(string.valueOf()).toBe("wrapped"); + + var number = structuredClone(new Number(7.5)); + expect(number instanceof Number).toBe(true); + expect(number.valueOf()).toBe(7.5); + }); + + it("clones Error, preserving name and message", function () { + var source = new TypeError("boom"); + var cloned = structuredClone(source); + + expect(cloned instanceof Error).toBe(true); + expect(cloned).not.toBe(source); + expect(cloned.name).toBe("TypeError"); + expect(cloned.message).toBe("boom"); + + var plain = structuredClone(new Error("plain")); + expect(plain.name).toBe("Error"); + expect(plain.message).toBe("plain"); + }); + }); + + describe("binary data", function () { + it("copies an ArrayBuffer without detaching the source", function () { + var source = new ArrayBuffer(4); + new Uint8Array(source).set([1, 2, 3, 4]); + + var cloned = structuredClone(source); + expect(cloned instanceof ArrayBuffer).toBe(true); + expect(cloned).not.toBe(source); + expect(cloned.byteLength).toBe(4); + expect(source.byteLength).toBe(4); + + var clonedBytes = new Uint8Array(cloned); + expect(clonedBytes[0]).toBe(1); + expect(clonedBytes[3]).toBe(4); + + clonedBytes[0] = 42; + expect(new Uint8Array(source)[0]).toBe(1); + }); + + it("clones typed arrays of every element type", function () { + var constructors = [Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, + Int32Array, Uint32Array, Float32Array, Float64Array]; + + for (var i = 0; i < constructors.length; i++) { + var Ctor = constructors[i]; + var source = new Ctor([1, 2, 3]); + var cloned = structuredClone(source); + + expect(cloned instanceof Ctor).toBe(true); + expect(cloned.length).toBe(3); + expect(cloned[0]).toBe(1); + expect(cloned[2]).toBe(3); + } + }); + + it("preserves a typed array's byteOffset and length", function () { + var buffer = new ArrayBuffer(16); + new Uint8Array(buffer).set([0, 0, 0, 0, 9, 8, 7, 6, 0, 0, 0, 0, 0, 0, 0, 0]); + var source = new Uint8Array(buffer, 4, 4); + + var cloned = structuredClone(source); + expect(cloned.byteOffset).toBe(4); + expect(cloned.length).toBe(4); + expect(cloned.buffer.byteLength).toBe(16); + expect(cloned[0]).toBe(9); + expect(cloned[3]).toBe(6); + }); + + it("clones a DataView over its slice of the buffer", function () { + var buffer = new ArrayBuffer(12); + var source = new DataView(buffer, 4, 8); + source.setFloat64(0, 1.5); + + var cloned = structuredClone(source); + expect(cloned instanceof DataView).toBe(true); + expect(cloned.byteOffset).toBe(4); + expect(cloned.byteLength).toBe(8); + expect(cloned.buffer.byteLength).toBe(12); + expect(cloned.getFloat64(0)).toBe(1.5); + }); + + it("keeps views over one buffer sharing one cloned buffer", function () { + var buffer = new ArrayBuffer(8); + var cloned = structuredClone({ a: new Uint8Array(buffer), b: new Uint8Array(buffer) }); + + expect(cloned.a.buffer).toBe(cloned.b.buffer); + cloned.a[0] = 5; + expect(cloned.b[0]).toBe(5); + }); + }); + + describe("SharedArrayBuffer", function () { + if (!hasSharedArrayBuffer) { + it("is not exposed by this runtime", function () { + expect(typeof SharedArrayBuffer).toBe("undefined"); + }); + return; + } + + it("shares its memory with the clone instead of copying it", function () { + var shared = new SharedArrayBuffer(8); + var source = new Uint8Array(shared); + source[0] = 1; + + var cloned = structuredClone(shared); + expect(cloned instanceof SharedArrayBuffer).toBe(true); + expect(cloned).not.toBe(shared); + expect(cloned.byteLength).toBe(8); + + // Written through the clone, read through the original. + new Uint8Array(cloned)[3] = 42; + expect(source[3]).toBe(42); + + // And the other way around. + source[7] = 9; + expect(new Uint8Array(cloned)[7]).toBe(9); + }); + + it("keeps one shared buffer shared across two references", function () { + var shared = new SharedArrayBuffer(4); + var cloned = structuredClone({ a: shared, b: shared }); + + expect(cloned.a).toBe(cloned.b); + new Uint8Array(cloned.a)[0] = 7; + expect(new Uint8Array(shared)[0]).toBe(7); + }); + + it("is not transferable", function () { + var shared = new SharedArrayBuffer(4); + expectThrowsNamed("DataCloneError", function () { + structuredClone(shared, { transfer: [shared] }); + }); + }); + }); + + describe("graph shape", function () { + it("preserves identity of an object referenced twice", function () { + var shared = { s: 1 }; + var cloned = structuredClone({ x: shared, y: shared }); + + expect(cloned.x).toBe(cloned.y); + expect(cloned.x).not.toBe(shared); + + cloned.x.s = 2; + expect(cloned.y.s).toBe(2); + expect(shared.s).toBe(1); + }); + + it("round-trips a self-referencing object", function () { + var source = { name: "root" }; + source.self = source; + + var cloned = structuredClone(source); + expect(cloned.name).toBe("root"); + expect(cloned.self).toBe(cloned); + expect(cloned.self).not.toBe(source); + }); + + it("round-trips a longer cycle through arrays and Maps", function () { + var a = { name: "a" }; + var b = { name: "b", a: a }; + a.b = b; + a.list = [a, b]; + + var map = new Map(); + map.set("a", a); + a.map = map; + + var cloned = structuredClone(a); + expect(cloned.b.a).toBe(cloned); + expect(cloned.list[0]).toBe(cloned); + expect(cloned.list[1]).toBe(cloned.b); + expect(cloned.map.get("a")).toBe(cloned); + }); + }); + + describe("uncloneable values", function () { + it("throws DataCloneError for a function", function () { + expectThrowsNamed("DataCloneError", function () { + structuredClone(function () { }); + }); + expectThrowsNamed("DataCloneError", function () { + structuredClone({ fn: function () { } }); + }); + }); + + it("throws DataCloneError for a symbol", function () { + expectThrowsNamed("DataCloneError", function () { + structuredClone(Symbol("nope")); + }); + expectThrowsNamed("DataCloneError", function () { + structuredClone({ sym: Symbol("nope") }); + }); + }); + + it("throws DataCloneError for WeakMap and WeakSet", function () { + expectThrowsNamed("DataCloneError", function () { + structuredClone(new WeakMap()); + }); + expectThrowsNamed("DataCloneError", function () { + structuredClone(new WeakSet()); + }); + }); + + it("throws DataCloneError for a WeakRef", function () { + expectThrowsNamed("DataCloneError", function () { + structuredClone(new WeakRef({})); + }); + }); + + it("throws DataCloneError for a Promise", function () { + expectThrowsNamed("DataCloneError", function () { + structuredClone(Promise.resolve(1)); + }); + }); + + it("leaves nothing broken after a failed clone", function () { + var source = { ok: 1, bad: function () { } }; + expectThrowsNamed("DataCloneError", function () { + structuredClone(source); + }); + expect(structuredClone({ ok: source.ok }).ok).toBe(1); + }); + + if (isV8iOS) { + it("throws DataCloneError for a native object", function () { + expectThrowsNamed("DataCloneError", function () { + structuredClone(NSObject.alloc().init()); + }); + }); + } + }); + + describe("transfer", function () { + it("detaches the source buffer and hands over its memory", function () { + var source = new ArrayBuffer(4); + new Uint8Array(source).set([1, 2, 3, 4]); + + var cloned = structuredClone(source, { transfer: [source] }); + expect(cloned instanceof ArrayBuffer).toBe(true); + expect(cloned.byteLength).toBe(4); + expect(new Uint8Array(cloned)[2]).toBe(3); + expect(source.byteLength).toBe(0); + }); + + it("transfers a buffer reached through a typed array in the value", function () { + var buffer = new ArrayBuffer(8); + var view = new Uint8Array(buffer); + view[0] = 7; + view[7] = 9; + + var cloned = structuredClone({ view: view }, { transfer: [buffer] }); + expect(cloned.view.length).toBe(8); + expect(cloned.view[0]).toBe(7); + expect(cloned.view[7]).toBe(9); + expect(buffer.byteLength).toBe(0); + expect(view.length).toBe(0); + }); + + it("transfers several buffers at once", function () { + var first = new ArrayBuffer(2); + var second = new ArrayBuffer(3); + + var cloned = structuredClone({ first: first, second: second }, { transfer: [first, second] }); + expect(cloned.first.byteLength).toBe(2); + expect(cloned.second.byteLength).toBe(3); + expect(first.byteLength).toBe(0); + expect(second.byteLength).toBe(0); + }); + + it("transfers a buffer that is not part of the cloned value", function () { + var unrelated = new ArrayBuffer(4); + var cloned = structuredClone({ n: 1 }, { transfer: [unrelated] }); + + expect(cloned.n).toBe(1); + expect(unrelated.byteLength).toBe(0); + }); + + it("accepts any iterable as the transfer list", function () { + var buffer = new ArrayBuffer(4); + var cloned = structuredClone(buffer, { transfer: new Set([buffer]) }); + + expect(cloned.byteLength).toBe(4); + expect(buffer.byteLength).toBe(0); + }); + + it("reads the transfer iterator's next method only once", function () { + var buffer = new ArrayBuffer(4); + var nextReads = 0; + var exhausted = false; + + function next() { + if (exhausted) { + return { done: true, value: undefined }; + } + exhausted = true; + return { done: false, value: buffer }; + } + + var iterator = {}; + Object.defineProperty(iterator, "next", { + get: function () { + nextReads++; + if (nextReads > 1) { + throw new Error("next must be captured once, not re-read per step"); + } + return next; + } + }); + + var iterable = {}; + iterable[Symbol.iterator] = function () { + return iterator; + }; + + var cloned = structuredClone(buffer, { transfer: iterable }); + expect(nextReads).toBe(1); + expect(cloned.byteLength).toBe(4); + expect(buffer.byteLength).toBe(0); + }); + + it("accepts an absent, undefined or empty transfer list", function () { + var buffer = new ArrayBuffer(4); + expect(structuredClone(buffer, {}).byteLength).toBe(4); + expect(structuredClone(buffer, { transfer: undefined }).byteLength).toBe(4); + expect(structuredClone(buffer, { transfer: [] }).byteLength).toBe(4); + expect(buffer.byteLength).toBe(4); + }); + + it("throws DataCloneError when the same buffer is listed twice", function () { + var buffer = new ArrayBuffer(4); + expectThrowsNamed("DataCloneError", function () { + structuredClone(buffer, { transfer: [buffer, buffer] }); + }); + }); + + it("throws DataCloneError for a non-transferable entry", function () { + expectThrowsNamed("DataCloneError", function () { + structuredClone({}, { transfer: [{}] }); + }); + expectThrowsNamed("DataCloneError", function () { + structuredClone({}, { transfer: [5] }); + }); + expectThrowsNamed("DataCloneError", function () { + structuredClone({}, { transfer: [new Uint8Array(4)] }); + }); + }); + + it("throws DataCloneError for an already detached buffer", function () { + var buffer = new ArrayBuffer(4); + structuredClone(buffer, { transfer: [buffer] }); + + expectThrowsNamed("DataCloneError", function () { + structuredClone(buffer, { transfer: [buffer] }); + }); + expectThrowsNamed("DataCloneError", function () { + structuredClone(buffer); + }); + }); + + it("throws TypeError for a non-iterable transfer list", function () { + expectThrowsTypeError(function () { + structuredClone({}, { transfer: 5 }); + }); + expectThrowsTypeError(function () { + structuredClone({}, { transfer: null }); + }); + expectThrowsTypeError(function () { + structuredClone({}, { transfer: {} }); + }); + expectThrowsTypeError(function () { + structuredClone({}, { transfer: "abc" }); + }); + }); + }); + + describe("arguments", function () { + it("throws TypeError when called without a value", function () { + expectThrowsTypeError(function () { + structuredClone(); + }); + }); + + it("throws TypeError when options is not an object", function () { + expectThrowsTypeError(function () { + structuredClone({}, 5); + }); + expectThrowsTypeError(function () { + structuredClone({}, "transfer"); + }); + }); + + it("accepts undefined and null options", function () { + expect(structuredClone(1, undefined)).toBe(1); + expect(structuredClone(1, null)).toBe(1); + }); + }); + + describe("workers", function () { + var originalTimeout; + + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 8000; // For slower android emulators + }); + + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + it("is available inside a worker", function (done) { + var worker = new Worker("../Workers/EvalWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.t).toBe("function"); + worker.terminate(); + done(); + }; + worker.postMessage({ eval: "postMessage({ t: typeof structuredClone })" }); + }); + + // postMessage runs on the same serialization core as structuredClone, + // so the transfer rules below are the same ones asserted above. Its + // transfer list is a plain array only: there is no JS wrapper around + // postMessage to do the WebIDL iterable conversion. + it("transfers an ArrayBuffer to a worker and detaches the source", function (done) { + var worker = new Worker("../Workers/EvalWorker.js"); + var buffer = new ArrayBuffer(4); + new Uint8Array(buffer).set([5, 6, 7, 8]); + + worker.onmessage = function (msg) { + expect(msg.data.len).toBe(4); + expect(msg.data.first).toBe(5); + expect(msg.data.last).toBe(8); + worker.terminate(); + done(); + }; + + worker.postMessage({ + value: buffer, + eval: "postMessage({ len: value.byteLength, first: new Uint8Array(value)[0], last: new Uint8Array(value)[3] })" + }, [buffer]); + + expect(buffer.byteLength).toBe(0); + }); + + it("copies the buffer when it is not in the transfer list", function (done) { + var worker = new Worker("../Workers/EvalWorker.js"); + var buffer = new ArrayBuffer(4); + new Uint8Array(buffer).set([1, 2, 3, 4]); + + worker.onmessage = function (msg) { + expect(msg.data.len).toBe(4); + worker.terminate(); + done(); + }; + + worker.postMessage({ + value: buffer, + eval: "postMessage({ len: value.byteLength })" + }); + + expect(buffer.byteLength).toBe(4); + }); + + it("throws TypeError for a transfer list that is not an array", function () { + var worker = new Worker("../Workers/EvalWorker.js"); + expectThrowsTypeError(function () { + worker.postMessage({ value: 1 }, 5); + }); + expectThrowsTypeError(function () { + worker.postMessage({ value: 1 }, "buffer"); + }); + worker.terminate(); + }); + + it("throws DataCloneError for bad transfer list entries", function () { + var worker = new Worker("../Workers/EvalWorker.js"); + + var duplicated = new ArrayBuffer(4); + expectThrowsNamed("DataCloneError", function () { + worker.postMessage({ value: duplicated }, [duplicated, duplicated]); + }); + expect(duplicated.byteLength).toBe(4); + + expectThrowsNamed("DataCloneError", function () { + worker.postMessage({ value: 1 }, [{}]); + }); + + var detached = new ArrayBuffer(4); + structuredClone(detached, { transfer: [detached] }); + expectThrowsNamed("DataCloneError", function () { + worker.postMessage({ value: 1 }, [detached]); + }); + + worker.terminate(); + }); + + if (isV8iOS) { + // Deliberate asymmetry with structuredClone, which rejects host + // objects: posting a native object has always delivered an empty + // object rather than throwing, and Workers/index.js pins that it + // does not throw. Only the runtime's HostObjectPolicy encodes it. + it("delivers a posted native object as an empty object", function (done) { + var worker = new Worker("../Workers/EvalWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.type).toBe("object"); + expect(msg.data.keys).toBe(0); + worker.terminate(); + done(); + }; + worker.postMessage({ + value: NSObject.alloc().init(), + eval: "postMessage({ type: typeof value, keys: Object.keys(value).length })" + }); + }); + } + }); +}); diff --git a/index.js b/index.js index a2280b7..f7cbe1f 100644 --- a/index.js +++ b/index.js @@ -22,6 +22,10 @@ exports.runPerformanceTests = function() { require("./Performance"); } +exports.runStructuredCloneTests = function() { + require("./StructuredClone"); +} + exports.runAllTests = function() { exports.runImportTests(); exports.runRequireTests(); @@ -29,4 +33,5 @@ exports.runAllTests = function() { exports.runRuntimeTests(); exports.runWorkerTests(); exports.runPerformanceTests(); + exports.runStructuredCloneTests(); } From 8be1d9f539ef48861a889bef7216dad84e05b843 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 11 Aug 2026 11:52:10 -0300 Subject: [PATCH 3/4] test: assert structured-clone detail semantics where structuredClone exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail specs now follow the spec contract on a runtime that ships structuredClone — a distinct snapshot, immune to later mutation, with an uncloneable detail rejected by a DataCloneError-named error — and keep the by-reference assertions as the documented fallback for a runtime that implements the Performance API first. --- Performance/index.js | 58 +++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/Performance/index.js b/Performance/index.js index 4eea7ae..dd5cac3 100644 --- a/Performance/index.js +++ b/Performance/index.js @@ -3,11 +3,13 @@ * (mark/measure), the Performance Timeline queries and PerformanceObserver, * plus the same surface inside Worker isolates. * - * Two deliberate deviations from the specifications are asserted here: - * - `detail` is retained by reference instead of being structured-cloned, so - * a caller reads back the very object it passed in. + * Deliberate deviations from the specifications asserted here: * - Observer callbacks are only guaranteed to run asynchronously; nothing * pins them to a microtask or a macrotask turn. + * - `detail` is structured-cloned per spec where the runtime ships + * structuredClone, and degrades to by-reference where it does not (a + * runtime may implement the Performance API first); the detail specs + * assert whichever contract applies. */ var globalObject = typeof globalThis !== "undefined" ? globalThis : global; @@ -26,6 +28,8 @@ if (typeof globalObject.performance === "undefined" || typeof globalObject.Perfo return; } +var HAS_STRUCTURED_CLONE = typeof globalObject.structuredClone === "function"; + var PERFORMANCE_CONSTRUCTORS = [ "Performance", "PerformanceEntry", @@ -271,16 +275,35 @@ describe("Performance mark", function () { expect(performance.getEntriesByName("m2")[0].startTime).toBe(12.5); }); - it("Should default detail to null and keep a supplied detail by reference", function () { + it("Should default detail to null and snapshot a supplied detail", function () { expect(performance.mark("m3").detail).toBeNull(); - var detail = { nested: {} }; + var detail = { nested: { value: 1 } }; var mark = performance.mark("m4", { detail: detail }); - expect(mark.detail).toBe(detail); - expect(mark.detail.nested).toBe(detail.nested); - expect(performance.getEntriesByName("m4")[0].detail).toBe(detail); + expect(mark.detail).toEqual(detail); + expect(performance.getEntriesByName("m4")[0].detail).toBe(mark.detail); + if (HAS_STRUCTURED_CLONE) { + // Cloned once at creation: a distinct snapshot, immune to later + // mutation of the caller's object. + expect(mark.detail).not.toBe(detail); + detail.nested.value = 2; + expect(mark.detail.nested.value).toBe(1); + } else { + // Portability fallback for a runtime that ships the Performance + // API before structuredClone: detail degrades to by-reference. + expect(mark.detail).toBe(detail); + } }); + if (HAS_STRUCTURED_CLONE) { + it("Should reject an uncloneable detail with a DataCloneError named error", function () { + expectThrowsNamed(function () { + performance.mark("bad-detail", { detail: { fn: function () {} } }); + }, "DataCloneError"); + expect(performance.getEntries().length).toBe(0); + }); + } + it("Should reject a negative or non finite startTime", function () { var invalid = [-1, -0.5, NaN, Infinity, -Infinity]; for (var i = 0; i < invalid.length; i++) { @@ -300,7 +323,7 @@ describe("Performance mark", function () { var detail = { standalone: true }; var mark = new PerformanceMark("standalone", { startTime: 7, detail: detail }); expect(mark.startTime).toBe(7); - expect(mark.detail).toBe(detail); + expect(mark.detail).toEqual(detail); expect(performance.getEntriesByName("standalone")).toEqual([]); expect(performance.getEntries()).toEqual([]); }); @@ -313,7 +336,7 @@ describe("Performance mark", function () { expect(json.entryType).toBe("mark"); expect(json.startTime).toBe(3); expect(json.duration).toBe(0); - expect(json.detail).toBe(detail); + expect(json.detail).toBe(mark.detail); }); }); @@ -397,12 +420,19 @@ describe("Performance measure", function () { expect(measure.duration).toBe(30); }); - it("Should default detail to null and keep a supplied detail by reference", function () { - var detail = { nested: {} }; + it("Should default detail to null and snapshot a supplied detail", function () { + var detail = { nested: { value: 1 } }; var measure = performance.measure("detailed", { start: 0, end: 1, detail: detail }); - expect(measure.detail).toBe(detail); - expect(performance.getEntriesByName("detailed")[0].detail).toBe(detail); + expect(measure.detail).toEqual(detail); + expect(performance.getEntriesByName("detailed")[0].detail).toBe(measure.detail); expect(performance.measure("plain", { start: 0, end: 1 }).detail).toBeNull(); + if (HAS_STRUCTURED_CLONE) { + expect(measure.detail).not.toBe(detail); + detail.nested.value = 2; + expect(measure.detail.nested.value).toBe(1); + } else { + expect(measure.detail).toBe(detail); + } }); it("Should treat an options bag without members like no options at all", function () { From 0baab7cceaca2bb5fdb7b697c08b19be1e46d925 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 11 Aug 2026 19:08:57 -0300 Subject: [PATCH 4/4] test: measure the timeOrigin anchor as a min-of-N offset with a loose bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconstructing Date.now() from timeOrigin + performance.now() races three clock reads in one expression; a ~20ms scheduler or GC stall between them on a contended CI host blew the previous 10ms budget (observed as tightly clustered first-attempt failures that pass on rerun). A stall does not repeat across every sample, so the minimum offset over ten samples filters it out, while a genuine anchoring or unit error persists through all of them — 250ms still catches those. Applies to the main-isolate spec and the worker's inner sample alike. --- Performance/index.js | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/Performance/index.js b/Performance/index.js index dd5cac3..36c5f9a 100644 --- a/Performance/index.js +++ b/Performance/index.js @@ -227,7 +227,21 @@ describe("Performance high resolution time", function () { it("Should expose timeOrigin as wall clock milliseconds since the epoch", function () { expect(typeof performance.timeOrigin).toBe("number"); expect(performance.timeOrigin).toBeGreaterThan(0); - expect(Math.abs(Date.now() - (performance.timeOrigin + performance.now()))).toBeLessThan(10); + // Reconstructing Date.now() from timeOrigin + now() races three clock + // reads in one expression; a scheduler or GC stall between them shows + // up as tens of ms of apparent offset on a contended CI host. A stall + // does not repeat across every sample, so the minimum over a few + // samples is the honest measurement, while a genuine anchoring or + // unit error persists through all of them — the bound only needs to + // catch those. + var minOffset = Infinity; + for (var i = 0; i < 10; i++) { + var offset = Math.abs(Date.now() - (performance.timeOrigin + performance.now())); + if (offset < minOffset) { + minOffset = offset; + } + } + expect(minOffset).toBeLessThan(250); }); it("Should keep timeOrigin a readonly accessor on the prototype", function () { @@ -817,7 +831,14 @@ describe("Performance in workers", function () { var worker = new Worker(EVAL_WORKER); worker.postMessage({ - eval: "postMessage({ timeOrigin: performance.timeOrigin, now: performance.now(), date: Date.now() });" + // Min-of-N for the same reason as the main-isolate spec: a stall + // between the clock reads must not read as an anchoring error. + eval: "var minOffset = Infinity;" + + "for (var i = 0; i < 10; i++) {" + + " var offset = Math.abs(Date.now() - (performance.timeOrigin + performance.now()));" + + " if (offset < minOffset) { minOffset = offset; }" + + "}" + + "postMessage({ timeOrigin: performance.timeOrigin, now: performance.now(), minOffset: minOffset });" }); worker.onmessage = function (msg) { @@ -827,7 +848,7 @@ describe("Performance in workers", function () { // has accumulated stays below what the main isolate had already logged // before the worker existed. expect(msg.data.now).toBeLessThan(mainNowBeforeWorker); - expect(Math.abs(msg.data.date - (msg.data.timeOrigin + msg.data.now))).toBeLessThan(10); + expect(msg.data.minOffset).toBeLessThan(250); expect(Math.abs(mainDate - (msg.data.timeOrigin + msg.data.now))).toBeLessThan(500); worker.terminate(); done();