Merge pull request #14327 from meteor/oxc/phase-2

[phase2] Applying linter, formatter and new tests for a few pkgs
This commit is contained in:
Italo José
2026-04-17 09:23:34 -03:00
committed by GitHub
56 changed files with 3223 additions and 1659 deletions

View File

@@ -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/

View File

@@ -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

View File

@@ -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/

View File

@@ -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) {

View File

@@ -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);
});

View File

@@ -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"]);
});

View File

@@ -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");
});

View File

@@ -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";

View File

@@ -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;

View File

@@ -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();
}
};
}

View File

@@ -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();
}
};
}

View File

@@ -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");
});

View File

@@ -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);
};
}

View File

@@ -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"]);
});

View File

@@ -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");
});

View File

@@ -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<N; i++) {
for (let i = 0; i < N; i++) {
if (old_index_of_id[new_results[i]._id] !== undefined) {
var j = max_seq_len;
let j = max_seq_len;
// this inner loop would traditionally be a binary search,
// but scanning backwards we will likely find a subseq to extend
// pretty soon, bounded for example by the total number of ops.
// If this were to be changed to a binary search, we'd still want
// to scan backwards a bit as an optimization.
while (j > 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) {
}
});
};

View File

@@ -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"]);
});

View File

@@ -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, []);
});

View File

@@ -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,

View File

@@ -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<T>(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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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");
});

View File

@@ -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;

View File

@@ -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;
}
};
};

View File

@@ -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]);
});

View File

@@ -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;

View File

@@ -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"]);
});

View File

@@ -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;

View File

@@ -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,
};
};

View File

@@ -1,4 +1,4 @@
Formatter = {};
Formatter.prettify = function(line, color){
return line;
Formatter.prettify = function (line, _color) {
return line;
};

View File

@@ -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";

View File

@@ -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);
};

View File

@@ -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

View File

@@ -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"]);
});

View File

@@ -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]);
});

View File

@@ -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 };

View File

@@ -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"]);
});

View File

@@ -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");
});

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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"]);
});

View File

@@ -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();
});

View File

@@ -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;
}

View File

@@ -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");
});

View File

@@ -1,4 +1,4 @@
import { EJSONable } from 'meteor/ejson';
import { EJSONable } from "meteor/ejson";
export namespace Session {
/**

View File

@@ -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

View File

@@ -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"];
});

View File

@@ -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 = {
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;',
"'": '&apos;'
const XML_CHAR_MAP = {
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
'"': "&quot;",
"'": "&apos;",
};
// 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('<testsuite errors="" failures="" name="meteor" skips="" tests="" time="">');
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('<testcase classname="' + escapeXml(classname) + '" name="' + escapeXml(name) + '" time="' + time + '">');
xunit(
`<testcase classname="${escapeXml(classname)}" name="${escapeXml(testName)}" time="${time}">`,
);
if (error) {
xunit(' <failure message="test failure">' + escapeXml(error) + '</failure>');
xunit(` <failure message="test failure">${escapeXml(error)}</failure>`);
}
xunit('</testcase>');
xunit("</testcase>");
});
xunit('</testsuite>');
logMagic('state', 'done');
xunit("</testsuite>");
logMagic("state", "done");
},
["tinytest"]);
}
["tinytest"],
);
};

View File

@@ -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");
});

View File

@@ -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));

View File

@@ -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

View File

@@ -5,4 +5,5 @@
* makes sure that at least one `.css` file can always be found, when the
* tests are run.
*/
.test { }
.test {
}

View File

@@ -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");
});

View File

@@ -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);
},
);

View File

@@ -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");
};