Skip to content

Commit 1ff280a

Browse files
kblokclaude
andauthored
Implement upstream PR #14821: browser extensions API (#3404)
* Implement upstream PR #14821: browser extensions API Ports puppeteer/puppeteer#14821 to PuppeteerSharp. - Rename GetExtensionsAsync to ExtensionsAsync (IBrowser, Browser, CdpBrowser, BidiBrowser, IsolatedWorld) to match upstream naming. - Add TriggerExtensionActionAsync abstract method (IPage, Page, CdpPage; BidiPage throws NotSupportedException). - Filter extension targets in CdpBrowser.Targets() and target event handlers via IsTargetExposed. - Use AsPageAsync (with try/catch for closed targets) in CdpExtension.PagesAsync. - Add ExtensionsTests covering the new API surface. - Add extension-with-page test asset (background.js, manifest.json, popup.html). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: wire tab->page session hierarchy and fix prerender swap hang Extensions.triggerAction expects a tab target id, but Page._tabId was falling back to the page's own session because pages auto-attached at the connection level. Adding `{type:'page', exclude:true}` to the browser-level setAutoAttach matches upstream and routes pages through their owning tab session, so TabId is now correct. That change exposed two latent bugs: - Prerender activation re-emits a navigation request on the new session but never produces an HTTP response, so LifecycleWatcher hung waiting on _navigationResponseReceived. FrameSwapped now resolves it. - Connection.Dispose can be re-entered from the Disconnected callback while a message is mid-flight, deadlocking on the TaskQueue semaphore. Bounded the dispose wait to 1s; in-flight tasks already tolerate a disposed semaphore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: don't force DevTools tab target to Type=Other; sync TargetManager test With the previous commit's filter, tab targets auto-attach. The DevTools tab target's URL matches isDevToolsPageTarget so we wrap it as a CdpDevToolsTarget — and that class hard-overrode Type to TargetType.Other, making Targets()/PagesAsync() treat the tab as a page, then try to send Page.enable on a tab session. Removing the override matches upstream (DevToolsTarget extends PageTarget with no type override). ShouldHandleTargets was written for the pre-filter world where tabs weren't attached. Synced it to upstream's version, which accounts for the tab/page pair on each new page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4f30498 commit 1ff280a

20 files changed

