Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions Jint.Tests.Test262/Test262Harness.settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,6 @@
// poisoned value's own error instead of the RangeError. Removed by fixing that ordering.
"staging/sm/TypedArray/constructor-buffer-sequence.js",

// Set.prototype.intersection / .isSubsetOf let the *receiver* be mutated while they traverse it
// (a set-like's has/keys callback calling this.delete(v) or this.clear()), and the spec models
// that with the [[SetData]] tombstone: a deleted entry becomes EMPTY in place rather than being
// removed, so the index-based traversal keeps its position and still visits the right elements.
// Jint's JsSet compacts on delete, so the traversal skips or repeats entries. Removed by giving
// the ordered set a tombstone representation.
"staging/sm/Set/intersection.js",
"staging/sm/Set/is-subset-of.js",

// Acornima (the external parser) rejects a SuperCall inside a direct eval. Jint already parses
// eval code with AllowSuperOutsideMethod, but that covers SuperProperty only; Acornima 1.7.0 has
// no separate switch for a direct `super()`, so `eval("super()")` in a derived constructor fails
Expand Down
325 changes: 325 additions & 0 deletions Jint.Tests/Runtime/KeyedCollectionMutationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
using Jint.Native;

namespace Jint.Tests.Runtime;

/// <summary>
/// Map and Set are specified over a List of entries in which a deleted entry becomes <c>~empty~</c>
/// <em>in place</em> rather than being removed — https://tc39.es/ecma262/#sec-set.prototype.delete and
/// https://tc39.es/ecma262/#sec-map.prototype.delete. Every traversal the spec defines walks that List
/// by index while user code is free to mutate the collection between two steps: <c>forEach</c>
/// (https://tc39.es/ecma262/#sec-set.prototype.foreach), the iterators
/// (https://tc39.es/ecma262/#sec-createsetiterator, https://tc39.es/ecma262/#sec-createmapiterator) and
/// the index-walking half of <c>difference</c>, <c>intersection</c>, <c>isDisjointFrom</c> and
/// <c>isSubsetOf</c>. The tombstone is what makes those walks coherent, and these tests pin the
/// behaviour that depends on it from outside test262.
/// </summary>
public class KeyedCollectionMutationTests
{
private static string Run(string script) => new Engine().Evaluate(script).AsString();

[Fact]
public void SetForEachRevisitsAValueDeletedAndReAddedWhileVisiting()
{
const string Script = """
var s = new Set([1, 2, 3]);
var seen = [];
var once = true;
s.forEach(function (v) {
seen.push(v);
if (v === 2 && once) { once = false; s.delete(2); s.add(2); }
});
seen.join(',') + '|' + [...s].join(',');
""";

Run(Script).Should().Be("1,2,3,2|1,3,2");
}

[Fact]
public void SetForEachDoesNotVisitAValueDeletedBeforeItsTurn()
{
const string Script = """
var s = new Set([1, 2, 3, 4]);
var seen = [];
s.forEach(function (v) { seen.push(v); if (v === 1) { s.delete(3); } });
seen.join(',');
""";

Run(Script).Should().Be("1,2,4");
}

[Fact]
public void SetForEachKeepsItsPlaceWhenAnAlreadyVisitedValueIsDeleted()
{
const string Script = """
var s = new Set([1, 2, 3, 4]);
var seen = [];
s.forEach(function (v) { seen.push(v); if (v === 1) { s.delete(1); } });
seen.join(',');
""";

Run(Script).Should().Be("1,2,3,4");
}

[Fact]
public void SetIteratorResumesAtTheRightEntryAfterDeletesAndAdds()
{
const string Script = """
var s = new Set([1, 2, 3]);
var it = s.values();
var out = [it.next().value];
s.delete(1);
s.delete(2);
out.push(it.next().value);
s.add(4);
out.push(it.next().value);
out.push(String(it.next().done));
out.join(',');
""";

Run(Script).Should().Be("1,3,4,true");
}

/// <summary>
/// The entry List is reclaimed once at least half of its slots are deleted, which moves every live
/// entry. A suspended iterator has to survive that, so it resumes by the entry's own sequence
/// number rather than by a raw slot index.
/// </summary>
[Fact]
public void SetIteratorSurvivesAnEntryListCompaction()
{
const string Script = """
var s = new Set();
for (var i = 0; i < 100; i++) { s.add(i); }
var it = s.values();
var out = [it.next().value];
for (var i = 1; i < 100; i += 2) { s.delete(i); }
for (var i = 100; i < 300; i++) { s.add(i); }
out.push(it.next().value);
out.push(it.next().value);
out.push(it.next().value);
out.join(',');
""";

Run(Script).Should().Be("0,2,4,6");
}

[Fact]
public void SetIteratorSeesEntriesAddedAfterAClear()
{
const string Script = """
var s = new Set([1, 2, 3]);
var it = s.values();
var out = [it.next().value];
s.clear();
s.add(7);
s.add(8);
out.push(it.next().value);
out.push(it.next().value);
out.push(String(it.next().done));
out.join(',');
""";

Run(Script).Should().Be("1,7,8,true");
}

[Fact]
public void AnExhaustedSetIteratorStaysDone()
{
const string Script = """
var s = new Set([1]);
var it = s.values();
it.next();
var first = it.next().done;
s.add(2);
String(first) + ',' + String(it.next().done);
""";

Run(Script).Should().Be("true,true");
}

/// <summary>
/// The receiver's <c>has</c> callback removes and re-adds an element that has already been visited,
/// which the spec says makes that element visible a second time, at its new position — and the walk
/// must still reach the elements after it.
/// </summary>
[Fact]
public void IntersectionRevisitsAnElementItsHasCallbackReAdded()
{
const string Script = """
var seen = [];
var setLike = {
size: 100,
has: function (v) {
if (v === 2 && seen.indexOf(v) < 0) { s.delete(v); s.add(v); }
seen.push(v);
return true;
},
keys: function () { throw new Error('unexpected keys'); }
};
var s = new Set([1, 2, 3]);
[...s.intersection(setLike)].join(',') + '|' + seen.join(',');
""";

Run(Script).Should().Be("1,2,3|1,2,3,2");
}

/// <summary>
/// Each call to <c>has</c> deletes the element it was handed and appends a new one; the walk has to
/// keep its place across both, so every element ever in the set is visited exactly once.
/// </summary>
[Fact]
public void IsSubsetOfVisitsEveryElementWhenHasDeletesTheCurrentOne()
{
const string Script = """
var s = new Set([1]);
var seen = [];
var newKeys = [2, 3, 4, 5];
var setLike = {
size: 100,
has: function (v) {
seen.push(v);
s.delete(v);
if (newKeys.length) { s.add(newKeys.shift()); }
return true;
},
keys: function () { throw new Error('unexpected keys'); }
};
String(s.isSubsetOf(setLike)) + '|' + seen.join(',') + '|' + s.size;
""";

Run(Script).Should().Be("true|1,2,3,4,5|0");
}

/// <summary>
/// A delete followed by an add moves the added element to the end, and every Set method has to
/// report that order. The combining methods used to answer from an unordered hash set, whose
/// enumeration reuses the slot a delete freed, so the re-added element came back first.
/// </summary>
[Fact]
public void SetMethodsReportInsertionOrderAfterADeleteAndAnAdd()
{
const string Script = """
var s = new Set([1, 2, 3]);
s.delete(1);
s.add(4);
[
[...s].join(''),
[...s.intersection(new Set([2, 3, 4]))].join(''),
[...s.union(new Set([9]))].join(''),
[...s.symmetricDifference(new Set([9]))].join(''),
[...s.difference(new Set([9]))].join(''),
[...s.difference(new Set([3]))].join('')
].join('|');
""";

Run(Script).Should().Be("234|234|2349|2349|234|24");
}

[Fact]
public void MapForEachRevisitsAKeyDeletedAndReAddedWhileVisiting()
{
const string Script = """
var m = new Map([[1, 'a'], [2, 'b'], [3, 'c']]);
var seen = [];
var once = true;
m.forEach(function (v, k) {
seen.push(k);
if (k === 2 && once) { once = false; m.delete(2); m.set(2, 'B'); }
});
seen.join(',') + '|' + [...m.keys()].join(',');
""";

Run(Script).Should().Be("1,2,3,2|1,3,2");
}

[Fact]
public void MapForEachKeepsItsPlaceWhenAnAlreadyVisitedKeyIsDeleted()
{
const string Script = """
var m = new Map([[1, 'a'], [2, 'b'], [3, 'c'], [4, 'd']]);
var seen = [];
m.forEach(function (v, k) { seen.push(k + '=' + v); if (k === 1) { m.delete(1); } });
seen.join(',');
""";

Run(Script).Should().Be("1=a,2=b,3=c,4=d");
}

[Fact]
public void MapIteratorResumesAtTheRightEntryAfterDeletesAndAdds()
{
const string Script = """
var m = new Map([[1, 'a'], [2, 'b'], [3, 'c']]);
var it = m.entries();
var out = [it.next().value[0]];
m.delete(1);
m.delete(2);
out.push(it.next().value[0]);
m.set(4, 'd');
out.push(it.next().value[0]);
out.push(String(it.next().done));
out.join(',');
""";

Run(Script).Should().Be("1,3,4,true");
}

[Fact]
public void MapIteratorSurvivesAnEntryListCompaction()
{
const string Script = """
var m = new Map();
for (var i = 0; i < 100; i++) { m.set(i, i); }
var it = m.keys();
var out = [it.next().value];
for (var i = 1; i < 100; i += 2) { m.delete(i); }
for (var i = 100; i < 300; i++) { m.set(i, i); }
out.push(it.next().value);
out.push(it.next().value);
out.join(',');
""";

Run(Script).Should().Be("0,2,4");
}

[Fact]
public void SettingAnExistingMapKeyLeavesItWhereItIs()
{
const string Script = """
var m = new Map([[1, 'a'], [2, 'b']]);
m.set(1, 'z');
[...m.keys()].join(',') + '|' + [...m.values()].join(',');
""";

Run(Script).Should().Be("1,2|z,b");
}

/// <summary>
/// A tombstone that is never reclaimed turns add/delete churn into unbounded growth, so the entry
/// List has to stay within a constant factor of the live count. Deleting the last entry drops its
/// slot outright, which is what keeps this shape flat.
/// </summary>
[Fact]
public void AddDeleteChurnDoesNotGrowTheSetEntryList()
{
var engine = new Engine();
var set = (JsSet) engine.Evaluate("var s = new Set([0]); for (var i = 1; i <= 20000; i++) { s.add(i); s.delete(i); } s;");

set.Size.Should().Be(1);
set._data.SlotCount.Should().BeLessThanOrEqualTo(4);
}

/// <summary>
/// The sliding-window shape deletes from the front rather than the tail, so the slot list is held
/// down by compaction instead: it may never grow while more than half of it is live.
/// </summary>
[Fact]
public void ASlidingWindowDoesNotGrowTheMapEntryList()
{
var engine = new Engine();
var map = (JsMap) engine.Evaluate("var m = new Map(); for (var i = 0; i < 20000; i++) { m.set(i, i); if (i >= 100) { m.delete(i - 100); } } m;");

map.Size.Should().Be(100);
map._data.SlotCount.Should().BeLessThanOrEqualTo(512);
}
}
39 changes: 39 additions & 0 deletions Jint.Tests/Runtime/SetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,43 @@ public void HasProperIteratorPrototypeChain()
engine.Evaluate("!iterator.hasOwnProperty(Symbol.iterator)").AsBoolean().Should().BeTrue();
engine.Evaluate("iterator[Symbol.iterator]() === iterator").AsBoolean().Should().BeTrue();
}

