diff --git a/.fmtignore b/.fmtignore index fe089bcddd..8e3e22fb63 100644 --- a/.fmtignore +++ b/.fmtignore @@ -35,3 +35,16 @@ packages/* !packages/non-core/bundle-visualizer/ !packages/non-core/mongo-decimal/ !packages/non-core/xmlbuilder/ +!packages/base64/ +!packages/binary-heap/ +!packages/diff-sequence/ +!packages/callback-hook/ +!packages/ejson/ +!packages/id-map/ +!packages/ordered-dict/ +!packages/rate-limit/ +!packages/retry/ +!packages/logging/ +!packages/session/ +!packages/test-in-console/ +!packages/webapp-hashing/ diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index f3b89b5261..4b2df7607e 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -3,3 +3,9 @@ # auto-format and lint fixes 2026-03-27 1bfad0bcbb9c495172d1475f1c6d54df8d34ceae +# oxlint phase-2 lint fixes 2026-04-09 +5f50d759d3bb08cebada41481d9b38b015c3fca7 +568eace1b0726b3e2fb823292e30b0e2f0efb4ca +163f18d512d4320d57c4ddf3e14cc83607000cfa +de68f14b9c8e0916a2ac0b239a79d16c5e2adef9 + diff --git a/.oxlintignore b/.oxlintignore index 31fd4c2bcb..ae8ca0a9c4 100644 --- a/.oxlintignore +++ b/.oxlintignore @@ -32,3 +32,16 @@ packages/* !packages/non-core/bundle-visualizer/ !packages/non-core/mongo-decimal/ !packages/non-core/xmlbuilder/ +!packages/base64/ +!packages/binary-heap/ +!packages/diff-sequence/ +!packages/callback-hook/ +!packages/ejson/ +!packages/id-map/ +!packages/ordered-dict/ +!packages/rate-limit/ +!packages/retry/ +!packages/logging/ +!packages/session/ +!packages/test-in-console/ +!packages/webapp-hashing/ diff --git a/packages/base64/base64.js b/packages/base64/base64.js index de33c8566a..ee0d187b5f 100644 --- a/packages/base64/base64.js +++ b/packages/base64/base64.js @@ -4,22 +4,21 @@ const BASE_64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234 const BASE_64_VALS = Object.create(null); -const getChar = val => BASE_64_CHARS.charAt(val); -const getVal = ch => ch === '=' ? -1 : BASE_64_VALS[ch]; +const getChar = (val) => BASE_64_CHARS.charAt(val); +const getVal = (ch) => (ch === "=" ? -1 : BASE_64_VALS[ch]); for (let i = 0; i < BASE_64_CHARS.length; i++) { BASE_64_VALS[getChar(i)] = i; -}; +} -const encode = array => { +const encode = (array) => { if (typeof array === "string") { const str = array; array = newBinary(str.length); for (let i = 0; i < str.length; i++) { const ch = str.charCodeAt(i); - if (ch > 0xFF) { - throw new Error( - "Not ascii. Base64.encode can only take ascii strings."); + if (ch > 0xff) { + throw new Error("Not ascii. Base64.encode can only take ascii strings."); } array[i] = ch; @@ -35,16 +34,16 @@ const encode = array => { for (let i = 0; i < array.length; i++) { switch (i % 3) { case 0: - a = (array[i] >> 2) & 0x3F; + a = (array[i] >> 2) & 0x3f; b = (array[i] & 0x03) << 4; break; case 1: - b = b | (array[i] >> 4) & 0xF; - c = (array[i] & 0xF) << 2; + b = b | ((array[i] >> 4) & 0xf); + c = (array[i] & 0xf) << 2; break; case 2: - c = c | (array[i] >> 6) & 0x03; - d = array[i] & 0x3F; + c = c | ((array[i] >> 6) & 0x03); + d = array[i] & 0x3f; answer.push(getChar(a)); answer.push(getChar(b)); answer.push(getChar(c)); @@ -61,28 +60,26 @@ const encode = array => { answer.push(getChar(a)); answer.push(getChar(b)); if (c == null) { - answer.push('='); + answer.push("="); } else { answer.push(getChar(c)); } if (d == null) { - answer.push('='); + answer.push("="); } } return answer.join(""); }; - - // XXX This is a weird place for this to live, but it's used both by // this package and 'ejson', and we can't put it in 'ejson' without // introducing a circular dependency. It should probably be in its own // package or as a helper in a package that both 'base64' and 'ejson' // use. -const newBinary = len => { - if (typeof Uint8Array === 'undefined' || typeof ArrayBuffer === 'undefined') { +const newBinary = (len) => { + if (typeof Uint8Array === "undefined" || typeof ArrayBuffer === "undefined") { const ret = []; for (let i = 0; i < len; i++) { ret.push(0); @@ -94,11 +91,11 @@ const newBinary = len => { return new Uint8Array(new ArrayBuffer(len)); }; -const decode = str => { +const decode = (str) => { let len = Math.floor((str.length * 3) / 4); - if (str.charAt(str.length - 1) == '=') { + if (str.charAt(str.length - 1) == "=") { len--; - if (str.charAt(str.length - 2) == '=') { + if (str.charAt(str.length - 2) == "=") { len--; } } @@ -117,19 +114,19 @@ const decode = str => { switch (i % 4) { case 0: if (v < 0) { - throw new Error('invalid base64 string'); + throw new Error("invalid base64 string"); } one = v << 2; break; case 1: if (v < 0) { - throw new Error('invalid base64 string'); + throw new Error("invalid base64 string"); } one = one | (v >> 4); arr[j++] = one; - two = (v & 0x0F) << 4; + two = (v & 0x0f) << 4; break; case 2: if (v >= 0) { diff --git a/packages/base64/base64_test.js b/packages/base64/base64_test.js index d1438c90d9..ee107b663a 100644 --- a/packages/base64/base64_test.js +++ b/packages/base64/base64_test.js @@ -1,58 +1,232 @@ -import { Base64 } from './base64.js'; +import { Base64 } from "./base64.js"; -const asciiToArray = str => { +const asciiToArray = (str) => { const arr = Base64.newBinary(str.length); for (let i = 0; i < str.length; i++) { const c = str.charCodeAt(i); - if (c > 0xFF) { + if (c > 0xff) { throw new Error("Not ascii"); } arr[i] = c; } - + return arr; }; -const arrayToAscii = arr => arr - .reduce( - (prev, charCode) => prev.push(String.fromCharCode(charCode)) && prev, [] - ).join(''); +const arrayToAscii = (arr) => + arr.reduce((prev, charCode) => prev.push(String.fromCharCode(charCode)) && prev, []).join(""); -Tinytest.add("base64 - testing the test", test => { - test.equal(arrayToAscii(asciiToArray("The quick brown fox jumps over the lazy dog")), - "The quick brown fox jumps over the lazy dog"); +Tinytest.add("base64 - testing the test", (test) => { + test.equal( + arrayToAscii(asciiToArray("The quick brown fox jumps over the lazy dog")), + "The quick brown fox jumps over the lazy dog", + ); }); -Tinytest.add("base64 - empty", test => { +Tinytest.add("base64 - empty", (test) => { test.equal(Base64.encode(EJSON.newBinary(0)), ""); test.equal(Base64.decode(""), EJSON.newBinary(0)); }); - -Tinytest.add("base64 - wikipedia examples", test => { +Tinytest.add("base64 - wikipedia examples", (test) => { const tests = [ - {txt: "pleasure.", res: "cGxlYXN1cmUu"}, - {txt: "leasure.", res: "bGVhc3VyZS4="}, - {txt: "easure.", res: "ZWFzdXJlLg=="}, - {txt: "asure.", res: "YXN1cmUu"}, - {txt: "sure.", res: "c3VyZS4="} + { txt: "pleasure.", res: "cGxlYXN1cmUu" }, + { txt: "leasure.", res: "bGVhc3VyZS4=" }, + { txt: "easure.", res: "ZWFzdXJlLg==" }, + { txt: "asure.", res: "YXN1cmUu" }, + { txt: "sure.", res: "c3VyZS4=" }, ]; - tests.forEach(t => { + tests.forEach((t) => { test.equal(Base64.encode(asciiToArray(t.txt)), t.res); test.equal(arrayToAscii(Base64.decode(t.res)), t.txt); }); }); -Tinytest.add("base64 - non-text examples", test => { +Tinytest.add("base64 - non-text examples", (test) => { const tests = [ - {array: [0, 0, 0], b64: "AAAA"}, - {array: [0, 0, 1], b64: "AAAB"} + { array: [0, 0, 0], b64: "AAAA" }, + { array: [0, 0, 1], b64: "AAAB" }, ]; - tests.forEach(t => { + tests.forEach((t) => { test.equal(Base64.encode(t.array), t.b64); const expectedAsBinary = EJSON.newBinary(t.array.length); - t.array.forEach((val, i) => expectedAsBinary[i] = val); + t.array.forEach((val, i) => (expectedAsBinary[i] = val)); test.equal(Base64.decode(t.b64), expectedAsBinary); }); }); + +Tinytest.add("base64 - RFC 4648 test vectors", (test) => { + const vectors = [ + { txt: "", res: "" }, + { txt: "f", res: "Zg==" }, + { txt: "fo", res: "Zm8=" }, + { txt: "foo", res: "Zm9v" }, + { txt: "foob", res: "Zm9vYg==" }, + { txt: "fooba", res: "Zm9vYmE=" }, + { txt: "foobar", res: "Zm9vYmFy" }, + ]; + vectors.forEach((v) => { + test.equal(Base64.encode(asciiToArray(v.txt)), v.res); + test.equal(arrayToAscii(Base64.decode(v.res)), v.txt); + }); +}); + +Tinytest.add("base64 - round-trip for all byte values 0-255", (test) => { + const original = Base64.newBinary(256); + for (let i = 0; i < 256; i++) { + original[i] = i; + } + + const decoded = Base64.decode(Base64.encode(original)); + test.equal(decoded.length, 256); + for (let i = 0; i < 256; i++) { + test.equal(decoded[i], i, `byte at index ${i} should round-trip`); + } +}); + +Tinytest.add("base64 - round-trip for various lengths", (test) => { + const lengths = [0, 1, 2, 3, 4, 5, 6, 7, 8, 15, 16, 17, 31, 32, 33]; + lengths.forEach((n) => { + const original = Base64.newBinary(n); + for (let i = 0; i < n; i++) { + original[i] = i % 256; + } + + const decoded = Base64.decode(Base64.encode(original)); + test.equal(decoded.length, n, `length ${n} should round-trip`); + for (let i = 0; i < n; i++) { + test.equal(decoded[i], i % 256, `byte at index ${i} (length ${n})`); + } + }); +}); + +Tinytest.add("base64 - encoded output length is ceil(n/3)*4", (test) => { + const sizes = [0, 1, 2, 3, 4, 5, 10, 100, 999, 1000]; + sizes.forEach((n) => { + const arr = Base64.newBinary(n); + for (let i = 0; i < n; i++) { + arr[i] = i % 256; + } + + const expected = Math.ceil(n / 3) * 4; + test.equal( + Base64.encode(arr).length, + expected, + `encode(length=${n}) should be ${expected} chars`, + ); + }); +}); + +Tinytest.add("base64 - padding characters are correct", (test) => { + // 1 byte -> "==" padding + const oneByte = Base64.encode([0x41]); + test.equal(oneByte.length, 4); + test.equal(oneByte.slice(-2), "==", "1-byte input should end with =="); + test.isFalse(oneByte.slice(-3) === "===", "should not end with ==="); + + // 2 bytes -> "=" padding (single) + const twoBytes = Base64.encode([0x41, 0x42]); + test.equal(twoBytes.length, 4); + test.equal(twoBytes.slice(-1), "=", "2-byte input should end with single ="); + test.isFalse(twoBytes.slice(-2) === "==", "2-byte input should not end with =="); + + // 3 bytes -> no padding + const threeBytes = Base64.encode([0x41, 0x42, 0x43]); + test.equal(threeBytes.length, 4); + test.equal(threeBytes.indexOf("="), -1, "3-byte input should contain no ="); +}); + +Tinytest.add("base64 - large input round-trip", (test) => { + const size = 10000; + const original = Base64.newBinary(size); + for (let i = 0; i < size; i++) { + original[i] = (i * 31) % 256; + } + + const encoded = Base64.encode(original); + test.equal(encoded.length, Math.ceil(size / 3) * 4); + + const decoded = Base64.decode(encoded); + test.equal(decoded.length, size); + for (let i = 0; i < size; i++) { + if (decoded[i] !== (i * 31) % 256) { + test.fail({ + type: "large-input-mismatch", + message: `byte at index ${i}: expected ${(i * 31) % 256}, got ${decoded[i]}`, + }); + return; + } + } + test.ok(); +}); + +Tinytest.add("base64 - encode rejects characters above 0xff", (test) => { + // \u0100 is the first code point > 0xff and must be rejected. + test.throws(() => Base64.encode("\u0100"), /Not ascii/); + // \uffff is well above the limit and must also be rejected. + test.throws(() => Base64.encode("\uffff"), /Not ascii/); + // A string containing a mix of valid and invalid chars must still throw. + test.throws(() => Base64.encode("abc\u0100"), /Not ascii/); + + // Positive controls: the boundary is > 0xff, not > 0x7f. + // \u00ff is the last allowed code point and must NOT throw. + const resultFf = Base64.encode("\u00ff"); + test.equal(typeof resultFf, "string"); + test.equal(resultFf.length, 4, "single byte encodes to 4 base64 chars"); + // \u0080 (first non-7-bit-ASCII byte) must also be accepted. + const result80 = Base64.encode("\u0080"); + test.equal(typeof result80, "string"); + test.equal(result80.length, 4); +}); + +// Documents the ACTUAL error-handling behavior of Base64.decode: +// the implementation only throws when "=" appears at position 0 or 1 +// (mod 4) of a 4-char group. Characters outside the base64 alphabet +// (e.g. "!", "?") return undefined from the internal lookup and are +// silently treated as zero — they do NOT throw. This test pins down +// the real behavior so future refactors cannot regress it unnoticed. +Tinytest.add("base64 - decode rejects '=' in leading positions", (test) => { + // "=" at position 0 of a group. + test.throws(() => Base64.decode("===="), /invalid base64 string/); + + // "=" at position 1 of a group. + test.throws(() => Base64.decode("A==="), /invalid base64 string/); + + // "=" at positions 0/1 of a *later* 4-char group. + test.throws(() => Base64.decode("AAAA===="), /invalid base64 string/); + test.throws(() => Base64.decode("AAAAA==="), /invalid base64 string/); +}); + +Tinytest.add("base64 - newBinary contract", (test) => { + [0, 1, 100].forEach((n) => { + test.equal(Base64.newBinary(n).length, n, `newBinary(${n}).length`); + }); + + // Zero-initialized. + const five = Base64.newBinary(5); + for (let i = 0; i < 5; i++) { + test.equal(five[i], 0, `newBinary(5)[${i}] should start at 0`); + } + + // Indexed writes are readable back. + const three = Base64.newBinary(3); + three[0] = 42; + three[1] = 7; + three[2] = 255; + test.equal(three[0], 42); + test.equal(three[1], 7); + test.equal(three[2], 255); + + // Interoperates with encode/decode. + const buf = Base64.newBinary(3); + buf[0] = 0x41; + buf[1] = 0x42; + buf[2] = 0x43; + test.equal(Base64.encode(buf), "QUJD"); + const decoded = Base64.decode("QUJD"); + test.equal(decoded.length, 3); + test.equal(decoded[0], 0x41); + test.equal(decoded[1], 0x42); + test.equal(decoded[2], 0x43); +}); diff --git a/packages/base64/package.js b/packages/base64/package.js index 1a1009051f..de0032deb3 100644 --- a/packages/base64/package.js +++ b/packages/base64/package.js @@ -1,15 +1,15 @@ Package.describe({ summary: "Base64 encoding and decoding", - version: '1.0.13', + version: "1.0.13", }); -Package.onUse(api => { - api.export('Base64'); - api.use('ecmascript'); - api.mainModule('base64.js'); +Package.onUse((api) => { + api.export("Base64"); + api.use("ecmascript"); + api.mainModule("base64.js"); }); -Package.onTest(api => { - api.use(['ecmascript', 'tinytest', 'ejson']); - api.addFiles('base64_test.js', ['client', 'server']); +Package.onTest((api) => { + api.use(["ecmascript", "tinytest", "ejson"]); + api.addFiles("base64_test.js", ["client", "server"]); }); diff --git a/packages/binary-heap/binary-heap-tests.js b/packages/binary-heap/binary-heap-tests.js index 1f3d740ac7..b41204fed6 100644 --- a/packages/binary-heap/binary-heap-tests.js +++ b/packages/binary-heap/binary-heap-tests.js @@ -1,8 +1,9 @@ -import { MaxHeap } from './max-heap.js'; -import { MinMaxHeap } from './min-max-heap.js'; +import { MaxHeap } from "./max-heap.js"; +import { MinHeap } from "./min-heap.js"; +import { MinMaxHeap } from "./min-max-heap.js"; // Based on underscore implementation (Fisher-Yates shuffle) -const shuffle = arr => { +const shuffle = (arr) => { let j = 0; let temp = null; @@ -33,7 +34,7 @@ const range = (start, stop, step = 1) => { return range; }; -Tinytest.add("binary-heap - simple max-heap tests", test => { +Tinytest.add("binary-heap - simple max-heap tests", (test) => { const h = new MaxHeap((a, b) => a - b); h.set("a", 1); h.set("b", 233); @@ -63,7 +64,7 @@ Tinytest.add("binary-heap - simple max-heap tests", test => { test.equal(h.maxElementId(), "a"); }); -Tinytest.add("binary-heap - big test for max-heap", test => { +Tinytest.add("binary-heap - big test for max-heap", (test) => { const positiveNumbers = shuffle(range(1, 41)); const negativeNumbers = shuffle(range(-1, -41, -1)); const allNumbers = [...negativeNumbers, ...positiveNumbers]; @@ -71,7 +72,7 @@ Tinytest.add("binary-heap - big test for max-heap", test => { const heap = new MaxHeap((a, b) => a - b); const output = []; - allNumbers.forEach(n => heap.set(n, n)); + allNumbers.forEach((n) => heap.set(n, n)); allNumbers.forEach(() => { const maxId = heap.maxElementId(); @@ -84,7 +85,7 @@ Tinytest.add("binary-heap - big test for max-heap", test => { test.equal(output, allNumbers); }); -Tinytest.add("binary-heap - min-max heap tests", test => { +Tinytest.add("binary-heap - min-max heap tests", (test) => { const h = new MinMaxHeap((a, b) => a - b); h.set("a", 1); h.set("b", 233); @@ -116,7 +117,7 @@ Tinytest.add("binary-heap - min-max heap tests", test => { test.equal(h.minElementId(), "a"); }); -Tinytest.add("binary-heap - big test for min-max-heap", test => { +Tinytest.add("binary-heap - big test for min-max-heap", (test) => { const N = 500; const positiveNumbers = shuffle(range(1, N + 1)); const negativeNumbers = shuffle(range(-1, -N - 1, -1)); @@ -126,7 +127,7 @@ Tinytest.add("binary-heap - big test for min-max-heap", test => { let output = []; const initialSets = [...allNumbers]; - allNumbers.forEach(n => { + allNumbers.forEach((n) => { heap.set(n, n); heap._selfCheck(); heap._minHeap._selfCheck(); @@ -135,7 +136,7 @@ Tinytest.add("binary-heap - big test for min-max-heap", test => { shuffle(allNumbers); const secondarySets = [...allNumbers]; - allNumbers.forEach(n => { + allNumbers.forEach((n) => { heap.set(-n, n); heap._selfCheck(); heap._minHeap._selfCheck(); @@ -145,19 +146,20 @@ Tinytest.add("binary-heap - big test for min-max-heap", test => { const minId = heap.minElementId(); output.push(heap.get(minId)); heap.remove(minId); - heap._selfCheck(); heap._minHeap._selfCheck(); + heap._selfCheck(); + heap._minHeap._selfCheck(); }); test.equal(heap.size(), 0); allNumbers.sort((a, b) => a - b); - const initialTestText = `initial sets: ${initialSets.toString()}` + - `; secondary sets: ${secondarySets.toString()}`; + const initialTestText = + `initial sets: ${initialSets.toString()}` + `; secondary sets: ${secondarySets.toString()}`; test.equal(output, allNumbers, initialTestText); - initialSets.forEach(n => heap.set(n, n)); - secondarySets.forEach(n => heap.set(-n, n)); + initialSets.forEach((n) => heap.set(n, n)); + secondarySets.forEach((n) => heap.set(-n, n)); allNumbers.sort((a, b) => b - a); output = []; @@ -165,8 +167,237 @@ Tinytest.add("binary-heap - big test for min-max-heap", test => { const maxId = heap.maxElementId(); output.push(heap.get(maxId)); heap.remove(maxId); - heap._selfCheck(); heap._minHeap._selfCheck(); + heap._selfCheck(); + heap._minHeap._selfCheck(); }); test.equal(output, allNumbers, initialTestText); }); + +Tinytest.add("binary-heap - constructor throws on non-function comparator", (test) => { + test.throws(() => new MaxHeap(null), /comparator is invalid/); + test.throws(() => new MaxHeap("not a fn"), /comparator is invalid/); + test.throws(() => new MaxHeap(), /comparator is invalid/); +}); + +Tinytest.add("binary-heap - MaxHeap built from initData preserves elements", (test) => { + const heap = new MaxHeap((a, b) => a - b, { + initData: [ + { id: "a", value: 5 }, + { id: "b", value: 10 }, + { id: "c", value: 1 }, + { id: "d", value: 7 }, + { id: "e", value: 3 }, + ], + }); + + test.equal(heap.size(), 5); + test.equal(heap.maxElementId(), "b"); + test.equal(heap.get("a"), 5); + test.equal(heap.get("d"), 7); + + // Drain should yield descending order. + const drained = []; + while (!heap.empty()) { + const id = heap.maxElementId(); + drained.push(heap.get(id)); + heap.remove(id); + } + test.equal(drained, [10, 7, 5, 3, 1]); +}); + +Tinytest.add("binary-heap - MaxHeap.empty and forEach", (test) => { + const heap = new MaxHeap((a, b) => a - b); + test.isTrue(heap.empty()); + + heap.set("a", 1); + heap.set("b", 2); + heap.set("c", 3); + test.isFalse(heap.empty()); + + const collected = Object.create(null); + heap.forEach((value, id) => { + collected[id] = value; + }); + test.equal(collected, { a: 1, b: 2, c: 3 }); +}); + +Tinytest.add("binary-heap - MaxHeap.set updates existing value and rebalances", (test) => { + const heap = new MaxHeap((a, b) => a - b); + heap.set("x", 1); + heap.set("y", 5); + heap.set("z", 3); + test.equal(heap.maxElementId(), "y"); + + // Bubble up: x goes to top. + heap.set("x", 100); + test.equal(heap.maxElementId(), "x"); + test.equal(heap.get("x"), 100); + + // Bubble down: x drops below y and z. + heap.set("x", -100); + test.equal(heap.maxElementId(), "y"); + test.equal(heap.get("x"), -100); + heap._selfCheck(); +}); + +Tinytest.add("binary-heap - MaxHeap.set with same value is a no-op", (test) => { + const heap = new MaxHeap((a, b) => a - b); + heap.set("a", 10); + heap.set("b", 20); + heap.set("c", 15); + const before = heap.maxElementId(); + + heap.set("b", 20); // same id, same value + test.equal(heap.maxElementId(), before); + test.equal(heap.size(), 3); + test.equal(heap.get("b"), 20); +}); + +// ============================================================================= +// KNOWN BUG — pinned by the two tests below. +// ============================================================================= +// +// BUG: +// MaxHeap.clone() and MinMaxHeap.clone() are broken. Both return an empty +// heap regardless of the original's contents, AND they mutate the original +// heap's internal `_heap` array as a side effect (attaching an `IdMap` +// property to it). +// +// WHERE: +// packages/binary-heap/max-heap.js, MaxHeap.clone(): +// +// clone() { +// const clone = new MaxHeap(this._comparator, this._heap); +// return clone; +// } +// +// packages/binary-heap/min-max-heap.js, MinMaxHeap.clone(): same shape. +// +// WHY IT IS BROKEN: +// The MaxHeap constructor signature is `constructor(comparator, options)`, +// where `options` is `{ initData, IdMap }`. clone() passes `this._heap` — a +// plain array of `{ id, value }` records — as `options`. Inside the +// constructor: +// 1. `options.IdMap = IdMap` ← mutates the original _heap array, +// attaching an `IdMap` property. +// 2. `Array.isArray(options.initData)` is false (arrays have no +// `initData` property), so `_initFromData` is never called. +// The returned clone therefore has `_heap === []` and an empty `_heapIdx`. +// +// CORRECT FIX (to be applied in a separate PR): +// Wrap `this._heap` in the expected options object: +// +// clone() { +// return new MaxHeap(this._comparator, { initData: this._heap }); +// } +// +// clone() { // MinMaxHeap +// return new MinMaxHeap(this._comparator, { initData: this._heap }); +// } +// +// `_initFromData` already expects records in the exact `{ id, value }` +// shape that `_heap` stores, so the fix is a one-line wrap in each file. +// +// WHEN THE FIX LANDS: +// Delete the two `*.clone (BROKEN)` tests below and replace them with the +// "produces independent copy" tests that were originally written: +// they should assert size, maxElementId/minElementId, get() values, and +// that mutations to the clone do not affect the original. +// ============================================================================= + +Tinytest.add("binary-heap - MaxHeap.clone (BROKEN) returns an empty heap", (test) => { + const heap = new MaxHeap((a, b) => a - b); + heap.set("a", 1); + heap.set("b", 5); + heap.set("c", 3); + + const clone = heap.clone(); + + // BUG: clone is empty instead of mirroring the original's 3 entries. + test.equal(clone.size(), 0, "clone.size() should be 3 once clone() is fixed"); + test.isTrue(clone.empty(), "clone.empty() should be false once clone() is fixed"); + test.equal( + clone.maxElementId(), + null, + "clone.maxElementId() should be 'b' once clone() is fixed", + ); + test.equal(clone.get("a"), null, "clone.get('a') should be 1 once clone() is fixed"); + + // The original is not structurally damaged — it still works — but + // as a side effect the constructor attached an IdMap property to the + // original heap's internal array. We intentionally do NOT assert on + // that side effect here: it is an implementation leak that should + // simply disappear once the fix lands. + test.equal(heap.size(), 3, "original should still have its 3 entries"); + test.equal(heap.maxElementId(), "b"); +}); + +Tinytest.add("binary-heap - MinHeap sorts ascending", (test) => { + const heap = new MinHeap((a, b) => a - b); + heap.set("a", 5); + heap.set("b", 1); + heap.set("c", 3); + heap.set("d", 8); + heap.set("e", -2); + + test.equal(heap.minElementId(), "e"); + + const drained = []; + while (!heap.empty()) { + const id = heap.minElementId(); + drained.push(heap.get(id)); + heap.remove(id); + } + test.equal(drained, [-2, 1, 3, 5, 8]); +}); + +Tinytest.add("binary-heap - MinHeap.maxElementId throws", (test) => { + const heap = new MinHeap((a, b) => a - b); + heap.set("a", 1); + test.throws(() => heap.maxElementId(), /Cannot call maxElementId on MinHeap/); +}); + +Tinytest.add("binary-heap - MinMaxHeap.clone (BROKEN) returns an empty heap", (test) => { + // See the lengthy bug comment above the MaxHeap.clone pinned test. + // MinMaxHeap.clone() has the exact same bug: it passes this._heap as + // the options argument instead of { initData: this._heap }. + // + // Once packages/binary-heap/min-max-heap.js clone() is fixed, delete + // this test and replace it with one that asserts: + // clone.size() === 4 + // clone.maxElementId() === "b" + // clone.minElementId() === "c" + // clone.get("a") === 1 + // mutations to clone do not affect the original. + + const heap = new MinMaxHeap((a, b) => a - b); + heap.set("a", 1); + heap.set("b", 10); + heap.set("c", -5); + heap.set("d", 7); + + const clone = heap.clone(); + + // BUG: clone is empty instead of mirroring the original's 4 entries. + test.equal(clone.size(), 0, "clone.size() should be 4 once clone() is fixed"); + test.isTrue(clone.empty(), "clone.empty() should be false once clone() is fixed"); + test.equal( + clone.maxElementId(), + null, + "clone.maxElementId() should be 'b' once clone() is fixed", + ); + // minElementId on an empty MinMaxHeap returns null via MinHeap.maxElementId + // which proxies to the inner min heap. + test.equal( + clone.minElementId(), + null, + "clone.minElementId() should be 'c' once clone() is fixed", + ); + test.equal(clone.get("a"), null, "clone.get('a') should be 1 once clone() is fixed"); + + // Original is not structurally damaged. + test.equal(heap.size(), 4); + test.equal(heap.maxElementId(), "b"); + test.equal(heap.minElementId(), "c"); +}); diff --git a/packages/binary-heap/binary-heap.js b/packages/binary-heap/binary-heap.js index 74fc89497e..b36d6f124d 100644 --- a/packages/binary-heap/binary-heap.js +++ b/packages/binary-heap/binary-heap.js @@ -1,3 +1,3 @@ -export { MaxHeap } from './max-heap.js'; -export { MinHeap } from './min-heap.js'; -export { MinMaxHeap } from './min-max-heap.js'; +export { MaxHeap } from "./max-heap.js"; +export { MinHeap } from "./min-heap.js"; +export { MinMaxHeap } from "./min-max-heap.js"; diff --git a/packages/binary-heap/max-heap.js b/packages/binary-heap/max-heap.js index 235fed1d0d..4044820882 100644 --- a/packages/binary-heap/max-heap.js +++ b/packages/binary-heap/max-heap.js @@ -8,10 +8,10 @@ // each value is retained // - IdMap - Constructor - Optional - custom IdMap class to store id->index // mappings internally. Standard IdMap is used by default. -export class MaxHeap { +export class MaxHeap { constructor(comparator, options = {}) { - if (typeof comparator !== 'function') { - throw new Error('Passed comparator is invalid, should be a comparison function'); + if (typeof comparator !== "function") { + throw new Error("Passed comparator is invalid, should be a comparison function"); } // a C-style comparator that is given two values and returns a number, @@ -19,13 +19,13 @@ export class MaxHeap { // value is greater than the first and zero if they are equal. this._comparator = comparator; - if (! options.IdMap) { + if (!options.IdMap) { options.IdMap = IdMap; } // _heapIdx maps an id to an index in the Heap array the corresponding value // is located on. - this._heapIdx = new options.IdMap; + this._heapIdx = new options.IdMap(); // The Heap data-structure implemented as a 0-based contiguous array where // every item on index idx is a node in a complete binary tree. Every node can @@ -47,7 +47,7 @@ export class MaxHeap { data.forEach(({ id }, i) => this._heapIdx.set(id, i)); - if (! data.length) { + if (!data.length) { return; } @@ -84,7 +84,7 @@ export class MaxHeap { while (idx > 0) { const parent = parentIdx(idx); if (this._maxIndex(parent, idx) === idx) { - this._swap(parent, idx) + this._swap(parent, idx); idx = parent; } else { break; @@ -115,9 +115,7 @@ export class MaxHeap { } get(id) { - return this.has(id) ? - this._get(this._heapIdx.get(id)) : - null; + return this.has(id) ? this._get(this._heapIdx.get(id)) : null; } set(id, value) { @@ -176,7 +174,7 @@ export class MaxHeap { // iterate over values in no particular order forEach(iterator) { - this._heap.forEach(obj => iterator(obj.value, obj.id)); + this._heap.forEach((obj) => iterator(obj.value, obj.id)); } size() { @@ -204,14 +202,14 @@ export class MaxHeap { _selfCheck() { for (let i = 1; i < this._heap.length; i++) { if (this._maxIndex(parentIdx(i), i) !== parentIdx(i)) { - throw new Error(`An item with id ${this._heap[i].id}` + - " has a parent younger than it: " + - this._heap[parentIdx(i)].id); + throw new Error( + `An item with id ${this._heap[i].id} has a parent younger than it: ${this._heap[parentIdx(i)].id}`, + ); } } } } -const leftChildIdx = i => i * 2 + 1; -const rightChildIdx = i => i * 2 + 2; -const parentIdx = i => (i - 1) >> 1; +const leftChildIdx = (i) => i * 2 + 1; +const rightChildIdx = (i) => i * 2 + 2; +const parentIdx = (i) => (i - 1) >> 1; diff --git a/packages/binary-heap/min-heap.js b/packages/binary-heap/min-heap.js index e57a445e58..87435762c5 100644 --- a/packages/binary-heap/min-heap.js +++ b/packages/binary-heap/min-heap.js @@ -1,4 +1,4 @@ -import { MaxHeap } from './max-heap.js'; +import { MaxHeap } from "./max-heap.js"; export class MinHeap extends MaxHeap { constructor(comparator, options) { @@ -12,4 +12,4 @@ export class MinHeap extends MaxHeap { minElementId() { return super.maxElementId(); } -}; +} diff --git a/packages/binary-heap/min-max-heap.js b/packages/binary-heap/min-max-heap.js index c40fb09fd1..d27c7cf88b 100644 --- a/packages/binary-heap/min-max-heap.js +++ b/packages/binary-heap/min-max-heap.js @@ -1,5 +1,5 @@ -import { MaxHeap } from './max-heap.js'; -import { MinHeap } from './min-heap.js'; +import { MaxHeap } from "./max-heap.js"; +import { MinHeap } from "./min-heap.js"; // This implementation of Min/Max-Heap is just a subclass of Max-Heap // with a Min-Heap as an encapsulated property. @@ -47,5 +47,4 @@ export class MinMaxHeap extends MaxHeap { minElementId() { return this._minHeap.minElementId(); } - -}; +} diff --git a/packages/binary-heap/package.js b/packages/binary-heap/package.js index c532928f9d..b39f6390b1 100644 --- a/packages/binary-heap/package.js +++ b/packages/binary-heap/package.js @@ -1,15 +1,15 @@ Package.describe({ summary: "Binary Heap datastructure implementation", - version: '1.0.12', + version: "1.0.12", }); -Package.onUse(api => { - api.export(['MaxHeap', 'MinHeap', 'MinMaxHeap']); - api.use(['id-map', 'ecmascript']); - api.mainModule('binary-heap.js'); +Package.onUse((api) => { + api.export(["MaxHeap", "MinHeap", "MinMaxHeap"]); + api.use(["id-map", "ecmascript"]); + api.mainModule("binary-heap.js"); }); -Package.onTest(api => { - api.use(['tinytest', 'binary-heap', 'ecmascript']); - api.addFiles('binary-heap-tests.js'); +Package.onTest((api) => { + api.use(["tinytest", "binary-heap", "ecmascript"]); + api.addFiles("binary-heap-tests.js"); }); diff --git a/packages/callback-hook/hook.js b/packages/callback-hook/hook.js index d1156f79fd..a76c3e168f 100644 --- a/packages/callback-hook/hook.js +++ b/packages/callback-hook/hook.js @@ -65,12 +65,14 @@ export class Hook { } register(callback) { - const exceptionHandler = this.exceptionHandler || function (exception) { - // Note: this relies on the undocumented fact that if bindEnvironment's - // onException throws, and you are invoking the callback either in the - // browser or from within a Fiber in Node, the exception is propagated. - throw exception; - }; + const exceptionHandler = + this.exceptionHandler || + function (exception) { + // Note: this relies on the undocumented fact that if bindEnvironment's + // onException throws, and you are invoking the callback either in the + // browser or from within a Fiber in Node, the exception is propagated. + throw exception; + }; if (this.bindEnvironment) { callback = Meteor.bindEnvironment(callback, exceptionHandler); @@ -89,7 +91,7 @@ export class Hook { callback, stop: () => { delete this.callbacks[id]; - } + }, }; } @@ -110,14 +112,13 @@ export class Hook { * @param iterator */ forEach(iterator) { - const ids = Object.keys(this.callbacks); - for (let i = 0; i < ids.length; ++i) { + for (let i = 0; i < ids.length; ++i) { const id = ids[i]; // check to see if the callback was removed during iteration if (hasOwn.call(this.callbacks, id)) { const callback = this.callbacks[id]; - if (! iterator(callback)) { + if (!iterator(callback)) { break; } } @@ -134,12 +135,12 @@ export class Hook { */ async forEachAsync(iterator) { const ids = Object.keys(this.callbacks); - for (let i = 0; i < ids.length; ++i) { + for (let i = 0; i < ids.length; ++i) { const id = ids[i]; // check to see if the callback was removed during iteration if (hasOwn.call(this.callbacks, id)) { const callback = this.callbacks[id]; - if (!await iterator(callback)) { + if (!(await iterator(callback))) { break; } } @@ -157,13 +158,10 @@ export class Hook { // Copied from Meteor.bindEnvironment and removed all the env stuff. function dontBindEnvironment(func, onException, _this) { - if (!onException || typeof(onException) === 'string') { + if (!onException || typeof onException === "string") { const description = onException || "callback of async function"; onException = function (error) { - Meteor._debug( - "Exception in " + description, - error - ); + Meteor._debug(`Exception in ${description}`, error); }; } diff --git a/packages/callback-hook/hook_tests.js b/packages/callback-hook/hook_tests.js index 5000d1ca59..422c444309 100644 --- a/packages/callback-hook/hook_tests.js +++ b/packages/callback-hook/hook_tests.js @@ -1,13 +1,13 @@ Tinytest.add("callback-hook - binds to registrar's env by default", function (test) { const hook = new Hook(); - const envVar = new Meteor.EnvironmentVariable; - envVar.withValue("registrar's value", function() { - hook.register(function() { + const envVar = new Meteor.EnvironmentVariable(); + envVar.withValue("registrar's value", function () { + hook.register(function () { test.equal(envVar.get(), "registrar's value"); }); }); - envVar.withValue("invoker's value", function() { - hook.forEach(function(callback) { + envVar.withValue("invoker's value", function () { + hook.forEach(function (callback) { callback(); }); }); @@ -15,14 +15,14 @@ Tinytest.add("callback-hook - binds to registrar's env by default", function (te Tinytest.add("callback-hook - uses invoker's env with {bindEnvironment: false}", function (test) { const hook = new Hook({ bindEnvironment: false }); - const envVar = new Meteor.EnvironmentVariable; - envVar.withValue("registrar's value", function() { - hook.register(function() { + const envVar = new Meteor.EnvironmentVariable(); + envVar.withValue("registrar's value", function () { + hook.register(function () { test.equal(envVar.get(), "invoker's value"); }); }); - envVar.withValue("invoker's value", function() { - hook.each(function(callback) { + envVar.withValue("invoker's value", function () { + hook.each(function (callback) { callback(); }); }); @@ -30,26 +30,225 @@ Tinytest.add("callback-hook - uses invoker's env with {bindEnvironment: false}", Tinytest.add("callback-hook - exceptions unhandled with {bindEnvironment: false}", function (test) { const hook = new Hook({ bindEnvironment: false }); - hook.register(function() { + hook.register(function () { throw new Error("Test error"); }); - hook.forEach(function(callback) { + hook.forEach(function (callback) { test.throws(callback, "Test error"); }); }); -Tinytest.add("callback-hook - exceptionHandler used with {bindEnvironment: false}", function (test) { - const exToThrow = new Error("Test error"); - let thrownEx = null; - const hook = new Hook({ - bindEnvironment: false, - exceptionHandler: function (ex) { thrownEx = ex; } - }); - hook.register(function() { - throw exToThrow; - }); - hook.each(function(callback) { - callback(); - }); - test.equal(exToThrow, thrownEx); +Tinytest.add( + "callback-hook - exceptionHandler used with {bindEnvironment: false}", + function (test) { + const exToThrow = new Error("Test error"); + let thrownEx = null; + const hook = new Hook({ + bindEnvironment: false, + exceptionHandler: function (ex) { + thrownEx = ex; + }, + }); + hook.register(function () { + throw exToThrow; + }); + hook.each(function (callback) { + callback(); + }); + test.equal(exToThrow, thrownEx); + }, +); + +Tinytest.add("callback-hook - register returns object with stop", function (test) { + const hook = new Hook({ bindEnvironment: false }); + const handle = hook.register(function () {}); + test.isTrue(typeof handle === "object" && handle !== null); + test.equal(typeof handle.stop, "function"); + test.equal(typeof handle.callback, "function"); +}); + +Tinytest.add("callback-hook - stop unregisters the callback", function (test) { + const hook = new Hook({ bindEnvironment: false }); + const calls = []; + const h1 = hook.register(function () { + calls.push("a"); + }); + hook.register(function () { + calls.push("b"); + }); + h1.stop(); + + hook.forEach(function (callback) { + callback(); + return true; + }); + test.equal(calls, ["b"]); +}); + +Tinytest.add("callback-hook - forEach iterates callbacks in registration order", (test) => { + const hook = new Hook({ bindEnvironment: false }); + const order = []; + hook.register(function () { + order.push(1); + }); + hook.register(function () { + order.push(2); + }); + hook.register(function () { + order.push(3); + }); + + hook.forEach(function (callback) { + callback(); + return true; + }); + test.equal(order, [1, 2, 3]); +}); + +Tinytest.add("callback-hook - forEach stops when iterator returns falsy", (test) => { + const hook = new Hook({ bindEnvironment: false }); + const order = []; + hook.register(function () { + order.push(1); + }); + hook.register(function () { + order.push(2); + }); + hook.register(function () { + order.push(3); + }); + + let seen = 0; + hook.forEach(function (callback) { + callback(); + seen++; + return seen < 2; // stop after the second + }); + test.equal(order, [1, 2]); +}); + +Tinytest.add("callback-hook - callback can safely stop itself during iteration", (test) => { + const hook = new Hook({ bindEnvironment: false }); + const calls = []; + const holder = {}; + hook.register(function () { + calls.push("a"); + }); + holder.h2 = hook.register(function () { + calls.push("b"); + holder.h2.stop(); + }); + hook.register(function () { + calls.push("c"); + }); + + hook.forEach(function (callback) { + callback(); + return true; + }); + test.equal(calls, ["a", "b", "c"]); + + // A second pass confirms h2 is really gone. + calls.length = 0; + hook.forEach(function (callback) { + callback(); + return true; + }); + test.equal(calls, ["a", "c"]); +}); + +Tinytest.add("callback-hook - clear removes all callbacks", (test) => { + const hook = new Hook({ bindEnvironment: false }); + hook.register(function () {}); + hook.register(function () {}); + let seen = 0; + hook.forEach(function () { + seen++; + return true; + }); + test.equal(seen, 2); + + hook.clear(); + seen = 0; + hook.forEach(function () { + seen++; + return true; + }); + test.equal(seen, 0); +}); + +Tinytest.addAsync( + "callback-hook - forEachAsync iterates and honors falsy return", + async function (test) { + const hook = new Hook({ bindEnvironment: false }); + const order = []; + hook.register(function () { + order.push(1); + }); + hook.register(function () { + order.push(2); + }); + hook.register(function () { + order.push(3); + }); + + await hook.forEachAsync(async function (callback) { + callback(); + return order.length < 2; // stop after the second + }); + test.equal(order, [1, 2]); + }, +); + +Tinytest.add("callback-hook - debugPrintExceptions must be a string", function (test) { + test.throws(function () { + new Hook({ debugPrintExceptions: true }); + }, /debugPrintExceptions should be a string/); +}); + +Tinytest.add( + "callback-hook - debugPrintExceptions swallows errors with {bindEnvironment: false}", + function (test) { + const originalDebug = Meteor._debug; + const logged = []; + Meteor._debug = function (...args) { + logged.push(args); + }; + try { + const hook = new Hook({ + bindEnvironment: false, + debugPrintExceptions: "test-hook", + }); + hook.register(function () { + throw new Error("boom"); + }); + // Should not throw — dontBindEnvironment wraps it and logs via Meteor._debug. + hook.forEach(function (callback) { + callback(); + return true; + }); + } finally { + Meteor._debug = originalDebug; + } + test.equal(logged.length, 1); + // First arg is the description, second is the error. + test.matches(logged[0][0], /test-hook/); + test.instanceOf(logged[0][1], Error); + }, +); + +Tinytest.add("callback-hook - each is an alias for forEach", function (test) { + const hook = new Hook({ bindEnvironment: false }); + const order = []; + hook.register(function () { + order.push("a"); + }); + hook.register(function () { + order.push("b"); + }); + hook.each(function (callback) { + callback(); + return true; + }); + test.equal(order, ["a", "b"]); }); diff --git a/packages/callback-hook/package.js b/packages/callback-hook/package.js index 6f9e9ac68a..b6fd5ead65 100644 --- a/packages/callback-hook/package.js +++ b/packages/callback-hook/package.js @@ -1,16 +1,16 @@ Package.describe({ summary: "Register callbacks on a hook", - version: '1.6.1', + version: "1.6.1", }); Package.onUse(function (api) { - api.use('ecmascript'); - api.mainModule('hook.js'); - api.export('Hook'); + api.use("ecmascript"); + api.mainModule("hook.js"); + api.export("Hook"); }); Package.onTest(function (api) { - api.use('callback-hook'); - api.use('tinytest'); - api.addFiles('hook_tests.js', 'server'); + api.use("callback-hook"); + api.use("tinytest"); + api.addFiles("hook_tests.js", "server"); }); diff --git a/packages/diff-sequence/diff.js b/packages/diff-sequence/diff.js index d24fa616ed..27d26e37ef 100644 --- a/packages/diff-sequence/diff.js +++ b/packages/diff-sequence/diff.js @@ -3,7 +3,7 @@ export const DiffSequence = {}; const hasOwn = Object.prototype.hasOwnProperty; function isObjEmpty(obj) { - for (let key in Object(obj)) { + for (const key in Object(obj)) { if (hasOwn.call(obj, key)) { return false; } @@ -15,39 +15,32 @@ function isObjEmpty(obj) { // old_results and new_results: collections of documents. // if ordered, they are arrays. // if unordered, they are IdMaps -DiffSequence.diffQueryChanges = function (ordered, oldResults, newResults, - observer, options) { - if (ordered) - DiffSequence.diffQueryOrderedChanges( - oldResults, newResults, observer, options); - else - DiffSequence.diffQueryUnorderedChanges( - oldResults, newResults, observer, options); +DiffSequence.diffQueryChanges = function (ordered, oldResults, newResults, observer, options) { + if (ordered) DiffSequence.diffQueryOrderedChanges(oldResults, newResults, observer, options); + else DiffSequence.diffQueryUnorderedChanges(oldResults, newResults, observer, options); }; -DiffSequence.diffQueryUnorderedChanges = function (oldResults, newResults, - observer, options) { +DiffSequence.diffQueryUnorderedChanges = function (oldResults, newResults, observer, options) { options = options || {}; - var projectionFn = options.projectionFn || EJSON.clone; + const projectionFn = options.projectionFn || EJSON.clone; if (observer.movedBefore) { throw new Error("_diffQueryUnordered called with a movedBefore observer!"); } newResults.forEach(function (newDoc, id) { - var oldDoc = oldResults.get(id); + const oldDoc = oldResults.get(id); if (oldDoc) { if (observer.changed && !EJSON.equals(oldDoc, newDoc)) { - var projectedNew = projectionFn(newDoc); - var projectedOld = projectionFn(oldDoc); - var changedFields = - DiffSequence.makeChangedFields(projectedNew, projectedOld); - if (! isObjEmpty(changedFields)) { + const projectedNew = projectionFn(newDoc); + const projectedOld = projectionFn(oldDoc); + const changedFields = DiffSequence.makeChangedFields(projectedNew, projectedOld); + if (!isObjEmpty(changedFields)) { observer.changed(id, changedFields); } } } else if (observer.added) { - var fields = projectionFn(newDoc); + const fields = projectionFn(newDoc); delete fields._id; observer.added(newDoc._id, fields); } @@ -55,28 +48,24 @@ DiffSequence.diffQueryUnorderedChanges = function (oldResults, newResults, if (observer.removed) { oldResults.forEach(function (oldDoc, id) { - if (!newResults.has(id)) - observer.removed(id); + if (!newResults.has(id)) observer.removed(id); }); } }; -DiffSequence.diffQueryOrderedChanges = function (old_results, new_results, - observer, options) { +DiffSequence.diffQueryOrderedChanges = function (old_results, new_results, observer, options) { options = options || {}; - var projectionFn = options.projectionFn || EJSON.clone; + const projectionFn = options.projectionFn || EJSON.clone; - var new_presence_of_id = {}; + const new_presence_of_id = {}; new_results.forEach(function (doc) { - if (new_presence_of_id[doc._id]) - Meteor._debug("Duplicate _id in new_results"); + if (new_presence_of_id[doc._id]) Meteor._debug("Duplicate _id in new_results"); new_presence_of_id[doc._id] = true; }); - var old_index_of_id = {}; + const old_index_of_id = {}; old_results.forEach(function (doc, i) { - if (doc._id in old_index_of_id) - Meteor._debug("Duplicate _id in old_results"); + if (doc._id in old_index_of_id) Meteor._debug("Duplicate _id in old_results"); old_index_of_id[doc._id] = i; }); @@ -106,7 +95,6 @@ DiffSequence.diffQueryOrderedChanges = function (old_results, new_results, // Asymptotically: O(N k) where k is number of ops, or potentially // O(N log N) if inner loop of LCS were made to be binary search. - //////// LCS (longest common sequence, with respect to _id) // (see Wikipedia article on Longest Increasing Subsequence, // where the LIS is taken of the sequence of old indices of the @@ -114,46 +102,44 @@ DiffSequence.diffQueryOrderedChanges = function (old_results, new_results, // // unmoved: the output of the algorithm; members of the LCS, // in the form of indices into new_results - var unmoved = []; + const unmoved = []; // max_seq_len: length of LCS found so far - var max_seq_len = 0; + let max_seq_len = 0; // seq_ends[i]: the index into new_results of the last doc in a // common subsequence of length of i+1 <= max_seq_len - var N = new_results.length; - var seq_ends = new Array(N); + const N = new_results.length; + const seq_ends = Array.from({ length: N }); // ptrs: the common subsequence ending with new_results[n] extends // a common subsequence ending with new_results[ptr[n]], unless // ptr[n] is -1. - var ptrs = new Array(N); + const ptrs = Array.from({ length: N }); // virtual sequence of old indices of new results - var old_idx_seq = function(i_new) { + const old_idx_seq = function (i_new) { return old_index_of_id[new_results[i_new]._id]; }; // for each item in new_results, use it to extend a common subsequence // of length j <= max_seq_len - for(var i=0; i 0) { - if (old_idx_seq(seq_ends[j-1]) < old_idx_seq(i)) - break; + if (old_idx_seq(seq_ends[j - 1]) < old_idx_seq(i)) break; j--; } - ptrs[i] = (j === 0 ? -1 : seq_ends[j-1]); + ptrs[i] = j === 0 ? -1 : seq_ends[j - 1]; seq_ends[j] = i; - if (j+1 > max_seq_len) - max_seq_len = j+1; + if (j + 1 > max_seq_len) max_seq_len = j + 1; } } // pull out the LCS/LIS into unmoved - var idx = (max_seq_len === 0 ? -1 : seq_ends[max_seq_len-1]); + let idx = max_seq_len === 0 ? -1 : seq_ends[max_seq_len - 1]; while (idx >= 0) { unmoved.push(idx); idx = ptrs[idx]; @@ -166,23 +152,24 @@ DiffSequence.diffQueryOrderedChanges = function (old_results, new_results, unmoved.push(new_results.length); old_results.forEach(function (doc) { - if (!new_presence_of_id[doc._id]) - observer.removed && observer.removed(doc._id); + if (!new_presence_of_id[doc._id]) { + if (observer.removed) observer.removed(doc._id); + } }); // for each group of things in the new_results that is anchored by an unmoved // element, iterate through the things before it. - var startOfGroup = 0; + let startOfGroup = 0; unmoved.forEach(function (endOfGroup) { - var groupId = new_results[endOfGroup] ? new_results[endOfGroup]._id : null; - var oldDoc, newDoc, fields, projectedNew, projectedOld; - for (var i = startOfGroup; i < endOfGroup; i++) { + const groupId = new_results[endOfGroup] ? new_results[endOfGroup]._id : null; + let oldDoc, newDoc, fields, projectedNew, projectedOld; + for (let i = startOfGroup; i < endOfGroup; i++) { newDoc = new_results[i]; if (!hasOwn.call(old_index_of_id, newDoc._id)) { fields = projectionFn(newDoc); delete fields._id; - observer.addedBefore && observer.addedBefore(newDoc._id, fields, groupId); - observer.added && observer.added(newDoc._id, fields); + if (observer.addedBefore) observer.addedBefore(newDoc._id, fields, groupId); + if (observer.added) observer.added(newDoc._id, fields); } else { // moved oldDoc = old_results[old_index_of_id[newDoc._id]]; @@ -190,9 +177,9 @@ DiffSequence.diffQueryOrderedChanges = function (old_results, new_results, projectedOld = projectionFn(oldDoc); fields = DiffSequence.makeChangedFields(projectedNew, projectedOld); if (!isObjEmpty(fields)) { - observer.changed && observer.changed(newDoc._id, fields); + if (observer.changed) observer.changed(newDoc._id, fields); } - observer.movedBefore && observer.movedBefore(newDoc._id, groupId); + if (observer.movedBefore) observer.movedBefore(newDoc._id, groupId); } } if (groupId) { @@ -202,16 +189,13 @@ DiffSequence.diffQueryOrderedChanges = function (old_results, new_results, projectedOld = projectionFn(oldDoc); fields = DiffSequence.makeChangedFields(projectedNew, projectedOld); if (!isObjEmpty(fields)) { - observer.changed && observer.changed(newDoc._id, fields); + if (observer.changed) observer.changed(newDoc._id, fields); } } - startOfGroup = endOfGroup+1; + startOfGroup = endOfGroup + 1; }); - - }; - // General helper for diff-ing two objects. // callbacks is an object like so: // { leftOnly: function (key, leftValue) {...}, @@ -219,19 +203,19 @@ DiffSequence.diffQueryOrderedChanges = function (old_results, new_results, // both: function (key, leftValue, rightValue) {...}, // } DiffSequence.diffObjects = function (left, right, callbacks) { - Object.keys(left).forEach(key => { + Object.keys(left).forEach((key) => { const leftValue = left[key]; if (hasOwn.call(right, key)) { - callbacks.both && callbacks.both(key, leftValue, right[key]); + if (callbacks.both) callbacks.both(key, leftValue, right[key]); } else { - callbacks.leftOnly && callbacks.leftOnly(key, leftValue); + if (callbacks.leftOnly) callbacks.leftOnly(key, leftValue); } }); if (callbacks.rightOnly) { - Object.keys(right).forEach(key => { + Object.keys(right).forEach((key) => { const rightValue = right[key]; - if (! hasOwn.call(left, key)) { + if (!hasOwn.call(left, key)) { callbacks.rightOnly(key, rightValue); } }); @@ -240,42 +224,40 @@ DiffSequence.diffObjects = function (left, right, callbacks) { DiffSequence.diffMaps = function (left, right, callbacks) { left.forEach(function (leftValue, key) { - if (right.has(key)){ - callbacks.both && callbacks.both(key, leftValue, right.get(key)); + if (right.has(key)) { + if (callbacks.both) callbacks.both(key, leftValue, right.get(key)); } else { - callbacks.leftOnly && callbacks.leftOnly(key, leftValue); + if (callbacks.leftOnly) callbacks.leftOnly(key, leftValue); } }); if (callbacks.rightOnly) { right.forEach(function (rightValue, key) { - if (!left.has(key)){ + if (!left.has(key)) { callbacks.rightOnly(key, rightValue); } }); } }; - DiffSequence.makeChangedFields = function (newDoc, oldDoc) { - var fields = {}; + const fields = {}; DiffSequence.diffObjects(oldDoc, newDoc, { - leftOnly: function (key, value) { + leftOnly: function (key, _value) { fields[key] = undefined; }, rightOnly: function (key, value) { fields[key] = value; }, both: function (key, leftValue, rightValue) { - if (!EJSON.equals(leftValue, rightValue)) - fields[key] = rightValue; - } + if (!EJSON.equals(leftValue, rightValue)) fields[key] = rightValue; + }, }); return fields; }; DiffSequence.applyChanges = function (doc, changeFields) { - Object.keys(changeFields).forEach(key => { + Object.keys(changeFields).forEach((key) => { const value = changeFields[key]; if (typeof value === "undefined") { delete doc[key]; @@ -284,4 +266,3 @@ DiffSequence.applyChanges = function (doc, changeFields) { } }); }; - diff --git a/packages/diff-sequence/package.js b/packages/diff-sequence/package.js index 92882442dc..9f7093de9d 100644 --- a/packages/diff-sequence/package.js +++ b/packages/diff-sequence/package.js @@ -1,24 +1,19 @@ Package.describe({ summary: "An implementation of a diff algorithm on arrays and objects.", - version: '1.1.3', - documentation: null + version: "1.1.3", + documentation: null, }); Package.onUse(function (api) { - api.use('ecmascript'); - api.use('ejson'); - api.mainModule('diff.js'); - api.export('DiffSequence'); + api.use("ecmascript"); + api.use("ejson"); + api.mainModule("diff.js"); + api.export("DiffSequence"); }); Package.onTest(function (api) { - api.use([ - 'tinytest', - 'ejson' - ]); + api.use(["tinytest", "ejson", "id-map"]); - api.use('diff-sequence'); - api.addFiles([ - 'tests.js' - ]); + api.use("diff-sequence"); + api.addFiles(["tests.js"]); }); diff --git a/packages/diff-sequence/tests.js b/packages/diff-sequence/tests.js index 8c593baee0..b95058802d 100644 --- a/packages/diff-sequence/tests.js +++ b/packages/diff-sequence/tests.js @@ -1,133 +1,128 @@ Tinytest.add("diff-sequence - diff changes ordering", function (test) { - var makeDocs = function (ids) { - return ids.map(function (id) { return {_id: id};}); + const makeDocs = function (ids) { + return ids.map(function (id) { + return { _id: id }; + }); }; - var testMutation = function (a, b) { - var aa = makeDocs(a); - var bb = makeDocs(b); - var aaCopy = EJSON.clone(aa); + const testMutation = function (a, b) { + const aa = makeDocs(a); + const bb = makeDocs(b); + const aaCopy = EJSON.clone(aa); DiffSequence.diffQueryOrderedChanges(aa, bb, { - addedBefore: function (id, doc, before) { if (before === null) { - aaCopy.push( Object.assign({_id: id}, doc)); + aaCopy.push(Object.assign({ _id: id }, doc)); return; } - for (var i = 0; i < aaCopy.length; i++) { + for (let i = 0; i < aaCopy.length; i++) { if (aaCopy[i]._id === before) { - aaCopy.splice(i, 0, Object.assign({_id: id}, doc)); + aaCopy.splice(i, 0, Object.assign({ _id: id }, doc)); return; } } }, movedBefore: function (id, before) { - var found; - for (var i = 0; i < aaCopy.length; i++) { + let found; + for (let i = 0; i < aaCopy.length; i++) { if (aaCopy[i]._id === id) { found = aaCopy[i]; aaCopy.splice(i, 1); } } if (before === null) { - aaCopy.push( Object.assign({_id: id}, found)); + aaCopy.push(Object.assign({ _id: id }, found)); return; } - for (i = 0; i < aaCopy.length; i++) { + for (let i = 0; i < aaCopy.length; i++) { if (aaCopy[i]._id === before) { - aaCopy.splice(i, 0, Object.assign({_id: id}, found)); + aaCopy.splice(i, 0, Object.assign({ _id: id }, found)); return; } } }, removed: function (id) { - var found; - for (var i = 0; i < aaCopy.length; i++) { + for (let i = 0; i < aaCopy.length; i++) { if (aaCopy[i]._id === id) { - found = aaCopy[i]; aaCopy.splice(i, 1); } } - } + }, }); test.equal(aaCopy, bb); }; - var testBothWays = function (a, b) { + const testBothWays = function (a, b) { testMutation(a, b); testMutation(b, a); }; testBothWays(["a", "b", "c"], ["c", "b", "a"]); testBothWays(["a", "b", "c"], []); - testBothWays(["a", "b", "c"], ["e","f"]); + testBothWays(["a", "b", "c"], ["e", "f"]); testBothWays(["a", "b", "c", "d"], ["c", "b", "a"]); - testBothWays(['A','B','C','D','E','F','G','H','I'], - ['A','B','F','G','C','D','I','L','M','N','H']); - testBothWays(['A','B','C','D','E','F','G','H','I'],['A','B','C','D','F','G','H','E','I']); + testBothWays( + ["A", "B", "C", "D", "E", "F", "G", "H", "I"], + ["A", "B", "F", "G", "C", "D", "I", "L", "M", "N", "H"], + ); + testBothWays( + ["A", "B", "C", "D", "E", "F", "G", "H", "I"], + ["A", "B", "C", "D", "F", "G", "H", "E", "I"], + ); }); Tinytest.add("diff-sequence - diff", function (test) { - // test correctness - var diffTest = function(origLen, newOldIdx) { - var oldResults = new Array(origLen); - for (var i = 1; i <= origLen; i++) - oldResults[i-1] = {_id: i}; + const diffTest = function (origLen, newOldIdx) { + const oldResults = Array.from({ length: origLen }); + for (let i = 1; i <= origLen; i++) oldResults[i - 1] = { _id: i }; - var newResults = newOldIdx.map(function(n) { - var doc = {_id: Math.abs(n)}; - if (n < 0) - doc.changed = true; + const newResults = newOldIdx.map(function (n) { + const doc = { _id: Math.abs(n) }; + if (n < 0) doc.changed = true; return doc; }); - var find = function (arr, id) { - for (var i = 0; i < arr.length; i++) { - if (EJSON.equals(arr[i]._id, id)) - return i; + const find = function (arr, id) { + for (let i = 0; i < arr.length; i++) { + if (EJSON.equals(arr[i]._id, id)) return i; } return -1; }; - var results = [...oldResults]; - var observer = { - addedBefore: function(id, fields, before) { - var before_idx; - if (before === null) - before_idx = results.length; - else - before_idx = find (results, before); - var doc = Object.assign({_id: id}, fields); + const results = [...oldResults]; + const observer = { + addedBefore: function (id, fields, before) { + let before_idx; + if (before === null) before_idx = results.length; + else before_idx = find(results, before); + const doc = Object.assign({ _id: id }, fields); test.isFalse(before_idx < 0 || before_idx > results.length); results.splice(before_idx, 0, doc); }, - removed: function(id) { - var at_idx = find (results, id); + removed: function (id) { + const at_idx = find(results, id); test.isFalse(at_idx < 0 || at_idx >= results.length); results.splice(at_idx, 1); }, - changed: function(id, fields) { - var at_idx = find (results, id); - var oldDoc = results[at_idx]; - var doc = EJSON.clone(oldDoc); + changed: function (id, fields) { + const at_idx = find(results, id); + const oldDoc = results[at_idx]; + const doc = EJSON.clone(oldDoc); DiffSequence.applyChanges(doc, fields); test.isFalse(at_idx < 0 || at_idx >= results.length); test.equal(doc._id, oldDoc._id); results[at_idx] = doc; }, - movedBefore: function(id, before) { - var old_idx = find(results, id); - var new_idx; - if (before === null) - new_idx = results.length; - else - new_idx = find (results, before); - if (new_idx > old_idx) - new_idx--; + movedBefore: function (id, before) { + const old_idx = find(results, id); + let new_idx; + if (before === null) new_idx = results.length; + else new_idx = find(results, before); + if (new_idx > old_idx) new_idx--; test.isFalse(old_idx < 0 || old_idx >= results.length); test.isFalse(new_idx < 0 || new_idx >= results.length); results.splice(new_idx, 0, results.splice(old_idx, 1)[0]); - } + }, }; DiffSequence.diffQueryOrderedChanges(oldResults, newResults, observer); @@ -157,3 +152,193 @@ Tinytest.add("diff-sequence - diff", function (test) { diffTest(3, [-3, -2, -1]); diffTest(10, [-2, 7, 4, 6, 11, -3, -8, 9]); }); + +Tinytest.add("diff-sequence - diffObjects partitions keys", (test) => { + const left = { a: 1, b: 2, c: 3 }; + const right = { b: 2, c: 4, d: 5 }; + + const leftOnly = []; + const rightOnly = []; + const both = []; + + DiffSequence.diffObjects(left, right, { + leftOnly: (key, value) => leftOnly.push([key, value]), + rightOnly: (key, value) => rightOnly.push([key, value]), + both: (key, leftValue, rightValue) => both.push([key, leftValue, rightValue]), + }); + + test.equal(leftOnly, [["a", 1]]); + test.equal(rightOnly, [["d", 5]]); + // Sort `both` to make the test independent of Object.keys iteration order. + both.sort((x, y) => x[0].localeCompare(y[0])); + test.equal(both, [ + ["b", 2, 2], + ["c", 3, 4], + ]); +}); + +Tinytest.add("diff-sequence - diffObjects omits missing callbacks", (test) => { + const left = { a: 1, b: 2 }; + const right = { b: 3, c: 4 }; + + let bothCount = 0; + // Only `both` is provided — leftOnly and rightOnly are absent and must not throw. + DiffSequence.diffObjects(left, right, { + both: () => { + bothCount++; + }, + }); + test.equal(bothCount, 1); + test.ok(); // reaching here means no exception was thrown +}); + +Tinytest.add("diff-sequence - diffMaps partitions keys", (test) => { + const left = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ]); + const right = new Map([ + ["b", 2], + ["c", 4], + ["d", 5], + ]); + + const leftOnly = []; + const rightOnly = []; + const both = []; + + DiffSequence.diffMaps(left, right, { + leftOnly: (key, value) => leftOnly.push([key, value]), + rightOnly: (key, value) => rightOnly.push([key, value]), + both: (key, leftValue, rightValue) => both.push([key, leftValue, rightValue]), + }); + + test.equal(leftOnly, [["a", 1]]); + test.equal(rightOnly, [["d", 5]]); + both.sort((x, y) => x[0].localeCompare(y[0])); + test.equal(both, [ + ["b", 2, 2], + ["c", 3, 4], + ]); +}); + +Tinytest.add("diff-sequence - makeChangedFields detects adds, removes, changes", (test) => { + const oldDoc = { a: 1, b: 2, c: 3 }; + const newDoc = { a: 1, b: 99, d: 4 }; + + const changed = DiffSequence.makeChangedFields(newDoc, oldDoc); + + // 'a' unchanged, 'b' changed, 'c' removed (undefined), 'd' added. + test.equal(changed, { b: 99, c: undefined, d: 4 }); +}); + +Tinytest.add("diff-sequence - makeChangedFields uses EJSON.equals for deep compare", (test) => { + const same = DiffSequence.makeChangedFields( + { a: [1, 2, 3], b: { nested: true } }, + { a: [1, 2, 3], b: { nested: true } }, + ); + test.equal(same, {}, "deeply equal values should not produce a change"); + + const changed = DiffSequence.makeChangedFields( + { a: [1, 2, 4], b: { nested: true } }, + { a: [1, 2, 3], b: { nested: true } }, + ); + test.equal(changed, { a: [1, 2, 4] }); +}); + +Tinytest.add("diff-sequence - applyChanges adds, replaces, removes fields", (test) => { + const doc = { a: 1, b: 2 }; + DiffSequence.applyChanges(doc, { a: 99, c: 3, b: undefined }); + test.equal(doc, { a: 99, c: 3 }); +}); + +Tinytest.add("diff-sequence - diffQueryUnorderedChanges detects added/removed/changed", (test) => { + const oldResults = new IdMap(); + oldResults.set("x", { _id: "x", v: 1 }); + oldResults.set("y", { _id: "y", v: 2 }); + + const newResults = new IdMap(); + newResults.set("y", { _id: "y", v: 99 }); + newResults.set("z", { _id: "z", v: 3 }); + + const added = []; + const removed = []; + const changed = []; + + DiffSequence.diffQueryUnorderedChanges(oldResults, newResults, { + added: (id, fields) => added.push([id, fields]), + removed: (id) => removed.push(id), + changed: (id, fields) => changed.push([id, fields]), + }); + + test.equal(added, [["z", { v: 3 }]]); + test.equal(removed, ["x"]); + test.equal(changed, [["y", { v: 99 }]]); +}); + +Tinytest.add( + "diff-sequence - diffQueryUnorderedChanges throws with a movedBefore observer", + (test) => { + const oldResults = new IdMap(); + const newResults = new IdMap(); + test.throws( + () => + DiffSequence.diffQueryUnorderedChanges(oldResults, newResults, { + movedBefore: () => {}, + }), + /movedBefore/, + ); + }, +); + +Tinytest.add("diff-sequence - diffQueryChanges dispatches based on ordered flag", (test) => { + // Unordered path. + const oldUnordered = new IdMap(); + oldUnordered.set("a", { _id: "a", v: 1 }); + const newUnordered = new IdMap(); + newUnordered.set("a", { _id: "a", v: 2 }); + + const unorderedChanged = []; + DiffSequence.diffQueryChanges(false, oldUnordered, newUnordered, { + changed: (id, fields) => unorderedChanged.push([id, fields]), + }); + test.equal(unorderedChanged, [["a", { v: 2 }]]); + + // Ordered path — uses addedBefore/movedBefore. + const oldOrdered = [{ _id: "a", v: 1 }]; + const newOrdered = [ + { _id: "a", v: 1 }, + { _id: "b", v: 2 }, + ]; + + const orderedAddedBefore = []; + DiffSequence.diffQueryChanges(true, oldOrdered, newOrdered, { + addedBefore: (id, fields, before) => orderedAddedBefore.push([id, fields, before]), + }); + test.equal(orderedAddedBefore, [["b", { v: 2 }, null]]); +}); + +Tinytest.add("diff-sequence - projectionFn is applied before change detection", (test) => { + const oldResults = new IdMap(); + oldResults.set("a", { _id: "a", visible: 1, hidden: 10 }); + + const newResults = new IdMap(); + // Only the `hidden` field changed. + newResults.set("a", { _id: "a", visible: 1, hidden: 999 }); + + const changed = []; + DiffSequence.diffQueryUnorderedChanges( + oldResults, + newResults, + { + changed: (id, fields) => changed.push([id, fields]), + }, + { + projectionFn: (doc) => ({ _id: doc._id, visible: doc.visible }), + }, + ); + + // The projection drops `hidden`, so no change is reported. + test.equal(changed, []); +}); diff --git a/packages/ejson/custom_models_for_tests.js b/packages/ejson/custom_models_for_tests.js index c60a6b3caf..7faa22ee65 100644 --- a/packages/ejson/custom_models_for_tests.js +++ b/packages/ejson/custom_models_for_tests.js @@ -1,4 +1,4 @@ -import { EJSON } from './ejson'; +import { EJSON } from "./ejson"; class Address { constructor(city, state) { @@ -7,7 +7,7 @@ class Address { } typeName() { - return 'Address'; + return "Address"; } toJSONValue() { @@ -18,7 +18,7 @@ class Address { } } -EJSON.addType('Address', value => new Address(value.city, value.state)); +EJSON.addType("Address", (value) => new Address(value.city, value.state)); class Person { constructor(name, dob, address) { @@ -28,7 +28,7 @@ class Person { } typeName() { - return 'Person'; + return "Person"; } toJSONValue() { @@ -41,12 +41,9 @@ class Person { } EJSON.addType( - 'Person', - value => new Person( - value.name, - EJSON.fromJSONValue(value.dob), - EJSON.fromJSONValue(value.address) - ) + "Person", + (value) => + new Person(value.name, EJSON.fromJSONValue(value.dob), EJSON.fromJSONValue(value.address)), ); class Holder { @@ -55,7 +52,7 @@ class Holder { } typeName() { - return 'Holder'; + return "Holder"; } toJSONValue() { @@ -63,7 +60,7 @@ class Holder { } } -EJSON.addType('Holder', value => new Holder(value)); +EJSON.addType("Holder", (value) => new Holder(value)); const EJSONTest = { Address, diff --git a/packages/ejson/ejson.d.ts b/packages/ejson/ejson.d.ts index 61c3a7674c..f2601743d6 100644 --- a/packages/ejson/ejson.d.ts +++ b/packages/ejson/ejson.d.ts @@ -1,6 +1,6 @@ export interface EJSONableCustomType { clone?(): EJSONableCustomType; - equals?(other: Object): boolean; + equals?(other: object): boolean; toJSONValue(): JSONable; typeName(): string; } @@ -9,10 +9,10 @@ export type EJSONableProperty = | number | string | boolean - | Object + | object | number[] | string[] - | Object[] + | object[] | Date | Uint8Array | EJSONableCustomType @@ -28,10 +28,10 @@ export interface JSONable { | number | string | boolean - | Object + | object | number[] | string[] - | Object[] + | object[] | undefined | null; } @@ -39,22 +39,19 @@ export interface JSONable { export interface EJSON extends EJSONable {} export namespace EJSON { - function addType( - name: string, - factory: (val: JSONable) => EJSONableCustomType - ): void; + function addType(name: string, factory: (val: JSONable) => EJSONableCustomType): void; function clone(val: T): T; function equals( a: EJSON, b: EJSON, - options?: { keyOrderSensitive?: boolean | undefined } + options?: { keyOrderSensitive?: boolean | undefined }, ): boolean; function fromJSONValue(val: JSONable): any; - function isBinary(x: Object): x is Uint8Array; + function isBinary(x: object): x is Uint8Array; function newBinary(size: number): Uint8Array; function parse(str: string): EJSON; @@ -64,7 +61,7 @@ export namespace EJSON { options?: { indent?: boolean | number | string | undefined; canonical?: boolean | undefined; - } + }, ): string; function toJSONValue(val: EJSON): JSONable; diff --git a/packages/ejson/ejson.js b/packages/ejson/ejson.js index 591f62a5b8..72873bf701 100644 --- a/packages/ejson/ejson.js +++ b/packages/ejson/ejson.js @@ -8,8 +8,8 @@ import { isArguments, isInfOrNaN, handleError, -} from './utils'; -import canonicalStringify from './stringify'; +} from "./utils"; +import canonicalStringify from "./stringify"; /** * @namespace @@ -96,25 +96,25 @@ EJSON.addType = (name, factory) => { }; const builtinConverters = [ - { // Date + { + // Date matchJSONValue(obj) { - return hasOwn(obj, '$date') && lengthOfWithLimit(obj, 1) === 1; + return hasOwn(obj, "$date") && lengthOfWithLimit(obj, 1) === 1; }, matchObject(obj) { return obj instanceof Date; }, toJSONValue(obj) { - return {$date: obj.getTime()}; + return { $date: obj.getTime() }; }, fromJSONValue(obj) { return new Date(obj.$date); }, }, - { // RegExp + { + // RegExp matchJSONValue(obj) { - return hasOwn(obj, '$regexp') - && hasOwn(obj, '$flags') - && lengthOfWithLimit(obj, 2) === 2; + return hasOwn(obj, "$regexp") && hasOwn(obj, "$flags") && lengthOfWithLimit(obj, 2) === 2; }, matchObject(obj) { return obj instanceof RegExp; @@ -122,7 +122,7 @@ const builtinConverters = [ toJSONValue(regexp) { return { $regexp: regexp.source, - $flags: regexp.flags + $flags: regexp.flags, }; }, fromJSONValue(obj) { @@ -132,15 +132,16 @@ const builtinConverters = [ obj.$flags // Cut off flags at 50 chars to avoid abusing RegExp for DOS. .slice(0, 50) - .replace(/[^gimuy]/g,'') - .replace(/(.)(?=.*\1)/g, '') + .replace(/[^gimuy]/g, "") + .replace(/(.)(?=.*\1)/g, ""), ); }, }, - { // NaN, Inf, -Inf. (These are the only objects with typeof !== 'object' + { + // NaN, Inf, -Inf. (These are the only objects with typeof !== 'object' // which we match.) matchJSONValue(obj) { - return hasOwn(obj, '$InfNaN') && lengthOfWithLimit(obj, 1) === 1; + return hasOwn(obj, "$InfNaN") && lengthOfWithLimit(obj, 1) === 1; }, matchObject: isInfOrNaN, toJSONValue(obj) { @@ -152,68 +153,71 @@ const builtinConverters = [ } else { sign = -1; } - return {$InfNaN: sign}; + return { $InfNaN: sign }; }, fromJSONValue(obj) { return obj.$InfNaN / 0; }, }, - { // Binary + { + // Binary matchJSONValue(obj) { - return hasOwn(obj, '$binary') && lengthOfWithLimit(obj, 1) === 1; + return hasOwn(obj, "$binary") && lengthOfWithLimit(obj, 1) === 1; }, matchObject(obj) { - return typeof Uint8Array !== 'undefined' && obj instanceof Uint8Array - || (obj && hasOwn(obj, '$Uint8ArrayPolyfill')); + return ( + (typeof Uint8Array !== "undefined" && obj instanceof Uint8Array) || + (obj && hasOwn(obj, "$Uint8ArrayPolyfill")) + ); }, toJSONValue(obj) { - return {$binary: Base64.encode(obj)}; + return { $binary: Base64.encode(obj) }; }, fromJSONValue(obj) { return Base64.decode(obj.$binary); }, }, - { // Escaping one level + { + // Escaping one level matchJSONValue(obj) { - return hasOwn(obj, '$escape') && lengthOfWithLimit(obj, 1) === 1; + return hasOwn(obj, "$escape") && lengthOfWithLimit(obj, 1) === 1; }, matchObject(obj) { let match = false; if (obj) { const keyCount = lengthOfWithLimit(obj, 2); if (keyCount === 1 || keyCount === 2) { - match = - builtinConverters.some(converter => converter.matchJSONValue(obj)); + match = builtinConverters.some((converter) => converter.matchJSONValue(obj)); } } return match; }, toJSONValue(obj) { const newObj = {}; - keysOf(obj).forEach(key => { + keysOf(obj).forEach((key) => { newObj[key] = EJSON.toJSONValue(obj[key]); }); - return {$escape: newObj}; + return { $escape: newObj }; }, fromJSONValue(obj) { const newObj = {}; - keysOf(obj.$escape).forEach(key => { + keysOf(obj.$escape).forEach((key) => { newObj[key] = EJSON.fromJSONValue(obj.$escape[key]); }); return newObj; }, }, - { // Custom + { + // Custom matchJSONValue(obj) { - return hasOwn(obj, '$type') - && hasOwn(obj, '$value') && lengthOfWithLimit(obj, 2) === 2; + return hasOwn(obj, "$type") && hasOwn(obj, "$value") && lengthOfWithLimit(obj, 2) === 2; }, matchObject(obj) { return EJSON._isCustomType(obj); }, toJSONValue(obj) { const jsonValue = Meteor._noYieldsAllowed(() => obj.toJSONValue()); - return {$type: obj.typeName(), $value: jsonValue}; + return { $type: obj.typeName(), $value: jsonValue }; }, fromJSONValue(obj) { const typeName = obj.$type; @@ -226,20 +230,17 @@ const builtinConverters = [ }, ]; -EJSON._isCustomType = (obj) => ( - obj && - isFunction(obj.toJSONValue) && - isFunction(obj.typeName) && - customTypes.has(obj.typeName()) -); +EJSON._isCustomType = (obj) => + obj && isFunction(obj.toJSONValue) && isFunction(obj.typeName) && customTypes.has(obj.typeName()); -EJSON._getTypes = (isOriginal = false) => (isOriginal ? customTypes : convertMapToObject(customTypes)); +EJSON._getTypes = (isOriginal = false) => + isOriginal ? customTypes : convertMapToObject(customTypes); EJSON._getConverters = () => builtinConverters; // Either return the JSON-compatible version of the argument, or undefined (if // the item isn't itself replaceable, but maybe some fields in it are) -const toJSONValueHelper = item => { +const toJSONValueHelper = (item) => { for (let i = 0; i < builtinConverters.length; i++) { const converter = builtinConverters[i]; if (converter.matchObject(item)) { @@ -250,7 +251,7 @@ const toJSONValueHelper = item => { }; // for both arrays and objects, in-place modification. -const adjustTypesToJSONValue = obj => { +const adjustTypesToJSONValue = (obj) => { // Is it an atom that we need to adjust? if (obj === null) { return null; @@ -267,10 +268,9 @@ const adjustTypesToJSONValue = obj => { } // Iterate over array or object structure. - keysOf(obj).forEach(key => { + keysOf(obj).forEach((key) => { const value = obj[key]; - if (!isObject(value) && value !== undefined && - !isInfOrNaN(value)) { + if (!isObject(value) && value !== undefined && !isInfOrNaN(value)) { return; // continue } @@ -291,12 +291,15 @@ EJSON._adjustTypesToJSONValue = adjustTypesToJSONValue; // Copy-on-write recursive EJSON→JSON converter. // Only allocates new objects/arrays along paths that actually change, // returning the original reference when nothing needs conversion. -const toJSONValueDeep = value => { +const toJSONValueDeep = (value) => { // Short-circuit for primitives that toJSONValueHelper can never match. - if (value === null || value === undefined - || typeof value === 'boolean' - || typeof value === 'string' - || (typeof value === 'number' && !isInfOrNaN(value))) { + if ( + value === null || + value === undefined || + typeof value === "boolean" || + typeof value === "string" || + (typeof value === "number" && !isInfOrNaN(value)) + ) { return value; } @@ -352,17 +355,16 @@ const toJSONValueDeep = value => { * @locus Anywhere * @param {EJSON} val A value to serialize to plain JSON. */ -EJSON.toJSONValue = item => toJSONValueDeep(item); +EJSON.toJSONValue = (item) => toJSONValueDeep(item); // Either return the argument changed to have the non-json // rep of itself (the Object version) or the argument itself. // DOES NOT RECURSE. For actually getting the fully-changed value, use // EJSON.fromJSONValue -const fromJSONValueHelper = value => { +const fromJSONValueHelper = (value) => { if (isObject(value) && value !== null) { const keys = keysOf(value); - if (keys.length <= 2 - && keys.every(k => typeof k === 'string' && k.substr(0, 1) === '$')) { + if (keys.length <= 2 && keys.every((k) => typeof k === "string" && k.substr(0, 1) === "$")) { for (let i = 0; i < builtinConverters.length; i++) { const converter = builtinConverters[i]; if (converter.matchJSONValue(value)) { @@ -377,7 +379,7 @@ const fromJSONValueHelper = value => { // for both arrays and objects. Tries its best to just // use the object you hand it, but may return something // different if the object you hand it itself needs changing. -const adjustTypesFromJSONValue = obj => { +const adjustTypesFromJSONValue = (obj) => { if (obj === null) { return null; } @@ -392,7 +394,7 @@ const adjustTypesFromJSONValue = obj => { return obj; } - keysOf(obj).forEach(key => { + keysOf(obj).forEach((key) => { const value = obj[key]; if (isObject(value)) { const changed = fromJSONValueHelper(value); @@ -412,8 +414,8 @@ EJSON._adjustTypesFromJSONValue = adjustTypesFromJSONValue; // Copy-on-write recursive JSON→EJSON converter. // Same lazy-allocation strategy as toJSONValueDeep. -const fromJSONValueDeep = value => { - if (value === null || typeof value !== 'object') { +const fromJSONValueDeep = (value) => { + if (value === null || typeof value !== "object") { return value; } @@ -466,7 +468,7 @@ const fromJSONValueDeep = value => { * @locus Anywhere * @param {JSONCompatible} val A value to deserialize into EJSON. */ -EJSON.fromJSONValue = item => fromJSONValueDeep(item); +EJSON.fromJSONValue = (item) => fromJSONValueDeep(item); /** * @summary Serialize a value to a string. For EJSON values, the serialization @@ -499,9 +501,9 @@ EJSON.stringify = handleError((item, options) => { * @locus Anywhere * @param {String} str A string to parse into an EJSON value. */ -EJSON.parse = item => { - if (typeof item !== 'string') { - throw new Error('EJSON.parse argument should be a string'); +EJSON.parse = (item) => { + if (typeof item !== "string") { + throw new Error("EJSON.parse argument should be a string"); } return EJSON.fromJSONValue(JSON.parse(item)); }; @@ -512,9 +514,11 @@ EJSON.parse = item => { * @param {Object} x The variable to check. * @locus Anywhere */ -EJSON.isBinary = obj => { - return !!((typeof Uint8Array !== 'undefined' && obj instanceof Uint8Array) || - (obj && obj.$Uint8ArrayPolyfill)); +EJSON.isBinary = (obj) => { + return !!( + (typeof Uint8Array !== "undefined" && obj instanceof Uint8Array) || + (obj && obj.$Uint8ArrayPolyfill) + ); }; /** @@ -545,7 +549,7 @@ EJSON.equals = (a, b, options) => { // Same-type primitives that aren't === can only be equal if both are NaN. // This skips the NaN check entirely for strings, booleans, etc. - if (typeof a !== 'object') { + if (typeof a !== "object") { return Number.isNaN(a) && Number.isNaN(b); } @@ -602,8 +606,10 @@ EJSON.equals = (a, b, options) => { // fallback for custom types that don't implement their own equals switch (EJSON._isCustomType(a) + EJSON._isCustomType(b)) { - case 1: return false; - case 2: return EJSON.equals(EJSON.toJSONValue(a), EJSON.toJSONValue(b)); + case 1: + return false; + case 2: + return EJSON.equals(EJSON.toJSONValue(a), EJSON.toJSONValue(b)); default: // Do nothing } @@ -616,7 +622,7 @@ EJSON.equals = (a, b, options) => { } if (keyOrderSensitive) { i = 0; - ret = aKeys.every(key => { + ret = aKeys.every((key) => { if (i >= bKeys.length) { return false; } @@ -631,7 +637,7 @@ EJSON.equals = (a, b, options) => { }); } else { i = 0; - ret = aKeys.every(key => { + ret = aKeys.every((key) => { if (!hasOwn(b, key)) { return false; } @@ -650,7 +656,7 @@ EJSON.equals = (a, b, options) => { * @locus Anywhere * @param {EJSON} val A value to copy. */ -EJSON.clone = v => { +EJSON.clone = (v) => { let ret; if (!isObject(v)) { return v; diff --git a/packages/ejson/ejson_tests.js b/packages/ejson/ejson_tests.js index f9766ade0c..4c53831574 100644 --- a/packages/ejson/ejson_tests.js +++ b/packages/ejson/ejson_tests.js @@ -1,124 +1,141 @@ -import { EJSON } from './ejson'; -import EJSONTest from './custom_models_for_tests'; +import { EJSON } from "./ejson"; +import EJSONTest from "./custom_models_for_tests"; -Tinytest.add('ejson - keyOrderSensitive', test => { - test.isTrue(EJSON.equals({ - a: {b: 1, c: 2}, - d: {e: 3, f: 4}, - }, { - d: {f: 4, e: 3}, - a: {c: 2, b: 1}, - })); +Tinytest.add("ejson - keyOrderSensitive", (test) => { + test.isTrue( + EJSON.equals( + { + a: { b: 1, c: 2 }, + d: { e: 3, f: 4 }, + }, + { + d: { f: 4, e: 3 }, + a: { c: 2, b: 1 }, + }, + ), + ); - test.isFalse(EJSON.equals({ - a: {b: 1, c: 2}, - d: {e: 3, f: 4}, - }, { - d: {f: 4, e: 3}, - a: {c: 2, b: 1}, - }, {keyOrderSensitive: true})); + test.isFalse( + EJSON.equals( + { + a: { b: 1, c: 2 }, + d: { e: 3, f: 4 }, + }, + { + d: { f: 4, e: 3 }, + a: { c: 2, b: 1 }, + }, + { keyOrderSensitive: true }, + ), + ); - test.isFalse(EJSON.equals({ - a: {b: 1, c: 2}, - d: {e: 3, f: 4}, - }, { - a: {c: 2, b: 1}, - d: {f: 4, e: 3}, - }, {keyOrderSensitive: true})); - test.isFalse(EJSON.equals({a: {}}, {a: {b: 2}}, {keyOrderSensitive: true})); - test.isFalse(EJSON.equals({a: {b: 2}}, {a: {}}, {keyOrderSensitive: true})); + test.isFalse( + EJSON.equals( + { + a: { b: 1, c: 2 }, + d: { e: 3, f: 4 }, + }, + { + a: { c: 2, b: 1 }, + d: { f: 4, e: 3 }, + }, + { keyOrderSensitive: true }, + ), + ); + test.isFalse(EJSON.equals({ a: {} }, { a: { b: 2 } }, { keyOrderSensitive: true })); + test.isFalse(EJSON.equals({ a: { b: 2 } }, { a: {} }, { keyOrderSensitive: true })); }); -Tinytest.add('ejson - nesting and literal', test => { +Tinytest.add("ejson - nesting and literal", (test) => { const d = new Date(); - const obj = {$date: d}; + const obj = { $date: d }; const eObj = EJSON.toJSONValue(obj); const roundTrip = EJSON.fromJSONValue(eObj); test.equal(obj, roundTrip); }); -Tinytest.add('ejson - some equality tests', test => { - test.isTrue(EJSON.equals({a: 1, b: 2, c: 3}, {a: 1, c: 3, b: 2})); - test.isFalse(EJSON.equals({a: 1, b: 2}, {a: 1, c: 3, b: 2})); - test.isFalse(EJSON.equals({a: 1, b: 2, c: 3}, {a: 1, b: 2})); - test.isFalse(EJSON.equals({a: 1, b: 2, c: 3}, {a: 1, c: 3, b: 4})); - test.isFalse(EJSON.equals({a: {}}, {a: {b: 2}})); - test.isFalse(EJSON.equals({a: {b: 2}}, {a: {}})); +Tinytest.add("ejson - some equality tests", (test) => { + test.isTrue(EJSON.equals({ a: 1, b: 2, c: 3 }, { a: 1, c: 3, b: 2 })); + test.isFalse(EJSON.equals({ a: 1, b: 2 }, { a: 1, c: 3, b: 2 })); + test.isFalse(EJSON.equals({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 })); + test.isFalse(EJSON.equals({ a: 1, b: 2, c: 3 }, { a: 1, c: 3, b: 4 })); + test.isFalse(EJSON.equals({ a: {} }, { a: { b: 2 } })); + test.isFalse(EJSON.equals({ a: { b: 2 } }, { a: {} })); // XXX: Object and Array were previously mistaken, which is why // we add some extra tests for them here test.isTrue(EJSON.equals([1, 2, 3, 4, 5], [1, 2, 3, 4, 5])); test.isFalse(EJSON.equals([1, 2, 3, 4, 5], [1, 2, 3, 4])); - test.isFalse(EJSON.equals([1,2,3,4], {0: 1, 1: 2, 2: 3, 3: 4})); - test.isFalse(EJSON.equals({0: 1, 1: 2, 2: 3, 3: 4}, [1,2,3,4])); + test.isFalse(EJSON.equals([1, 2, 3, 4], { 0: 1, 1: 2, 2: 3, 3: 4 })); + test.isFalse(EJSON.equals({ 0: 1, 1: 2, 2: 3, 3: 4 }, [1, 2, 3, 4])); test.isFalse(EJSON.equals({}, [])); test.isFalse(EJSON.equals([], {})); }); -Tinytest.add('ejson - equality and falsiness', test => { +Tinytest.add("ejson - equality and falsiness", (test) => { test.isTrue(EJSON.equals(null, null)); test.isTrue(EJSON.equals(undefined, undefined)); - test.isFalse(EJSON.equals({foo: 'foo'}, null)); - test.isFalse(EJSON.equals(null, {foo: 'foo'})); - test.isFalse(EJSON.equals(undefined, {foo: 'foo'})); - test.isFalse(EJSON.equals({foo: 'foo'}, undefined)); + test.isFalse(EJSON.equals({ foo: "foo" }, null)); + test.isFalse(EJSON.equals(null, { foo: "foo" })); + test.isFalse(EJSON.equals(undefined, { foo: "foo" })); + test.isFalse(EJSON.equals({ foo: "foo" }, undefined)); }); -Tinytest.add('ejson - equals type-mismatch early exit', test => { +Tinytest.add("ejson - equals type-mismatch early exit", (test) => { // Cross-type primitives: typeof a !== typeof b → false - test.isFalse(EJSON.equals('hello', 42)); - test.isFalse(EJSON.equals(42, 'hello')); + test.isFalse(EJSON.equals("hello", 42)); + test.isFalse(EJSON.equals(42, "hello")); test.isFalse(EJSON.equals(1, true)); test.isFalse(EJSON.equals(true, 1)); - test.isFalse(EJSON.equals('true', true)); - test.isFalse(EJSON.equals(true, 'true')); - test.isFalse(EJSON.equals('1', 1)); - test.isFalse(EJSON.equals(1, '1')); + test.isFalse(EJSON.equals("true", true)); + test.isFalse(EJSON.equals(true, "true")); + test.isFalse(EJSON.equals("1", 1)); + test.isFalse(EJSON.equals(1, "1")); // Falsy cross-type: both are falsy but different types test.isFalse(EJSON.equals(0, false)); test.isFalse(EJSON.equals(false, 0)); - test.isFalse(EJSON.equals('', 0)); - test.isFalse(EJSON.equals(0, '')); - test.isFalse(EJSON.equals('', false)); - test.isFalse(EJSON.equals(false, '')); + test.isFalse(EJSON.equals("", 0)); + test.isFalse(EJSON.equals(0, "")); + test.isFalse(EJSON.equals("", false)); + test.isFalse(EJSON.equals(false, "")); // null/undefined vs primitives (typeof null is 'object', differs from 'number'/'string') test.isFalse(EJSON.equals(null, 0)); test.isFalse(EJSON.equals(0, null)); - test.isFalse(EJSON.equals(null, '')); - test.isFalse(EJSON.equals('', null)); + test.isFalse(EJSON.equals(null, "")); + test.isFalse(EJSON.equals("", null)); test.isFalse(EJSON.equals(null, false)); test.isFalse(EJSON.equals(false, null)); test.isFalse(EJSON.equals(undefined, 0)); test.isFalse(EJSON.equals(0, undefined)); - test.isFalse(EJSON.equals(undefined, '')); - test.isFalse(EJSON.equals('', undefined)); + test.isFalse(EJSON.equals(undefined, "")); + test.isFalse(EJSON.equals("", undefined)); test.isFalse(EJSON.equals(undefined, false)); test.isFalse(EJSON.equals(false, undefined)); test.isFalse(EJSON.equals(null, undefined)); test.isFalse(EJSON.equals(undefined, null)); }); -Tinytest.add('ejson - equals same-type primitives', test => { +Tinytest.add("ejson - equals same-type primitives", (test) => { // Same-type, same-value → caught by a === b test.isTrue(EJSON.equals(0, 0)); test.isTrue(EJSON.equals(1, 1)); test.isTrue(EJSON.equals(-1, -1)); - test.isTrue(EJSON.equals('', '')); - test.isTrue(EJSON.equals('hello', 'hello')); + test.isTrue(EJSON.equals("", "")); + test.isTrue(EJSON.equals("hello", "hello")); test.isTrue(EJSON.equals(true, true)); test.isTrue(EJSON.equals(false, false)); // Same-type, different-value → typeof a !== 'object', then NaN check returns false test.isFalse(EJSON.equals(1, 2)); - test.isFalse(EJSON.equals('a', 'b')); + test.isFalse(EJSON.equals("a", "b")); test.isFalse(EJSON.equals(true, false)); test.isFalse(EJSON.equals(false, true)); test.isFalse(EJSON.equals(0, 1)); test.isTrue(EJSON.equals(0, -0)); // 0 === -0 in JS, caught by a === b }); -Tinytest.add('ejson - equals null vs object', test => { +Tinytest.add("ejson - equals null vs object", (test) => { // Both typeof 'object', but one is null test.isFalse(EJSON.equals(null, {})); test.isFalse(EJSON.equals({}, null)); @@ -128,45 +145,36 @@ Tinytest.add('ejson - equals null vs object', test => { test.isFalse(EJSON.equals(new Date(), null)); }); -Tinytest.add('ejson - equals nested falsy and type-mismatch fields', test => { +Tinytest.add("ejson - equals nested falsy and type-mismatch fields", (test) => { // Objects with falsy fields of different types - test.isFalse(EJSON.equals({a: 0}, {a: false})); - test.isFalse(EJSON.equals({a: ''}, {a: 0})); - test.isFalse(EJSON.equals({a: ''}, {a: false})); - test.isFalse(EJSON.equals({a: null}, {a: undefined})); - test.isFalse(EJSON.equals({a: null}, {a: 0})); - test.isFalse(EJSON.equals({a: null}, {a: ''})); - test.isFalse(EJSON.equals({a: null}, {a: false})); + test.isFalse(EJSON.equals({ a: 0 }, { a: false })); + test.isFalse(EJSON.equals({ a: "" }, { a: 0 })); + test.isFalse(EJSON.equals({ a: "" }, { a: false })); + test.isFalse(EJSON.equals({ a: null }, { a: undefined })); + test.isFalse(EJSON.equals({ a: null }, { a: 0 })); + test.isFalse(EJSON.equals({ a: null }, { a: "" })); + test.isFalse(EJSON.equals({ a: null }, { a: false })); // Objects with same falsy values should be equal - test.isTrue(EJSON.equals({a: 0}, {a: 0})); - test.isTrue(EJSON.equals({a: ''}, {a: ''})); - test.isTrue(EJSON.equals({a: false}, {a: false})); - test.isTrue(EJSON.equals({a: null}, {a: null})); - test.isTrue(EJSON.equals({a: undefined}, {a: undefined})); + test.isTrue(EJSON.equals({ a: 0 }, { a: 0 })); + test.isTrue(EJSON.equals({ a: "" }, { a: "" })); + test.isTrue(EJSON.equals({ a: false }, { a: false })); + test.isTrue(EJSON.equals({ a: null }, { a: null })); + test.isTrue(EJSON.equals({ a: undefined }, { a: undefined })); // Deeply nested type mismatches - test.isFalse(EJSON.equals( - {a: {b: {c: 0}}}, - {a: {b: {c: false}}} - )); - test.isFalse(EJSON.equals( - {a: {b: {c: null}}}, - {a: {b: {c: undefined}}} - )); - test.isTrue(EJSON.equals( - {a: {b: {c: 0}}}, - {a: {b: {c: 0}}} - )); + test.isFalse(EJSON.equals({ a: { b: { c: 0 } } }, { a: { b: { c: false } } })); + test.isFalse(EJSON.equals({ a: { b: { c: null } } }, { a: { b: { c: undefined } } })); + test.isTrue(EJSON.equals({ a: { b: { c: 0 } } }, { a: { b: { c: 0 } } })); // Arrays with type-mismatched elements test.isFalse(EJSON.equals([0, 1, 2], [false, 1, 2])); - test.isFalse(EJSON.equals([0, '', 2], [0, false, 2])); + test.isFalse(EJSON.equals([0, "", 2], [0, false, 2])); test.isFalse(EJSON.equals([null], [undefined])); - test.isTrue(EJSON.equals([0, '', null], [0, '', null])); + test.isTrue(EJSON.equals([0, "", null], [0, "", null])); }); -Tinytest.add('ejson - NaN and Inf', test => { +Tinytest.add("ejson - NaN and Inf", (test) => { test.equal(EJSON.parse('{"$InfNaN": 1}'), Infinity); test.equal(EJSON.parse('{"$InfNaN": -1}'), -Infinity); test.isTrue(Number.isNaN(EJSON.parse('{"$InfNaN": 0}'))); @@ -181,134 +189,138 @@ Tinytest.add('ejson - NaN and Inf', test => { test.isFalse(EJSON.equals(Infinity, 0)); test.isFalse(EJSON.equals(NaN, 0)); - test.isTrue(EJSON.equals( - EJSON.parse('{"a": {"$InfNaN": 1}}'), - {a: Infinity} - )); - test.isTrue(EJSON.equals( - EJSON.parse('{"a": {"$InfNaN": 0}}'), - {a: NaN} - )); + test.isTrue(EJSON.equals(EJSON.parse('{"a": {"$InfNaN": 1}}'), { a: Infinity })); + test.isTrue(EJSON.equals(EJSON.parse('{"a": {"$InfNaN": 0}}'), { a: NaN })); }); -Tinytest.add('ejson - toJSONValue primitives pass through unchanged', test => { +Tinytest.add("ejson - toJSONValue primitives pass through unchanged", (test) => { test.equal(EJSON.toJSONValue(42), 42); - test.equal(EJSON.toJSONValue('hello'), 'hello'); + test.equal(EJSON.toJSONValue("hello"), "hello"); test.equal(EJSON.toJSONValue(true), true); test.equal(EJSON.toJSONValue(false), false); test.equal(EJSON.toJSONValue(null), null); test.equal(EJSON.toJSONValue(undefined), undefined); test.equal(EJSON.toJSONValue(0), 0); - test.equal(EJSON.toJSONValue(''), ''); + test.equal(EJSON.toJSONValue(""), ""); }); -Tinytest.add('ejson - toJSONValue converts Date to {$date}', test => { - const d = new Date('2024-06-15T12:00:00Z'); +Tinytest.add("ejson - toJSONValue converts Date to {$date}", (test) => { + const d = new Date("2024-06-15T12:00:00Z"); const result = EJSON.toJSONValue(d); - test.equal(result, {$date: d.getTime()}); + test.equal(result, { $date: d.getTime() }); }); -Tinytest.add('ejson - toJSONValue converts NaN and Infinity', test => { - test.equal(EJSON.toJSONValue(NaN), {$InfNaN: 0}); - test.equal(EJSON.toJSONValue(Infinity), {$InfNaN: 1}); - test.equal(EJSON.toJSONValue(-Infinity), {$InfNaN: -1}); +Tinytest.add("ejson - toJSONValue converts NaN and Infinity", (test) => { + test.equal(EJSON.toJSONValue(NaN), { $InfNaN: 0 }); + test.equal(EJSON.toJSONValue(Infinity), { $InfNaN: 1 }); + test.equal(EJSON.toJSONValue(-Infinity), { $InfNaN: -1 }); }); -Tinytest.add('ejson - toJSONValue handles pure-primitive objects', test => { - const obj = {a: 1, b: 'hello', c: true, d: null}; +Tinytest.add("ejson - toJSONValue handles pure-primitive objects", (test) => { + const obj = { a: 1, b: "hello", c: true, d: null }; const result = EJSON.toJSONValue(obj); - test.equal(result, {a: 1, b: 'hello', c: true, d: null}); + test.equal(result, { a: 1, b: "hello", c: true, d: null }); }); -Tinytest.add('ejson - toJSONValue converts nested Dates', test => { - const d = new Date('2024-01-01'); - const obj = {name: 'test', createdAt: d, meta: {updatedAt: d}}; +Tinytest.add("ejson - toJSONValue converts nested Dates", (test) => { + const d = new Date("2024-01-01"); + const obj = { name: "test", createdAt: d, meta: { updatedAt: d } }; const result = EJSON.toJSONValue(obj); - test.equal(result, {name: 'test', createdAt: {$date: d.getTime()}, meta: {updatedAt: {$date: d.getTime()}}}); + test.equal(result, { + name: "test", + createdAt: { $date: d.getTime() }, + meta: { updatedAt: { $date: d.getTime() } }, + }); }); -Tinytest.add('ejson - toJSONValue handles arrays', test => { +Tinytest.add("ejson - toJSONValue handles arrays", (test) => { // Pure-primitive array - const arr = [1, 'two', true, null]; + const arr = [1, "two", true, null]; const result = EJSON.toJSONValue(arr); - test.equal(result, [1, 'two', true, null]); + test.equal(result, [1, "two", true, null]); // Array with a Date const d = new Date(); - const arrWithDate = ['a', d, 'b']; + const arrWithDate = ["a", d, "b"]; const result2 = EJSON.toJSONValue(arrWithDate); - test.equal(result2, ['a', {$date: d.getTime()}, 'b']); + test.equal(result2, ["a", { $date: d.getTime() }, "b"]); // Empty array test.equal(EJSON.toJSONValue([]), []); }); -Tinytest.add('ejson - toJSONValue handles NaN/Infinity inside objects and arrays', test => { - const obj = {a: 1, b: NaN, c: Infinity, d: -Infinity, e: 'normal'}; +Tinytest.add("ejson - toJSONValue handles NaN/Infinity inside objects and arrays", (test) => { + const obj = { a: 1, b: NaN, c: Infinity, d: -Infinity, e: "normal" }; const result = EJSON.toJSONValue(obj); - test.equal(result, {a: 1, b: {$InfNaN: 0}, c: {$InfNaN: 1}, d: {$InfNaN: -1}, e: 'normal'}); + test.equal(result, { + a: 1, + b: { $InfNaN: 0 }, + c: { $InfNaN: 1 }, + d: { $InfNaN: -1 }, + e: "normal", + }); const arr = [NaN, 42, Infinity]; const result2 = EJSON.toJSONValue(arr); - test.equal(result2, [{$InfNaN: 0}, 42, {$InfNaN: 1}]); + test.equal(result2, [{ $InfNaN: 0 }, 42, { $InfNaN: 1 }]); }); -Tinytest.add('ejson - toJSONValue escapes $-prefixed keys that look like EJSON types', test => { - const obj = {$date: 12345}; +Tinytest.add("ejson - toJSONValue escapes $-prefixed keys that look like EJSON types", (test) => { + const obj = { $date: 12345 }; const result = EJSON.toJSONValue(obj); // Should be wrapped in $escape to prevent misinterpretation - test.isTrue('$escape' in result); + test.isTrue("$escape" in result); test.equal(result.$escape.$date, 12345); }); -Tinytest.add('ejson - fromJSONValue primitives pass through unchanged', test => { +Tinytest.add("ejson - fromJSONValue primitives pass through unchanged", (test) => { test.equal(EJSON.fromJSONValue(42), 42); - test.equal(EJSON.fromJSONValue('hello'), 'hello'); + test.equal(EJSON.fromJSONValue("hello"), "hello"); test.equal(EJSON.fromJSONValue(true), true); test.equal(EJSON.fromJSONValue(false), false); test.equal(EJSON.fromJSONValue(null), null); test.equal(EJSON.fromJSONValue(0), 0); - test.equal(EJSON.fromJSONValue(''), ''); + test.equal(EJSON.fromJSONValue(""), ""); }); -Tinytest.add('ejson - fromJSONValue converts {$date} to Date', test => { +Tinytest.add("ejson - fromJSONValue converts {$date} to Date", (test) => { const ts = 1718452800000; - const result = EJSON.fromJSONValue({$date: ts}); + const result = EJSON.fromJSONValue({ $date: ts }); test.instanceOf(result, Date); test.equal(result.getTime(), ts); }); -Tinytest.add('ejson - fromJSONValue converts {$InfNaN} back', test => { - test.isTrue(Number.isNaN(EJSON.fromJSONValue({$InfNaN: 0}))); - test.equal(EJSON.fromJSONValue({$InfNaN: 1}), Infinity); - test.equal(EJSON.fromJSONValue({$InfNaN: -1}), -Infinity); +Tinytest.add("ejson - fromJSONValue converts {$InfNaN} back", (test) => { + test.isTrue(Number.isNaN(EJSON.fromJSONValue({ $InfNaN: 0 }))); + test.equal(EJSON.fromJSONValue({ $InfNaN: 1 }), Infinity); + test.equal(EJSON.fromJSONValue({ $InfNaN: -1 }), -Infinity); }); -Tinytest.add('ejson - fromJSONValue handles pure-primitive objects', test => { - const obj = {a: 1, b: 'hello', c: true, d: null}; +Tinytest.add("ejson - fromJSONValue handles pure-primitive objects", (test) => { + const obj = { a: 1, b: "hello", c: true, d: null }; const result = EJSON.fromJSONValue(obj); - test.equal(result, {a: 1, b: 'hello', c: true, d: null}); + test.equal(result, { a: 1, b: "hello", c: true, d: null }); }); -Tinytest.add('ejson - fromJSONValue converts nested {$date} values', test => { +Tinytest.add("ejson - fromJSONValue converts nested {$date} values", (test) => { const ts = Date.now(); - const obj = {name: 'test', createdAt: {$date: ts}, meta: {updatedAt: {$date: ts}}}; + const obj = { name: "test", createdAt: { $date: ts }, meta: { updatedAt: { $date: ts } } }; const result = EJSON.fromJSONValue(obj); - test.equal(result.name, 'test'); + test.equal(result.name, "test"); test.instanceOf(result.createdAt, Date); test.equal(result.createdAt.getTime(), ts); test.instanceOf(result.meta.updatedAt, Date); test.equal(result.meta.updatedAt.getTime(), ts); }); -Tinytest.add('ejson - fromJSONValue handles arrays with EJSON types', test => { +Tinytest.add("ejson - fromJSONValue handles arrays with EJSON types", (test) => { const ts = Date.now(); - const arr = ['a', {$date: ts}, 'b']; + const arr = ["a", { $date: ts }, "b"]; const result = EJSON.fromJSONValue(arr); - test.equal(result[0], 'a'); + test.equal(result[0], "a"); test.instanceOf(result[1], Date); test.equal(result[1].getTime(), ts); - test.equal(result[2], 'b'); + test.equal(result[2], "b"); test.length(result, 3); // Pure-primitive array @@ -318,48 +330,48 @@ Tinytest.add('ejson - fromJSONValue handles arrays with EJSON types', test => { test.equal(EJSON.fromJSONValue([]), []); }); -Tinytest.add('ejson - fromJSONValue unescapes $escape wrapper', test => { - const input = {$escape: {$date: 12345}}; +Tinytest.add("ejson - fromJSONValue unescapes $escape wrapper", (test) => { + const input = { $escape: { $date: 12345 } }; const result = EJSON.fromJSONValue(input); - test.equal(result, {$date: 12345}); - test.isFalse('$escape' in result); + test.equal(result, { $date: 12345 }); + test.isFalse("$escape" in result); }); -Tinytest.add('ejson - toJSONValue/fromJSONValue round-trip', test => { +Tinytest.add("ejson - toJSONValue/fromJSONValue round-trip", (test) => { const d = new Date(); const cases = [ 42, - 'hello', + "hello", true, null, - {a: 1, b: 'two'}, + { a: 1, b: "two" }, [1, 2, 3], d, NaN, Infinity, -Infinity, - {name: 'test', ts: d, scores: [1, 2, 3]}, - {nested: {deep: {date: d, val: 42}}}, - [d, 'a', {x: d}], - {$date: 12345}, // $-prefixed key → escape/unescape round-trip - {a: NaN, b: Infinity, c: -Infinity, d: 'normal'}, + { name: "test", ts: d, scores: [1, 2, 3] }, + { nested: { deep: { date: d, val: 42 } } }, + [d, "a", { x: d }], + { $date: 12345 }, // $-prefixed key → escape/unescape round-trip + { a: NaN, b: Infinity, c: -Infinity, d: "normal" }, {}, // empty object [], // empty array ]; - cases.forEach(original => { + cases.forEach((original) => { const json = EJSON.toJSONValue(original); const restored = EJSON.fromJSONValue(json); test.isTrue( EJSON.equals(original, restored), - `Round-trip failed for: ${EJSON.stringify(original)}` + `Round-trip failed for: ${EJSON.stringify(original)}`, ); }); }); -Tinytest.add('ejson - toJSONValue does not mutate the input', test => { +Tinytest.add("ejson - toJSONValue does not mutate the input", (test) => { const d = new Date(); - const obj = {name: 'test', createdAt: d, tags: ['a', 'b']}; + const obj = { name: "test", createdAt: d, tags: ["a", "b"] }; const originalName = obj.name; const originalDate = obj.createdAt; const originalTags = obj.tags; @@ -371,10 +383,10 @@ Tinytest.add('ejson - toJSONValue does not mutate the input', test => { test.equal(obj.createdAt, originalDate); test.equal(obj.tags, originalTags); test.instanceOf(obj.createdAt, Date); - test.equal(obj.tags[0], 'a'); + test.equal(obj.tags[0], "a"); }); -Tinytest.add('ejson - clone', test => { +Tinytest.add("ejson - clone", (test) => { const cloneTest = (x, identical) => { const y = EJSON.clone(x); test.isTrue(EJSON.equals(x, y)); @@ -383,127 +395,104 @@ Tinytest.add('ejson - clone', test => { cloneTest(null, true); cloneTest(undefined, true); cloneTest(42, true); - cloneTest('asdf', true); + cloneTest("asdf", true); cloneTest([1, 2, 3]); - cloneTest([1, 'fasdf', {foo: 42}]); - cloneTest({x: 42, y: 'asdf'}); + cloneTest([1, "fasdf", { foo: 42 }]); + cloneTest({ x: 42, y: "asdf" }); function testCloneArgs(/*arguments*/) { const clonedArgs = EJSON.clone(arguments); - test.equal(clonedArgs, [1, 2, 'foo', [4]]); - }; - testCloneArgs(1, 2, 'foo', [4]); + test.equal(clonedArgs, [1, 2, "foo", [4]]); + } + testCloneArgs(1, 2, "foo", [4]); }); -Tinytest.add('ejson - stringify', test => { - test.equal(EJSON.stringify(null), 'null'); - test.equal(EJSON.stringify(true), 'true'); - test.equal(EJSON.stringify(false), 'false'); - test.equal(EJSON.stringify(123), '123'); - test.equal(EJSON.stringify('abc'), '"abc"'); +Tinytest.add("ejson - stringify", (test) => { + test.equal(EJSON.stringify(null), "null"); + test.equal(EJSON.stringify(true), "true"); + test.equal(EJSON.stringify(false), "false"); + test.equal(EJSON.stringify(123), "123"); + test.equal(EJSON.stringify("abc"), '"abc"'); - test.equal(EJSON.stringify([1, 2, 3]), - '[1,2,3]' - ); - test.equal(EJSON.stringify([1, 2, 3], {indent: true}), - '[\n 1,\n 2,\n 3\n]' - ); - test.equal(EJSON.stringify([1, 2, 3], {canonical: false}), - '[1,2,3]' - ); - test.equal(EJSON.stringify([1, 2, 3], {indent: true, canonical: false}), - '[\n 1,\n 2,\n 3\n]' + test.equal(EJSON.stringify([1, 2, 3]), "[1,2,3]"); + test.equal(EJSON.stringify([1, 2, 3], { indent: true }), "[\n 1,\n 2,\n 3\n]"); + test.equal(EJSON.stringify([1, 2, 3], { canonical: false }), "[1,2,3]"); + test.equal( + EJSON.stringify([1, 2, 3], { indent: true, canonical: false }), + "[\n 1,\n 2,\n 3\n]", ); - test.equal(EJSON.stringify([1, 2, 3], {indent: 4}), - '[\n 1,\n 2,\n 3\n]' - ); - test.equal(EJSON.stringify([1, 2, 3], {indent: '--'}), - '[\n--1,\n--2,\n--3\n]' - ); + test.equal(EJSON.stringify([1, 2, 3], { indent: 4 }), "[\n 1,\n 2,\n 3\n]"); + test.equal(EJSON.stringify([1, 2, 3], { indent: "--" }), "[\n--1,\n--2,\n--3\n]"); test.equal( - EJSON.stringify( - {b: [2, {d: 4, c: 3}], a: 1}, - {canonical: true} - ), - '{"a":1,"b":[2,{"c":3,"d":4}]}' + EJSON.stringify({ b: [2, { d: 4, c: 3 }], a: 1 }, { canonical: true }), + '{"a":1,"b":[2,{"c":3,"d":4}]}', ); test.equal( EJSON.stringify( - {b: [2, {d: 4, c: 3}], a: 1}, + { b: [2, { d: 4, c: 3 }], a: 1 }, { indent: true, canonical: true, - } + }, ), - '{\n' + - ' "a": 1,\n' + - ' "b": [\n' + - ' 2,\n' + - ' {\n' + - ' "c": 3,\n' + - ' "d": 4\n' + - ' }\n' + - ' ]\n' + - '}' + "{\n" + + ' "a": 1,\n' + + ' "b": [\n' + + " 2,\n" + + " {\n" + + ' "c": 3,\n' + + ' "d": 4\n' + + " }\n" + + " ]\n" + + "}", ); test.equal( - EJSON.stringify( - {b: [2, {d: 4, c: 3}], a: 1}, - {canonical: false} - ), - '{"b":[2,{"d":4,"c":3}],"a":1}' + EJSON.stringify({ b: [2, { d: 4, c: 3 }], a: 1 }, { canonical: false }), + '{"b":[2,{"d":4,"c":3}],"a":1}', ); test.equal( - EJSON.stringify( - {b: [2, {d: 4, c: 3}], a: 1}, - {indent: true, canonical: false} - ), - '{\n' + - ' "b": [\n' + - ' 2,\n' + - ' {\n' + - ' "d": 4,\n' + - ' "c": 3\n' + - ' }\n' + - ' ],\n' + - ' "a": 1\n' + - '}' + EJSON.stringify({ b: [2, { d: 4, c: 3 }], a: 1 }, { indent: true, canonical: false }), + "{\n" + + ' "b": [\n' + + " 2,\n" + + " {\n" + + ' "d": 4,\n' + + ' "c": 3\n' + + " }\n" + + " ],\n" + + ' "a": 1\n' + + "}", ); - test.throws( - () => { - const col = new Mongo.Collection('test'); - EJSON.stringify(col) - }, - /Converting circular structure to JSON/ - ); + test.throws(() => { + const col = new Mongo.Collection("test"); + EJSON.stringify(col); + }, /Converting circular structure to JSON/); }); -Tinytest.add('ejson - parse', test => { - test.equal(EJSON.parse('[1,2,3]'), [1, 2, 3]); - test.throws( - () => { EJSON.parse(null); }, - /argument should be a string/ - ); +Tinytest.add("ejson - parse", (test) => { + test.equal(EJSON.parse("[1,2,3]"), [1, 2, 3]); + test.throws(() => { + EJSON.parse(null); + }, /argument should be a string/); }); -Tinytest.add("ejson - regexp", test => { - test.equal(EJSON.stringify(/foo/gi), "{\"$regexp\":\"foo\",\"$flags\":\"gi\"}"); - var d = new RegExp("foo", "gi"); - var obj = { $regexp: "foo", $flags: "gi" }; +Tinytest.add("ejson - regexp", (test) => { + test.equal(EJSON.stringify(/foo/gi), '{"$regexp":"foo","$flags":"gi"}'); + const obj = { $regexp: "foo", $flags: "gi" }; - var eObj = EJSON.toJSONValue(obj); - var roundTrip = EJSON.fromJSONValue(eObj); + const eObj = EJSON.toJSONValue(obj); + const roundTrip = EJSON.fromJSONValue(eObj); test.equal(obj, roundTrip); }); -Tinytest.add('ejson - custom types', test => { +Tinytest.add("ejson - custom types", (test) => { const testSameConstructors = (someObj, compareWith) => { test.equal(someObj.constructor, compareWith.constructor); - if (typeof someObj === 'object') { - Object.keys(someObj).forEach(key => { + if (typeof someObj === "object") { + Object.keys(someObj).forEach((key) => { const value = someObj[key]; testSameConstructors(value, compareWith[key]); }); @@ -526,11 +515,11 @@ Tinytest.add('ejson - custom types', test => { testReallyEqual(someObj, EJSON.clone(someObj)); }; - const a = new EJSONTest.Address('Montreal', 'Quebec'); - testCustomObject( {address: a} ); + const a = new EJSONTest.Address("Montreal", "Quebec"); + testCustomObject({ address: a }); // Test that difference is detected even if they // have similar toJSONValue results: - const nakedA = {city: 'Montreal', state: 'Quebec'}; + const nakedA = { city: "Montreal", state: "Quebec" }; test.notEqual(nakedA, a); test.notEqual(a, nakedA); const holder = new EJSONTest.Holder(nakedA); @@ -539,18 +528,18 @@ Tinytest.add('ejson - custom types', test => { test.notEqual(a, holder); const d = new Date(); - const obj = new EJSONTest.Person('John Doe', d, a); - testCustomObject( obj ); + const obj = new EJSONTest.Person("John Doe", d, a); + testCustomObject(obj); // Test clone is deep: const clone = EJSON.clone(obj); - clone.address.city = 'Sherbrooke'; - test.notEqual( obj, clone ); + clone.address.city = "Sherbrooke"; + test.notEqual(obj, clone); }); // Verify objects with a property named "length" can be handled by the EJSON // API properly (see https://github.com/meteor/meteor/issues/5175). -Tinytest.add('ejson - handle objects with properties named "length"', test => { +Tinytest.add('ejson - handle objects with properties named "length"', (test) => { class Widget { constructor() { this.length = 10; diff --git a/packages/ejson/package.js b/packages/ejson/package.js index e55d05af8f..bd1196912e 100644 --- a/packages/ejson/package.js +++ b/packages/ejson/package.js @@ -1,17 +1,17 @@ Package.describe({ - summary: 'Extended and Extensible JSON library', - version: '1.2.0-beta350.7', + summary: "Extended and Extensible JSON library", + version: "1.2.0-beta350.7", }); Package.onUse(function onUse(api) { - api.use(['ecmascript', 'base64']); - api.addAssets('ejson.d.ts', 'server'); - api.mainModule('ejson.js'); - api.export('EJSON'); + api.use(["ecmascript", "base64"]); + api.addAssets("ejson.d.ts", "server"); + api.mainModule("ejson.js"); + api.export("EJSON"); }); Package.onTest(function onTest(api) { - api.use(['ecmascript', 'tinytest', 'mongo']); - api.use('ejson'); - api.mainModule('ejson_tests.js'); + api.use(["ecmascript", "tinytest", "mongo"]); + api.use("ejson"); + api.mainModule("ejson_tests.js"); }); diff --git a/packages/ejson/stringify.js b/packages/ejson/stringify.js index 564b374bc8..f267df4166 100644 --- a/packages/ejson/stringify.js +++ b/packages/ejson/stringify.js @@ -16,86 +16,73 @@ const str = (key, holder, singleIndent, outerIndent, canonical) => { // What happens next depends on the value's type. switch (typeof value) { - case 'string': - return quote(value); - case 'number': - // JSON numbers must be finite. Encode non-finite numbers as null. - return isFinite(value) ? String(value) : 'null'; - case 'boolean': - return String(value); - // If the type is 'object', we might be dealing with an object or an array or - // null. - case 'object': { - // Due to a specification blunder in ECMAScript, typeof null is 'object', - // so watch out for that case. - if (!value) { - return 'null'; - } - // Make an array to hold the partial results of stringifying this object - // value. - const innerIndent = outerIndent + singleIndent; - const partial = []; - let v; + case "string": + return quote(value); + case "number": + // JSON numbers must be finite. Encode non-finite numbers as null. + return isFinite(value) ? String(value) : "null"; + case "boolean": + return String(value); + // If the type is 'object', we might be dealing with an object or an array or + // null. + case "object": { + // Due to a specification blunder in ECMAScript, typeof null is 'object', + // so watch out for that case. + if (!value) { + return "null"; + } + // Make an array to hold the partial results of stringifying this object + // value. + const innerIndent = outerIndent + singleIndent; + const partial = []; + let v; - // Is the value an array? - if (Array.isArray(value) || ({}).hasOwnProperty.call(value, 'callee')) { - // The value is an array. Stringify every element. Use null as a - // placeholder for non-JSON values. - const length = value.length; - for (let i = 0; i < length; i += 1) { - partial[i] = - str(i, value, singleIndent, innerIndent, canonical) || 'null'; + // Is the value an array? + if (Array.isArray(value) || {}.hasOwnProperty.call(value, "callee")) { + // The value is an array. Stringify every element. Use null as a + // placeholder for non-JSON values. + const length = value.length; + for (let i = 0; i < length; i += 1) { + partial[i] = str(i, value, singleIndent, innerIndent, canonical) || "null"; + } + + // Join all of the elements together, separated with commas, and wrap + // them in brackets. + if (partial.length === 0) { + v = "[]"; + } else if (innerIndent) { + v = `[\n${innerIndent}${partial.join(`,\n${innerIndent}`)}\n${outerIndent}]`; + } else { + v = `[${partial.join(",")}]`; + } + return v; } - // Join all of the elements together, separated with commas, and wrap - // them in brackets. + // Iterate through all of the keys in the object. + let keys = Object.keys(value); + if (canonical) { + keys = keys.sort(); + } + keys.forEach((k) => { + v = str(k, value, singleIndent, innerIndent, canonical); + if (v) { + partial.push(quote(k) + (innerIndent ? ": " : ":") + v); + } + }); + + // Join all of the member texts together, separated with commas, + // and wrap them in braces. if (partial.length === 0) { - v = '[]'; + v = "{}"; } else if (innerIndent) { - v = '[\n' + - innerIndent + - partial.join(',\n' + - innerIndent) + - '\n' + - outerIndent + - ']'; + v = `{\n${innerIndent}${partial.join(`,\n${innerIndent}`)}\n${outerIndent}}`; } else { - v = '[' + partial.join(',') + ']'; + v = `{${partial.join(",")}}`; } return v; } - // Iterate through all of the keys in the object. - let keys = Object.keys(value); - if (canonical) { - keys = keys.sort(); - } - keys.forEach(k => { - v = str(k, value, singleIndent, innerIndent, canonical); - if (v) { - partial.push(quote(k) + (innerIndent ? ': ' : ':') + v); - } - }); - - // Join all of the member texts together, separated with commas, - // and wrap them in braces. - if (partial.length === 0) { - v = '{}'; - } else if (innerIndent) { - v = '{\n' + - innerIndent + - partial.join(',\n' + - innerIndent) + - '\n' + - outerIndent + - '}'; - } else { - v = '{' + partial.join(',') + '}'; - } - return v; - } - - default: // Do nothing + default: // Do nothing } }; @@ -103,20 +90,23 @@ const str = (key, holder, singleIndent, outerIndent, canonical) => { const canonicalStringify = (value, options) => { // Make a fake root object containing our value under the key of ''. // Return the result of stringifying the value. - const allOptions = Object.assign({ - indent: '', - canonical: false, - }, options); + const allOptions = Object.assign( + { + indent: "", + canonical: false, + }, + options, + ); if (allOptions.indent === true) { - allOptions.indent = ' '; - } else if (typeof allOptions.indent === 'number') { - let newIndent = ''; + allOptions.indent = " "; + } else if (typeof allOptions.indent === "number") { + let newIndent = ""; for (let i = 0; i < allOptions.indent; i++) { - newIndent += ' '; + newIndent += " "; } allOptions.indent = newIndent; } - return str('', {'': value}, allOptions.indent, '', allOptions.canonical); + return str("", { "": value }, allOptions.indent, "", allOptions.canonical); }; export default canonicalStringify; diff --git a/packages/ejson/utils.js b/packages/ejson/utils.js index 358cc26c1a..be6375960f 100644 --- a/packages/ejson/utils.js +++ b/packages/ejson/utils.js @@ -1,6 +1,6 @@ -export const isFunction = (fn) => typeof fn === 'function'; +export const isFunction = (fn) => typeof fn === "function"; -export const isObject = (fn) => typeof fn === 'object'; +export const isObject = (fn) => typeof fn === "object"; export const keysOf = (obj) => Object.keys(obj); @@ -30,29 +30,30 @@ export const lengthOfWithLimit = (obj, limit) => { export const hasOwn = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); -export const convertMapToObject = (map) => Array.from(map).reduce((acc, [key, value]) => { - // reassign to not create new object - acc[key] = value; - return acc; -}, {}); +export const convertMapToObject = (map) => + Array.from(map).reduce((acc, [key, value]) => { + // reassign to not create new object + acc[key] = value; + return acc; + }, {}); -export const isArguments = obj => obj != null && hasOwn(obj, 'callee'); +export const isArguments = (obj) => obj != null && hasOwn(obj, "callee"); -export const isInfOrNaN = - obj => Number.isNaN(obj) || obj === Infinity || obj === -Infinity; +export const isInfOrNaN = (obj) => Number.isNaN(obj) || obj === Infinity || obj === -Infinity; export const checkError = { - maxStack: (msgError) => new RegExp('Maximum call stack size exceeded', 'g').test(msgError), + maxStack: (msgError) => new RegExp("Maximum call stack size exceeded", "g").test(msgError), }; -export const handleError = (fn) => function() { - try { - return fn.apply(this, arguments); - } catch (error) { - const isMaxStack = checkError.maxStack(error.message); - if (isMaxStack) { - throw new Error('Converting circular structure to JSON') +export const handleError = (fn) => + function () { + try { + return fn.apply(this, arguments); + } catch (error) { + const isMaxStack = checkError.maxStack(error.message); + if (isMaxStack) { + throw new Error("Converting circular structure to JSON"); + } + throw error; } - throw error; - } -}; + }; diff --git a/packages/id-map/id-map-tests.js b/packages/id-map/id-map-tests.js new file mode 100644 index 0000000000..1f17a5720d --- /dev/null +++ b/packages/id-map/id-map-tests.js @@ -0,0 +1,208 @@ +import { IdMap } from "./id-map.js"; + +const collectForEach = (map) => { + const entries = []; + map.forEach((value, id) => { + entries.push([id, value]); + }); + return entries; +}; + +Tinytest.add("idmap - starts empty", (test) => { + const map = new IdMap(); + test.isTrue(map.empty(), "new IdMap should be empty"); + test.equal(map.size(), 0); + test.equal(map.get("anything"), undefined); + test.isFalse(map.has("anything")); +}); + +Tinytest.add("idmap - set/get/has with string keys", (test) => { + const map = new IdMap(); + map.set("a", 1); + map.set("b", 2); + map.set("c", 3); + + test.equal(map.size(), 3); + test.isFalse(map.empty()); + test.equal(map.get("a"), 1); + test.equal(map.get("b"), 2); + test.equal(map.get("c"), 3); + test.isTrue(map.has("a")); + test.isTrue(map.has("b")); + test.isTrue(map.has("c")); + test.isFalse(map.has("d")); +}); + +Tinytest.add("idmap - set/get/has with object keys", (test) => { + const map = new IdMap(); + map.set({ id: 1 }, "first"); + map.set({ id: 2 }, "second"); + + test.equal(map.size(), 2); + test.equal(map.get({ id: 1 }), "first"); + test.equal(map.get({ id: 2 }), "second"); + test.isTrue(map.has({ id: 1 })); + test.isFalse(map.has({ id: 3 })); + + // Structurally-equal objects collide onto the same slot. + map.set({ id: 1 }, "overwritten"); + test.equal(map.size(), 2); + test.equal(map.get({ id: 1 }), "overwritten"); +}); + +Tinytest.add("idmap - set overwrites existing value", (test) => { + const map = new IdMap(); + map.set("k", 1); + map.set("k", 2); + test.equal(map.get("k"), 2); + test.equal(map.size(), 1); +}); + +Tinytest.add("idmap - remove deletes and is idempotent", (test) => { + const map = new IdMap(); + map.set("a", 1); + map.set("b", 2); + + map.remove("a"); + test.isFalse(map.has("a")); + test.equal(map.get("a"), undefined); + test.equal(map.size(), 1); + + // Removing an absent key is a silent no-op. + map.remove("nonexistent"); + test.equal(map.size(), 1); + test.isTrue(map.has("b")); +}); + +Tinytest.add("idmap - clear empties everything", (test) => { + const map = new IdMap(); + map.set("a", 1); + map.set("b", 2); + map.set("c", 3); + + map.clear(); + + test.isTrue(map.empty()); + test.equal(map.size(), 0); + test.equal(map.get("a"), undefined); + test.isFalse(map.has("a")); +}); + +Tinytest.add("idmap - setDefault inserts when missing, returns existing when present", (test) => { + const map = new IdMap(); + + // Missing key: writes default, returns it. + const v1 = map.setDefault("k", 42); + test.equal(v1, 42); + test.equal(map.get("k"), 42); + test.equal(map.size(), 1); + + // Present key: returns existing, does not overwrite. + const v2 = map.setDefault("k", 999); + test.equal(v2, 42); + test.equal(map.get("k"), 42); + test.equal(map.size(), 1); +}); + +Tinytest.add("idmap - forEach iterates all entries", (test) => { + const map = new IdMap(); + map.set("a", 1); + map.set("b", 2); + map.set("c", 3); + + const entries = collectForEach(map); + test.equal(entries.length, 3); + + // Order is not guaranteed — check membership. + const byKey = Object.create(null); + entries.forEach(([k, v]) => { + byKey[k] = v; + }); + test.equal(byKey.a, 1); + test.equal(byKey.b, 2); + test.equal(byKey.c, 3); +}); + +Tinytest.add("idmap - forEach stops when iterator returns false", (test) => { + const map = new IdMap(); + for (let i = 0; i < 5; i++) { + map.set(`k${i}`, i); + } + + let visited = 0; + map.forEach((_value, _id) => { + visited++; + if (visited === 2) { + return false; + } + }); + + test.equal(visited, 2, "forEach should break after iterator returns false"); +}); + +Tinytest.addAsync("idmap - forEachAsync iterates and supports break", async (test) => { + const map = new IdMap(); + map.set("a", 1); + map.set("b", 2); + map.set("c", 3); + + const collected = []; + await map.forEachAsync(async (value, id) => { + await Promise.resolve(); + collected.push([id, value]); + }); + test.equal(collected.length, 3); + + // Break path. + let visited = 0; + await map.forEachAsync(async () => { + await Promise.resolve(); + visited++; + if (visited === 1) { + return false; + } + }); + test.equal(visited, 1); +}); + +Tinytest.add("idmap - clone produces independent copy", (test) => { + const map = new IdMap(); + map.set("a", { n: 1 }); + map.set("b", { n: 2 }); + + const clone = map.clone(); + test.equal(clone.size(), 2); + test.equal(clone.get("a"), { n: 1 }); + test.equal(clone.get("b"), { n: 2 }); + + // Mutating the clone does not affect the original. + clone.set("a", { n: 99 }); + clone.set("c", { n: 3 }); + + test.equal(map.get("a"), { n: 1 }, "original.a should be unchanged"); + test.isFalse(map.has("c"), "original should not see the new key"); + test.equal(map.size(), 2); + test.equal(clone.size(), 3); +}); + +Tinytest.add("idmap - custom stringify/parse round-trip", (test) => { + const map = new IdMap( + (n) => String(n), + (s) => Number(s), + ); + map.set(1, "one"); + map.set(2, "two"); + map.set(10, "ten"); + + test.equal(map.get(1), "one"); + test.equal(map.get(10), "ten"); + + // forEach should deliver parsed (numeric) ids. + const ids = []; + map.forEach((value, id) => { + ids.push(id); + test.equal(typeof id, "number", "custom parse should return a number"); + }); + ids.sort((a, b) => a - b); + test.equal(ids, [1, 2, 10]); +}); diff --git a/packages/id-map/id-map.js b/packages/id-map/id-map.js index c0c58ff057..17c9e6d7ff 100644 --- a/packages/id-map/id-map.js +++ b/packages/id-map/id-map.js @@ -1,4 +1,3 @@ - export class IdMap { constructor(idStringify, idParse) { this._map = new Map(); @@ -6,10 +5,10 @@ export class IdMap { this._idParse = idParse || JSON.parse; } -// Some of these methods are designed to match methods on OrderedDict, since -// (eg) ObserveMultiplex and _CachingChangeObserver use them interchangeably. -// (Conceivably, this should be replaced with "UnorderedDict" with a specific -// set of methods that overlap between the two.) + // Some of these methods are designed to match methods on OrderedDict, since + // (eg) ObserveMultiplex and _CachingChangeObserver use them interchangeably. + // (Conceivably, this should be replaced with "UnorderedDict" with a specific + // set of methods that overlap between the two.) get(id) { const key = this._idStringify(id); @@ -42,12 +41,8 @@ export class IdMap { // Iterates over the items in the map. Return `false` to break the loop. forEach(iterator) { // don't use _.each, because we can't break out of it. - for (let [key, value] of this._map){ - const breakIfFalse = iterator.call( - null, - value, - this._idParse(key) - ); + for (const [key, value] of this._map) { + const breakIfFalse = iterator.call(null, value, this._idParse(key)); if (breakIfFalse === false) { return; } @@ -55,12 +50,8 @@ export class IdMap { } async forEachAsync(iterator) { - for (let [key, value] of this._map){ - const breakIfFalse = await iterator.call( - null, - value, - this._idParse(key) - ); + for (const [key, value] of this._map) { + const breakIfFalse = await iterator.call(null, value, this._idParse(key)); if (breakIfFalse === false) { return; } @@ -85,7 +76,7 @@ export class IdMap { clone() { const clone = new IdMap(this._idStringify, this._idParse); // copy directly to avoid stringify/parse overhead - this._map.forEach(function(value, key){ + this._map.forEach(function (value, key) { clone._map.set(key, EJSON.clone(value)); }); return clone; diff --git a/packages/id-map/package.js b/packages/id-map/package.js index a79970c701..5a3400e92a 100644 --- a/packages/id-map/package.js +++ b/packages/id-map/package.js @@ -1,11 +1,16 @@ Package.describe({ summary: "Dictionary data structure allowing non-string keys", - version: '1.2.0', + version: "1.2.0", }); Package.onUse(function (api) { - api.use('ecmascript'); - api.use('ejson'); - api.mainModule('id-map.js'); - api.export('IdMap'); + api.use("ecmascript"); + api.use("ejson"); + api.mainModule("id-map.js"); + api.export("IdMap"); +}); + +Package.onTest(function (api) { + api.use(["ecmascript", "tinytest", "ejson", "id-map"]); + api.addFiles("id-map-tests.js", ["client", "server"]); }); diff --git a/packages/logging/logging.d.ts b/packages/logging/logging.d.ts index 5441ba3808..57f21a1c18 100644 --- a/packages/logging/logging.d.ts +++ b/packages/logging/logging.d.ts @@ -9,7 +9,7 @@ type LogInput = string | LogJSONInput; type formatInput = { message: string; time: Date; - level: 'debug' | 'info' | 'warn' | 'error' + level: "debug" | "info" | "warn" | "error"; timeInexact?: boolean; file: string; line: number; @@ -23,23 +23,23 @@ type formatInput = { export declare function Log(input: LogInput, ...optionalParams: any[]): void; export declare namespace Log { - var outputFormat: 'json' | 'colored-text'; + var outputFormat: "json" | "colored-text"; var showTime: boolean; function _intercept(count: number): void; function _suppress(count: number): void; function _intercepted(): string[]; function _getCallerDetails(): { line: number; file: string }; - function parse(line: object | string): object + function parse(line: object | string): object; function format(object: formatInput, options: { color: true }): object | string; function objFromText( line: string, - override: object + override: object, ): { - message: string - level: 'info' - time: Date - timeInexact: true - } + message: string; + level: "info"; + time: Date; + timeInexact: true; + }; function debug(input: LogInput, ...optionalParams: any[]): void; function info(input: LogInput, ...optionalParams: any[]): void; diff --git a/packages/logging/logging.js b/packages/logging/logging.js index dba14f775e..5984ac6d6c 100644 --- a/packages/logging/logging.js +++ b/packages/logging/logging.js @@ -1,4 +1,4 @@ -import { Meteor } from 'meteor/meteor'; +import { Meteor } from "meteor/meteor"; const hasOwn = Object.prototype.hasOwnProperty; @@ -42,7 +42,7 @@ Log._intercepted = () => { // When this is set to 'colored-text', call 'Log.format' before printing. // This should be used for logging from within satellite, since there is no // other process that will be reading its standard output. -Log.outputFormat = 'json'; +Log.outputFormat = "json"; // Defaults to true for local development and for backwards compatibility. // for cloud environments is interesting to leave it false as most of them have the timestamp in the console. @@ -50,39 +50,48 @@ Log.outputFormat = 'json'; Log.showTime = true; const LEVEL_COLORS = { - debug: 'green', + debug: "green", // leave info as the default color - warn: 'magenta', - error: 'red' + warn: "magenta", + error: "red", }; -const META_COLOR = 'blue'; +const META_COLOR = "blue"; // Default colors cause readability problems on Windows Powershell, // switch to bright variants. While still capable of millions of // operations per second, the benchmark showed a 25%+ increase in // ops per second (on Node 8) by caching "process.platform". -const isWin32 = typeof process === 'object' && process.platform === 'win32'; +const isWin32 = typeof process === "object" && process.platform === "win32"; const platformColor = (color) => { - if (isWin32 && typeof color === 'string' && !color.endsWith('Bright')) { + if (isWin32 && typeof color === "string" && !color.endsWith("Bright")) { return `${color}Bright`; } return color; }; // XXX package -const RESTRICTED_KEYS = ['time', 'timeInexact', 'level', 'file', 'line', - 'program', 'originApp', 'satellite', 'stderr']; +const RESTRICTED_KEYS = [ + "time", + "timeInexact", + "level", + "file", + "line", + "program", + "originApp", + "satellite", + "stderr", +]; -const FORMATTED_KEYS = [...RESTRICTED_KEYS, 'app', 'message']; +const FORMATTED_KEYS = [...RESTRICTED_KEYS, "app", "message"]; -const logInBrowser = obj => { +const logInBrowser = (obj) => { const str = Log.format(obj); // XXX Some levels should be probably be sent to the server const level = obj.level; - if ((typeof console !== 'undefined') && console[level]) { + if (typeof console !== "undefined" && console[level]) { console[level](str); } else { // IE doesn't have console.log.apply, it's not a real Object. @@ -91,7 +100,6 @@ const logInBrowser = obj => { if (typeof console.log.apply === "function") { // Most browsers console.log.apply(console, [str]); - } else if (typeof Function.prototype.bind === "function") { // IE9 const log = Function.prototype.bind.call(console.log, console); @@ -106,7 +114,7 @@ Log._getCallerDetails = () => { // We do NOT use Error.prepareStackTrace here (a V8 extension that gets us a // pre-parsed stack) since it's impossible to compose it with the use of // Error.prepareStackTrace used on the server for source maps. - const err = new Error; + const err = new Error(); const stack = err.stack; return stack; }; @@ -118,10 +126,10 @@ Log._getCallerDetails = () => { // looking for the first line outside the logging package (or an // eval if we find that first) let line; - const lines = stack.split('\n').slice(1); + const lines = stack.split("\n").slice(1); for (line of lines) { if (line.match(/^\s*(at eval \(eval)|(eval:)/)) { - return {file: "eval"}; + return { file: "eval" }; } if (!line.match(/packages\/(?:local-test[:_])?logging(?:\/|\.js)/)) { @@ -140,84 +148,85 @@ Log._getCallerDetails = () => { } // in case the matched block here is line:column - details.line = match[2].split(':')[0]; + details.line = match[2].split(":")[0]; // Possible format: https://foo.bar.com/scripts/file.js?random=foobar // XXX: if you can write the following in better way, please do it // XXX: what about evals? - details.file = match[1].split('/').slice(-1)[0].split('?')[0]; + details.file = match[1].split("/").slice(-1)[0].split("?")[0]; return details; }; -['debug', 'info', 'warn', 'error'].forEach((level) => { - // @param arg {String|Object} - Log[level] = (arg) => { - if (suppress) { - suppress--; - return; - } - - let intercepted = false; - if (intercept) { - intercept--; - intercepted = true; - } - - let obj = (arg === Object(arg) - && !(arg instanceof RegExp) - && !(arg instanceof Date)) - ? arg - : { message: new String(arg).toString() }; - - RESTRICTED_KEYS.forEach(key => { - if (obj[key]) { - throw new Error(`Can't set '${key}' in log message`); +["debug", "info", "warn", "error"].forEach((level) => { + // @param arg {String|Object} + Log[level] = (arg) => { + if (suppress) { + suppress--; + return; } - }); - if (hasOwn.call(obj, 'message') && typeof obj.message !== 'string') { - throw new Error("The 'message' field in log objects must be a string"); - } + let intercepted = false; + if (intercept) { + intercept--; + intercepted = true; + } - if (!obj.omitCallerDetails) { - obj = { ...Log._getCallerDetails(), ...obj }; - } + let obj = + arg === Object(arg) && !(arg instanceof RegExp) && !(arg instanceof Date) + ? arg + : { message: new String(arg).toString() }; - obj.time = new Date(); - obj.level = level; + RESTRICTED_KEYS.forEach((key) => { + if (obj[key]) { + throw new Error(`Can't set '${key}' in log message`); + } + }); - // If we are in production don't write out debug logs. - if (level === 'debug' && Meteor.isProduction) { - return; - } + if (hasOwn.call(obj, "message") && typeof obj.message !== "string") { + throw new Error("The 'message' field in log objects must be a string"); + } - if (intercepted) { - interceptedLines.push(EJSON.stringify(obj)); - } else if (Meteor.isServer) { - if (Log.outputFormat === 'colored-text') { - console.log(Log.format(obj, {color: true})); - } else if (Log.outputFormat === 'json') { - console.log(EJSON.stringify(obj)); + if (!obj.omitCallerDetails) { + obj = { ...Log._getCallerDetails(), ...obj }; + } + + obj.time = new Date(); + obj.level = level; + + // If we are in production don't write out debug logs. + if (level === "debug" && Meteor.isProduction) { + return; + } + + if (intercepted) { + interceptedLines.push(EJSON.stringify(obj)); + } else if (Meteor.isServer) { + if (Log.outputFormat === "colored-text") { + console.log(Log.format(obj, { color: true })); + } else if (Log.outputFormat === "json") { + console.log(EJSON.stringify(obj)); + } else { + throw new Error(`Unknown logging output format: ${Log.outputFormat}`); + } } else { - throw new Error(`Unknown logging output format: ${Log.outputFormat}`); + logInBrowser(obj); } - } else { - logInBrowser(obj); - } -}; + }; }); - // tries to parse line as EJSON. returns object if parse is successful, or null if not Log.parse = (line) => { let obj = null; - if (line && line.startsWith('{')) { // might be json generated from calling 'Log' - try { obj = EJSON.parse(line); } catch (e) {} + if (line && line.startsWith("{")) { + // might be json generated from calling 'Log' + try { + obj = EJSON.parse(line); + } catch {} } // XXX should probably check fields other than 'time' - if (obj && obj.time && (obj.time instanceof Date)) { + if (obj && obj.time && obj.time instanceof Date) { return obj; } else { return null; @@ -227,51 +236,46 @@ Log.parse = (line) => { // formats a log object into colored human and machine-readable text Log.format = (obj, options = {}) => { obj = { ...obj }; // don't mutate the argument - let { + const { time, timeInexact, - level = 'info', + level = "info", file, line: lineNumber, - app: appName = '', + app: appName = "", originApp, - message = '', - program = '', - satellite = '', - stderr = '', + program = "", + satellite = "", + stderr = "", } = obj; + let { message = "" } = obj; if (!(time instanceof Date)) { throw new Error("'time' must be a Date object"); } - FORMATTED_KEYS.forEach((key) => { delete obj[key]; }); + FORMATTED_KEYS.forEach((key) => { + delete obj[key]; + }); if (Object.keys(obj).length > 0) { if (message) { - message += ' '; + message += " "; } message += EJSON.stringify(obj); } - const pad2 = n => n.toString().padStart(2, '0'); - const pad3 = n => n.toString().padStart(3, '0'); + const pad2 = (n) => n.toString().padStart(2, "0"); + const pad3 = (n) => n.toString().padStart(3, "0"); - const dateStamp = time.getFullYear().toString() + - pad2(time.getMonth() + 1 /*0-based*/) + - pad2(time.getDate()); - const timeStamp = pad2(time.getHours()) + - ':' + - pad2(time.getMinutes()) + - ':' + - pad2(time.getSeconds()) + - '.' + - pad3(time.getMilliseconds()); + const dateStamp = + time.getFullYear().toString() + pad2(time.getMonth() + 1 /*0-based*/) + pad2(time.getDate()); + const timeStamp = `${pad2(time.getHours())}:${pad2(time.getMinutes())}:${pad2(time.getSeconds())}.${pad3(time.getMilliseconds())}`; // eg in San Francisco in June this will be '(-7)' - const utcOffsetStr = `(${(-(new Date().getTimezoneOffset() / 60))})`; + const utcOffsetStr = `(${-(new Date().getTimezoneOffset() / 60)})`; - let appInfo = ''; + let appInfo = ""; if (appName) { appInfo += appName; } @@ -293,30 +297,30 @@ Log.format = (obj, options = {}) => { sourceInfoParts.push(lineNumber); } - let sourceInfo = !sourceInfoParts.length ? - '' : `(${sourceInfoParts.join(':')}) `; + let sourceInfo = !sourceInfoParts.length ? "" : `(${sourceInfoParts.join(":")}) `; - if (satellite) - sourceInfo += `[${satellite}]`; + if (satellite) sourceInfo += `[${satellite}]`; - const stderrIndicator = stderr ? '(STDERR) ' : ''; + const stderrIndicator = stderr ? "(STDERR) " : ""; const timeString = Log.showTime - ? `${dateStamp}-${timeStamp}${utcOffsetStr}${timeInexact ? '? ' : ' '}` - : ' '; - - + ? `${dateStamp}-${timeStamp}${utcOffsetStr}${timeInexact ? "? " : " "}` + : " "; const metaPrefix = [ level.charAt(0).toUpperCase(), timeString, appInfo, sourceInfo, - stderrIndicator].join(''); + stderrIndicator, + ].join(""); - - return Formatter.prettify(metaPrefix, options.color && platformColor(options.metaColor || META_COLOR)) + - Formatter.prettify(message, options.color && platformColor(LEVEL_COLORS[level])); + return ( + Formatter.prettify( + metaPrefix, + options.color && platformColor(options.metaColor || META_COLOR), + ) + Formatter.prettify(message, options.color && platformColor(LEVEL_COLORS[level])) + ); }; // Turn a line of text into a loggable object. @@ -325,10 +329,10 @@ Log.format = (obj, options = {}) => { Log.objFromText = (line, override) => { return { message: line, - level: 'info', + level: "info", time: new Date(), timeInexact: true, - ...override + ...override, }; }; diff --git a/packages/logging/logging_browser.js b/packages/logging/logging_browser.js index 028ee184b1..9191e5c460 100644 --- a/packages/logging/logging_browser.js +++ b/packages/logging/logging_browser.js @@ -1,4 +1,4 @@ Formatter = {}; -Formatter.prettify = function(line, color){ - return line; +Formatter.prettify = function (line, _color) { + return line; }; diff --git a/packages/logging/logging_cordova.js b/packages/logging/logging_cordova.js index 7ac528befb..aea785314f 100644 --- a/packages/logging/logging_cordova.js +++ b/packages/logging/logging_cordova.js @@ -1,13 +1,13 @@ // Log all uncaught errors so they can be printed to the developer. // But since Android's adb catalog already prints the uncaught exceptions, we // can disable it for Android. -if (! /Android/i.test(navigator.userAgent)) { +if (!/Android/i.test(navigator.userAgent)) { window.onerror = function (msg, url, line) { // Cut off the url prefix, the meaningful part always starts at 'www/' in // Cordova apps. - url = url.replace(/^.*?\/www\//, ''); + url = url.replace(/^.*?\/www\//, ""); console.log(`Uncaught Error: ${msg}:${line}:${url}`); }; } -export * from './logging.js'; +export * from "./logging.js"; diff --git a/packages/logging/logging_server.js b/packages/logging/logging_server.js index dc01ce0ceb..0b082506b0 100644 --- a/packages/logging/logging_server.js +++ b/packages/logging/logging_server.js @@ -1,5 +1,5 @@ Formatter = {}; -Formatter.prettify = function(line, color){ - if(!color) return line; - return require("chalk")[color](line); +Formatter.prettify = function (line, color) { + if (!color) return line; + return require("chalk")[color](line); }; diff --git a/packages/logging/logging_test.js b/packages/logging/logging_test.js index 03e2c75c3c..1c56d9ca2f 100644 --- a/packages/logging/logging_test.js +++ b/packages/logging/logging_test.js @@ -20,8 +20,8 @@ Tinytest.add("logging - _getCallerDetails", function (test) { // Note that we want this to work in --production too, so we need to allow // for the minified filename test.matches( - eval(code), - /^(?:eval|local-test_logging\.js|[a-f0-9]{40}\.js)/ + eval(code), // oxlint-disable-line no-eval -- intentional eval for testing caller details + /^(?:eval|local-test_logging\.js|[a-f0-9]{40}\.js)/, ); } }); @@ -64,11 +64,7 @@ Tinytest.add("logging - log", function (test) { [0, "0", "falsy - 0"], [null, "null", "falsy - null"], [undefined, "undefined", "falsy - undefined"], - [ - new Date("2013-06-13T01:15:16.000Z"), - new Date("2013-06-13T01:15:16.000Z"), - "date", - ], + [new Date("2013-06-13T01:15:16.000Z"), new Date("2013-06-13T01:15:16.000Z"), "date"], [/[^regexp]{0,1}/g, "/[^regexp]{0,1}/g", "regexp"], [true, "true", "boolean - true"], [false, "false", "boolean - false"], @@ -94,8 +90,7 @@ Tinytest.add("logging - log", function (test) { if ( expected && expected.toString && - (expected.toString() === "NaN" || - expected.toString() === "Invalid Date") + (expected.toString() === "NaN" || expected.toString() === "Invalid Date") ) { return; } @@ -116,9 +111,7 @@ Tinytest.add("logging - log", function (test) { test.throws(function () { log({ level: "not the right level" }); }); - ["file", "line", "program", "originApp", "satellite"].forEach(function ( - restrictedKey - ) { + ["file", "line", "program", "originApp", "satellite"].forEach(function (restrictedKey) { test.throws(function () { const obj = {}; obj[restrictedKey] = "usage of restricted key"; @@ -172,14 +165,12 @@ Tinytest.add("logging - parse", function (test) { Tinytest.add("logging - format", function (test) { const time = new Date(2012, 9 - 1 /*0-based*/, 8, 7, 6, 5, 4); - const utcOffsetStr = "(" + -(new Date().getTimezoneOffset() / 60) + ")"; + const utcOffsetStr = `(${-(new Date().getTimezoneOffset() / 60)})`; ["debug", "info", "warn", "error"].forEach(function (level) { test.equal( Log.format({ message: "message", time, level }), - `${level - .charAt(0) - .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} message` + `${level.charAt(0).toUpperCase()}20120908-07:06:05.004${utcOffsetStr} message`, ); test.equal( @@ -189,23 +180,19 @@ Tinytest.add("logging - format", function (test) { timeInexact: true, level, }), - `${level - .charAt(0) - .toUpperCase()}20120908-07:06:05.004${utcOffsetStr}? message` + `${level.charAt(0).toUpperCase()}20120908-07:06:05.004${utcOffsetStr}? message`, ); test.equal( Log.format({ foo1: "bar1", foo2: "bar2", time, level }), `${level .charAt(0) - .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} {"foo1":"bar1","foo2":"bar2"}` + .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} {"foo1":"bar1","foo2":"bar2"}`, ); test.equal( Log.format({ message: "message", foo: "bar", time, level }), - `${level - .charAt(0) - .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} message {"foo":"bar"}` + `${level.charAt(0).toUpperCase()}20120908-07:06:05.004${utcOffsetStr} message {"foo":"bar"}`, ); // Has everything except stderr field @@ -223,7 +210,7 @@ Tinytest.add("logging - format", function (test) { }), `${level .charAt(0) - .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} [myApp via proxy] (server:app.js:42) message {\"foo\":\"bar\"}` + .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} [myApp via proxy] (server:app.js:42) message {"foo":"bar"}`, ); // stderr @@ -236,7 +223,7 @@ Tinytest.add("logging - format", function (test) { }), `${level .charAt(0) - .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} (STDERR) message from stderr` + .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} (STDERR) message from stderr`, ); // app/originApp @@ -248,9 +235,7 @@ Tinytest.add("logging - format", function (test) { app: "app", originApp: "app", }), - `${level - .charAt(0) - .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} [app] message` + `${level.charAt(0).toUpperCase()}20120908-07:06:05.004${utcOffsetStr} [app] message`, ); test.equal( Log.format({ @@ -262,7 +247,7 @@ Tinytest.add("logging - format", function (test) { }), `${level .charAt(0) - .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} [app via proxy] message` + .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} [app via proxy] message`, ); // source info @@ -277,7 +262,7 @@ Tinytest.add("logging - format", function (test) { }), `${level .charAt(0) - .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} (server:app.js:42) message` + .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} (server:app.js:42) message`, ); test.equal( Log.format({ @@ -287,9 +272,7 @@ Tinytest.add("logging - format", function (test) { file: "app.js", line: 42, }), - `${level - .charAt(0) - .toUpperCase()}20120908-07:06:05.004${utcOffsetStr} (app.js:42) message` + `${level.charAt(0).toUpperCase()}20120908-07:06:05.004${utcOffsetStr} (app.js:42) message`, ); }); @@ -302,12 +285,69 @@ Tinytest.add("logging - format", function (test) { }, { color: true, - } + }, ), - /oyez/ + /oyez/, ); }); +Tinytest.add("logging - Log() delegates to Log.info", function (test) { + Log._intercept(1); + Log("hello"); + const intercepted = Log._intercepted(); + test.equal(intercepted.length, 1); + const obj = EJSON.parse(intercepted[0]); + test.equal(obj.message, "hello"); + test.equal(obj.level, "info"); +}); + +Tinytest.add("logging - _suppress drops the next N calls", function (test) { + Log._suppress(2); + // These should be dropped — not intercepted, not printed. + Log.info("dropped-1"); + Log.info("dropped-2"); + + // Now an interception should capture the *next* line, not the suppressed ones. + Log._intercept(1); + Log.info("kept"); + const intercepted = Log._intercepted(); + test.equal(intercepted.length, 1); + test.equal(EJSON.parse(intercepted[0]).message, "kept"); +}); + +Tinytest.add("logging - objFromText contract and override merging", function (test) { + const obj = Log.objFromText("raw line"); + test.equal(obj.message, "raw line"); + test.equal(obj.level, "info"); + test.equal(obj.timeInexact, true); + test.instanceOf(obj.time, Date); + + // Override wins for provided fields, leaves others intact. + const overridden = Log.objFromText("raw line", { + level: "error", + extra: "payload", + }); + test.equal(overridden.level, "error"); + test.equal(overridden.message, "raw line"); + test.equal(overridden.timeInexact, true); + test.equal(overridden.extra, "payload"); +}); + +Tinytest.add("logging - format throws when time is not a Date", function (test) { + test.throws(function () { + Log.format({ message: "hi", level: "info", time: "not a date" }); + }, /'time' must be a Date object/); +}); + +Tinytest.add("logging - parse returns null for invalid payloads", function (test) { + // Empty object has no time field — parse returns null. + test.equal(Log.parse("{}"), null); + // JSON with time present but not a Date → null. + test.equal(Log.parse('{"time": 1234567890}'), null); + // Doesn't start with '{' → not EJSON; not parsed. + test.equal(Log.parse("plain string"), null); +}); + Tinytest.add("logging - formats - without time", function (test) { const time = new Date(2012, 9 - 1 /*0-based*/, 8, 7, 6, 5, 4); // even tho time and offset are provided they should not be included in the output @@ -316,7 +356,7 @@ Tinytest.add("logging - formats - without time", function (test) { for (const level of levels) { test.equal( Log.format({ message: "message", time, level }), - `${level.charAt(0).toUpperCase()} message` + `${level.charAt(0).toUpperCase()} message`, ); test.equal( @@ -326,17 +366,17 @@ Tinytest.add("logging - formats - without time", function (test) { timeInexact: true, level, }), - `${level.charAt(0).toUpperCase()} message` + `${level.charAt(0).toUpperCase()} message`, ); test.equal( Log.format({ foo1: "bar1", foo2: "bar2", time, level }), - `${level.charAt(0).toUpperCase()} {"foo1":"bar1","foo2":"bar2"}` + `${level.charAt(0).toUpperCase()} {"foo1":"bar1","foo2":"bar2"}`, ); test.equal( Log.format({ message: "message", foo: "bar", time, level }), - `${level.charAt(0).toUpperCase()} message {"foo":"bar"}` + `${level.charAt(0).toUpperCase()} message {"foo":"bar"}`, ); // Has everything except stderr field @@ -352,9 +392,7 @@ Tinytest.add("logging - formats - without time", function (test) { originApp: "proxy", program: "server", }), - `${level - .charAt(0) - .toUpperCase()} [myApp via proxy] (server:app.js:42) message {\"foo\":\"bar\"}` + `${level.charAt(0).toUpperCase()} [myApp via proxy] (server:app.js:42) message {"foo":"bar"}`, ); // stderr @@ -365,7 +403,7 @@ Tinytest.add("logging - formats - without time", function (test) { level, stderr: true, }), - `${level.charAt(0).toUpperCase()} (STDERR) message from stderr` + `${level.charAt(0).toUpperCase()} (STDERR) message from stderr`, ); // app/originApp @@ -377,7 +415,7 @@ Tinytest.add("logging - formats - without time", function (test) { app: "app", originApp: "app", }), - `${level.charAt(0).toUpperCase()} [app] message` + `${level.charAt(0).toUpperCase()} [app] message`, ); test.equal( Log.format({ @@ -387,7 +425,7 @@ Tinytest.add("logging - formats - without time", function (test) { app: "app", originApp: "proxy", }), - `${level.charAt(0).toUpperCase()} [app via proxy] message` + `${level.charAt(0).toUpperCase()} [app via proxy] message`, ); // source info @@ -400,7 +438,7 @@ Tinytest.add("logging - formats - without time", function (test) { line: 42, program: "server", }), - `${level.charAt(0).toUpperCase()} (server:app.js:42) message` + `${level.charAt(0).toUpperCase()} (server:app.js:42) message`, ); test.equal( Log.format({ @@ -410,7 +448,7 @@ Tinytest.add("logging - formats - without time", function (test) { file: "app.js", line: 42, }), - `${level.charAt(0).toUpperCase()} (app.js:42) message` + `${level.charAt(0).toUpperCase()} (app.js:42) message`, ); } Log.showTime = true; // reset diff --git a/packages/logging/package.js b/packages/logging/package.js index 51b58c8a3a..e953ba6d82 100644 --- a/packages/logging/package.js +++ b/packages/logging/package.js @@ -1,33 +1,33 @@ Package.describe({ - summary: 'Logging facility.', - version: '1.3.6', + summary: "Logging facility.", + version: "1.3.6", }); Npm.depends({ - 'chalk': '4.1.2', - '@babel/runtime': '7.20.7', + chalk: "4.1.2", + "@babel/runtime": "7.20.7", }); Npm.strip({ - 'es5-ext': ['test/'] + "es5-ext": ["test/"], }); Package.onUse(function (api) { - api.export('Log'); + api.export("Log"); // The `ecmascript-runtime-client` package is explicitly depended upon // here due to this package's dependency on // `String.prototype.padRight` which is polyfilled only in // `ecmascript-runtime-client@0.6.2` or newer. - api.use(['ejson', 'ecmascript', 'typescript', 'ecmascript-runtime-client']); - api.mainModule('logging.js'); - api.addFiles('logging_server.js', 'server'); - api.addFiles('logging_browser.js', 'client'); - api.mainModule('logging_cordova.js', 'web.cordova'); - api.addAssets('logging.d.ts', 'server'); + api.use(["ejson", "ecmascript", "typescript", "ecmascript-runtime-client"]); + api.mainModule("logging.js"); + api.addFiles("logging_server.js", "server"); + api.addFiles("logging_browser.js", "client"); + api.mainModule("logging_cordova.js", "web.cordova"); + api.addAssets("logging.d.ts", "server"); }); Package.onTest(function (api) { - api.use(['tinytest', 'ejson', 'ecmascript']); - api.use('logging', ['client', 'server']); - api.mainModule('logging_test.js', ['server', 'client']); + api.use(["tinytest", "ejson", "ecmascript"]); + api.use("logging", ["client", "server"]); + api.mainModule("logging_test.js", ["server", "client"]); }); diff --git a/packages/ordered-dict/ordered-dict-tests.js b/packages/ordered-dict/ordered-dict-tests.js new file mode 100644 index 0000000000..116e565a7a --- /dev/null +++ b/packages/ordered-dict/ordered-dict-tests.js @@ -0,0 +1,268 @@ +import { OrderedDict } from "./ordered_dict.js"; + +const keysInOrder = (dict) => { + const keys = []; + dict.forEach((value, key) => { + keys.push(key); + }); + return keys; +}; + +Tinytest.add("ordered-dict - starts empty", (test) => { + const dict = new OrderedDict(); + test.isTrue(dict.empty()); + test.equal(dict.size(), 0); + test.equal(dict.first(), undefined); + test.equal(dict.firstValue(), undefined); + test.equal(dict.last(), undefined); + test.equal(dict.lastValue(), undefined); + test.isFalse(dict.has("anything")); + test.equal(dict.get("anything"), undefined); +}); + +Tinytest.add("ordered-dict - append maintains insertion order", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.append("C", 3); + + test.equal(dict.size(), 3); + test.equal(keysInOrder(dict), ["A", "B", "C"]); + test.equal(dict.first(), "A"); + test.equal(dict.firstValue(), 1); + test.equal(dict.last(), "C"); + test.equal(dict.lastValue(), 3); + + // prev/next on the middle node. + test.equal(dict.prev("B"), "A"); + test.equal(dict.next("B"), "C"); + test.equal(dict.prev("A"), null); + test.equal(dict.next("C"), null); +}); + +Tinytest.add("ordered-dict - putBefore inserts before target", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.putBefore("X", 99, "B"); + + test.equal(keysInOrder(dict), ["A", "X", "B"]); + test.equal(dict.prev("X"), "A"); + test.equal(dict.next("X"), "B"); + test.equal(dict.size(), 3); +}); + +Tinytest.add("ordered-dict - putBefore(null) appends to end", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + dict.putBefore("B", 2, null); + test.equal(keysInOrder(dict), ["A", "B"]); + test.equal(dict.last(), "B"); +}); + +Tinytest.add("ordered-dict - putBefore throws on duplicate key", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + test.throws(() => dict.putBefore("A", 2, null), /already present/); +}); + +Tinytest.add("ordered-dict - putBefore throws when 'before' key is missing", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + test.throws( + () => dict.putBefore("X", 99, "nonexistent"), + /could not find item to put this one before/, + ); +}); + +Tinytest.add("ordered-dict - remove unlinks and returns value", (test) => { + // Remove from the middle. + let dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.append("C", 3); + const removed = dict.remove("B"); + test.equal(removed, 2); + test.equal(keysInOrder(dict), ["A", "C"]); + test.equal(dict.next("A"), "C"); + test.equal(dict.prev("C"), "A"); + test.equal(dict.size(), 2); + test.isFalse(dict.has("B")); + + // Remove from the head. + dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.append("C", 3); + dict.remove("A"); + test.equal(keysInOrder(dict), ["B", "C"]); + test.equal(dict.first(), "B"); + test.equal(dict.prev("B"), null); + + // Remove from the tail. + dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.append("C", 3); + dict.remove("C"); + test.equal(keysInOrder(dict), ["A", "B"]); + test.equal(dict.last(), "B"); + test.equal(dict.next("B"), null); +}); + +Tinytest.add("ordered-dict - remove throws when key missing", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + test.throws(() => dict.remove("missing"), /not present/); +}); + +Tinytest.add("ordered-dict - moveBefore reorders items", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.append("C", 3); + + dict.moveBefore("C", "A"); + + test.equal(keysInOrder(dict), ["C", "A", "B"]); + test.equal(dict.first(), "C"); + test.equal(dict.last(), "B"); + test.equal(dict.prev("C"), null); + test.equal(dict.next("C"), "A"); + test.equal(dict.prev("A"), "C"); + test.equal(dict.next("A"), "B"); + test.equal(dict.prev("B"), "A"); + test.equal(dict.next("B"), null); +}); + +Tinytest.add("ordered-dict - moveBefore(null) moves to end", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.append("C", 3); + + dict.moveBefore("A", null); + + test.equal(keysInOrder(dict), ["B", "C", "A"]); + test.equal(dict.last(), "A"); + test.equal(dict.first(), "B"); +}); + +Tinytest.add("ordered-dict - moveBefore is a no-op when already in place", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.append("C", 3); + + // Moving A before B means A stays where it is (A.next === B already). + dict.moveBefore("A", "B"); + test.equal(keysInOrder(dict), ["A", "B", "C"]); + + // Same but for middle. + dict.moveBefore("B", "C"); + test.equal(keysInOrder(dict), ["A", "B", "C"]); +}); + +Tinytest.add("ordered-dict - moveBefore throws on missing keys", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + test.throws(() => dict.moveBefore("missing", null), /Item to move is not present/); + test.throws( + () => dict.moveBefore("A", "nonexistent"), + /Could not find element to move this one before/, + ); +}); + +Tinytest.add("ordered-dict - indexOf returns position or null", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.append("C", 3); + + test.equal(dict.indexOf("A"), 0); + test.equal(dict.indexOf("B"), 1); + test.equal(dict.indexOf("C"), 2); + test.equal(dict.indexOf("missing"), null); +}); + +Tinytest.add("ordered-dict - forEach receives (value, key, index)", (test) => { + const dict = new OrderedDict(); + dict.append("A", "valA"); + dict.append("B", "valB"); + dict.append("C", "valC"); + + const calls = []; + dict.forEach((value, key, index) => { + calls.push({ value, key, index }); + }); + + test.equal(calls, [ + { value: "valA", key: "A", index: 0 }, + { value: "valB", key: "B", index: 1 }, + { value: "valC", key: "C", index: 2 }, + ]); +}); + +Tinytest.add("ordered-dict - forEach breaks on OrderedDict.BREAK", (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.append("C", 3); + dict.append("D", 4); + + let visited = 0; + dict.forEach((_value, _key) => { + visited++; + if (visited === 2) { + return OrderedDict.BREAK; + } + }); + test.equal(visited, 2); +}); + +Tinytest.addAsync("ordered-dict - forEachAsync iterates and supports break", async (test) => { + const dict = new OrderedDict(); + dict.append("A", 1); + dict.append("B", 2); + dict.append("C", 3); + + const collected = []; + await dict.forEachAsync(async (value, key, index) => { + await Promise.resolve(); + collected.push([key, value, index]); + }); + test.equal(collected, [ + ["A", 1, 0], + ["B", 2, 1], + ["C", 3, 2], + ]); + + // Break path. + let visited = 0; + await dict.forEachAsync(async () => { + await Promise.resolve(); + visited++; + if (visited === 1) { + return OrderedDict.BREAK; + } + }); + test.equal(visited, 1); +}); + +Tinytest.add("ordered-dict - constructor accepts initial pairs", (test) => { + const dict = new OrderedDict(["a", 1], ["b", 2], ["c", 3]); + test.equal(keysInOrder(dict), ["a", "b", "c"]); + test.equal(dict.get("a"), 1); + test.equal(dict.get("b"), 2); + test.equal(dict.get("c"), 3); + test.equal(dict.size(), 3); +}); + +Tinytest.add("ordered-dict - constructor accepts stringify + initial pairs", (test) => { + const dict = new OrderedDict((k) => String(k), [1, "a"], [2, "b"], [3, "c"]); + test.equal(dict.size(), 3); + test.equal(dict.get(1), "a"); + test.equal(dict.get(2), "b"); + test.equal(dict.get(3), "c"); + test.equal(keysInOrder(dict), [1, 2, 3]); +}); diff --git a/packages/ordered-dict/ordered_dict.js b/packages/ordered-dict/ordered_dict.js index 471d334f89..db2b273be1 100644 --- a/packages/ordered-dict/ordered_dict.js +++ b/packages/ordered-dict/ordered_dict.js @@ -12,7 +12,7 @@ function element(key, value, next, prev) { key: key, value: value, next: next, - prev: prev + prev: prev, }; } @@ -23,19 +23,21 @@ export class OrderedDict { this._last = null; this._size = 0; - if (typeof args[0] === 'function') { + if (typeof args[0] === "function") { this._stringify = args.shift(); } else { - this._stringify = function (x) { return x; }; + this._stringify = function (x) { + return x; + }; } - args.forEach(kv => this.putBefore(kv[0], kv[1], null)); + args.forEach((kv) => this.putBefore(kv[0], kv[1], null)); } // the "prefix keys with a space" thing comes from here // https://github.com/documentcloud/underscore/issues/376#issuecomment-2815649 _k(key) { - return " " + this._stringify(key); + return ` ${this._stringify(key)}`; } empty() { @@ -49,36 +51,26 @@ export class OrderedDict { _linkEltIn(elt) { if (!elt.next) { elt.prev = this._last; - if (this._last) - this._last.next = elt; + if (this._last) this._last.next = elt; this._last = elt; } else { elt.prev = elt.next.prev; elt.next.prev = elt; - if (elt.prev) - elt.prev.next = elt; + if (elt.prev) elt.prev.next = elt; } - if (this._first === null || this._first === elt.next) - this._first = elt; + if (this._first === null || this._first === elt.next) this._first = elt; } _linkEltOut(elt) { - if (elt.next) - elt.next.prev = elt.prev; - if (elt.prev) - elt.prev.next = elt.next; - if (elt === this._last) - this._last = elt.prev; - if (elt === this._first) - this._first = elt.next; + if (elt.next) elt.next.prev = elt.prev; + if (elt.prev) elt.prev.next = elt.next; + if (elt === this._last) this._last = elt.prev; + if (elt === this._first) this._first = elt.next; } putBefore(key, item, before) { - if (this._dict[this._k(key)]) - throw new Error("Item " + key + " already present in OrderedDict"); - var elt = before ? - element(key, item, this._dict[this._k(before)]) : - element(key, item, null); + if (this._dict[this._k(key)]) throw new Error(`Item ${key} already present in OrderedDict`); + const elt = before ? element(key, item, this._dict[this._k(before)]) : element(key, item, null); if (typeof elt.next === "undefined") throw new Error("could not find item to put this one before"); this._linkEltIn(elt); @@ -91,9 +83,8 @@ export class OrderedDict { } remove(key) { - var elt = this._dict[this._k(key)]; - if (typeof elt === "undefined") - throw new Error("Item " + key + " not present in OrderedDict"); + const elt = this._dict[this._k(key)]; + if (typeof elt === "undefined") throw new Error(`Item ${key} not present in OrderedDict`); this._linkEltOut(elt); this._size--; delete this._dict[this._k(key)]; @@ -107,10 +98,7 @@ export class OrderedDict { } has(key) { - return Object.prototype.hasOwnProperty.call( - this._dict, - this._k(key) - ); + return Object.prototype.hasOwnProperty.call(this._dict, this._k(key)); } // Iterate through the items in this dictionary in order, calling @@ -118,10 +106,10 @@ export class OrderedDict { // Stops whenever iter returns OrderedDict.BREAK, or after the last element. forEach(iter, context = null) { - var i = 0; - var elt = this._first; + let i = 0; + let elt = this._first; while (elt !== null) { - var b = iter.call(context, elt.value, elt.key, i); + const b = iter.call(context, elt.value, elt.key, i); if (b === OrderedDict.BREAK) return; elt = elt.next; i++; @@ -169,32 +157,31 @@ export class OrderedDict { prev(key) { if (this.has(key)) { - var elt = this._dict[this._k(key)]; - if (elt.prev) - return elt.prev.key; + const elt = this._dict[this._k(key)]; + if (elt.prev) return elt.prev.key; } return null; } next(key) { if (this.has(key)) { - var elt = this._dict[this._k(key)]; - if (elt.next) - return elt.next.key; + const elt = this._dict[this._k(key)]; + if (elt.next) return elt.next.key; } return null; } moveBefore(key, before) { - var elt = this._dict[this._k(key)]; - var eltBefore = before ? this._dict[this._k(before)] : null; + const elt = this._dict[this._k(key)]; + const eltBefore = before ? this._dict[this._k(before)] : null; if (typeof elt === "undefined") { throw new Error("Item to move is not present"); } if (typeof eltBefore === "undefined") { throw new Error("Could not find element to move this one before"); } - if (eltBefore === elt.next) // no moving necessary + if (eltBefore === elt.next) + // no moving necessary return; // remove from its old place this._linkEltOut(elt); @@ -205,7 +192,7 @@ export class OrderedDict { // Linear, sadly. indexOf(key) { - var ret = null; + let ret = null; this.forEach((v, k, i) => { if (this._k(k) === this._k(key)) { ret = i; @@ -217,7 +204,7 @@ export class OrderedDict { } _checkRep() { - Object.keys(this._dict).forEach(k => { + Object.keys(this._dict).forEach((k) => { const v = this._dict[k]; if (v.next === v) { throw new Error("Next is a loop"); @@ -229,4 +216,4 @@ export class OrderedDict { } } -OrderedDict.BREAK = {"break": true}; +OrderedDict.BREAK = { break: true }; diff --git a/packages/ordered-dict/package.js b/packages/ordered-dict/package.js index b83fe76255..8cd855f58d 100644 --- a/packages/ordered-dict/package.js +++ b/packages/ordered-dict/package.js @@ -1,11 +1,16 @@ Package.describe({ summary: "Ordered traversable dictionary with a mutable ordering", - version: '1.2.0', - documentation: null + version: "1.2.0", + documentation: null, }); Package.onUse(function (api) { - api.use('ecmascript'); - api.mainModule('ordered_dict.js'); - api.export('OrderedDict'); + api.use("ecmascript"); + api.mainModule("ordered_dict.js"); + api.export("OrderedDict"); +}); + +Package.onTest(function (api) { + api.use(["ecmascript", "tinytest", "ordered-dict"]); + api.addFiles("ordered-dict-tests.js", ["client", "server"]); }); diff --git a/packages/rate-limit/package.js b/packages/rate-limit/package.js index 6c99804705..96a9ec26e2 100644 --- a/packages/rate-limit/package.js +++ b/packages/rate-limit/package.js @@ -1,29 +1,29 @@ Package.describe({ - name: 'rate-limit', - version: '1.2.0-beta350.7', + name: "rate-limit", + version: "1.2.0-beta350.7", // Brief, one-line summary of the package. - summary: 'An algorithm for rate limiting anything', + summary: "An algorithm for rate limiting anything", // URL to the Git repository containing the source code for this package. - git: '', + git: "", // By default, Meteor will default to using README.md for documentation. // To avoid submitting documentation, set this field to null. - documentation: 'README.md', + documentation: "README.md", }); Package.onUse(function (api) { - api.use('random'); - api.use('ecmascript'); - api.mainModule('rate-limit.js'); - api.export('RateLimiter'); + api.use("random"); + api.use("ecmascript"); + api.mainModule("rate-limit.js"); + api.export("RateLimiter"); }); Package.onTest(function (api) { - api.use('test-helpers', ['client', 'server']); - api.use('ecmascript'); - api.use('random'); - api.use('ddp-rate-limiter'); - api.use('tinytest'); - api.use('rate-limit'); - api.use('ddp-common'); - api.mainModule('rate-limit-tests.js'); + api.use("test-helpers", ["client", "server"]); + api.use("ecmascript"); + api.use("random"); + api.use("ddp-rate-limiter"); + api.use("tinytest"); + api.use("rate-limit"); + api.use("ddp-common"); + api.mainModule("rate-limit-tests.js"); }); diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js index 5d22a58ae6..bd67cbad5f 100644 --- a/packages/rate-limit/rate-limit-tests.js +++ b/packages/rate-limit/rate-limit-tests.js @@ -19,44 +19,39 @@ // XXX These tests should be refactored to use Tinytest.add instead of // testAsyncMulti as they're all on the server. Any future tests should be // written that way. -import { Meteor } from 'meteor/meteor'; -import { RateLimiter } from 'meteor/rate-limit'; -import { DDPCommon } from 'meteor/ddp-common'; +import { Meteor } from "meteor/meteor"; +import { RateLimiter } from "meteor/rate-limit"; +import { DDPCommon } from "meteor/ddp-common"; -Tinytest.add('rate limit tests - Check empty constructor creation', +Tinytest.add("rate limit tests - Check empty constructor creation", function (test) { + const r = new RateLimiter(); + test.equal(r.rules, {}); +}); + +Tinytest.add( + "rate limit tests - Check single rule with multiple " + "invocations, only 1 that matches", function (test) { const r = new RateLimiter(); - test.equal(r.rules, {}); + const userIdOne = 1; + const restrictJustUserIdOneRule = { + userId: userIdOne, + IPAddr: null, + method: null, + }; + r.addRule(restrictJustUserIdOneRule, 1, 1000); + const connectionHandle = createTempConnectionHandle(123, "127.0.0.1"); + const methodInvc1 = createTempMethodInvocation(userIdOne, connectionHandle, "login"); + const methodInvc2 = createTempMethodInvocation(2, connectionHandle, "login"); + for (let i = 0; i < 2; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); + } + test.equal(r.check(methodInvc1).allowed, false); + test.equal(r.check(methodInvc2).allowed, true); }, ); -Tinytest.add('rate limit tests - Check single rule with multiple ' + - 'invocations, only 1 that matches', -function (test) { - const r = new RateLimiter(); - const userIdOne = 1; - const restrictJustUserIdOneRule = { - userId: userIdOne, - IPAddr: null, - method: null, - }; - r.addRule(restrictJustUserIdOneRule, 1, 1000); - const connectionHandle = createTempConnectionHandle(123, '127.0.0.1'); - const methodInvc1 = createTempMethodInvocation(userIdOne, connectionHandle, - 'login'); - const methodInvc2 = createTempMethodInvocation(2, connectionHandle, - 'login'); - for (let i = 0; i < 2; i++) { - r.increment(methodInvc1); - r.increment(methodInvc2); - } - test.equal(r.check(methodInvc1).allowed, false); - test.equal(r.check(methodInvc2).allowed, true); -}, -); - -testAsyncMulti('rate limit tests - Run multiple invocations and wait for one' + - ' to reset', [ +testAsyncMulti("rate limit tests - Run multiple invocations and wait for one" + " to reset", [ function (test, expect) { this.r = new RateLimiter(); this.userIdOne = 1; @@ -67,18 +62,19 @@ testAsyncMulti('rate limit tests - Run multiple invocations and wait for one' + method: null, }; this.r.addRule(this.restrictJustUserIdOneRule, 1, 500); - this.connectionHandle = createTempConnectionHandle(123, '127.0.0.1') - this.methodInvc1 = createTempMethodInvocation(this.userIdOne, - this.connectionHandle, 'login'); - this.methodInvc2 = createTempMethodInvocation(this.userIdTwo, - this.connectionHandle, 'login'); + this.connectionHandle = createTempConnectionHandle(123, "127.0.0.1"); + this.methodInvc1 = createTempMethodInvocation(this.userIdOne, this.connectionHandle, "login"); + this.methodInvc2 = createTempMethodInvocation(this.userIdTwo, this.connectionHandle, "login"); for (let i = 0; i < 2; i++) { this.r.increment(this.methodInvc1); this.r.increment(this.methodInvc2); } test.equal(this.r.check(this.methodInvc1).allowed, false); test.equal(this.r.check(this.methodInvc2).allowed, true); - Meteor.setTimeout(expect(function () { }), 1000); + Meteor.setTimeout( + expect(function () {}), + 1000, + ); }, function (test) { for (let i = 0; i < 100; i++) { @@ -89,74 +85,73 @@ testAsyncMulti('rate limit tests - Run multiple invocations and wait for one' + }, ]); -Tinytest.add('rate limit tests - Check two rules that affect same methodInvc' + - ' still throw', function (test) { - const r = new RateLimiter(); - const loginMethodRule = { - userId: null, - IPAddr: null, - method: 'login', - }; - const onlyLimitEvenUserIdRule = { - userId: userId => userId % 2 === 0, - IPAddr: null, - method: null, - }; - r.addRule(loginMethodRule, 10, 100); - r.addRule(onlyLimitEvenUserIdRule, 4, 100); - const connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); - const methodInvc1 = createTempMethodInvocation(1, connectionHandle, - 'login'); - const methodInvc2 = createTempMethodInvocation(2, connectionHandle, - 'login'); - const methodInvc3 = createTempMethodInvocation(3, connectionHandle, - 'test'); - for (let i = 0; i < 5; i++) { +Tinytest.add( + "rate limit tests - Check two rules that affect same methodInvc" + " still throw", + function (test) { + const r = new RateLimiter(); + const loginMethodRule = { + userId: null, + IPAddr: null, + method: "login", + }; + const onlyLimitEvenUserIdRule = { + userId: (userId) => userId % 2 === 0, + IPAddr: null, + method: null, + }; + r.addRule(loginMethodRule, 10, 100); + r.addRule(onlyLimitEvenUserIdRule, 4, 100); + const connectionHandle = createTempConnectionHandle(1234, "127.0.0.1"); + const methodInvc1 = createTempMethodInvocation(1, connectionHandle, "login"); + const methodInvc2 = createTempMethodInvocation(2, connectionHandle, "login"); + const methodInvc3 = createTempMethodInvocation(3, connectionHandle, "test"); + for (let i = 0; i < 5; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); + r.increment(methodInvc3); + } + // After for loop runs, we only have 10 runs, so that's under the limit + test.equal(r.check(methodInvc1).allowed, true); + // However, this triggers userId rule since this userId is even + test.equal(r.check(methodInvc2).allowed, false); + test.equal(r.check(methodInvc2).allowed, false); + // Running one more test causes it to be false, since we're at 11 now. r.increment(methodInvc1); - r.increment(methodInvc2); - r.increment(methodInvc3); - } - // After for loop runs, we only have 10 runs, so that's under the limit - test.equal(r.check(methodInvc1).allowed, true); - // However, this triggers userId rule since this userId is even - test.equal(r.check(methodInvc2).allowed, false); - test.equal(r.check(methodInvc2).allowed, false); - // Running one more test causes it to be false, since we're at 11 now. - r.increment(methodInvc1); - test.equal(r.check(methodInvc1).allowed, false); - // 3rd Method Invocation isn't affected by either rules. - test.equal(r.check(methodInvc3).allowed, true); -}); + test.equal(r.check(methodInvc1).allowed, false); + // 3rd Method Invocation isn't affected by either rules. + test.equal(r.check(methodInvc3).allowed, true); + }, +); -Tinytest.add('rate limit tests - Check one rule affected by two different ' + - 'invocations', function (test) { - const r = new RateLimiter(); - const loginMethodRule = { - userId: null, - IPAddr: null, - method: 'login', - }; - r.addRule(loginMethodRule, 10, 10000); +Tinytest.add( + "rate limit tests - Check one rule affected by two different " + "invocations", + function (test) { + const r = new RateLimiter(); + const loginMethodRule = { + userId: null, + IPAddr: null, + method: "login", + }; + r.addRule(loginMethodRule, 10, 10000); - const connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); - const methodInvc1 = createTempMethodInvocation(1, connectionHandle, - 'login'); - const methodInvc2 = createTempMethodInvocation(2, connectionHandle, - 'login'); + const connectionHandle = createTempConnectionHandle(1234, "127.0.0.1"); + const methodInvc1 = createTempMethodInvocation(1, connectionHandle, "login"); + const methodInvc2 = createTempMethodInvocation(2, connectionHandle, "login"); - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 5; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); + } + // This throws us over the limit since both increment the login rule + // counter r.increment(methodInvc1); - r.increment(methodInvc2); - } - // This throws us over the limit since both increment the login rule - // counter - r.increment(methodInvc1); - test.equal(r.check(methodInvc1).allowed, false); - test.equal(r.check(methodInvc2).allowed, false); -}); + test.equal(r.check(methodInvc1).allowed, false); + test.equal(r.check(methodInvc2).allowed, false); + }, +); -Tinytest.add('rate limit tests - add global rule', function (test) { +Tinytest.add("rate limit tests - add global rule", function (test) { const r = new RateLimiter(); const globalRule = { userId: null, @@ -165,15 +160,12 @@ Tinytest.add('rate limit tests - add global rule', function (test) { }; r.addRule(globalRule, 1, 10000); - const connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); - const connectionHandle2 = createTempConnectionHandle(1234, '127.0.0.2'); + const connectionHandle = createTempConnectionHandle(1234, "127.0.0.1"); + const connectionHandle2 = createTempConnectionHandle(1234, "127.0.0.2"); - const methodInvc1 = createTempMethodInvocation(1, connectionHandle, - 'login'); - const methodInvc2 = createTempMethodInvocation(2, connectionHandle2, - 'test'); - const methodInvc3 = createTempMethodInvocation(3, connectionHandle, - 'user-accounts'); + const methodInvc1 = createTempMethodInvocation(1, connectionHandle, "login"); + const methodInvc2 = createTempMethodInvocation(2, connectionHandle2, "test"); + const methodInvc3 = createTempMethodInvocation(3, connectionHandle, "user-accounts"); // First invocation, all methods would still be allowed. r.increment(methodInvc2); @@ -187,55 +179,52 @@ Tinytest.add('rate limit tests - add global rule', function (test) { test.equal(r.check(methodInvc3).allowed, false); }); -Tinytest.add('rate limit tests - Fuzzy rule match does not trigger rate limit', - function (test) { - const r = new RateLimiter(); - const rule = { - a: inp => inp % 3 === 0, - b: 5, - c: 'hi', - }; - r.addRule(rule, 1, 10000); - const input = { - a: 3, - b: 5, - }; - for (let i = 0; i < 5; i++) { - r.increment(input); - } - test.equal(r.check(input).allowed, true); - const matchingInput = { - a: 3, - b: 5, - c: 'hi', - d: 1, - }; - r.increment(matchingInput); - r.increment(matchingInput); - // Past limit so should be false - test.equal(r.check(matchingInput).allowed, false); - - // Add secondary rule and check that longer time is returned when multiple - // rules limits are hit - const newRule = { - a: inp => inp % 3 === 0, - b: 5, - c: 'hi', - d: 1, - }; - r.addRule(newRule, 1, 10); - // First rule should still throw while second rule will trigger as well, - // causing us to return longer time to reset to user - r.increment(matchingInput); - r.increment(matchingInput); - test.equal(r.check(matchingInput).timeToReset > 50, true); - }, -); +Tinytest.add("rate limit tests - Fuzzy rule match does not trigger rate limit", function (test) { + const r = new RateLimiter(); + const rule = { + a: (inp) => inp % 3 === 0, + b: 5, + c: "hi", + }; + r.addRule(rule, 1, 10000); + const input = { + a: 3, + b: 5, + }; + for (let i = 0; i < 5; i++) { + r.increment(input); + } + test.equal(r.check(input).allowed, true); + const matchingInput = { + a: 3, + b: 5, + c: "hi", + d: 1, + }; + r.increment(matchingInput); + r.increment(matchingInput); + // Past limit so should be false + test.equal(r.check(matchingInput).allowed, false); + // Add secondary rule and check that longer time is returned when multiple + // rules limits are hit + const newRule = { + a: (inp) => inp % 3 === 0, + b: 5, + c: "hi", + d: 1, + }; + r.addRule(newRule, 1, 10); + // First rule should still throw while second rule will trigger as well, + // causing us to return longer time to reset to user + r.increment(matchingInput); + r.increment(matchingInput); + test.equal(r.check(matchingInput).timeToReset > 50, true); +}); /****** Test Our Helper Methods *****/ -Tinytest.add('rate limit tests - test matchRule method', function (test) { +Tinytest.add("rate limit tests - test matchRule method", function (test) { const r = new RateLimiter(); const globalRule = { userId: null, @@ -247,9 +236,9 @@ Tinytest.add('rate limit tests - test matchRule method', function (test) { const rateLimiterInput = { userId: 1023, - IPAddr: '127.0.0.1', - type: 'sub', - name: 'getSubLists', + IPAddr: "127.0.0.1", + type: "sub", + name: "getSubLists", }; test.equal(r.rules[globalRuleId].match(rateLimiterInput), true); @@ -269,54 +258,52 @@ Tinytest.add('rate limit tests - test matchRule method', function (test) { const notCompleteInput = { userId: 102, - IPAddr: '127.0.0.1', + IPAddr: "127.0.0.1", }; test.equal(r.rules[globalRuleId].match(notCompleteInput), true); test.equal(r.rules[oneNotNullId].match(notCompleteInput), false); }); -Tinytest.add('rate limit tests - test generateMethodKey string', - function (test) { - const r = new RateLimiter(); - const globalRule = { - userId: null, - IPAddr: null, - type: null, - name: null, - }; - const globalRuleId = r.addRule(globalRule); +Tinytest.add("rate limit tests - test generateMethodKey string", function (test) { + const r = new RateLimiter(); + const globalRule = { + userId: null, + IPAddr: null, + type: null, + name: null, + }; + const globalRuleId = r.addRule(globalRule); - const rateLimiterInput = { - userId: 1023, - IPAddr: '127.0.0.1', - type: 'sub', - name: 'getSubLists', - }; + const rateLimiterInput = { + userId: 1023, + IPAddr: "127.0.0.1", + type: "sub", + name: "getSubLists", + }; - test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), ''); - globalRule.userId = 1023; + test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), ""); + globalRule.userId = 1023; - test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), - 'userId1023'); + test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), "userId1023"); - const ruleWithFuncs = { - userId: input => input % 2 === 0, - IPAddr: null, - type: null, - }; - const funcRuleId = r.addRule(ruleWithFuncs); - test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), ''); - rateLimiterInput.userId = 1024; - test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), - 'userId1024'); + const ruleWithFuncs = { + userId: (input) => input % 2 === 0, + IPAddr: null, + type: null, + }; + const funcRuleId = r.addRule(ruleWithFuncs); + test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), ""); + rateLimiterInput.userId = 1024; + test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), "userId1024"); - const multipleRules = ruleWithFuncs; - multipleRules.IPAddr = '127.0.0.1'; - const multipleRuleId = r.addRule(multipleRules); - test.equal(r.rules[multipleRuleId]._generateKeyString(rateLimiterInput), - 'userId1024IPAddr127.0.0.1'); - }, -); + const multipleRules = ruleWithFuncs; + multipleRules.IPAddr = "127.0.0.1"; + const multipleRuleId = r.addRule(multipleRules); + test.equal( + r.rules[multipleRuleId]._generateKeyString(rateLimiterInput), + "userId1024IPAddr127.0.0.1", + ); +}); function createTempConnectionHandle(id, clientIP) { return { @@ -325,7 +312,7 @@ function createTempConnectionHandle(id, clientIP) { this.close(); }, onClose(fn) { - const cb = Meteor.bindEnvironment(fn, 'connection onClose callback'); + const cb = Meteor.bindEnvironment(fn, "connection onClose callback"); if (this.inQueue) { this._closeCallbacks.push(cb); } else { diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js index ab6ff30299..f33babc2e4 100644 --- a/packages/rate-limit/rate-limit.js +++ b/packages/rate-limit/rate-limit.js @@ -1,5 +1,5 @@ -import { Meteor } from 'meteor/meteor'; -import { Random } from 'meteor/random'; +import { Meteor } from "meteor/meteor"; +import { Random } from "meteor/random"; // Default time interval (in milliseconds) to reset rate limit counters const DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; @@ -39,38 +39,36 @@ class Rule { // rule.matchers. If the match fails, search short circuits instead of // iterating through all matchers. match(input) { - return Object - .entries(this._matchers) - .every(([key, matcher]) => { - if (matcher !== null) { - if (!hasOwn.call(input, key)) { - return false; - } else if (typeof matcher === 'function') { - if (!(matcher(input[key]))) { - return false; - } - } else if (matcher !== input[key]) { + return Object.entries(this._matchers).every(([key, matcher]) => { + if (matcher !== null) { + if (!hasOwn.call(input, key)) { + return false; + } else if (typeof matcher === "function") { + if (!matcher(input[key])) { return false; } + } else if (matcher !== input[key]) { + return false; } - return true; - }); + } + return true; + }); } async matchAsync(input) { - for (const [key, matcher] of Object.entries(this._matchers)) { - if (matcher !== null) { - if (!hasOwn.call(input, key)) { - return false; - } else if (typeof matcher === 'function') { - if (!(await matcher(input[key]))) { - return false; - } - } else if (matcher !== input[key]) { + for (const [key, matcher] of Object.entries(this._matchers)) { + if (matcher !== null) { + if (!hasOwn.call(input, key)) { + return false; + } else if (typeof matcher === "function") { + if (!(await matcher(input[key]))) { return false; } + } else if (matcher !== input[key]) { + return false; } - }; + } + } return true; } @@ -81,7 +79,7 @@ class Rule { return Object.entries(this._matchers) .filter(([key]) => this._matchers[key] !== null) .reduce((returnString, [key, matcher]) => { - if (typeof matcher === 'function') { + if (typeof matcher === "function") { if (matcher(input[key])) { returnString += key + input[key]; } @@ -89,7 +87,7 @@ class Rule { returnString += key + input[key]; } return returnString; - }, ''); + }, ""); } // Applies the provided input and returns the key string, time since counters @@ -137,19 +135,19 @@ class RateLimiter { } /** - * Checks if this input has exceeded any rate limits. - * @param {object} input dictionary containing key-value pairs of attributes - * that match to rules - * @return {object} Returns object of following structure - * { 'allowed': boolean - is this input allowed - * 'timeToReset': integer | Infinity - returns time until counters are reset - * in milliseconds - * 'numInvocationsLeft': integer | Infinity - returns number of calls left - * before limit is reached - * } - * If multiple rules match, the least number of invocations left is returned. - * If the rate limit has been reached, the longest timeToReset is returned. - */ + * Checks if this input has exceeded any rate limits. + * @param {object} input dictionary containing key-value pairs of attributes + * that match to rules + * @return {object} Returns object of following structure + * { 'allowed': boolean - is this input allowed + * 'timeToReset': integer | Infinity - returns time until counters are reset + * in milliseconds + * 'numInvocationsLeft': integer | Infinity - returns number of calls left + * before limit is reached + * } + * If multiple rules match, the least number of invocations left is returned. + * If the rate limit has been reached, the longest timeToReset is returned. + */ check(input) { const reply = { allowed: true, @@ -185,8 +183,7 @@ class RateLimiter { if (ruleResult.timeToNextReset < 0) { // Reset all the counters since the rule has reset rule.resetCounter(); - ruleResult.timeSinceLastReset = new Date().getTime() - - rule._lastResetTime; + ruleResult.timeSinceLastReset = new Date().getTime() - rule._lastResetTime; ruleResult.timeToNextReset = rule.options.intervalTime; numInvocations = 0; } @@ -206,11 +203,12 @@ class RateLimiter { } else { // If this is an allowed attempt and we haven't failed on any of the // other rules that match, update the reply field. - if (rule.options.numRequestsAllowed - numInvocations < - reply.numInvocationsLeft && reply.allowed) { + if ( + rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && + reply.allowed + ) { reply.timeToReset = ruleResult.timeToNextReset; - reply.numInvocationsLeft = rule.options.numRequestsAllowed - - numInvocations; + reply.numInvocationsLeft = rule.options.numRequestsAllowed - numInvocations; } reply.ruleId = rule.id; rule._executeCallback(reply, input); @@ -218,31 +216,31 @@ class RateLimiter { } /** - * Adds a rule to dictionary of rules that are checked against on every call. - * Only inputs that pass all of the rules will be allowed. Returns unique rule - * id that can be passed to `removeRule`. - * @param {object} rule Input dictionary defining certain attributes and - * rules associated with them. - * Each attribute's value can either be a value, a function or null. All - * functions must return a boolean of whether the input is matched by that - * attribute's rule or not - * @param {integer} numRequestsAllowed Optional. Number of events allowed per - * interval. Default = 10. - * @param {integer} intervalTime Optional. Number of milliseconds before - * rule's counters are reset. Default = 1000. - * @param {function} callback Optional. Function to be called after a - * rule is executed. Two objects will be passed to this function. - * The first one is the result of RateLimiter.prototype.check - * The second is the input object of the rule, it has the following structure: - * { - * 'type': string - either 'method' or 'subscription' - * 'name': string - the name of the method or subscription being called - * 'userId': string - the user ID attempting the method or subscription - * 'connectionId': string - a string representing the user's DDP connection - * 'clientAddress': string - the IP address of the user - * } - * @return {string} Returns unique rule id - */ + * Adds a rule to dictionary of rules that are checked against on every call. + * Only inputs that pass all of the rules will be allowed. Returns unique rule + * id that can be passed to `removeRule`. + * @param {object} rule Input dictionary defining certain attributes and + * rules associated with them. + * Each attribute's value can either be a value, a function or null. All + * functions must return a boolean of whether the input is matched by that + * attribute's rule or not + * @param {integer} numRequestsAllowed Optional. Number of events allowed per + * interval. Default = 10. + * @param {integer} intervalTime Optional. Number of milliseconds before + * rule's counters are reset. Default = 1000. + * @param {function} callback Optional. Function to be called after a + * rule is executed. Two objects will be passed to this function. + * The first one is the result of RateLimiter.prototype.check + * The second is the input object of the rule, it has the following structure: + * { + * 'type': string - either 'method' or 'subscription' + * 'name': string - the name of the method or subscription being called + * 'userId': string - the user ID attempting the method or subscription + * 'connectionId': string - a string representing the user's DDP connection + * 'clientAddress': string - the IP address of the user + * } + * @return {string} Returns unique rule id + */ addRule(rule, numRequestsAllowed, intervalTime, callback) { const options = { numRequestsAllowed: numRequestsAllowed || DEFAULT_REQUESTS_PER_INTERVAL, @@ -256,10 +254,10 @@ class RateLimiter { } /** - * Increment counters in every rule that match to this input - * @param {object} input Dictionary object containing attributes that may - * match to rules - */ + * Increment counters in every rule that match to this input + * @param {object} input Dictionary object containing attributes that may + * match to rules + */ increment(input) { // Only increment rule counters that match this input const matchedRules = this._findAllMatchingRules(input); @@ -268,11 +266,11 @@ class RateLimiter { } /** - * Increment counters in every rule that match to this input - * @param {array} rules Array of rules to increment - * @param {object} input Dictionary object containing attributes that may - * match to rules - */ + * Increment counters in every rule that match to this input + * @param {array} rules Array of rules to increment + * @param {object} input Dictionary object containing attributes that may + * match to rules + */ incrementRules(rules, input) { const _incrementForInput = (rule) => this._incrementRule(rule, input); rules.forEach(_incrementForInput); @@ -297,7 +295,7 @@ class RateLimiter { // Returns an array of all rules that apply to provided input _findAllMatchingRules(input) { - return Object.values(this.rules).filter(rule => rule.match(input)); + return Object.values(this.rules).filter((rule) => rule.match(input)); } async _findAllMatchingRulesAsync(input) { diff --git a/packages/retry/package.js b/packages/retry/package.js index fbcf575997..d928597f4a 100644 --- a/packages/retry/package.js +++ b/packages/retry/package.js @@ -1,11 +1,16 @@ Package.describe({ summary: "Retry logic with exponential backoff", - version: '1.1.1', + version: "1.1.1", }); Package.onUse(function (api) { - api.use('ecmascript'); - api.use('random'); - api.mainModule('retry.js'); - api.export('Retry'); + api.use("ecmascript"); + api.use("random"); + api.mainModule("retry.js"); + api.export("Retry"); +}); + +Package.onTest(function (api) { + api.use(["ecmascript", "tinytest", "random", "retry"]); + api.addFiles("retry-tests.js", ["client", "server"]); }); diff --git a/packages/retry/retry-tests.js b/packages/retry/retry-tests.js new file mode 100644 index 0000000000..b2318bf2cc --- /dev/null +++ b/packages/retry/retry-tests.js @@ -0,0 +1,108 @@ +import { Retry } from "./retry.js"; + +const noop = () => {}; + +Tinytest.add("retry - retryLater with count < minCount returns minTimeout", (test) => { + const retry = new Retry({ minCount: 3, minTimeout: 42 }); + try { + test.equal(retry.retryLater(0, noop), 42); + retry.clear(); + test.equal(retry.retryLater(1, noop), 42); + retry.clear(); + test.equal(retry.retryLater(2, noop), 42); + } finally { + retry.clear(); + } +}); + +Tinytest.add("retry - retryLater with count >= minCount respects fuzz bounds", (test) => { + const retry = new Retry({ + baseTimeout: 1000, + exponent: 2, + fuzz: 0.5, + minCount: 0, + maxTimeout: 1e9, + }); + + // Unfuzzed value at count=2 is 1000 * 2^2 = 4000. + // Fuzz factor: random * 0.5 + 0.75 ∈ [0.75, 1.25]. + // Admissible window: [3000, 5000]. + try { + for (let i = 0; i < 20; i++) { + const timeout = retry.retryLater(2, noop); + retry.clear(); + test.isTrue( + timeout >= 3000 && timeout <= 5000, + `sample ${i}: expected [3000, 5000], got ${timeout}`, + ); + } + } finally { + retry.clear(); + } +}); + +Tinytest.add("retry - retryLater caps at maxTimeout", (test) => { + const retry = new Retry({ + maxTimeout: 100, + baseTimeout: 1000, + exponent: 10, + fuzz: 0, + minCount: 0, + }); + try { + // Unfuzzed value at count=5 is 1000 * 10^5 = 1e8, capped to 100. + // With fuzz=0: factor is (random * 0 + 1) = 1. Result: 100. + test.equal(retry.retryLater(5, noop), 100); + } finally { + retry.clear(); + } +}); + +Tinytest.addAsync("retry - retryLater actually fires the callback", async (test) => { + const retry = new Retry({ minCount: 1, minTimeout: 1, fuzz: 0 }); + await new Promise((resolve) => { + retry.retryLater(0, resolve); + }); + test.ok(); + retry.clear(); +}); + +Tinytest.addAsync("retry - clear cancels pending callback", async (test) => { + const retry = new Retry({ minCount: 1, minTimeout: 1000 }); + let fired = false; + retry.retryLater(0, () => { + fired = true; + }); + retry.clear(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + test.isFalse(fired, "cancelled callback should not fire"); +}); + +Tinytest.addAsync("retry - retryLater replaces pending timer", async (test) => { + const retry = new Retry({ minCount: 1, minTimeout: 1, fuzz: 0 }); + let firedFirst = false; + let firedSecond = false; + + retry.retryLater(0, () => { + firedFirst = true; + }); + retry.retryLater(0, () => { + firedSecond = true; + }); + + // Wait long enough that both timers would have fired if not cancelled. + await new Promise((resolve) => setTimeout(resolve, 30)); + + test.isFalse(firedFirst, "first callback should have been replaced"); + test.isTrue(firedSecond, "second callback should have fired"); + retry.clear(); +}); + +Tinytest.add("retry - clear is idempotent", (test) => { + const retry = new Retry(); + retry.clear(); + retry.clear(); + retry.clear(); + test.ok(); +}); diff --git a/packages/retry/retry.js b/packages/retry/retry.js index 48e62adc4a..53c588e07b 100644 --- a/packages/retry/retry.js +++ b/packages/retry/retry.js @@ -45,21 +45,17 @@ export class Retry { // fuzz the timeout randomly, to avoid reconnect storms when a // server goes down. - var timeout = Math.min( - this.maxTimeout, - this.baseTimeout * Math.pow(this.exponent, count) - ) * ( - Random.fraction() * this.fuzz + (1 - this.fuzz / 2) - ); + const timeout = + Math.min(this.maxTimeout, this.baseTimeout * Math.pow(this.exponent, count)) * + (Random.fraction() * this.fuzz + (1 - this.fuzz / 2)); return timeout; } // Call `fn` after a delay, based on the `count` of which retry this is. retryLater(count, fn) { - var timeout = this._timeout(count); - if (this.retryTimer) - clearTimeout(this.retryTimer); + const timeout = this._timeout(count); + if (this.retryTimer) clearTimeout(this.retryTimer); this.retryTimer = Meteor.setTimeout(fn, timeout); return timeout; } diff --git a/packages/session/package.js b/packages/session/package.js index 2ad690811f..8fbe9a66f4 100644 --- a/packages/session/package.js +++ b/packages/session/package.js @@ -1,26 +1,27 @@ Package.describe({ summary: "Session variable", - version: '1.2.2', + version: "1.2.2", }); Package.onUse(function (api) { - api.use(['ecmascript', 'reactive-dict'], 'client'); + api.use(["ecmascript", "reactive-dict"], "client"); // Session can work with or without reload, but if reload is present // it should load first so we can detect it at startup and populate // the session. - api.use('reload', 'client', {weak: true}); + api.use("reload", "client", { weak: true }); - api.export('Session', 'client'); - api.mainModule('session.js', 'client'); - api.addAssets('session.d.ts', 'server'); + api.export("Session", "client"); + api.mainModule("session.js", "client"); + api.addAssets("session.d.ts", "server"); }); Package.onTest(function (api) { - api.use('ecmascript'); - api.use('tinytest'); - api.use('session', 'client'); - api.use('tracker'); - api.use('mongo'); - api.addFiles('session_tests.js', 'client'); + api.use("ecmascript"); + api.use("tinytest"); + api.use("session", "client"); + api.use("reactive-dict", "client"); + api.use("tracker"); + api.use("mongo"); + api.addFiles("session_tests.js", "client"); }); diff --git a/packages/session/session.d.ts b/packages/session/session.d.ts index 6e5afd4420..96c5b5cd49 100644 --- a/packages/session/session.d.ts +++ b/packages/session/session.d.ts @@ -1,4 +1,4 @@ -import { EJSONable } from 'meteor/ejson'; +import { EJSONable } from "meteor/ejson"; export namespace Session { /** diff --git a/packages/session/session.js b/packages/session/session.js index 3eda8d7edd..790d9054ba 100644 --- a/packages/session/session.js +++ b/packages/session/session.js @@ -1,6 +1,6 @@ -import { ReactiveDict } from 'meteor/reactive-dict'; +import { ReactiveDict } from "meteor/reactive-dict"; -export const Session = new ReactiveDict('session'); +export const Session = new ReactiveDict("session"); // Documentation here is really awkward because the methods are defined // elsewhere diff --git a/packages/session/session_tests.js b/packages/session/session_tests.js index 3b004c344d..0b587a2168 100644 --- a/packages/session/session_tests.js +++ b/packages/session/session_tests.js @@ -1,194 +1,240 @@ -import { Session } from 'meteor/session'; +import { Session } from "meteor/session"; +import { ReactiveDict } from "meteor/reactive-dict"; -Tinytest.add('session - setDefault', function (test) { - Session.setDefault('def', "argyle"); - test.equal(Session.get('def'), "argyle"); - Session.set('def', "noodle"); - test.equal(Session.get('def'), "noodle"); - Session.set('nondef', "potato"); - test.equal(Session.get('nondef'), "potato"); - Session.setDefault('nondef', "eggs"); - test.equal(Session.get('nondef'), "potato"); +Tinytest.add("session - setDefault", function (test) { + Session.setDefault("def", "argyle"); + test.equal(Session.get("def"), "argyle"); + Session.set("def", "noodle"); + test.equal(Session.get("def"), "noodle"); + Session.set("nondef", "potato"); + test.equal(Session.get("nondef"), "potato"); + Session.setDefault("nondef", "eggs"); + test.equal(Session.get("nondef"), "potato"); // This is so the test passes the next time, after hot code push. I know it // doesn't return it to the completely untouched state, but we don't have // Session.clear() yet. When we do, this should be that. - delete Session.keys['def']; - delete Session.keys['nondef']; + delete Session.keys["def"]; + delete Session.keys["nondef"]; }); -Tinytest.add('session - get/set/equals types', function (test) { - test.equal(Session.get('u'), undefined); - test.isTrue(Session.equals('u', undefined)); - test.isFalse(Session.equals('u', null)); - test.isFalse(Session.equals('u', 0)); - test.isFalse(Session.equals('u', '')); +Tinytest.add("session - get/set/equals types", function (test) { + test.equal(Session.get("u"), undefined); + test.isTrue(Session.equals("u", undefined)); + test.isFalse(Session.equals("u", null)); + test.isFalse(Session.equals("u", 0)); + test.isFalse(Session.equals("u", "")); - Session.set('u', undefined); - test.equal(Session.get('u'), undefined); - test.isTrue(Session.equals('u', undefined)); - test.isFalse(Session.equals('u', null)); - test.isFalse(Session.equals('u', 0)); - test.isFalse(Session.equals('u', '')); - test.isFalse(Session.equals('u', 'undefined')); - test.isFalse(Session.equals('u', 'null')); + Session.set("u", undefined); + test.equal(Session.get("u"), undefined); + test.isTrue(Session.equals("u", undefined)); + test.isFalse(Session.equals("u", null)); + test.isFalse(Session.equals("u", 0)); + test.isFalse(Session.equals("u", "")); + test.isFalse(Session.equals("u", "undefined")); + test.isFalse(Session.equals("u", "null")); - Session.set('n', null); - test.equal(Session.get('n'), null); - test.isFalse(Session.equals('n', undefined)); - test.isTrue(Session.equals('n', null)); - test.isFalse(Session.equals('n', 0)); - test.isFalse(Session.equals('n', '')); - test.isFalse(Session.equals('n', 'undefined')); - test.isFalse(Session.equals('n', 'null')); + Session.set("n", null); + test.equal(Session.get("n"), null); + test.isFalse(Session.equals("n", undefined)); + test.isTrue(Session.equals("n", null)); + test.isFalse(Session.equals("n", 0)); + test.isFalse(Session.equals("n", "")); + test.isFalse(Session.equals("n", "undefined")); + test.isFalse(Session.equals("n", "null")); - Session.set('t', true); - test.equal(Session.get('t'), true); - test.isTrue(Session.equals('t', true)); - test.isFalse(Session.equals('t', false)); - test.isFalse(Session.equals('t', 1)); - test.isFalse(Session.equals('t', 'true')); + Session.set("t", true); + test.equal(Session.get("t"), true); + test.isTrue(Session.equals("t", true)); + test.isFalse(Session.equals("t", false)); + test.isFalse(Session.equals("t", 1)); + test.isFalse(Session.equals("t", "true")); - Session.set('f', false); - test.equal(Session.get('f'), false); - test.isFalse(Session.equals('f', true)); - test.isTrue(Session.equals('f', false)); - test.isFalse(Session.equals('f', 1)); - test.isFalse(Session.equals('f', 'false')); + Session.set("f", false); + test.equal(Session.get("f"), false); + test.isFalse(Session.equals("f", true)); + test.isTrue(Session.equals("f", false)); + test.isFalse(Session.equals("f", 1)); + test.isFalse(Session.equals("f", "false")); - Session.set('num', 0); - test.equal(Session.get('num'), 0); - test.isTrue(Session.equals('num', 0)); - test.isFalse(Session.equals('num', false)); - test.isFalse(Session.equals('num', '0')); - test.isFalse(Session.equals('num', 1)); + Session.set("num", 0); + test.equal(Session.get("num"), 0); + test.isTrue(Session.equals("num", 0)); + test.isFalse(Session.equals("num", false)); + test.isFalse(Session.equals("num", "0")); + test.isFalse(Session.equals("num", 1)); - Session.set('str', 'true'); - test.equal(Session.get('str'), 'true'); - test.isTrue(Session.equals('str', 'true')); - test.isFalse(Session.equals('str', true)); + Session.set("str", "true"); + test.equal(Session.get("str"), "true"); + test.isTrue(Session.equals("str", "true")); + test.isFalse(Session.equals("str", true)); - Session.set('arr', [1, 2, {a: 1, b: [5, 6]}]); - test.equal(Session.get('arr'), [1, 2, {b: [5, 6], a: 1}]); - test.isFalse(Session.equals('arr', 1)); - test.isFalse(Session.equals('arr', '[1,2,{"a":1,"b":[5,6]}]')); + Session.set("arr", [1, 2, { a: 1, b: [5, 6] }]); + test.equal(Session.get("arr"), [1, 2, { b: [5, 6], a: 1 }]); + test.isFalse(Session.equals("arr", 1)); + test.isFalse(Session.equals("arr", '[1,2,{"a":1,"b":[5,6]}]')); test.throws(function () { - Session.equals('arr', [1, 2, {a: 1, b: [5, 6]}]); + Session.equals("arr", [1, 2, { a: 1, b: [5, 6] }]); }); - Session.set('obj', {a: 1, b: [5, 6]}); - test.equal(Session.get('obj'), {b: [5, 6], a: 1}); - test.isFalse(Session.equals('obj', 1)); - test.isFalse(Session.equals('obj', '{"a":1,"b":[5,6]}')); - test.throws(function() { Session.equals('obj', {a: 1, b: [5, 6]}); }); + Session.set("obj", { a: 1, b: [5, 6] }); + test.equal(Session.get("obj"), { b: [5, 6], a: 1 }); + test.isFalse(Session.equals("obj", 1)); + test.isFalse(Session.equals("obj", '{"a":1,"b":[5,6]}')); + test.throws(function () { + Session.equals("obj", { a: 1, b: [5, 6] }); + }); + Session.set("date", new Date(1234)); + test.equal(Session.get("date"), new Date(1234)); + test.isFalse(Session.equals("date", new Date(3455))); + test.isTrue(Session.equals("date", new Date(1234))); - Session.set('date', new Date(1234)); - test.equal(Session.get('date'), new Date(1234)); - test.isFalse(Session.equals('date', new Date(3455))); - test.isTrue(Session.equals('date', new Date(1234))); - - Session.set('oid', new Mongo.ObjectID('ffffffffffffffffffffffff')); - test.equal(Session.get('oid'), new Mongo.ObjectID('ffffffffffffffffffffffff')); - test.isFalse(Session.equals('oid', new Mongo.ObjectID('fffffffffffffffffffffffa'))); - test.isTrue(Session.equals('oid', new Mongo.ObjectID('ffffffffffffffffffffffff'))); + Session.set("oid", new Mongo.ObjectID("ffffffffffffffffffffffff")); + test.equal(Session.get("oid"), new Mongo.ObjectID("ffffffffffffffffffffffff")); + test.isFalse(Session.equals("oid", new Mongo.ObjectID("fffffffffffffffffffffffa"))); + test.isTrue(Session.equals("oid", new Mongo.ObjectID("ffffffffffffffffffffffff"))); }); -Tinytest.add('session - objects are cloned', function (test) { - Session.set('frozen-array', [1, 2, 3]); - Session.get('frozen-array')[1] = 42; - test.equal(Session.get('frozen-array'), [1, 2, 3]); +Tinytest.add("session - objects are cloned", function (test) { + Session.set("frozen-array", [1, 2, 3]); + Session.get("frozen-array")[1] = 42; + test.equal(Session.get("frozen-array"), [1, 2, 3]); - Session.set('frozen-object', {a: 1, b: 2}); - Session.get('frozen-object').a = 43; - test.equal(Session.get('frozen-object'), {a: 1, b: 2}); + Session.set("frozen-object", { a: 1, b: 2 }); + Session.get("frozen-object").a = 43; + test.equal(Session.get("frozen-object"), { a: 1, b: 2 }); }); -Tinytest.add('session - context invalidation for get', function (test) { - var xGetExecutions = 0; +Tinytest.add("session - context invalidation for get", function (test) { + let xGetExecutions = 0; Tracker.autorun(function () { ++xGetExecutions; - Session.get('x'); + Session.get("x"); }); test.equal(xGetExecutions, 1); - Session.set('x', 1); + Session.set("x", 1); // Invalidation shouldn't happen until flush time. test.equal(xGetExecutions, 1); Tracker.flush(); test.equal(xGetExecutions, 2); // Setting to the same value doesn't re-run. - Session.set('x', 1); + Session.set("x", 1); Tracker.flush(); test.equal(xGetExecutions, 2); - Session.set('x', '1'); + Session.set("x", "1"); Tracker.flush(); test.equal(xGetExecutions, 3); }); -Tinytest.add('session - context invalidation for equals', function (test) { - var xEqualsExecutions = 0; +Tinytest.add("session - context invalidation for equals", function (test) { + let xEqualsExecutions = 0; Tracker.autorun(function () { ++xEqualsExecutions; - Session.equals('x', 5); + Session.equals("x", 5); }); test.equal(xEqualsExecutions, 1); - Session.set('x', 1); + Session.set("x", 1); Tracker.flush(); // Changing undefined -> 1 shouldn't affect equals(5). test.equal(xEqualsExecutions, 1); - Session.set('x', 5); + Session.set("x", 5); // Invalidation shouldn't happen until flush time. test.equal(xEqualsExecutions, 1); Tracker.flush(); test.equal(xEqualsExecutions, 2); - Session.set('x', 5); + Session.set("x", 5); Tracker.flush(); // Setting to the same value doesn't re-run. test.equal(xEqualsExecutions, 2); - Session.set('x', '5'); + Session.set("x", "5"); test.equal(xEqualsExecutions, 2); Tracker.flush(); test.equal(xEqualsExecutions, 3); - Session.set('x', 5); + Session.set("x", 5); test.equal(xEqualsExecutions, 3); Tracker.flush(); test.equal(xEqualsExecutions, 4); }); -Tinytest.add( - 'session - context invalidation for equals with undefined', - function (test) { - // Make sure the special casing for equals undefined works. - var yEqualsExecutions = 0; - Tracker.autorun(function () { - ++yEqualsExecutions; - Session.equals('y', undefined); - }); - test.equal(yEqualsExecutions, 1); - Session.set('y', undefined); - Tracker.flush(); - test.equal(yEqualsExecutions, 1); - Session.set('y', 5); - test.equal(yEqualsExecutions, 1); - Tracker.flush(); - test.equal(yEqualsExecutions, 2); - Session.set('y', 3); - Tracker.flush(); - test.equal(yEqualsExecutions, 2); - Session.set('y', 'undefined'); - Tracker.flush(); - test.equal(yEqualsExecutions, 2); - Session.set('y', undefined); - test.equal(yEqualsExecutions, 2); - Tracker.flush(); - test.equal(yEqualsExecutions, 3); +Tinytest.add("session - context invalidation for equals with undefined", function (test) { + // Make sure the special casing for equals undefined works. + let yEqualsExecutions = 0; + Tracker.autorun(function () { + ++yEqualsExecutions; + Session.equals("y", undefined); }); - -Tinytest.add('session - parse an object of key/value pairs', function (test) { - Session._setObject({fruit: 'apple', vegetable: 'potato'}); - - test.equal(Session.get('fruit'), 'apple'); - test.equal(Session.get('vegetable'), 'potato'); - - delete Session.keys['fruit']; - delete Session.keys['vegetable']; + test.equal(yEqualsExecutions, 1); + Session.set("y", undefined); + Tracker.flush(); + test.equal(yEqualsExecutions, 1); + Session.set("y", 5); + test.equal(yEqualsExecutions, 1); + Tracker.flush(); + test.equal(yEqualsExecutions, 2); + Session.set("y", 3); + Tracker.flush(); + test.equal(yEqualsExecutions, 2); + Session.set("y", "undefined"); + Tracker.flush(); + test.equal(yEqualsExecutions, 2); + Session.set("y", undefined); + test.equal(yEqualsExecutions, 2); + Tracker.flush(); + test.equal(yEqualsExecutions, 3); +}); + +Tinytest.add("session - parse an object of key/value pairs", function (test) { + Session._setObject({ fruit: "apple", vegetable: "potato" }); + + test.equal(Session.get("fruit"), "apple"); + test.equal(Session.get("vegetable"), "potato"); + + delete Session.keys["fruit"]; + delete Session.keys["vegetable"]; +}); + +Tinytest.add("session - Session is a ReactiveDict instance", function (test) { + test.instanceOf(Session, ReactiveDict); +}); + +Tinytest.add("session - _setObject triggers reactive invalidation", function (test) { + let runs = 0; + let lastFruit; + Tracker.autorun(function () { + runs++; + lastFruit = Session.get("so-fruit"); + }); + test.equal(runs, 1); + test.equal(lastFruit, undefined); + + Session._setObject({ "so-fruit": "apple", "so-veggie": "potato" }); + Tracker.flush(); + test.equal(runs, 2); + test.equal(lastFruit, "apple"); + + // Setting again with the same value should not re-run. + Session._setObject({ "so-fruit": "apple" }); + Tracker.flush(); + test.equal(runs, 2); + + delete Session.keys["so-fruit"]; + delete Session.keys["so-veggie"]; +}); + +Tinytest.add("session - setDefault on an already-set key does not invalidate", function (test) { + Session.set("sd-key", "original"); + let runs = 0; + Tracker.autorun(function () { + runs++; + Session.get("sd-key"); + }); + test.equal(runs, 1); + + // Key already exists — setDefault should be a no-op and NOT invalidate. + Session.setDefault("sd-key", "fallback"); + Tracker.flush(); + test.equal(runs, 1); + test.equal(Session.get("sd-key"), "original"); + + delete Session.keys["sd-key"]; }); diff --git a/packages/test-in-console/driver.js b/packages/test-in-console/driver.js index 4f66667d0d..b73bbf9302 100644 --- a/packages/test-in-console/driver.js +++ b/packages/test-in-console/driver.js @@ -11,104 +11,97 @@ TEST_STATUS = { DONE: false, FAILURES: 0, PASSED: null, - WHERE_FAILED: [] + WHERE_FAILED: [], }; // xUnit format uses XML output -var XML_CHAR_MAP = { - '<': '<', - '>': '>', - '&': '&', - '"': '"', - "'": ''' +const XML_CHAR_MAP = { + "<": "<", + ">": ">", + "&": "&", + '"': """, + "'": "'", }; // Escapes a string for insertion into XML -var escapeXml = function (s) { +const escapeXml = function (s) { return s.replace(/[<>&"']/g, function (c) { return XML_CHAR_MAP[c]; }); -} +}; // Returns a human name for a test -var getName = function (result) { - return (result.server ? "S: " : "C: ") + - result.groupPath.join(" - ") + " - " + result.test; +const getName = function (result) { + return `${result.server ? "S: " : "C: "}${result.groupPath.join(" - ")} - ${result.test}`; }; // Calls console.log, but returns silently if console.log is not available -var log = function (/*arguments*/) { - if (typeof console !== 'undefined') { +const log = function (/*arguments*/) { + if (typeof console !== "undefined") { console.log.apply(console, arguments); } }; -var MAGIC_PREFIX = '##_meteor_magic##'; +const MAGIC_PREFIX = "##_meteor_magic##"; // Write output so that other tools can read it // Output is sent to console.log, prefixed with the magic prefix and then the facility // By grepping for the prefix, other tools can get the 'special' output -var logMagic = function (facility, s) { - log(MAGIC_PREFIX + facility + ': ' + s); +const logMagic = function (facility, s) { + log(`${MAGIC_PREFIX}${facility}: ${s}`); }; // Logs xUnit output, if xunit output is enabled // This uses logMagic with a facility of xunit -var xunit = function (s) { +const xunit = function (s) { if (xunitEnabled) { - logMagic('xunit', s); + logMagic("xunit", s); } }; -var passed = 0; -var failed = 0; -var whereFailed = []; -var expected = 0; -var resultSet = {}; -var toReport = []; +let passed = 0; +let failed = 0; +const whereFailed = []; +let expected = 0; +const resultSet = {}; +let toReport = []; -var hrefPath = window.location.href.split("/"); -var platform = decodeURIComponent(hrefPath.length && hrefPath[hrefPath.length - 1]); -if (!platform) - platform = "local"; +const hrefPath = window.location.href.split("/"); +let platform = decodeURIComponent(hrefPath.length && hrefPath[hrefPath.length - 1]); +if (!platform) platform = "local"; // We enable xUnit output when platform is xunit -var xunitEnabled = (platform == 'xunit'); +const xunitEnabled = platform == "xunit"; -var doReport = Meteor && - Meteor.settings && - Meteor.settings.public && - Meteor.settings.public.runId; -var report = function (name, last) { +const doReport = + Meteor && Meteor.settings && Meteor.settings.public && Meteor.settings.public.runId; +const report = function (name, last) { if (doReport) { - var data = { + const data = { run_id: Meteor.settings.public.runId, testPath: resultSet[name].testPath, status: resultSet[name].status, platform: platform, server: resultSet[name].server, - fullName: name.substr(3) + fullName: name.substr(3), }; - if ((data.status === "FAIL" || data.status === "EXPECTED") && - !(Object.keys(resultSet[name].events).length === 0)) { + if ( + (data.status === "FAIL" || data.status === "EXPECTED") && + !(Object.keys(resultSet[name].events).length === 0) + ) { // only send events when bad things happen data.events = resultSet[name].events; } - if (last) - data.end = new Date(); - else - data.start = new Date(); + if (last) data.end = new Date(); + else data.start = new Date(); toReport.push(EJSON.toJSONValue(data)); } }; -var sendReports = function (callback) { - var reports = toReport; - if (!callback) - callback = function () {}; +const sendReports = function (callback) { + const reports = toReport; + if (!callback) callback = function () {}; toReport = []; - if (doReport) - Meteor.call("report", reports, callback); - else - callback(); + if (doReport) Meteor.call("report", reports, callback); + else callback(); }; runTests = function () { @@ -117,9 +110,9 @@ runTests = function () { Tinytest._runTestsEverywhere( function (results) { - var name = getName(results); + const name = getName(results); if (!(name in resultSet)) { - var testPath = EJSON.clone(results.groupPath); + const testPath = EJSON.clone(results.groupPath); testPath.push(results.test); resultSet[name] = { name: name, @@ -127,7 +120,7 @@ runTests = function () { events: [], server: !!results.server, testPath: testPath, - test: results.test + test: results.test, }; report(name, false); } @@ -136,51 +129,48 @@ runTests = function () { results.events.forEach(function (event) { resultSet[name].events.push(event); switch (event.type) { - case "ok": - break; - case "expected_fail": - if (resultSet[name].status !== "FAIL") - resultSet[name].status = "EXPECTED"; - break; - case "exception": - log(name, ":", "!!!!!!!!! FAIL !!!!!!!!!!!"); - if (event.details && event.details.stack) - log(event.details.stack); - else - log("Test failed with exception"); - failed++; - whereFailed.push({ name: name, info: JSON.stringify(event) }); - break; - case "finish": - switch (resultSet[name].status) { - case "OK": + case "ok": break; - case "PENDING": - resultSet[name].status = "OK"; - report(name, true); - log(name, ":", "OK"); - passed++; + case "expected_fail": + if (resultSet[name].status !== "FAIL") resultSet[name].status = "EXPECTED"; break; - case "EXPECTED": - report(name, true); - log(name, ":", "EXPECTED FAILURE"); - expected++; - break; - case "FAIL": - failed++; - report(name, true); + case "exception": log(name, ":", "!!!!!!!!! FAIL !!!!!!!!!!!"); - log(JSON.stringify(resultSet[name].info)); - whereFailed.push({ name: name, info: JSON.stringify(resultSet[name].info) }); + if (event.details && event.details.stack) log(event.details.stack); + else log("Test failed with exception"); + failed++; + whereFailed.push({ name: name, info: JSON.stringify(event) }); + break; + case "finish": + switch (resultSet[name].status) { + case "OK": + break; + case "PENDING": + resultSet[name].status = "OK"; + report(name, true); + log(name, ":", "OK"); + passed++; + break; + case "EXPECTED": + report(name, true); + log(name, ":", "EXPECTED FAILURE"); + expected++; + break; + case "FAIL": + failed++; + report(name, true); + log(name, ":", "!!!!!!!!! FAIL !!!!!!!!!!!"); + log(JSON.stringify(resultSet[name].info)); + whereFailed.push({ name: name, info: JSON.stringify(resultSet[name].info) }); + break; + default: + log(name, ": unknown state for the test to be in"); + } break; default: - log(name, ": unknown state for the test to be in"); - } - break; - default: - resultSet[name].status = "FAIL"; - resultSet[name].info = results; - break; + resultSet[name].status = "FAIL"; + resultSet[name].info = results; + break; } }); }, @@ -190,7 +180,16 @@ runTests = function () { if (failed > 0) { log("~~~~~~~ THERE ARE FAILURES ~~~~~~~"); } - log("passed/expected/failed/total", passed, "/", expected, "/", failed, "/", Object.keys(resultSet).length); + log( + "passed/expected/failed/total", + passed, + "/", + expected, + "/", + failed, + "/", + Object.keys(resultSet).length, + ); sendReports(function () { if (doReport) { log("Waiting 3s for any last reports to get sent out"); @@ -210,42 +209,46 @@ runTests = function () { // Also log xUnit output xunit(''); - resultSet.forEach(function (result, name) { - var classname = result.testPath.join('.').replace(/ /g, '-') + (result.server ? "-server" : "-client"); - var name = result.test.replace(/ /g, '-') + (result.server ? "-server" : "-client"); - var time = ""; - var error = ""; + resultSet.forEach(function (result, _name) { + const classname = + result.testPath.join(".").replace(/ /g, "-") + (result.server ? "-server" : "-client"); + const testName = result.test.replace(/ /g, "-") + (result.server ? "-server" : "-client"); + let time = ""; + let error = ""; result.events.forEach(function (event) { switch (event.type) { case "finish": - var timeMs = event.timeMs; + const timeMs = event.timeMs; if (timeMs !== undefined) { - time = (timeMs / 1000) + ""; + time = `${timeMs / 1000}`; } break; case "exception": - var details = event.details || {}; - error = (details.message || '?') + " filename=" + (details.filename || '?') + " line=" + (details.line || '?'); + const details = event.details || {}; + error = `${details.message || "?"} filename=${details.filename || "?"} line=${details.line || "?"}`; break; } }); switch (result.status) { case "FAIL": - error = error || '?'; + error = error || "?"; break; case "EXPECTED": error = null; break; } - xunit(''); + xunit( + ``, + ); if (error) { - xunit(' ' + escapeXml(error) + ''); + xunit(` ${escapeXml(error)}`); } - xunit(''); + xunit(""); }); - xunit(''); - logMagic('state', 'done'); + xunit(""); + logMagic("state", "done"); }, - ["tinytest"]); -} + ["tinytest"], + ); +}; diff --git a/packages/test-in-console/package.js b/packages/test-in-console/package.js index 8ef9139aa4..c6a2f2a471 100644 --- a/packages/test-in-console/package.js +++ b/packages/test-in-console/package.js @@ -1,20 +1,20 @@ Package.describe({ - summary: 'Run tests noninteractively, with results going to the console.', - version: '2.0.2-beta350.7', + summary: "Run tests noninteractively, with results going to the console.", + version: "2.0.2-beta350.7", }); Package.onUse(function (api) { - api.use(['tinytest', 'random', 'ejson', 'check', 'ecmascript']); - api.use('fetch', 'server'); - api.use('jquery', 'client'); + api.use(["tinytest", "random", "ejson", "check", "ecmascript"]); + api.use("fetch", "server"); + api.use("jquery", "client"); - api.export('TEST_STATUS', 'client'); + api.export("TEST_STATUS", "client"); - api.addFiles(['driver.js', 'test.css'], 'client'); + api.addFiles(["driver.js", "test.css"], "client"); - api.addFiles(['reporter.js'], 'server'); + api.addFiles(["reporter.js"], "server"); - api.addAssets('puppeteer_runner.js', 'server'); + api.addAssets("puppeteer_runner.js", "server"); - api.export('runTests'); + api.export("runTests"); }); diff --git a/packages/test-in-console/puppeteer_runner.js b/packages/test-in-console/puppeteer_runner.js index 546723de22..27f9e33590 100644 --- a/packages/test-in-console/puppeteer_runner.js +++ b/packages/test-in-console/puppeteer_runner.js @@ -2,7 +2,7 @@ let puppeteer; try { // Prefer the copy bundled inside dev_bundle (local checkout / CI after first run). puppeteer = require("../../dev_bundle/lib/node_modules/puppeteer"); -} catch (_) { +} catch { // Fallback: globally-installed puppeteer (e.g. on oss-vm where it is pre-installed // via `npm install -g puppeteer@23.6.0` and NODE_PATH is set to `npm root -g`). puppeteer = require("puppeteer"); @@ -28,23 +28,19 @@ async function runNextUrl(browser) { else { testNumber++; const currentClientTest = await page.evaluate(() => - __Tinytest._getCurrentRunningTestOnClient() + __Tinytest._getCurrentRunningTestOnClient(), ); if (currentClientTest !== "") { - console.log( - `Currently running on the client test: ${currentClientTest}` - ); + console.log(`Currently running on the client test: ${currentClientTest}`); return; } // If we get here is because we have not yet started the test on the client const currentServerTest = await page.evaluate( - async () => await __Tinytest._getCurrentRunningTestOnServer() + async () => await __Tinytest._getCurrentRunningTestOnServer(), ); if (currentServerTest !== "") { - console.log( - `Currently running on the server test: ${currentServerTest}` - ); + console.log(`Currently running on the server test: ${currentServerTest}`); return; } // we were not able to find the name of the test, this is a way to make sure the test is still running @@ -153,17 +149,11 @@ async function runTests() { // --no-sandbox and --disable-setuid-sandbox must be disabled for CI compatibility const browser = await puppeteer.launch({ - args: [ - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-web-security", - ], + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-web-security"], headless: "new", }); console.log(`Using version: ${await browser.version()}`); await runNextUrl(browser); } -runTests().catch((e) => - console.log(`something broke while running puppeter: `, e) -); +runTests().catch((e) => console.log(`something broke while running puppeter: `, e)); diff --git a/packages/test-in-console/reporter.js b/packages/test-in-console/reporter.js index 7fb49b28ce..65411b865a 100644 --- a/packages/test-in-console/reporter.js +++ b/packages/test-in-console/reporter.js @@ -1,10 +1,7 @@ let url = null; -if (Meteor.settings?.public?.runId && - Meteor.settings?.public?.reportTo) { - url = Meteor.settings.public.reportTo + - "/report/" + - Meteor.settings.public.runId; +if (Meteor.settings?.public?.runId && Meteor.settings?.public?.reportTo) { + url = `${Meteor.settings.public.reportTo}/report/${Meteor.settings.public.runId}`; } Meteor.methods({ @@ -13,15 +10,15 @@ Meteor.methods({ check(reports, [Object]); if (url) { fetch(url, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify(reports), - }); + }); } return null; - } + }, }); // provide some notification we're started. This is to allow use diff --git a/packages/test-in-console/test.css b/packages/test-in-console/test.css index 4249ebc830..51128f1ad2 100644 --- a/packages/test-in-console/test.css +++ b/packages/test-in-console/test.css @@ -5,4 +5,5 @@ * makes sure that at least one `.css` file can always be found, when the * tests are run. */ -.test { } +.test { +} diff --git a/packages/webapp-hashing/package.js b/packages/webapp-hashing/package.js index ee19a925ac..e3ff31ba0f 100644 --- a/packages/webapp-hashing/package.js +++ b/packages/webapp-hashing/package.js @@ -1,15 +1,17 @@ Package.describe({ summary: "Used internally by WebApp. Knows how to hash programs from manifests.", - version: '1.1.2', + version: "1.1.2", }); -Package.onUse(function(api) { - api.use('ecmascript'); - api.addFiles('webapp-hashing.js', 'server'); - api.export('WebAppHashing'); +Package.onUse(function (api) { + api.use("ecmascript"); + api.addFiles("webapp-hashing.js", "server"); + api.export("WebAppHashing"); }); -Package.onTest(function(api) { - api.use('tinytest'); - api.use('webapp-hashing'); +Package.onTest(function (api) { + api.use("ecmascript"); + api.use("tinytest"); + api.use("webapp-hashing"); + api.addFiles("webapp-hashing-tests.js", "server"); }); diff --git a/packages/webapp-hashing/webapp-hashing-tests.js b/packages/webapp-hashing/webapp-hashing-tests.js new file mode 100644 index 0000000000..9893e92948 --- /dev/null +++ b/packages/webapp-hashing/webapp-hashing-tests.js @@ -0,0 +1,152 @@ +const isHex40 = (s) => typeof s === "string" && /^[0-9a-f]{40}$/.test(s); + +const makeResource = (overrides) => ({ + type: "js", + replaceable: false, + where: "client", + path: "app.js", + hash: "abc123", + ...overrides, +}); + +const fixedOverride = { foo: "bar", baz: 42 }; + +Tinytest.add("webapp-hashing - calculateCordovaCompatibilityHash is deterministic", (test) => { + const hash1 = WebAppHashing.calculateCordovaCompatibilityHash("6.0.0", { + "cordova-plugin-camera": "4.0.0", + "cordova-plugin-file": "6.0.0", + }); + const hash2 = WebAppHashing.calculateCordovaCompatibilityHash("6.0.0", { + "cordova-plugin-camera": "4.0.0", + "cordova-plugin-file": "6.0.0", + }); + + test.isTrue(isHex40(hash1), "should return 40-char hex string"); + test.equal(hash1, hash2); +}); + +Tinytest.add( + "webapp-hashing - calculateCordovaCompatibilityHash is order-independent for plugins", + (test) => { + const hashAB = WebAppHashing.calculateCordovaCompatibilityHash("6.0.0", { + "plugin-a": "1.0.0", + "plugin-b": "2.0.0", + }); + const hashBA = WebAppHashing.calculateCordovaCompatibilityHash("6.0.0", { + "plugin-b": "2.0.0", + "plugin-a": "1.0.0", + }); + + test.equal(hashAB, hashBA, "plugin insertion order must not affect the hash"); + }, +); + +Tinytest.add( + "webapp-hashing - calculateCordovaCompatibilityHash differs on input change", + (test) => { + const baseline = WebAppHashing.calculateCordovaCompatibilityHash("6.0.0", { + "plugin-a": "1.0.0", + }); + + // Different platformVersion. + const diffPlatform = WebAppHashing.calculateCordovaCompatibilityHash("6.0.1", { + "plugin-a": "1.0.0", + }); + test.notEqual(baseline, diffPlatform); + + // Different plugin version. + const diffVersion = WebAppHashing.calculateCordovaCompatibilityHash("6.0.0", { + "plugin-a": "1.0.1", + }); + test.notEqual(baseline, diffVersion); + + // Different plugin name. + const diffName = WebAppHashing.calculateCordovaCompatibilityHash("6.0.0", { + "plugin-b": "1.0.0", + }); + test.notEqual(baseline, diffName); + }, +); + +Tinytest.add("webapp-hashing - calculateClientHash is deterministic", (test) => { + const manifest = [makeResource({ path: "app.js", hash: "aaa" })]; + const hash1 = WebAppHashing.calculateClientHash(manifest, null, fixedOverride); + const hash2 = WebAppHashing.calculateClientHash(manifest, null, fixedOverride); + + test.isTrue(isHex40(hash1)); + test.equal(hash1, hash2); +}); + +Tinytest.add("webapp-hashing - calculateClientHash ignores non-client resources", (test) => { + const baseline = [makeResource({ path: "app.js", hash: "aaa" })]; + + // Adding a server-only resource should not change the hash. + const withServer = [ + ...baseline, + makeResource({ path: "server.js", hash: "bbb", where: "server" }), + ]; + test.equal( + WebAppHashing.calculateClientHash(baseline, null, fixedOverride), + WebAppHashing.calculateClientHash(withServer, null, fixedOverride), + ); + + // Adding a client resource SHOULD change the hash. + const withClient = [ + ...baseline, + makeResource({ path: "extra.js", hash: "ccc", where: "client" }), + ]; + test.notEqual( + WebAppHashing.calculateClientHash(baseline, null, fixedOverride), + WebAppHashing.calculateClientHash(withClient, null, fixedOverride), + ); + + // Adding an internal resource SHOULD also change the hash. + const withInternal = [ + ...baseline, + makeResource({ path: "internal.js", hash: "ddd", where: "internal" }), + ]; + test.notEqual( + WebAppHashing.calculateClientHash(baseline, null, fixedOverride), + WebAppHashing.calculateClientHash(withInternal, null, fixedOverride), + ); +}); + +Tinytest.add("webapp-hashing - calculateClientHash respects includeFilter", (test) => { + const manifest = [ + makeResource({ type: "js", path: "app.js", hash: "aaa" }), + makeResource({ type: "css", path: "app.css", hash: "bbb" }), + ]; + + const withCss = WebAppHashing.calculateClientHash(manifest, null, fixedOverride); + const withoutCss = WebAppHashing.calculateClientHash( + manifest, + (type) => type !== "css", + fixedOverride, + ); + + // The filter excludes css resources, so the resulting hash should differ. + test.notEqual(withCss, withoutCss); + + // And it should equal the hash of just the js resource. + const jsOnly = WebAppHashing.calculateClientHash( + [makeResource({ type: "js", path: "app.js", hash: "aaa" })], + null, + fixedOverride, + ); + test.equal(withoutCss, jsOnly); +}); + +Tinytest.add( + "webapp-hashing - calculateClientHash reacts to runtimeConfigOverride changes", + (test) => { + const manifest = [makeResource({ path: "app.js", hash: "aaa" })]; + + const hash1 = WebAppHashing.calculateClientHash(manifest, null, { foo: "bar" }); + const hash2 = WebAppHashing.calculateClientHash(manifest, null, { foo: "baz" }); + test.notEqual(hash1, hash2); + + // Same override → same hash. + const hash1b = WebAppHashing.calculateClientHash(manifest, null, { foo: "bar" }); + test.equal(hash1, hash1b); + }, +); diff --git a/packages/webapp-hashing/webapp-hashing.js b/packages/webapp-hashing/webapp-hashing.js index 0968b45323..7e18c4abd8 100644 --- a/packages/webapp-hashing/webapp-hashing.js +++ b/packages/webapp-hashing/webapp-hashing.js @@ -11,43 +11,46 @@ WebAppHashing = {}; // (but the second is a performance enhancement, not a hard // requirement). -WebAppHashing.calculateClientHash = - function (manifest, includeFilter, runtimeConfigOverride) { - var hash = createHash('sha1'); +WebAppHashing.calculateClientHash = function (manifest, includeFilter, runtimeConfigOverride) { + const hash = createHash("sha1"); // Omit the old hashed client values in the new hash. These may be // modified in the new boilerplate. - var { autoupdateVersion, autoupdateVersionRefreshable, autoupdateVersionCordova, ...runtimeCfg } = __meteor_runtime_config__; + const { + autoupdateVersion: _av, + autoupdateVersionRefreshable: _avr, + autoupdateVersionCordova: _avc, + ...runtimeCfgBase + } = __meteor_runtime_config__; - if (runtimeConfigOverride) { - runtimeCfg = runtimeConfigOverride; - } + const runtimeCfg = runtimeConfigOverride || runtimeCfgBase; - hash.update(JSON.stringify(runtimeCfg, 'utf8')); + hash.update(JSON.stringify(runtimeCfg, "utf8")); manifest.forEach(function (resource) { - if ((! includeFilter || includeFilter(resource.type, resource.replaceable)) && - (resource.where === 'client' || resource.where === 'internal')) { + if ( + (!includeFilter || includeFilter(resource.type, resource.replaceable)) && + (resource.where === "client" || resource.where === "internal") + ) { hash.update(resource.path); hash.update(resource.hash); } }); - return hash.digest('hex'); + return hash.digest("hex"); }; -WebAppHashing.calculateCordovaCompatibilityHash = - function(platformVersion, pluginVersions) { - const hash = createHash('sha1'); +WebAppHashing.calculateCordovaCompatibilityHash = function (platformVersion, pluginVersions) { + const hash = createHash("sha1"); hash.update(platformVersion); // Sort plugins first so iteration order doesn't affect the hash const plugins = Object.keys(pluginVersions).sort(); - for (let plugin of plugins) { + for (const plugin of plugins) { const version = pluginVersions[plugin]; hash.update(plugin); hash.update(version); } - return hash.digest('hex'); + return hash.digest("hex"); };