Lines changed: 273 additions & 41 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
chrome.action.onClicked.addListener((tab) => {
2+
console.log("example text");
3+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "Extension with Page",
3+
"version": "1.0",
4+
"manifest_version": 3,
5+
"action": {
6+
"default_popup": "popup.html"
7+
},
8+
"background": {
9+
"service_worker": "background.js"
10+
}
11+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
3+
<html>
4+
5+
<body>
6+
<h1>Popup</h1>
7+
</body>
8+
9+
</html>
Lines changed: 152 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.IO;
3+
using System.Linq;
34
using System.Threading.Tasks;
45
using NUnit.Framework;
56
using PuppeteerSharp.Helpers;
@@ -10,15 +11,12 @@ namespace PuppeteerSharp.Tests.ExtensionsTests
1011
public class ExtensionsTests : PuppeteerBaseTest
1112
{
1213
private static readonly string _extensionPath = Path.Combine(AppContext.BaseDirectory, "Assets", "simple-extension");
14+
private static readonly string _extensionWithPagePath = Path.Combine(AppContext.BaseDirectory, "Assets", "extension-with-page");
1315

1416
private static LaunchOptions BrowserWithExtensionOptions() => new()
1517
{
16-
Headless = false,
1718
EnableExtensions = true,
18-
Args = new[]
19-
{
20-
$"--load-extension={_extensionPath.Quote()}"
21-
}
19+
Pipe = true,
2220
};
2321

2422
[Test, PuppeteerTest("extensions.spec", "extensions", "service_worker target type should be available")]
@@ -27,6 +25,7 @@ public async Task ServiceWorkerTargetTypeShouldBeAvailable()
2725
await using var browserWithExtension = await Puppeteer.LaunchAsync(
2826
BrowserWithExtensionOptions(),
2927
TestConstants.LoggerFactory);
28+
await browserWithExtension.InstallExtensionAsync(_extensionPath);
3029
var serviceWorkTarget = await browserWithExtension.WaitForTargetAsync(t => t.Type == TargetType.ServiceWorker);
3130
Assert.That(serviceWorkTarget, Is.Not.Null);
3231
}
@@ -37,6 +36,7 @@ public async Task CanEvaluateInTheServiceWorker()
3736
await using var browserWithExtension = await Puppeteer.LaunchAsync(
3837
BrowserWithExtensionOptions(),
3938
TestConstants.LoggerFactory);
39+
await browserWithExtension.InstallExtensionAsync(_extensionPath);
4040
var serviceWorkerTarget = await browserWithExtension.WaitForTargetAsync(t => t.Type == TargetType.ServiceWorker);
4141
var worker = await serviceWorkerTarget.WorkerAsync();
4242
Assert.That(await worker.EvaluateFunctionAsync<int>("() => globalThis.MAGIC"), Is.EqualTo(42));
@@ -45,21 +45,13 @@ public async Task CanEvaluateInTheServiceWorker()
4545
[Test, PuppeteerTest("extensions.spec", "extensions", "should list extensions and their properties")]
4646
public async Task ShouldListExtensionsAndTheirProperties()
4747
{
48-
var options = new LaunchOptions
49-
{
50-
Headless = false,
51-
EnableExtensions = true,
52-
Pipe = true,
53-
};
54-
55-
await using var browser = await Puppeteer.LaunchAsync(options, TestConstants.LoggerFactory);
56-
57-
var extensionId = await browser.InstallExtensionAsync(_extensionPath);
48+
await using var browserWithExtension = await Puppeteer.LaunchAsync(
49+
BrowserWithExtensionOptions(),
50+
TestConstants.LoggerFactory);
5851

59-
await browser.WaitForTargetAsync(t =>
60-
t.Url.Contains(extensionId) && t.Type == TargetType.ServiceWorker);
52+
var extensionId = await browserWithExtension.InstallExtensionAsync(_extensionPath);
53+
var extensions = await browserWithExtension.ExtensionsAsync();
6154

62-
var extensions = await browser.GetExtensionsAsync();
6355
var extension = extensions[extensionId];
6456

6557
Assert.That(extension, Is.Not.Null);
@@ -68,8 +60,149 @@ await browser.WaitForTargetAsync(t =>
6860
Assert.That(extension.Path, Is.EqualTo(_extensionPath));
6961
Assert.That(extension.Enabled, Is.True);
7062
Assert.That(extension.Id, Is.EqualTo(extensionId));
63+
}
64+
65+
[Test, PuppeteerTest("extensions.spec", "extensions", "should list extension workers")]
66+
public async Task ShouldListExtensionWorkers()
67+
{
68+
await using var browserWithExtension = await Puppeteer.LaunchAsync(
69+
BrowserWithExtensionOptions(),
70+
TestConstants.LoggerFactory);
71+
72+
var extensionId = await browserWithExtension.InstallExtensionAsync(_extensionPath);
73+
var extension = (await browserWithExtension.ExtensionsAsync())[extensionId];
74+
75+
var page = await browserWithExtension.NewPageAsync();
76+
await extension.TriggerActionAsync(page);
77+
78+
await browserWithExtension.WaitForTargetAsync(target =>
79+
target.Url.Contains(extensionId) && target.Type == TargetType.ServiceWorker);
80+
81+
var workers = await extension.WorkersAsync();
82+
Assert.That(workers.Count, Is.GreaterThan(0));
83+
}
84+
85+
[Test, PuppeteerTest("extensions.spec", "extensions", "should trigger extension action")]
86+
public async Task ShouldTriggerExtensionAction()
87+
{
88+
await using var browserWithExtension = await Puppeteer.LaunchAsync(
89+
BrowserWithExtensionOptions(),
90+
TestConstants.LoggerFactory);
91+
92+
var page = await browserWithExtension.NewPageAsync();
93+
var extensionId = await browserWithExtension.InstallExtensionAsync(_extensionPath);
94+
var extensions = await browserWithExtension.ExtensionsAsync();
95+
var extension = extensions[extensionId];
96+
97+
await page.TriggerExtensionActionAsync(extension);
98+
// If it doesn't throw, we consider it successful for this level of testing.
99+
}
100+
101+
[Test, PuppeteerTest("extensions.spec", "extensions", "should list extension pages")]
102+
public async Task ShouldListExtensionPages()
103+
{
104+
await using var browserWithExtension = await Puppeteer.LaunchAsync(
105+
BrowserWithExtensionOptions(),
106+
TestConstants.LoggerFactory);
107+
108+
var extensionId = await browserWithExtension.InstallExtensionAsync(_extensionWithPagePath);
109+
var extensions = await browserWithExtension.ExtensionsAsync();
110+
var extension = extensions[extensionId];
111+
112+
var page = await browserWithExtension.NewPageAsync();
113+
await page.GoToAsync(TestConstants.EmptyPage);
114+
115+
await extension.TriggerActionAsync(page);
116+
117+
await browserWithExtension.WaitForTargetAsync(target =>
118+
target.Url.Contains("popup.html") && target.Url.Contains(extensionId));
119+
120+
var pages = await extension.PagesAsync();
121+
Assert.That(pages.Count, Is.GreaterThanOrEqualTo(1));
122+
Assert.That(pages.Any(p => p.Url.Contains("popup.html")), Is.True);
123+
}
124+
125+
[Test, PuppeteerTest("extensions.spec", "extensions", "should capture console logs from extension pages")]
126+
public async Task ShouldCaptureConsoleLogsFromExtensionPages()
127+
{
128+
await using var browserWithExtension = await Puppeteer.LaunchAsync(
129+
BrowserWithExtensionOptions(),
130+
TestConstants.LoggerFactory);
131+
132+
var extensionId = await browserWithExtension.InstallExtensionAsync(_extensionWithPagePath);
133+
var extensions = await browserWithExtension.ExtensionsAsync();
134+
var extension = extensions[extensionId];
135+
136+
var page = await browserWithExtension.NewPageAsync();
137+
await page.GoToAsync(TestConstants.EmptyPage);
138+
139+
await page.TriggerExtensionActionAsync(extension);
140+
141+
var popupTarget = await browserWithExtension.WaitForTargetAsync(target =>
142+
target.Url.Contains("popup.html") && target.Url.Contains(extensionId));
143+
144+
var extPage = await popupTarget.AsPageAsync();
145+
146+
var messageTask = new TaskCompletionSource<string>();
147+
extPage.Console += (sender, e) => messageTask.TrySetResult(e.Message.Text);
148+
149+
await extPage.EvaluateExpressionAsync("console.log('hello from extension page')");
150+
151+
var message = await messageTask.Task.WithTimeout(5000);
152+
Assert.That(message, Is.EqualTo("hello from extension page"));
153+
}
154+
155+
[Test, PuppeteerTest("extensions.spec", "extensions", "should capture console logs from extension workers")]
156+
public async Task ShouldCaptureConsoleLogsFromExtensionWorkers()
157+
{
158+
await using var browserWithExtension = await Puppeteer.LaunchAsync(
159+
BrowserWithExtensionOptions(),
160+
TestConstants.LoggerFactory);
161+
162+
var extensionId = await browserWithExtension.InstallExtensionAsync(_extensionWithPagePath);
163+
var extensions = await browserWithExtension.ExtensionsAsync();
164+
var extension = extensions[extensionId];
165+
166+
var page = await browserWithExtension.NewPageAsync();
167+
await page.GoToAsync(TestConstants.EmptyPage);
168+
await extension.TriggerActionAsync(page);
169+
170+
var workerTarget = await browserWithExtension.WaitForTargetAsync(target =>
171+
target.Url.Contains(extensionId) && target.Type == TargetType.ServiceWorker);
172+
173+
var worker = await workerTarget.WorkerAsync();
174+
var messageToLog = "hello from extension worker";
175+
176+
var messageTask = new TaskCompletionSource<string>();
177+
worker.Console += (sender, e) =>
178+
{
179+
if (e.Message.Text == messageToLog)
180+
{
181+
messageTask.TrySetResult(e.Message.Text);
182+
}
183+
};
184+
185+
await worker.EvaluateFunctionAsync("msg => console.log(msg)", messageToLog);
186+
187+
var message = await messageTask.Task.WithTimeout(5000);
188+
Assert.That(message, Is.EqualTo(messageToLog));
189+
}
190+
191+
[Test, PuppeteerTest("extensions.spec", "extensions", "should remove extension from list after uninstall")]
192+
public async Task ShouldRemoveExtensionFromListAfterUninstall()
193+
{
194+
await using var browserWithExtension = await Puppeteer.LaunchAsync(
195+
BrowserWithExtensionOptions(),
196+
TestConstants.LoggerFactory);
197+
198+
var id = await browserWithExtension.InstallExtensionAsync(_extensionPath);
199+
var extensions = await browserWithExtension.ExtensionsAsync();
200+
Assert.That(extensions.ContainsKey(id), Is.True);
201+
202+
await browserWithExtension.UninstallExtensionAsync(id);
71203

72-
await browser.UninstallExtensionAsync(extensionId);
204+
extensions = await browserWithExtension.ExtensionsAsync();
205+
Assert.That(extensions.ContainsKey(id), Is.False);
73206
}
74207
}
75208
}

lib/PuppeteerSharp.Tests/TargetManagerTests/TargetManagerTests.cs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,38 +30,41 @@ public TargetManagerTests() : base()
3030
public async Task ShouldHandleTargets()
3131
{
3232
var targetManager = (Browser as CdpBrowser)!.TargetManager;
33-
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(2));
33+
var initialTargetCount = targetManager.GetAvailableTargets().Values.Count;
34+
35+
// There could be an conditional extra prerender target.
36+
Assert.That(initialTargetCount, Is.EqualTo(3).Or.EqualTo(4));
3437

3538
Assert.That(await Context.PagesAsync(), Is.Empty);
36-
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(2));
39+
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(initialTargetCount));
3740