/// <summary>
/// https://tc39.es/ecma262/#sec-set.prototype.issupersetof reads the receiver's size at step 4,
/// <em>after</em> GetSetRecord at step 3. The order is observable, because GetSetRecord runs the
/// set-like's own <c>size</c>, <c>has</c> and <c>keys</c> getters and those may add to the receiver:
/// a receiver grown from one to two elements is a superset of a two-element set-like, and reading
/// its size first answered false.
/// </summary>
[Fact]
public void IsSupersetOfReadsTheReceiverSizeAfterBuildingTheSetRecord()
{
const string Script = @"
var s = new Set([1]);
var log = [];
var setLike = {
get size() { log.push('size'); s.add(2); return 2; },
get has() { log.push('has'); return function () { throw new Error('unexpected has'); }; },
get keys() { log.push('keys'); return function () { return [1, 2][Symbol.iterator](); }; }
};
return String(s.isSupersetOf(setLike)) + '|' + log.join(',') + '|' + [...s].join(',');";

new Engine().Evaluate(Script).AsString().Should().Be("true|size,has,keys|1,2");
}

/// <summary>The sibling case, where the receiver really is too small, must still be false.</summary>
[Fact]
public void IsSupersetOfIsFalseWhenTheReceiverStaysSmaller()
{
const string Script = @"
var s = new Set([1]);
var setLike = {
size: 2,
has: function () { throw new Error('unexpected has'); },
keys: function () { throw new Error('unexpected keys'); }
};
return s.isSupersetOf(setLike);";

new Engine().Evaluate(Script).AsBoolean().Should().BeFalse();
}
}
Loading