devtools: Create source actors from Debugger API notifications (#38334)

currently our devtools impl creates source actors in script, when
executing scripts in HTMLScriptElement or DedicatedWorkerGlobalScope.
this approach is cumbersome, and it means that many pathways to running
scripts are missed, such as imported ES modules.

with the [SpiderMonkey Debugger
API](https://firefox-source-docs.mozilla.org/js/Debugger/), we can pick
up all of the scripts and all of their sources without any extra code,
as long as we tell it about every global we create (#38333, #38551).
this patch adds a [Debugger#onNewScript()
hook](https://firefox-source-docs.mozilla.org/js/Debugger/Debugger.html#onnewscript-script-global)
to the debugger script, which calls
DebuggerGlobalScope#notifyNewSource() to notify our script system when a
new script runs. if the source is relevant to the file tree in the
Sources tab, script tells devtools to create a source actor.

Testing: adds several new automated devtools tests
Fixes: part of #36027

Signed-off-by: Delan Azabani <dazabani@igalia.com>
Co-authored-by: atbrakhi <atbrakhi@igalia.com>
This commit is contained in:
shuppy 2025-08-11 14:04:51 +08:00 committed by GitHub
parent de73d4a25c
commit 4784668fa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 522 additions and 118 deletions

View file

@ -13,6 +13,7 @@ import logging
from geckordp.actors.root import RootActor
from geckordp.actors.descriptors.tab import TabActor
from geckordp.actors.watcher import WatcherActor
from geckordp.actors.web_console import WebConsoleActor
from geckordp.actors.resources import Resources
from geckordp.actors.events import Events
from geckordp.rdp_client import RDPClient
@ -37,6 +38,13 @@ class Source:
url: str
@dataclass
class Devtools:
client: RDPClient
watcher: WatcherActor
targets: list
class DevtoolsTests(unittest.IsolatedAsyncioTestCase):
# /path/to/servo/python/servo
script_path = None
@ -71,8 +79,9 @@ class DevtoolsTests(unittest.IsolatedAsyncioTestCase):
[
Source("srcScript", f"{self.base_urls[0]}/classic.js"),
Source("inlineScript", f"{self.base_urls[0]}/test.html"),
Source("srcScript", f"{self.base_urls[1]}/classic.js"),
Source("inlineScript", f"{self.base_urls[0]}/test.html"),
Source("srcScript", f"{self.base_urls[1]}/classic.js"),
Source("importedModule", f"{self.base_urls[0]}/module.js"),
]
),
tuple([Source("Worker", f"{self.base_urls[0]}/classic_worker.js")]),
@ -116,7 +125,6 @@ class DevtoolsTests(unittest.IsolatedAsyncioTestCase):
# Sources list for `introductionType` = `importedModule`
@unittest.expectedFailure
def test_sources_list_with_static_import_module(self):
self.start_web_server(test_dir=os.path.join(DevtoolsTests.script_path, "devtools_tests/sources"))
self.run_servoshell(url=f"{self.base_urls[0]}/test_sources_list_with_static_import_module.html")
@ -135,7 +143,6 @@ class DevtoolsTests(unittest.IsolatedAsyncioTestCase):
),
)
@unittest.expectedFailure
def test_sources_list_with_dynamic_import_module(self):
self.start_web_server(test_dir=os.path.join(DevtoolsTests.script_path, "devtools_tests/sources"))
self.run_servoshell(url=f"{self.base_urls[0]}/test_sources_list_with_dynamic_import_module.html")
@ -196,6 +203,324 @@ class DevtoolsTests(unittest.IsolatedAsyncioTestCase):
),
)
# Sources list for `introductionType` set to values that require `displayURL` (`//# sourceURL`)
def test_sources_list_with_injected_script_write_and_display_url(self):
self.run_servoshell(
url='data:text/html,<script>document.write("<script>//%23 sourceURL=http://test</scr"+"ipt>")</script>'
)
self.assert_sources_list(
set(
[
tuple(
[
Source(
"inlineScript",
'data:text/html,<script>document.write("<script>//%23 sourceURL=http://test</scr"+"ipt>")</script>',
),
Source("injectedScript", "http://test/"),
]
)
]
)
)
def test_sources_list_with_injected_script_write_but_no_display_url(self):
self.run_servoshell(url='data:text/html,<script>document.write("<script>1</scr"+"ipt>")</script>')
self.assert_sources_list(
set(
[
tuple(
[
Source(
"inlineScript",
'data:text/html,<script>document.write("<script>1</scr"+"ipt>")</script>',
),
]
)
]
)
)
def test_sources_list_with_injected_script_append_and_display_url(self):
script = 's=document.createElement("script");s.append("//%23 sourceURL=http://test");document.body.append(s)'
self.run_servoshell(url=f"data:text/html,<body><script>{script}</script>")
self.assert_sources_list(
set(
[
tuple(
[
Source(
"inlineScript",
f"data:text/html,<body><script>{script}</script>",
),
Source("injectedScript", "http://test/"),
]
)
]
)
)
def test_sources_list_with_injected_script_append_but_no_display_url(self):
script = 's=document.createElement("script");s.append("1");document.body.append(s)'
self.run_servoshell(url=f"data:text/html,<body><script>{script}</script>")
self.assert_sources_list(
set(
[
tuple(
[
Source(
"inlineScript",
f"data:text/html,<body><script>{script}</script>",
),
]
)
]
)
)
def test_sources_list_with_eval_and_display_url(self):
self.run_servoshell(url='data:text/html,<script>eval("//%23 sourceURL=http://test")</script>')
self.assert_sources_list(
set(
[
tuple(
[
Source(
"inlineScript", 'data:text/html,<script>eval("//%23 sourceURL=http://test")</script>'
),
Source("eval", "http://test/"),
]
)
]
)
)
def test_sources_list_with_eval_but_no_display_url(self):
self.run_servoshell(url='data:text/html,<script>eval("1")</script>')
self.assert_sources_list(set([tuple([Source("inlineScript", 'data:text/html,<script>eval("1")</script>')])]))
def test_sources_list_with_debugger_eval_and_display_url(self):
self.run_servoshell(url="data:text/html,")
devtools = self._setup_devtools_client()
console = WebConsoleActor(devtools.client, devtools.targets[0]["consoleActor"])
evaluation_result = Future()
async def on_evaluation_result(data: dict):
evaluation_result.set_result(data)
devtools.client.add_event_listener(console.actor_id, Events.WebConsole.EVALUATION_RESULT, on_evaluation_result)
console.evaluate_js_async("//# sourceURL=http://test")
evaluation_result.result(1)
self.assert_sources_list(set([tuple([Source("debugger eval", "http://test/")])]))
def test_sources_list_with_debugger_eval_but_no_display_url(self):
self.run_servoshell(url="data:text/html,")
devtools = self._setup_devtools_client()
console = WebConsoleActor(devtools.client, devtools.targets[0]["consoleActor"])
evaluation_result = Future()
async def on_evaluation_result(data: dict):
evaluation_result.set_result(data)
devtools.client.add_event_listener(console.actor_id, Events.WebConsole.EVALUATION_RESULT, on_evaluation_result)
console.evaluate_js_async("1")
evaluation_result.result(1)
self.assert_sources_list(set([tuple([])]))
def test_sources_list_with_function_and_display_url(self):
self.run_servoshell(url='data:text/html,<script>new Function("//%23 sourceURL=http://test")</script>')
self.assert_sources_list(
set(
[
tuple(
[
Source(
"inlineScript",
'data:text/html,<script>new Function("//%23 sourceURL=http://test")</script>',
),
Source("Function", "http://test/"),
]
)
]
)
)
def test_sources_list_with_function_but_no_display_url(self):
self.run_servoshell(url='data:text/html,<script>new Function("1")</script>')
self.assert_sources_list(
set(
[
tuple(
[
Source("inlineScript", 'data:text/html,<script>new Function("1")</script>'),
]
)
]
)
)
def test_sources_list_with_javascript_url_and_display_url(self):
# “1” prefix is a workaround for <https://github.com/servo/servo/issues/38547>
self.run_servoshell(
url='data:text/html,<a href="javascript:1//%23 sourceURL=http://test"></a><script>document.querySelector("a").click()</script>'
)
self.assert_sources_list(
set(
[
tuple(
[
Source(
"inlineScript",
'data:text/html,<a href="javascript:1//%23 sourceURL=http://test"></a><script>document.querySelector("a").click()</script>',
),
Source("javascriptURL", "http://test/"),
]
)
]
)
)
def test_sources_list_with_javascript_url_but_no_display_url(self):
self.run_servoshell(url='data:text/html,<a href="javascript:1"></a>')
self.assert_sources_list(set([tuple([])]))
@unittest.expectedFailure
def test_sources_list_with_event_handler_and_display_url(self):
self.run_servoshell(url='data:text/html,<a onclick="//%23 sourceURL=http://test"></a>')
self.assert_sources_list(
set(
[
tuple(
[
Source("eventHandler", "http://test/"),
]
)
]
)
)
def test_sources_list_with_event_handler_but_no_display_url(self):
self.run_servoshell(url='data:text/html,<a onclick="1"></a>')
self.assert_sources_list(set([tuple([])]))
@unittest.expectedFailure
def test_sources_list_with_dom_timer_and_display_url(self):
self.run_servoshell(url='data:text/html,<script>setTimeout("//%23 sourceURL=http://test",0)</script>')
self.assert_sources_list(
set(
[
tuple(
[
Source("domTimer", "http://test/"),
]
)
]
)
)
@unittest.expectedFailure
def test_sources_list_with_dom_timer_but_no_display_url(self):
self.run_servoshell(url='data:text/html,<script>setTimeout("1",0)</script>')
self.assert_sources_list(set([tuple([])]))
# Sources list for scripts with `displayURL` (`//# sourceURL`), despite not being required by `introductionType`
def test_sources_list_with_inline_script_and_display_url(self):
self.run_servoshell(url="data:text/html,<script>//%23 sourceURL=http://test</script>")
self.assert_sources_list(
set(
[
tuple(
[
Source("inlineScript", "http://test/"),
]
)
]
)
)
# Extra test case for situation where `//# sourceURL` cant be parsed with page url as base.
def test_sources_list_with_inline_script_but_invalid_display_url(self):
self.run_servoshell(url="data:text/html,<script>//%23 sourceURL=test</script>")
self.assert_sources_list(
set(
[
tuple(
[
Source("inlineScript", "data:text/html,<script>//%23 sourceURL=test</script>"),
]
)
]
)
)
def test_sources_list_with_inline_script_but_no_display_url(self):
self.run_servoshell(url="data:text/html,<script>1</script>")
self.assert_sources_list(
set(
[
tuple(
[
Source("inlineScript", "data:text/html,<script>1</script>"),
]
)
]
)
)
# Sources list for inline scripts in `<iframe srcdoc>`
@unittest.expectedFailure
def test_sources_list_with_iframe_srcdoc_and_display_url(self):
self.run_servoshell(url='data:text/html,<iframe srcdoc="<script>//%23 sourceURL=http://test</script>">')
self.assert_sources_list(
set(
[
tuple(
[
Source("inlineScript", "http://test/"),
]
)
]
)
)
@unittest.expectedFailure
def test_sources_list_with_iframe_srcdoc_but_no_display_url(self):
self.run_servoshell(url='data:text/html,<iframe srcdoc="<script>1</script>">')
self.assert_sources_list(
set(
[
tuple(
[
# FIXME: its not really gonna be 0
Source("inlineScript", "about:srcdoc#0"),
]
)
]
)
)
@unittest.expectedFailure
def test_sources_list_with_iframe_srcdoc_multiple_inline_scripts(self):
self.run_servoshell(
url='data:text/html,<iframe srcdoc="<script>//%23 sourceURL=http://test</script><script>2</script>">'
)
self.assert_sources_list(
set(
[
tuple(
[
Source("inlineScript", "http://test/"),
# FIXME: its not really gonna be 0
Source("inlineScript", "about:srcdoc#0"),
]
)
]
)
)
# Source contents
def test_source_content_inline_script(self):
@ -320,7 +645,7 @@ class DevtoolsTests(unittest.IsolatedAsyncioTestCase):
if self.base_urls is not None:
self.base_urls = None
def _setup_devtools_client(self, expected_targets=1):
def _setup_devtools_client(self, *, expected_targets=1) -> Devtools:
client = RDPClient()
client.connect("127.0.0.1", 6080)
root = RootActor(client)
@ -355,11 +680,14 @@ class DevtoolsTests(unittest.IsolatedAsyncioTestCase):
if result:
raise result
return client, watcher, targets
return Devtools(client, watcher, targets)
def assert_sources_list(self, expected_sources_by_target: set[tuple[Source]]):
def assert_sources_list(
self, expected_sources_by_target: set[tuple[Source]], *, devtools: Optional[Devtools] = None
):
expected_targets = len(expected_sources_by_target)
client, watcher, targets = self._setup_devtools_client(expected_targets)
if devtools is None:
devtools = self._setup_devtools_client(expected_targets=expected_targets)
done = Future()
# NOTE: breaks if two targets have the same list of source urls.
# This should really be a multiset, but Python does not have multisets.
@ -379,22 +707,25 @@ class DevtoolsTests(unittest.IsolatedAsyncioTestCase):
# Send the exception back so it can be raised.
done.set_result(e)
for target in targets:
client.add_event_listener(
for target in devtools.targets:
devtools.client.add_event_listener(
target["actor"],
Events.Watcher.RESOURCES_AVAILABLE_ARRAY,
on_source_resource,
)
watcher.watch_resources([Resources.SOURCE])
devtools.watcher.watch_resources([Resources.SOURCE])
result: Optional[Exception] = done.result(1)
if result:
raise result
self.assertEqual(actual_sources_by_target, expected_sources_by_target)
client.disconnect()
devtools.client.disconnect()
def assert_source_content(self, expected_source: Source, expected_content: str):
client, watcher, targets = self._setup_devtools_client()
def assert_source_content(
self, expected_source: Source, expected_content: str, *, devtools: Optional[Devtools] = None
):
if devtools is None:
devtools = self._setup_devtools_client()
done = Future()
source_actors = {}
@ -410,13 +741,13 @@ class DevtoolsTests(unittest.IsolatedAsyncioTestCase):
except Exception as e:
done.set_result(e)
for target in targets:
client.add_event_listener(
for target in devtools.targets:
devtools.client.add_event_listener(
target["actor"],
Events.Watcher.RESOURCES_AVAILABLE_ARRAY,
on_source_resource,
)
watcher.watch_resources([Resources.SOURCE])
devtools.watcher.watch_resources([Resources.SOURCE])
result: Optional[Exception] = done.result(1)
if result:
@ -426,11 +757,11 @@ class DevtoolsTests(unittest.IsolatedAsyncioTestCase):
self.assertIn(expected_source, source_actors)
source_actor = source_actors[expected_source]
response = client.send_receive({"to": source_actor, "type": "source"})
response = devtools.client.send_receive({"to": source_actor, "type": "source"})
self.assertEqual(response["source"], expected_content)
client.disconnect()
devtools.client.disconnect()
def get_test_path(self, path: str) -> str:
return os.path.join(DevtoolsTests.script_path, os.path.join("devtools_tests", path))