3841
var page = await Context.NewPageAsync();
3942
Assert.That((await Context.PagesAsync()), Has.Length.EqualTo(1));
40-
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(3));
43+
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(initialTargetCount + 2));
4144

4245
await page.GoToAsync(TestConstants.EmptyPage);
4346
Assert.That((await Context.PagesAsync()), Has.Length.EqualTo(1));
44-
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(3));
47+
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(initialTargetCount + 2));
4548

4649
var frameTask = page.WaitForFrameAsync(target => target.Url == TestConstants.EmptyPage);
4750
await FrameUtils.AttachFrameAsync(page, "frame1", TestConstants.EmptyPage);
4851
await frameTask.WithTimeout();
4952
Assert.That((await Context.PagesAsync()), Has.Length.EqualTo(1));
50-
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(3));
53+
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(initialTargetCount + 2));
5154
Assert.That(page.Frames, Has.Length.EqualTo(2));
5255

5356
frameTask = page.WaitForFrameAsync(target => target.Url == TestConstants.CrossProcessUrl + "/empty.html");
5457
await FrameUtils.AttachFrameAsync(page, "frame2", TestConstants.CrossProcessUrl + "/empty.html");
5558
await frameTask.WithTimeout();
5659
Assert.That((await Context.PagesAsync()), Has.Length.EqualTo(1));
57-
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(4));
60+
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(initialTargetCount + 3));
5861
Assert.That(page.Frames, Has.Length.EqualTo(3));
5962

