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
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on: [push, pull_request]

env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
DOCS_PATH: ${{ secrets.DOCS_PATH }}
DOCS_BRANCH: ${{ secrets.DOCS_BRANCH }}

Expand Down Expand Up @@ -60,6 +59,9 @@ jobs:

windows:
runs-on: windows-latest
permissions:
contents: write # create the GitHub release
id-token: write # request the OIDC token for NuGet trusted publishing

steps:
- uses: actions/checkout@v7
Expand All @@ -69,7 +71,18 @@ jobs:
dotnet-version: |
10.0.x

# Exchanges the GitHub OIDC token for a short-lived (1 hour) NuGet API key.
# Requires a trusted publishing policy on nuget.org for this repository and workflow file.
- name: NuGet login
id: nuget_login
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/devel')
uses: NuGet/login@v1
with:
user: ${{ secrets.NUGET_USER }}

- name: Build
env:
NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }}
run: |
if ($env:GITHUB_REF -eq "refs/heads/main") {
.\build.ps1 -Target Publish -AngleSharpVersion 1.8.0
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# 1.1.4

Released on Sunday, November 1 2026.

- Fixed callbacks getting their first parameter as `this` instead of as an argument (e.g. a `MutationObserver` callback got the observer instead of the records); only a leading window (timers, animation frames) is the receiver
- Fixed arrays (e.g. the records of a `MutationObserver`) not being JS arrays, so `for...of` failed with "The value is not iterable"
- Fixed members returning a task (e.g. `fetch`) giving an object without `then`; they return a promise settled on the event loop
- Fixed dictionaries (e.g. the parsed body of a JSON response) not being plain JS objects
- Added `unhandledrejection` (`PromiseRejectionEvent`, `window.onunhandledrejection`); a rejection nobody handles is reported as an error of the browsing context

# 1.1.3

Released on Friday, October 30 2026.
Expand Down
2 changes: 1 addition & 1 deletion build/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,9 @@ protected override void OnBuildInitialized()
.DependsOn(RunUnitTests)
.Executes(() =>
{
// In CI this is the short-lived key issued by NuGet trusted publishing (NuGet/login).
var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY");


if (apiKey.IsNullOrEmpty())
{
throw new BuildAbortedException("Could not resolve the NuGet API key.");
Expand Down
2 changes: 1 addition & 1 deletion src/AngleSharp.Js.Docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/js",
"version": "1.1.2",
"version": "1.1.4",
"preview": true,
"description": "The doclet for the AngleSharp.Js documentation.",
"keywords": [
Expand Down
14 changes: 13 additions & 1 deletion src/AngleSharp.Js.Tests/OverloadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ namespace AngleSharp.Js.Tests
using AngleSharp.Dom;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;

[DomName("ProbeMode")]
Expand All @@ -22,6 +24,9 @@ public sealed class ProbeOptions
[DomName("left")]
public Double? Left { get; set; }

[DomName("headers")]
public Dictionary<String, String> Headers { get; set; }

[DomName("mode")]
public ProbeMode Mode { get; set; } = ProbeMode.Near;
}
Expand All @@ -45,7 +50,12 @@ public static String Probe(this IElement element, Double x, Double y) =>

[DomName("probe")]
public static String Probe(this IElement element, ProbeOptions options) =>
String.Format(CultureInfo.InvariantCulture, "options:{0},{1}", options.Left?.ToString(CultureInfo.InvariantCulture) ?? "-", options.Mode);
String.Format(CultureInfo.InvariantCulture, "options:{0},{1}", options.Left?.ToString(CultureInfo.InvariantCulture) ?? "-", options.Mode) +
(options.Headers is null ? "" : ",headers:" + String.Join("|", options.Headers.Select(header => header.Key + "=" + header.Value)));

[DomName("probeRecord")]
public static String ProbeRecord(this IElement element, IDictionary<String, Double> record) =>
String.Join("|", record.Select(entry => entry.Key + "=" + entry.Value.ToString(CultureInfo.InvariantCulture)));
}

/// <summary>
Expand All @@ -67,6 +77,8 @@ public class OverloadTests
[TestCase("probe(true)", "flag:True")]
[TestCase("probe({ left: 5, mode: 'far' })", "options:5,Far")]
[TestCase("probe({})", "options:-,Near")]
[TestCase("probe({ headers: { Accept: 'application/json', 'X-Count': 2 } })", "options:-,Near,headers:Accept=application/json|X-Count=2")]
[TestCase("probeRecord({ a: 1, b: '2.5' })", "a=1|b=2.5")]
[TestCase("probe(undefined)", "none")]
public async Task OverloadFittingTheArgumentsIsCalled(String call, String expected)
{
Expand Down
116 changes: 116 additions & 0 deletions src/AngleSharp.Js.Tests/PromiseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
namespace AngleSharp.Js.Tests
{
using AngleSharp.Attributes;
using AngleSharp.Dom;
using AngleSharp.Browser.Dom.Events;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

[DomExposed("Window")]
public static class DeferredWindowExtensions
{
[DomName("later")]
public static async Task<String> Later(this IWindow window, String value)
{
await Task.Delay(20).ConfigureAwait(false);
return value;
}

[DomName("laterNode")]
public static async Task<IElement> LaterNode(this IWindow window, String id)
{
await Task.Delay(20).ConfigureAwait(false);
return window.Document.GetElementById(id);
}

[DomName("laterJson")]
public static async Task<Object> LaterJson(this IWindow window)
{
await Task.Delay(20).ConfigureAwait(false);
return new Dictionary<String, Object>
{
["token"] = "abc",
["user"] = null,
["tags"] = new Object[] { "a", 2.0, new Dictionary<String, Object> { ["x"] = true } },
};
}

[DomName("pause")]
public static Task Pause(this IWindow window) => Task.Delay(20);

[DomName("fail")]
public static async Task<String> Fail(this IWindow window, String message)
{
await Task.Delay(20).ConfigureAwait(false);
throw new InvalidOperationException(message);
}
}

/// <summary>
/// Registered so that this assembly counts as a library the engine exposes.
/// </summary>
sealed class DeferredService
{
}

/// <summary>
/// DOM members returning a task (e.g. fetch) give a promise to script, which settles with
/// the result of the task once it is done.
/// </summary>
[TestFixture]
public class PromiseTests
{
[TestCase("later('done').then(function (v) { document.title = v; })", "done")]
[TestCase("later('x').then(function (v) { return v + 'y'; }).then(function (v) { document.title = v; })", "xy")]
[TestCase("(async function () { document.title = await later('awaited'); })()", "awaited")]
[TestCase("document.title = later('a') instanceof Promise", "true")]
[TestCase("laterNode('d').then(function (e) { document.title = e === document.getElementById('d'); })", "true")]
[TestCase("laterJson().then(function (j) { document.title = [j.token, j.user, Array.isArray(j.tags), j.tags[1] + 1, j.tags[2].x, Object.keys(j).join('|')].join(); })", "abc,,true,3,true,token|user|tags")]
[TestCase("pause().then(function (v) { document.title = 'paused:' + v; })", "paused:undefined")]
[TestCase("fail('broken').catch(function (e) { document.title = (e instanceof Error) + ':' + e.message; })", "true:broken")]
public async Task TaskBecomesAPromise(String script, String expected)
{
var cfg = Configuration.Default.WithJs().WithEventLoop().With(new DeferredService());
var document = await BrowsingContext.New(cfg).OpenAsync(m => m.Content($"<!doctype html><div id=d></div><script>{script}</script>"));

for (var i = 0; i < 100 && document.Title != expected; i++)
{
await Task.Delay(20);
}

Assert.AreEqual(expected, document.Title);
}

[TestCase("Promise.reject(new Error('lost'))", "lost")]
[TestCase("fail('broken').then(function () {})", "broken")]
[TestCase("Promise.resolve().then(function () { throw new Error('thrown'); })", "thrown")]
[TestCase("var p = Promise.reject(new Error('late')); p.catch(function () {})", null)]
[TestCase("addEventListener('unhandledrejection', function (e) { document.title = e.reason.message; e.preventDefault(); }); Promise.reject(new Error('seen'))", null)]
[TestCase("window.onunhandledrejection = function (e) { document.title = e.reason.message; return false; }; Promise.reject(new Error('seen'))", null)]
public async Task UnhandledRejectionIsReported(String script, String expected)
{
var cfg = Configuration.Default.WithJs().WithEventLoop().With(new DeferredService());
var context = BrowsingContext.New(cfg);
String reported = null;
context.AddEventListener("error", (_, ev) =>
{
if (ev is TrackEvent { Error: Exception error })
{
reported = error.Message;
}
});
var document = await context.OpenAsync(m => m.Content($"<!doctype html><script>{script}</script>"));

for (var i = 0; i < 25 && reported == null && document.Title.Length == 0; i++)
{
await Task.Delay(20);
}

await Task.Delay(100);
Assert.AreEqual(expected, reported);
Assert.AreEqual(expected == null && script.Contains("seen") ? "seen" : "", document.Title);
}
}
}
14 changes: 14 additions & 0 deletions src/AngleSharp.Js.Tests/WindowApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,5 +134,19 @@ public async Task QueuedMicrotaskRunsAfterTheScript()

Assert.AreEqual("abc", document.Title);
}

[Test]
public async Task MutationObserverCallbackGetsTheRecordsAndTheObserver()
{
var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop());
var document = await context.OpenAsync(m => m.Content("<!doctype html><div id=target></div><script>new MutationObserver(function (records, observer) { var types = []; for (var record of records) { types.push(record.type); } document.title = Array.isArray(records) + ',' + types.join() + ',' + (observer instanceof MutationObserver); }).observe(document.getElementById('target'), { childList: true, attributes: true }); document.getElementById('target').appendChild(document.createElement('p'));</script>"));

for (var i = 0; i < 100 && document.Title.Length == 0; i++)
{
await Task.Delay(20);
}

Assert.AreEqual("true,childList,true", document.Title);
}
}
}
39 changes: 39 additions & 0 deletions src/AngleSharp.Js/Dom/PromiseRejectionEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace AngleSharp.Js.Dom
{
using AngleSharp.Attributes;
using AngleSharp.Dom.Events;
using System;

/// <summary>
/// The event fired at the window for a promise rejected without a handler
/// (unhandledrejection), which pages use to report such errors.
/// </summary>
[DomName("PromiseRejectionEvent")]
public sealed class PromiseRejectionEvent : Event
{
/// <summary>
/// Creates a new event for the given promise.
/// </summary>
/// <param name="type">The type of the event, e.g. unhandledrejection.</param>
/// <param name="promise">The rejected promise.</param>
/// <param name="reason">The value the promise was rejected with.</param>
public PromiseRejectionEvent(String type, Object promise, Object reason)
: base(type, false, true)
{
Promise = promise;
Reason = reason;
}

/// <summary>
/// Gets the rejected promise.
/// </summary>
[DomName("promise")]
public Object Promise { get; }

/// <summary>
/// Gets the value the promise was rejected with.
/// </summary>
[DomName("reason")]
public Object Reason { get; }
}
}
Loading
Loading