Restore output attribute of py-script tags, add py-script exec lifecycle hooks (#1063)

* Add beforePyScriptExec, afterPyScriptExec lifecycle hooks

* Add stdiodirector plugin for `output`, `stderr` attributes of py-script tag

* Add docs on `output` and `stderr` attributes of py-script tag

* Tests

* Removed output deprecation warning for `output` attribute

* Add createSingularWarning(), with createDeprecationWarning as alias
This commit is contained in:
Jeff Glass
2023-01-10 13:00:29 -06:00
committed by GitHub
parent e1b4415193
commit 470c3489dd
15 changed files with 748 additions and 33 deletions

View File

@@ -3,6 +3,12 @@
2023.01.1
=========
Features
--------
- Restored the `output` attribute of <py-script> tags to route `sys.stdout` to a DOM element with the given ID. ([#1063](https://github.com/pyscript/pyscript/pull/1063))
- Added a `stderr` attribute of <py-script> tags to route `sys.stderr` to a DOM element with the given ID. ([#1063](https://github.com/pyscript/pyscript/pull/1063))
Bug fixes
---------

View File

@@ -4,9 +4,24 @@ The `<py-script>` element lets you execute multi-line Python scripts both inline
## Attributes
| attribute | type | default | description |
|-----------|------|---------|------------------------------|
| **src** | url | | Url of a python source file. |
| attribute | type | default | description |
|-----------|--------|---------|------------------------------|
| **src** | url | | Url of a python source file. |
| **output**| string | | The id of a DOM element to route `sys.stdout` and `stderr` to, in addition to sending it to the `<py-terminal>`|
| **stderr**| string | | The id of a DOM element to route just `sys.stderr` to, in addition to sending it to the `<py-terminal>`|
### output
If the `output` attribute is provided, any output to [sys.stdout](https://docs.python.org/3/library/sys.html#sys.stdout) or [sys.stderr](https://docs.python.org/3/library/sys.html#sys.stderr) is written to the DOM element with the ID matching the attribute. If no DOM element is found with a matching ID, a warning is shown. The msg is output to the `innerHTML` of the HTML Element, with newlines (`\n'`) converted to breaks (`<br\>`).
This output is in addition to the output being written to the developer console and the `<py-terminal>` if it is being used.
### stderr
If the `stderr` attribute is provided, any output to [sys.stderr](https://docs.python.org/3/library/sys.html#sys.stderr) is written to the DOM element with the ID matching the attribute. If no DOM element is found with a matching ID, a warning is shown. The msg is output to the `innerHTML` of the HTML Element, with newlines (`\n'`) converted to breaks (`<br\>`).
This output is in addition to the output being written to the developer console and the `<py-terminal>` if it is being used.
## Examples

View File

@@ -1,24 +1,21 @@
import { htmlDecode, ensureUniqueId, showWarning, createDeprecationWarning } from '../utils';
import { htmlDecode, ensureUniqueId, createDeprecationWarning } from '../utils';
import type { Runtime } from '../runtime';
import { getLogger } from '../logger';
import { pyExec } from '../pyexec';
import { _createAlertBanner } from '../exceptions';
import { robustFetch } from '../fetch';
import { PyScriptApp } from '../main';
import { Stdio } from '../stdio';
const logger = getLogger('py-script');
export function make_PyScript(runtime: Runtime) {
export function make_PyScript(runtime: Runtime, app: PyScriptApp) {
class PyScript extends HTMLElement {
srcCode: string;
srcCode: string
stdout_manager: Stdio | null
stderr_manager: Stdio | null
async connectedCallback() {
if (this.hasAttribute('output')) {
const deprecationMessage =
"The 'output' attribute is deprecated and ignored. You should use " +
"'display()' to output the content to a specific element. " +
'For example display(myElement, target="divID").';
showWarning(deprecationMessage);
}
ensureUniqueId(this);
// Save innerHTML information in srcCode so we can access it later
// once we clean innerHTML (which is required since we don't want
@@ -26,7 +23,9 @@ export function make_PyScript(runtime: Runtime) {
this.srcCode = this.innerHTML;
const pySrc = await this.getPySrc();
this.innerHTML = '';
pyExec(runtime, pySrc, this);
app.plugins.beforePyScriptExec(runtime, pySrc, this);
const result = pyExec(runtime, pySrc, this);
app.plugins.afterPyScriptExec(runtime, pySrc, this, result);
}
async getPySrc(): Promise<string> {

View File

@@ -16,6 +16,7 @@ import { type Stdio, StdioMultiplexer, DEFAULT_STDIO } from './stdio';
import { PyTerminalPlugin } from './plugins/pyterminal';
import { SplashscreenPlugin } from './plugins/splashscreen';
import { ImportmapPlugin } from './plugins/importmap';
import { StdioDirector as StdioDirector } from './plugins/stdiodirector';
// eslint-disable-next-line
// @ts-ignore
import pyscript from './python/pyscript.py';
@@ -70,6 +71,8 @@ export class PyScriptApp {
this._stdioMultiplexer = new StdioMultiplexer();
this._stdioMultiplexer.addListener(DEFAULT_STDIO);
this.plugins.add(new StdioDirector(this._stdioMultiplexer))
}
// Error handling logic: if during the execution we encounter an error
@@ -309,7 +312,8 @@ modules must contain a "plugin" attribute. For more information check the plugin
// lifecycle (7)
executeScripts(runtime: Runtime) {
this.PyScript = make_PyScript(runtime);
// make_PyScript takes a runtime and a PyScriptApp as arguments
this.PyScript = make_PyScript(runtime, this);
customElements.define('py-script', this.PyScript);
}

View File

@@ -39,6 +39,25 @@ export class Plugin {
*/
afterSetup(runtime: Runtime) {}
/** The source of a <py-script>> tag has been fetched, and we're about
* to evaluate that source using the provided runtime.
*
* @param runtime The Runtime object that will be used to evaluated the Python source code
* @param src {string} The Python source code to be evaluated
* @param PyScriptTag The <py-script> HTML tag that originated the evaluation
*/
beforePyScriptExec(runtime, src, PyScriptTag) {}
/** The Python in a <py-script> has just been evaluated, but control
* has not been ceded back to the JavaScript event loop yet
*
* @param runtime The Runtime object that will be used to evaluated the Python source code
* @param src {string} The Python source code to be evaluated
* @param PyScriptTag The <py-script> HTML tag that originated the evaluation
* @param result The returned result of evaluating the Python (if any)
*/
afterPyScriptExec(runtime, src, PyScriptTag, result) {}
/** Startup complete. The interpreter is initialized and ready, user
* scripts have been executed: the main initialization logic ends here and
* the page is ready to accept user interactions.
@@ -89,6 +108,18 @@ export class PluginManager {
for (const p of this._pythonPlugins) p.afterStartup?.(runtime);
}
beforePyScriptExec(runtime, src, pyscriptTag) {
for (const p of this._plugins) p.beforePyScriptExec(runtime, src, pyscriptTag);
for (const p of this._pythonPlugins) p.beforePyScriptExec?.(runtime, src, pyscriptTag);
}
afterPyScriptExec(runtime: Runtime, src, pyscriptTag, result) {
for (const p of this._plugins) p.afterPyScriptExec(runtime, src, pyscriptTag, result);
for (const p of this._pythonPlugins) p.afterPyScriptExec?.(runtime, src, pyscriptTag, result);
}
onUserError(error: UserError) {
for (const p of this._plugins) p.onUserError(error);

View File

@@ -0,0 +1,53 @@
import { Plugin } from "../plugin";
import { TargetedStdio, StdioMultiplexer } from "../stdio";
/**
* The StdioDirector plugin captures the output to Python's sys.stdio and
* sys.stderr and writes it to a specific element in the DOM. It does this by
* creating a new TargetedStdio manager and adding it to the global stdioMultiplexer's
* list of listeners prior to executing the Python in a specific tag. Following
* execution of the Python in that tag, it removes the TargetedStdio as a listener
*
*/
export class StdioDirector extends Plugin {
_stdioMultiplexer: StdioMultiplexer;
constructor(stdio: StdioMultiplexer) {
super()
this._stdioMultiplexer = stdio
}
/** Prior to a <py-script> tag being evaluated, if that tag itself has
* an 'output' attribute, a new TargetedStdio object is created and added
* to the stdioMultiplexer to route sys.stdout and sys.stdout to the DOM object
* with that ID for the duration of the evaluation.
*
*/
beforePyScriptExec(runtime: any, src: any, PyScriptTag): void {
if (PyScriptTag.hasAttribute("output")){
const targeted_io = new TargetedStdio(PyScriptTag, "output", true, true)
PyScriptTag.stdout_manager = targeted_io
this._stdioMultiplexer.addListener(targeted_io)
}
if (PyScriptTag.hasAttribute("stderr")){
const targeted_io = new TargetedStdio(PyScriptTag, "stderr", false, true)
PyScriptTag.stderr_manager = targeted_io
this._stdioMultiplexer.addListener(targeted_io)
}
}
/** After a <py-script> tag is evaluated, if that tag has a 'stdout_manager'
* (presumably TargetedStdio, or some other future IO handler), it is removed.
*/
afterPyScriptExec(runtime: any, src: any, PyScriptTag: any, result: any): void {
if (PyScriptTag.stdout_manager != null){
this._stdioMultiplexer.removeListener(PyScriptTag.stdout_manager)
PyScriptTag.stdout_manager = null
}
if (PyScriptTag.stderr_manager != null){
this._stdioMultiplexer.removeListener(PyScriptTag.stderr_manager)
PyScriptTag.stderr_manager = null
}
}
}

View File

@@ -1,3 +1,5 @@
import { createSingularWarning, escape } from "./utils";
export interface Stdio {
stdout_writeline: (msg: string) => void;
stderr_writeline: (msg: string) => void;
@@ -36,6 +38,63 @@ export class CaptureStdio implements Stdio {
}
}
/** Stdio provider for sending output to DOM element
* specified by ID. Used with "output" keyword.
*
*/
export class TargetedStdio implements Stdio{
source_element: HTMLElement;
source_attribute: string;
capture_stdout: boolean;
capture_stderr: boolean;
constructor(source_element: HTMLElement, source_attribute: string, capture_stdout = true, capture_stderr = true) {
this.source_element = source_element;
this.source_attribute = source_attribute;
this.capture_stdout = capture_stdout;
this.capture_stderr = capture_stderr;
}
/** Writes the given msg to an element with a given ID. The ID is the value an attribute
* of the source_element specified by source_attribute.
* Both the element to be targeted and the ID of the element to write to
* are determined at write-time, not when the TargetdStdio object is
* created. This way, if either the 'output' attribute of the HTML tag
* or the ID of the target element changes during execution of the Python
* code, the output is still routed (or not) as expected
*
* @param msg The output to be written
*/
writeline_by_attribute(msg:string){
const target_id = this.source_element.getAttribute(this.source_attribute)
const target = document.getElementById(target_id)
if (target === null) { // No matching ID
createSingularWarning(`${this.source_attribute} = "${target_id}" does not match the id of any element on the page.`)
}
else {
msg = escape(msg).replace("\n", "<br>")
if (!msg.endsWith("<br/>") && !msg.endsWith("<br>")){
msg = msg + "<br>"
}
target.innerHTML += msg
}
}
stdout_writeline (msg: string) {
if (this.capture_stdout){
this.writeline_by_attribute(msg)
}
}
stderr_writeline (msg: string) {
if (this.capture_stderr){
this.writeline_by_attribute(msg)
}
}
}
/** Redirect stdio streams to multiple listeners
*/
export class StdioMultiplexer implements Stdio {
@@ -49,6 +108,13 @@ export class StdioMultiplexer implements Stdio {
this._listeners.push(obj);
}
removeListener(obj: Stdio) {
const index = this._listeners.indexOf(obj)
if (index > -1){
this._listeners.splice(index, 1)
}
}
stdout_writeline(msg: string) {
for (const obj of this._listeners) obj.stdout_writeline(msg);
}

View File

@@ -111,10 +111,22 @@ export function joinPaths(parts: string[], separator = '/') {
}
export function createDeprecationWarning(msg: string, elementName: string): void {
createSingularWarning(msg, elementName);
}
/** Adds a warning banner with content {msg} at the top of the page if
* and only if no banner containing the {sentinelText} already exists.
* If sentinelText is null, the full text of {msg} is used instead
*
* @param msg {string} The full text content of the warning banner to be displayed
* @param sentinelText {string} [null] The text to match against existing warning banners.
* If null, the full text of 'msg' is used instead.
*/
export function createSingularWarning(msg: string, sentinelText: string | null = null): void {
const banners = document.getElementsByClassName('alert-banner py-warning');
let bannerCount = 0;
for (const banner of banners) {
if (banner.innerHTML.includes(elementName)) {
if (banner.innerHTML.includes(sentinelText ? sentinelText : msg)) {
bannerCount++;
}
}

View File

@@ -39,18 +39,6 @@ class TestOutput(PyScriptTest):
lines = [line for line in lines if line != ""] # remove empty lines
assert lines == ["hello 1", "hello 2", "hello 3"]
def test_output_attribute_shows_deprecated_warning(self):
self.pyscript_run(
"""
<py-script output="myDiv">
display('hello world')
</py-script>
<div id="mydiv"></div>
"""
)
warning_banner = self.page.locator(".alert-banner")
assert "The 'output' attribute is deprecated" in warning_banner.inner_text()
def test_target_attribute(self):
self.pyscript_run(
"""

View File

@@ -92,7 +92,7 @@ class TestAsync(PyScriptTest):
"b func done",
]
def test_multiple_async_multiple_display_targetted(self):
def test_multiple_async_multiple_display_targeted(self):
self.pyscript_run(
"""
<py-script id='pyA'>
@@ -124,7 +124,7 @@ class TestAsync(PyScriptTest):
inner_text = self.page.inner_text("html")
assert "A0\nA1\nB0\nB1" in inner_text
def test_async_display_untargetted(self):
def test_async_display_untargeted(self):
self.pyscript_run(
"""
<py-script id='pyA'>

View File

@@ -37,6 +37,14 @@ class TestLogger(Plugin):
def afterStartup(self, config):
console.log('afterStartup called')
def beforePyScriptExec(self, runtime, src, pyscript_tag):
console.log(f'beforePyScriptExec called')
console.log(f'before_src:{src}')
def afterPyScriptExec(self, runtime, src, pyscript_tag, result):
console.log(f'afterPyScriptExec called')
console.log(f'after_src:{src}')
def onUserError(self, config):
console.log('onUserError called')
@@ -44,6 +52,29 @@ class TestLogger(Plugin):
plugin = TestLogger()
"""
# Source of script that defines a plugin with only beforePyScriptExec and
# afterPyScriptExec methods
EXEC_HOOKS_PLUGIN_CODE = """
from pyscript import Plugin
from js import console
class ExecTestLogger(Plugin):
def beforePyScriptExec(self, runtime, src, pyscript_tag):
console.log(f'beforePyScriptExec called')
console.log(f'before_src:{src}')
console.log(f'before_id:{pyscript_tag.id}')
def afterPyScriptExec(self, runtime, src, pyscript_tag, result):
console.log(f'afterPyScriptExec called')
console.log(f'after_src:{src}')
console.log(f'after_id:{pyscript_tag.id}')
console.log(f'result:{result}')
plugin = ExecTestLogger()
"""
# Source of a script that doesn't call define a `plugin` attribute
NO_PLUGIN_CODE = """
from pyscript import Plugin
@@ -159,7 +190,12 @@ class TestPlugin(PyScriptTest):
for each one of them"""
# GIVEN a plugin that logs specific strings for each app execution event
hooks_available = ["afterSetup", "afterStartup"]
hooks_unavailable = ["configure", "beforeLaunch"]
hooks_unavailable = [
"configure",
"beforeLaunch",
"beforePyScriptExec",
"afterPyScriptExec",
]
# EXPECT it to log the correct logs for the events it intercepts
log_lines = self.console.log.lines
@@ -173,6 +209,28 @@ class TestPlugin(PyScriptTest):
# TODO: It'd be actually better to check that the events get called in order
@prepare_test(
"exec_test_logger",
EXEC_HOOKS_PLUGIN_CODE,
template=HTML_TEMPLATE_NO_TAG + "\n<py-script id='pyid'>x=2; x</py-script>",
)
def test_pyscript_exec_hooks(self):
"""Test that the beforePyScriptExec and afterPyScriptExec hooks work as intended"""
assert self.page.locator("py-script") is not None
log_lines: list[str] = self.console.log.lines
assert "beforePyScriptExec called" in log_lines
assert "afterPyScriptExec called" in log_lines
# These could be made better with a utility function that found log lines
# that match a filter function, or start with something
assert "before_src:x=2; x" in log_lines
assert "before_id:pyid" in log_lines
assert "after_src:x=2; x" in log_lines
assert "after_id:pyid" in log_lines
assert "result:2" in log_lines
@prepare_test("no_plugin", NO_PLUGIN_CODE)
def test_no_plugin_attribute_error(self):
"""

View File

@@ -0,0 +1,363 @@
from .support import PyScriptTest
class TestOutputHandling(PyScriptTest):
# Source of a script to test the TargetedStdio functionality
def test_targeted_stdio(self):
self.pyscript_run(
"""
<py-config>
terminal = true
</py-config>
<py-terminal></py-terminal>
<div id="container">
<div id="first"></div>
<div id="second"></div>
<div id="third"></div>
</div>
<py-script output="first">print("first 1.")</py-script>
<py-script output="second">print("second.")</py-script>
<py-script output="third">print("third.")</py-script>
<py-script output="first">print("first 2.")</py-script>
<py-script>print("no output.")</py-script>
"""
)
# Check that page has desired parent/child structure, and that
# Output divs are correctly located
assert (container := self.page.locator("#container")).count() > 0
assert (first_div := container.locator("#first")).count() > 0
assert (second_div := container.locator("#second")).count() > 0
assert (third_div := container.locator("#third")).count() > 0
# Check that output ends up in proper div
assert first_div.text_content() == "first 1.first 2."
assert second_div.text_content() == "second."
assert third_div.text_content() == "third."
# Check that tag with no otuput attribute doesn't end up in container at all
assert container.get_by_text("no output.").count() == 0
# Check that all output ends up in py-terminal
assert (
self.page.locator("py-terminal").text_content()
== "first 1.second.third.first 2.no output."
)
# Check that all output ends up in the dev console, in order
last_index = -1
for line in ["first 1.", "second.", "third.", "first 2.", "no output."]:
assert (line_index := self.console.log.lines.index(line)) > -1
assert line_index > last_index
last_index = line_index
self.assert_no_banners()
def test_stdio_escape(self):
# Test that text that looks like HTML tags is properly escaped in stdio
self.pyscript_run(
"""
<div id="first"></div>
<py-script output="first">
print("<p>Hello</p>")
print('<img src="https://example.net">')
</py-script>
"""
)
text = self.page.locator("#first").text_content()
assert "<p>Hello</p>" in text
assert '<img src="https://example.net">' in text
self.assert_no_banners()
def test_targeted_stdio_linebreaks(self):
self.pyscript_run(
"""
<div id="first"></div>
<py-script output="first">
print("one.")
print("two.")
print("three.")
</py-script>
<div id="second"></div>
<py-script output="second">
print("one.\\ntwo.\\nthree.")
</py-script>
"""
)
# check line breaks at end of each input
assert self.page.locator("#first").inner_html() == "one.<br>two.<br>three.<br>"
# new lines are converted to line breaks
assert self.page.locator("#second").inner_html() == "one.<br>two.<br>three.<br>"
self.assert_no_banners()
def test_targeted_stdio_async(self):
# Test the behavior of stdio capture in async contexts
self.pyscript_run(
"""
<py-script>
import asyncio
import js
async def coro(value, delay):
print(value)
await asyncio.sleep(delay)
js.console.log(f"DONE {value}")
</py-script>
<div id="first"></div>
<py-script>
asyncio.ensure_future(coro("first", 1))
</py-script>
<div id="second"></div>
<py-script output="second">
asyncio.ensure_future(coro("second", 1))
</py-script>
<div id="third"></div>
<py-script output="third">
asyncio.ensure_future(coro("third", 0))
</py-script>
<py-script output="third">
asyncio.ensure_future(coro("DONE", 3))
</py-script>
"""
)
self.wait_for_console("DONE DONE")
# py-script tags without output parameter should not send
# stdout to element
assert self.page.locator("#first").text_content() == ""
# py-script tags with output parameter not expected to send
# std to element in coroutine
assert self.page.locator("#second").text_content() == ""
assert self.page.locator("#third").text_content() == ""
self.assert_no_banners()
def test_targeted_stdio_interleaved(self):
# Test that synchronous writes to stdout are placed correctly, even
# While interleaved with scheduling coroutines in the same tag
self.pyscript_run(
"""
<div id="good"></div>
<div id="bad"></div>
<py-script output="good">
import asyncio
import js
async def coro_bad(value, delay):
print(value)
await asyncio.sleep(delay)
print("one.")
asyncio.ensure_future(coro_bad("badone.", 0.1))
print("two.")
asyncio.ensure_future(coro_bad("badtwo.", 0.2))
print("three.")
asyncio.ensure_future(coro_bad("badthree.", 0))
asyncio.ensure_future(coro_bad("DONE", 1))
</py-script>
"""
)
# Three prints should appear from synchronous writes
assert self.page.locator("#good").text_content() == "one.two.three."
# Check that all output ends up in the dev console, in order
last_index = -1
for line in ["one.", "two.", "three.", "badthree.", "badone.", "badtwo."]:
assert (line_index := self.console.log.lines.index(line)) > -1
assert line_index > last_index
self.assert_no_banners()
def test_targeted_stdio_dynamic_tags(self):
# Test that creating py-script tags via Python still leaves
# stdio targets working
self.pyscript_run(
"""
<div id="first"></div>
<div id="second"></div>
<py-script output="first">
print("first.")
import js
tag = js.document.createElement("py-script")
tag.innerText = "print('second.')"
tag.setAttribute("output", "second")
js.document.body.appendChild(tag)
print("first.")
</py-script>
"""
)
# Ensure second tag was added to page
assert (second_div := self.page.locator("#second")).count() > 0
# Ensure output when to correct locations
assert self.page.locator("#first").text_content() == "first.first."
assert second_div.text_content() == "second."
self.assert_no_banners()
def test_stdio_stdout_id_errors(self):
# Test that using an ID not present on the page as the Output
# Attribute creates exactly 1 warning banner per missing id
self.pyscript_run(
"""
<py-script output="not-on-page">
print("bad.")
</py-script>
<div id="on-page"></div>
<py-script>
print("good.")
</py-script>
<py-script output="not-on-page">
print("bad.")
</py-script>
"""
)
banner = self.page.query_selector_all(".py-warning")
assert len(banner) == 1
banner_content = banner[0].inner_text()
expected = (
'output = "not-on-page" does not match the id of any element on the page.'
)
assert banner_content == expected
def test_stdio_stderr_id_errors(self):
# Test that using an ID not present on the page as the stderr
# attribute creates exactly 1 warning banner per missing id
self.pyscript_run(
"""
<py-script stderr="not-on-page">
import sys
print("bad.", file=sys.stderr)
</py-script>
<div id="on-page"></div>
<py-script>
print("good.", file=sys.stderr)
</py-script>
<py-script stderr="not-on-page">
print("bad.", file=sys.stderr)
</py-script>
"""
)
banner = self.page.query_selector_all(".py-warning")
assert len(banner) == 1
banner_content = banner[0].inner_text()
expected = (
'stderr = "not-on-page" does not match the id of any element on the page.'
)
assert banner_content == expected
def test_stdio_stderr(self):
# Test that stderr works, and routes to the same location as stdout
# Also, script tags with the stderr attribute route to an additional location
self.pyscript_run(
"""
<div id="stdout-div"></div>
<div id="stderr-div"></div>
<py-script output="stdout-div" stderr="stderr-div">
import sys
print("one.", file=sys.stderr)
print("two.")
</py-script>
"""
)
assert self.page.locator("#stdout-div").text_content() == "one.two."
assert self.page.locator("#stderr-div").text_content() == "one."
self.assert_no_banners()
def test_stdio_output_attribute_change(self):
# If the user changes the 'output' attribute of a <py-script> tag mid-execution,
# Output should no longer go to the selected div and a warning should appear
self.pyscript_run(
"""
<div id="first"></div>
<div id="second"></div>
<!-- There is no tag with id "third" -->
<py-script id="pyscript-tag" output="first">
print("one.")
# Change the 'output' attribute of this tag
import js
this_tag = js.document.getElementById("pyscript-tag")
this_tag.setAttribute("output", "second")
print("two.")
this_tag.setAttribute("output", "third")
print("three.")
</py-script>
"""
)
assert self.page.locator("#first").text_content() == "one."
assert self.page.locator("#second").text_content() == "two."
expected_alert_banner_msg = (
'output = "third" does not match the id of any element on the page.'
)
alert_banner = self.page.locator(".alert-banner")
assert expected_alert_banner_msg in alert_banner.inner_text()
def test_stdio_target_element_id_change(self):
# If the user changes the ID of the targeted DOM element mid-execution,
# Output should no longer go to the selected element and a warning should appear
self.pyscript_run(
"""
<div id="first"></div>
<div id="second"></div>
<!-- There is no tag with id "third" -->
<py-script id="pyscript-tag" output="first">
print("one.")
# Change the ID of the targeted DIV to something else
import js
target_tag = js.document.getElementById("first")
# should fail and show banner
target_tag.setAttribute("id", "second")
print("two.")
# But changing both the 'output' attribute and the id of the target
# should work
target_tag.setAttribute("id", "third")
js.document.getElementById("pyscript-tag").setAttribute("output", "third")
print("three.")
</py-script>
"""
)
# Note the ID of the div has changed by the time of this assert
assert self.page.locator("#third").text_content() == "one.three."
expected_alert_banner_msg = (
'output = "first" does not match the id of any element on the page.'
)
alert_banner = self.page.locator(".alert-banner")
assert expected_alert_banner_msg in alert_banner.inner_text()

View File

@@ -0,0 +1,28 @@
from .support import PyScriptTest
class TestWarningsAndBanners(PyScriptTest):
# Test the behavior of generated warning banners
def test_create_singular_warning(self):
# Use a script tag with an invalid output attribute to generate a warning, but only one
self.pyscript_run(
"""
<py-script output="foo">
print("one.")
print("two.")
</py-script>
<py-script output="foo">
print("three.")
</py-script>
"""
)
loc = self.page.locator(".alert-banner")
# Only one banner should appear
assert loc.count() == 1
assert (
loc.text_content()
== 'output = "foo" does not match the id of any element on the page.'
)

View File

@@ -1,4 +1,5 @@
import { type Stdio, CaptureStdio, StdioMultiplexer } from '../../src/stdio';
import { expect } from '@jest/globals';
import { type Stdio, CaptureStdio, StdioMultiplexer, TargetedStdio } from '../../src/stdio';
describe('CaptureStdio', () => {
it('captured streams are initialized to empty string', () => {
@@ -65,3 +66,75 @@ describe('StdioMultiplexer', () => {
expect(b.captured_stderr).toBe("err 2\n");
});
});
describe('TargetedStdio', () => {
let capture: CaptureStdio;
let targeted: TargetedStdio;
let error_targeted: TargetedStdio;
let multi: StdioMultiplexer;
beforeEach(() => {
//DOM element to capture stdout and stderr
let target_div = document.getElementById("output-id");
if (target_div=== null){
target_div = document.createElement('div');
target_div.id = "output-id";
document.body.appendChild(target_div);
}
else {
target_div.innerHTML = "";
}
//DOM element to capture stderr
let error_div = document.getElementById("error-id");
if (error_div=== null){
error_div = document.createElement('div');
error_div.id = "error-id";
document.body.appendChild(error_div);
}
else {
error_div.innerHTML = "";
}
const tag = document.createElement('div');
tag.setAttribute("output", "output-id");
tag.setAttribute("stderr", "error-id");
capture = new CaptureStdio();
targeted = new TargetedStdio(tag, 'output', true, true);
error_targeted = new TargetedStdio(tag, 'stderr', false, true);
multi = new StdioMultiplexer();
multi.addListener(capture);
multi.addListener(targeted);
multi.addListener(error_targeted);
});
it('targeted id is set by constructor', () =>{
expect(targeted.source_attribute).toBe("output");
});
it('targeted stdio/stderr also goes to multiplexer', () =>{
multi.stdout_writeline("out 1");
multi.stderr_writeline("out 2");
expect(capture.captured_stdout).toBe("out 1\n");
expect(capture.captured_stderr).toBe("out 2\n");
expect(document.getElementById("output-id")?.innerHTML).toBe("out 1<br>out 2<br>");
expect(document.getElementById("error-id")?.innerHTML).toBe("out 2<br>");
});
it('Add and remove targeted listener', () => {
multi.stdout_writeline("out 1");
multi.removeListener(targeted);
multi.stdout_writeline("out 2");
multi.addListener(targeted);
multi.stdout_writeline("out 3");
//all three should be captured by multiplexer
expect(capture.captured_stdout).toBe("out 1\nout 2\nout 3\n");
//out 2 should not be present in the DOM element
expect(document.getElementById("output-id")?.innerHTML).toBe("out 1<br>out 3<br>");
});
});

View File

@@ -1,5 +1,5 @@
import { beforeEach, expect, describe, it } from "@jest/globals"
import { ensureUniqueId, joinPaths} from "../../src/utils"
import { ensureUniqueId, joinPaths, createSingularWarning} from "../../src/utils"
describe("Utils", () => {
@@ -51,4 +51,23 @@ describe("JoinPaths", () => {
const joinedPath = joinPaths(paths);
expect(joinedPath).toStrictEqual('hhh/ll/pp/kkk');
})
describe("createSingularBanner", () => {
it("should create one and new banner containing the sentinel text, and not duplicate it", () => {
//One warning banner with the desired text should be created
createSingularWarning("A unique error message", "unique")
expect(document.getElementsByClassName("alert-banner")?.length).toEqual(1)
expect(document.getElementsByClassName("alert-banner")[0].textContent).toEqual(expect.stringContaining("A unique error message"))
//Should still only be one banner, since the second uses the existing sentinel value "unique"
createSingularWarning("This banner should not appear", "unique")
expect(document.getElementsByClassName("alert-banner")?.length).toEqual(1)
expect(document.getElementsByClassName("alert-banner")[0].textContent).toEqual(expect.stringContaining("A unique error message"))
//If the sentinel value is not provided, the entire msg is used as the sentinel
createSingularWarning("A unique error message", null)
expect(document.getElementsByClassName("alert-banner")?.length).toEqual(1)
expect(document.getElementsByClassName("alert-banner")[0].textContent).toEqual(expect.stringContaining("A unique error message"))
})
})
})