6063
frameTask = page.WaitForFrameAsync(target => target.Url == TestConstants.CrossProcessUrl + "/empty.html");
6164
await FrameUtils.AttachFrameAsync(page, "frame3", TestConstants.CrossProcessUrl + "/empty.html");
6265
await frameTask.WithTimeout();
6366
Assert.That((await Context.PagesAsync()), Has.Length.EqualTo(1));
64-
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(5));
67+
Assert.That(targetManager.GetAvailableTargets().Values, Has.Count.EqualTo(initialTargetCount + 4));
6568
Assert.That(page.Frames, Has.Length.EqualTo(4));
6669
}
6770
}

lib/PuppeteerSharp/Bidi/BidiBrowser.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,8 @@ await Driver.WebExtension.UninstallAsync(
205205
}
206206

207207
/// <inheritdoc/>
208-
public override Task<IReadOnlyDictionary<string, Extension>> GetExtensionsAsync()
209-
=> throw new NotSupportedException("GetExtensions is not supported in WebDriver BiDi.");
208+
public override Task<IReadOnlyDictionary<string, Extension>> ExtensionsAsync()
209+
=> throw new NotSupportedException("Extensions is not supported in WebDriver BiDi.");
210210

211211
/// <inheritdoc />
212212
public override ITarget[] Targets()

lib/PuppeteerSharp/Bidi/BidiPage.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,10 @@ public override async Task RemoveScriptToEvaluateOnNewDocumentAsync(string ident
880880
/// <inheritdoc />
881881
public override Task SetBypassServiceWorkerAsync(bool bypass) => throw new NotImplementedException();
882882

883+
/// <inheritdoc />
884+
public override Task TriggerExtensionActionAsync(Extension extension)
885+
=> throw new NotSupportedException("TriggerExtensionAction is not supported in WebDriver BiDi.");
886+
883887
/// <inheritdoc />
884888
public override IReadOnlyList<Realm> ExtensionRealms()
885889
=> throw new NotSupportedException("ExtensionRealms is not supported in WebDriver BiDi.");

lib/PuppeteerSharp/Browser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ public abstract class Browser : IBrowser
106106
public abstract Task UninstallExtensionAsync(string id);
107107

108108
/// <inheritdoc/>
109-
public abstract Task<IReadOnlyDictionary<string, Extension>> GetExtensionsAsync();
109+
public abstract Task<IReadOnlyDictionary<string, Extension>> ExtensionsAsync();
110110

111111
/// <inheritdoc/>
112112
public async Task<IPage[]> PagesAsync(bool includeAll = false)

lib/PuppeteerSharp/Cdp/CdpBrowser.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@ public override bool IsClosed
125125

126126
/// <inheritdoc/>
127127
public override ITarget[] Targets()
128-
=> TargetManager.GetAvailableTargets().Values.ToArray();
128+
=> TargetManager.GetAvailableTargets().Values
129+
.Where(IsTargetExposed)
130+
.ToArray();
129131

130132
/// <inheritdoc/>
131133
public override async Task<string> GetVersionAsync()
@@ -228,7 +230,7 @@ public override async Task UninstallExtensionAsync(string id)
228230
}
229231

230232
/// <inheritdoc/>
231-
public override async Task<IReadOnlyDictionary<string, Extension>> GetExtensionsAsync()
233+
public override async Task<IReadOnlyDictionary<string, Extension>> ExtensionsAsync()
232234
{
233235
var response = await Connection.SendAsync<ExtensionsGetExtensionsResponse>("Extensions.getExtensions")
234236
.ConfigureAwait(false);
@@ -409,6 +411,9 @@ internal async Task DisposeContextAsync(string contextId)
409411
_contexts.TryRemove(contextId, out var _);
410412
}
411413

414+
private static bool IsTargetExposed(CdpTarget target)
415+
=> target.Type != TargetType.Tab && string.IsNullOrEmpty(target.TargetInfo.Subtype);
416+
412417
private static bool IsDevToolsPageTarget(string url)
413418
{
414419
return url?.StartsWith("devtools://devtools/bundled/devtools_app.html", StringComparison.OrdinalIgnoreCase) == true;
@@ -544,9 +549,15 @@ private void TargetManager_TargetDiscovered(object sender, TargetChangedArgs e)
544549

545550
private void OnTargetChanged(object sender, TargetChangedArgs e)
546551
{
552+
var target = (CdpTarget)e.Target;
553+
if (!IsTargetExposed(target))
554+
{
555+
return;
556+
}
557+
547558
var args = new TargetChangedArgs(e.Target);
548559
OnTargetChanged(args);
549-
((CdpTarget)e.Target).BrowserContext.OnTargetChanged(args);
560+
target.BrowserContext.OnTargetChanged(args);
550561
}
551562

552563
private async void OnDetachedFromTargetAsync(object sender, TargetChangedArgs e)
@@ -557,6 +568,11 @@ private async void OnDetachedFromTargetAsync(object sender, TargetChangedArgs e)
557568
target.InitializedTaskWrapper.TrySetResult(InitializationStatus.Aborted);
558569
target.CloseTaskWrapper.TrySetResult(true);
559570

571+
if (!IsTargetExposed(target))
572+
{
573+
return;
574+
}
575+
560576
if ((await target.InitializedTask.ConfigureAwait(false)) == InitializationStatus.Success)
561577
{
562578
var args = new TargetChangedArgs(e.Target);
@@ -577,6 +593,11 @@ private async void OnAttachedToTargetAsync(object sender, TargetChangedArgs e)
577593
try
578594
{
579595
var target = (CdpTarget)e.Target;
596+
if (!IsTargetExposed(target))
597+
{
598+
return;
599+
}
600+
580601
if (await target.InitializedTask.ConfigureAwait(false) == InitializationStatus.Success)
581602
{
582603
var args = new TargetChangedArgs(e.Target);

0 commit comments

Comments
 (0)