More plugins: splashscreen and importmap (#938)

This PR move codes from main.ts into two new plugins:

- splashscreen (formerly known as py-loader)
- importmap

The old setting config.autoclose_loader is still supported but deprecated; the new setting is config.splashscreen.autoclose.

Moreover, it does a small refactoring around UserError: now UserErrors are correctly caught even if they are raised from within afterRuntimeLoad.
This commit is contained in:
Antonio Cuni
2022-11-16 18:08:17 +01:00
committed by GitHub
parent b79ceea7a8
commit 41ebaaf366
19 changed files with 498 additions and 284 deletions

View File

@@ -21,8 +21,6 @@
files = ["./utils.py"]
</py-config>
<py-script output="outputDiv">
# demonstrates how use the global PyScript pyscript_loader
# to send operation log messages to it
import utils
display(utils.now())
</py-script>
@@ -43,8 +41,6 @@ async def foo():
else:
out3.clear()
# close the global PyScript pyscript_loader
pyscript_loader.close()
pyscript.run_until_complete(foo())
</py-script>
</body>

View File

@@ -0,0 +1 @@
module.exports = "";

View File

@@ -18,5 +18,6 @@ module.exports = {
},
moduleNameMapper: {
'^[./a-zA-Z0-9$_-]+\\.py$': '<rootDir>/__mocks__/fileMock.js',
'\\.(css)$': '<rootDir>/__mocks__/cssMock.js',
},
};

View File

@@ -1,42 +0,0 @@
import { getLogger } from '../logger';
const logger = getLogger('py-loader');
export class PyLoader extends HTMLElement {
widths: string[];
label: string;
mount_name: string;
details: HTMLElement;
operation: HTMLElement;
constructor() {
super();
}
connectedCallback() {
this.innerHTML = `<div id="pyscript_loading_splash" class="py-overlay">
<div class="py-pop-up">
<div class="smooth spinner"></div>
<div id="pyscript-loading-label" class="label">
<div id="pyscript-operation-details">
</div>
</div>
</div>
</div>`;
this.mount_name = this.id.split('-').join('_');
this.operation = document.getElementById('pyscript-operation');
this.details = document.getElementById('pyscript-operation-details');
}
log(msg: string) {
// loader messages are showed both in the HTML and in the console
logger.info(msg);
const newLog = document.createElement('p');
newLog.innerText = msg;
this.details.appendChild(newLog);
}
close() {
logger.info('Closing');
this.remove();
}
}

View File

@@ -1,9 +1,14 @@
const CLOSEBUTTON = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill="currentColor" width="12px"><path d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/></svg>`;
type MessageType = "text" | "html";
export class UserError extends Error {
constructor(message: string) {
super(message)
this.name = "UserError"
messageType: MessageType;
constructor(message: string, t: MessageType = "text") {
super(message);
this.name = "UserError";
this.messageType = t;
}
}
@@ -17,7 +22,7 @@ export class FetchError extends Error {
export function _createAlertBanner(
message: string,
level: "error" | "warning" = "error",
messageType: "text" | "html" = "text",
messageType: MessageType = "text",
logMessage = true) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
switch (`log-${level}-${logMessage}`) {
@@ -53,27 +58,3 @@ export function _createAlertBanner(
document.body.prepend(banner);
}
/*
* This function is used to handle UserError, if we see an error of this
* type, we will automatically create a banner on the page that will tell
* the user what went wrong. Note that the error will still stop execution,
* any other errors we will simply throw them and no banner will be shown.
*/
export function withUserErrorHandler(fn) {
try {
return fn();
} catch (error: unknown) {
if (error instanceof UserError) {
/*
* Display a page-wide error message to show that something has gone wrong with
* PyScript or Pyodide during loading. Probably not be used for issues that occur within
* Python scripts, since stderr can be routed to somewhere in the DOM
*/
_createAlertBanner(error.message);
}
else {
throw error;
}
}
}

View File

@@ -5,20 +5,16 @@ import type { AppConfig } from './pyconfig';
import type { Runtime } from './runtime';
import { PluginManager } from './plugin';
import { make_PyScript, initHandlers, mountElements } from './components/pyscript';
import { PyLoader } from './components/pyloader';
import { PyodideRuntime } from './pyodide';
import { getLogger } from './logger';
import { handleFetchError, showWarning, globalExport } from './utils';
import { calculatePaths } from './plugins/fetch';
import { createCustomElements } from './components/elements';
import { UserError, withUserErrorHandler } from "./exceptions"
import { UserError, _createAlertBanner } from "./exceptions"
import { type Stdio, StdioMultiplexer, DEFAULT_STDIO } from './stdio';
import { PyTerminalPlugin } from './plugins/pyterminal';
type ImportType = { [key: string]: unknown };
type ImportMapType = {
imports: ImportType | null;
};
import { SplashscreenPlugin } from './plugins/splashscreen';
import { ImportmapPlugin } from './plugins/importmap';
const logger = getLogger('pyscript/main');
@@ -28,7 +24,7 @@ const logger = getLogger('pyscript/main');
2. loadConfig(): search for py-config and compute the config for the app
3. show the loader/splashscreen
3. (it used to be "show the splashscreen", but now it's a plugin)
4. loadRuntime(): start downloading the actual runtime (e.g. pyodide.js)
@@ -60,7 +56,6 @@ More concretely:
export class PyScriptApp {
config: AppConfig;
loader: PyLoader;
runtime: Runtime;
PyScript: any; // XXX would be nice to have a more precise type for the class itself
plugins: PluginManager;
@@ -69,20 +64,47 @@ export class PyScriptApp {
constructor() {
// initialize the builtin plugins
this.plugins = new PluginManager();
this.plugins.add(new PyTerminalPlugin(this));
this.plugins.add(
new SplashscreenPlugin(),
new PyTerminalPlugin(this),
new ImportmapPlugin(),
);
this._stdioMultiplexer = new StdioMultiplexer();
this._stdioMultiplexer.addListener(DEFAULT_STDIO);
}
// lifecycle (1)
// Error handling logic: if during the execution we encounter an error
// which is ultimate responsibility of the user (e.g.: syntax error in the
// config, file not found in fetch, etc.), we can throw UserError(). It is
// responsibility of main() to catch it and show it to the user in a
// proper way (e.g. by using a banner at the top of the page).
main() {
try {
this._realMain();
}
catch(error) {
this._handleUserErrorMaybe(error);
}
}
_handleUserErrorMaybe(error) {
if (error instanceof UserError) {
_createAlertBanner(error.message, "error", error.messageType);
this.plugins.onUserError(error);
}
else {
throw error;
}
}
// ============ lifecycle ============
// lifecycle (1)
_realMain() {
this.loadConfig();
this.plugins.configure(this.config);
this.showLoader(); // this should be a plugin
this.plugins.beforeLaunch(this.config);
this.loadRuntime();
}
@@ -97,10 +119,6 @@ export class PyScriptApp {
let el: Element | null = null;
if (elements.length > 0) el = elements[0];
if (elements.length >= 2) {
// XXX: ideally, I would like to have a way to raise "fatal
// errors" and stop the computation, but currently our life cycle
// is too messy to implement it reliably. We might want to revisit
// this once it's in a better shape.
showWarning(
'Multiple <py-config> tags detected. Only the first is ' +
'going to be parsed, all the others will be ignored',
@@ -110,15 +128,6 @@ export class PyScriptApp {
logger.info('config loaded:\n' + JSON.stringify(this.config, null, 2));
}
// lifecycle (3)
showLoader() {
// add loader to the page body
logger.info('add py-loader');
customElements.define('py-loader', PyLoader);
this.loader = <PyLoader>document.createElement('py-loader');
document.body.append(this.loader);
}
// lifecycle (4)
loadRuntime() {
logger.info('Initializing runtime');
@@ -135,11 +144,20 @@ export class PyScriptApp {
runtime_cfg.src,
runtime_cfg.name,
runtime_cfg.lang);
this.loader.log(`Downloading ${runtime_cfg.name}...`);
this.logStatus(`Downloading ${runtime_cfg.name}...`);
// download pyodide by using a <script> tag. Once it's ready, the
// "load" event will be fired and the exeuction logic will continue.
// Note that the load event is fired asynchronously and thus any
// exception which is throw inside the event handler is *NOT* caught
// by the try/catch inside main(): that's why we need to .catch() it
// explicitly and call _handleUserErrorMaybe also there.
const script = document.createElement('script'); // create a script DOM node
script.src = this.runtime.src;
script.addEventListener('load', () => {
void this.afterRuntimeLoad(this.runtime);
this.afterRuntimeLoad(this.runtime).catch((error) => {
this._handleUserErrorMaybe(error);
});
});
document.head.appendChild(script);
}
@@ -148,40 +166,35 @@ export class PyScriptApp {
// See the overview comment above for an explanation of how we jump from
// point (4) to point (5).
//
// Invariant: this.config and this.loader are set and available.
// Invariant: this.config is set and available.
async afterRuntimeLoad(runtime: Runtime): Promise<void> {
console.assert(this.config !== undefined);
console.assert(this.loader !== undefined);
this.loader.log('Python startup...');
this.logStatus('Python startup...');
await runtime.loadInterpreter();
this.loader.log('Python ready!');
this.logStatus('Python ready!');
// eslint-disable-next-line
runtime.globals.set('pyscript_loader', this.loader);
this.loader.log('Setting up virtual environment...');
this.logStatus('Setting up virtual environment...');
await this.setupVirtualEnv(runtime);
await mountElements(runtime);
// lifecycle (6.5)
this.plugins.afterSetup(runtime);
this.loader.log('Executing <py-script> tags...');
this.logStatus('Executing <py-script> tags...');
this.executeScripts(runtime);
this.loader.log('Initializing web components...');
this.logStatus('Initializing web components...');
// lifecycle (8)
createCustomElements(runtime);
if (runtime.config.autoclose_loader) {
this.loader.close();
}
await initHandlers(runtime);
// NOTE: runtime message is used by integration tests to know that
// pyscript initialization has complete. If you change it, you need to
// change it also in tests/integration/support.py
this.logStatus("Startup complete");
this.plugins.afterStartup(runtime);
logger.info('PyScript page fully initialized');
}
@@ -208,8 +221,6 @@ export class PyScriptApp {
try {
await runtime.loadFromFile(paths[i], fetchPaths[i]);
} catch (e) {
// Remove the loader so users can see the banner better
this.loader.remove()
// The 'TypeError' here happens when running pytest
// I'm not particularly happy with this solution.
if (e.name === "FetchError" || e.name === "TypeError") {
@@ -224,58 +235,21 @@ export class PyScriptApp {
// lifecycle (7)
executeScripts(runtime: Runtime) {
void this.register_importmap(runtime);
this.PyScript = make_PyScript(runtime);
customElements.define('py-script', this.PyScript);
}
// ================= registraton API ====================
logStatus(msg: string) {
logger.info(msg);
const ev = new CustomEvent("py-status-message", { detail: msg });
document.dispatchEvent(ev);
}
registerStdioListener(stdio: Stdio) {
this._stdioMultiplexer.addListener(stdio);
}
async register_importmap(runtime: Runtime) {
// make importmap ES modules available from python using 'import'.
//
// XXX: this code can probably be improved because errors are silently
// ignored. Moreover at the time of writing we don't really have a test
// for it and this functionality is used only by the d3 example. We
// might want to rethink the whole approach at some point. E.g., maybe
// we should move it to py-config?
//
// Moreover, it's also wrong because it's async and currently we don't
// await the module to be fully registered before executing the code
// inside py-script. It's also unclear whether we want to wait or not
// (or maybe only wait only if we do an actual 'import'?)
for (const node of document.querySelectorAll("script[type='importmap']")) {
const importmap: ImportMapType = (() => {
try {
return JSON.parse(node.textContent) as ImportMapType;
} catch {
return null;
}
})();
if (importmap?.imports == null) continue;
for (const [name, url] of Object.entries(importmap.imports)) {
if (typeof name != 'string' || typeof url != 'string') continue;
let exports: object;
try {
// XXX: pyodide doesn't like Module(), failing with
// "can't read 'name' of undefined" at import time
exports = { ...(await import(url)) } as object;
} catch {
logger.warn(`failed to fetch '${url}' for '${name}'`);
continue;
}
runtime.registerJsModule(name, exports);
}
}
}
}
function pyscript_get_config() {
@@ -285,6 +259,6 @@ globalExport('pyscript_get_config', pyscript_get_config);
// main entry point of execution
const globalApp = new PyScriptApp();
withUserErrorHandler(globalApp.main.bind(globalApp));
globalApp.main();
export const runtime = globalApp.runtime;

View File

@@ -1,5 +1,6 @@
import type { AppConfig } from './pyconfig';
import type { Runtime } from './runtime';
import type { UserError } from './exceptions';
export class Plugin {
@@ -38,6 +39,19 @@ export class Plugin {
*/
afterSetup(runtime: Runtime) {
}
/** 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.
*/
afterStartup(runtime: Runtime) {
}
/** Called when an UserError is raised
*/
onUserError(error: UserError) {
}
}
@@ -48,7 +62,8 @@ export class PluginManager {
this._plugins = [];
}
add(p: Plugin) {
add(...plugins: Plugin[]) {
for (const p of plugins)
this._plugins.push(p);
}
@@ -66,4 +81,14 @@ export class PluginManager {
for (const p of this._plugins)
p.afterSetup(runtime);
}
afterStartup(runtime: Runtime) {
for (const p of this._plugins)
p.afterStartup(runtime);
}
onUserError(error: UserError) {
for (const p of this._plugins)
p.onUserError(error);
}
}

View File

@@ -0,0 +1,56 @@
import type { Runtime } from '../runtime';
import { showWarning } from '../utils';
import { Plugin } from '../plugin';
import { getLogger } from '../logger';
const logger = getLogger('plugins/importmap');
type ImportType = { [key: string]: unknown };
type ImportMapType = {
imports: ImportType | null;
};
export class ImportmapPlugin extends Plugin {
async afterSetup(runtime: Runtime) {
// make importmap ES modules available from python using 'import'.
//
// XXX: this code can probably be improved because errors are silently
// ignored.
//
// Moreover, it's also wrong because it's async and currently we don't
// await the module to be fully registered before executing the code
// inside py-script. It's also unclear whether we want to wait or not
// (or maybe only wait only if we do an actual 'import'?)
for (const node of document.querySelectorAll("script[type='importmap']")) {
const importmap: ImportMapType = (() => {
try {
return JSON.parse(node.textContent) as ImportMapType;
}
catch(error) {
showWarning("Failed to parse import map: " + error.message);
}
})();
if (importmap?.imports == null) continue;
for (const [name, url] of Object.entries(importmap.imports)) {
if (typeof name != 'string' || typeof url != 'string') continue;
let exports: object;
try {
// XXX: pyodide doesn't like Module(), failing with
// "can't read 'name' of undefined" at import time
exports = { ...(await import(url)) } as object;
} catch {
logger.warn(`failed to fetch '${url}' for '${name}'`);
continue;
}
logger.info("Registering JS module", name);
runtime.registerJsModule(name, exports);
}
}
}
}

View File

@@ -1,5 +1,6 @@
import type { PyScriptApp } from '../main';
import type { AppConfig } from '../pyconfig';
import type { Runtime } from '../runtime';
import { Plugin } from '../plugin';
import { UserError } from "../exceptions"
import { getLogger } from '../logger';
@@ -46,7 +47,7 @@ export class PyTerminalPlugin extends Plugin {
}
}
afterSetup() {
afterSetup(runtime: Runtime) {
// the Python interpreter has been initialized and we are ready to
// execute user code:
//

View File

@@ -0,0 +1,103 @@
import type { PyScriptApp } from '../main';
import type { AppConfig } from '../pyconfig';
import type { UserError } from '../exceptions';
import type { Runtime } from '../runtime';
import { showWarning } from '../utils';
import { Plugin } from '../plugin';
import { getLogger } from '../logger';
const logger = getLogger('py-splashscreen');
const AUTOCLOSE_LOADER_DEPRECATED = `
The setting autoclose_loader is deprecated. Please use the
following instead:<br>
<pre>
&lt;py-config&gt;
[splashscreen]
autoclose = false
&lt;/py-config&gt;
</pre>`;
export class SplashscreenPlugin extends Plugin {
elem: PySplashscreen;
autoclose: boolean;
configure(config: AppConfig) {
// the officially supported setting is config.splashscreen.autoclose,
// but we still also support the old config.autoclose_loader (with a
// deprecation warning)
this.autoclose = true;
if ("autoclose_loader" in config) {
this.autoclose = config.autoclose_loader;
showWarning(AUTOCLOSE_LOADER_DEPRECATED);
}
if (config.splashscreen) {
this.autoclose = config.splashscreen.autoclose ?? true;
}
}
beforeLaunch(config: AppConfig) {
// add the splashscreen to the DOM
logger.info('add py-splashscreen');
customElements.define('py-splashscreen', PySplashscreen);
this.elem = <PySplashscreen>document.createElement('py-splashscreen');
document.body.append(this.elem);
document.addEventListener("py-status-message", (e: CustomEvent) => {
const msg = e.detail;
this.elem.log(msg);
});
}
afterStartup(runtime: Runtime) {
if (this.autoclose) {
this.elem.close();
}
}
onUserError(error: UserError) {
if (this.elem !== undefined) {
// Remove the splashscreen so users can see the banner better
this.elem.close();
}
}
}
export class PySplashscreen extends HTMLElement {
widths: string[];
label: string;
mount_name: string;
details: HTMLElement;
operation: HTMLElement;
constructor() {
super();
}
connectedCallback() {
this.innerHTML = `<div id="pyscript_loading_splash" class="py-overlay">
<div class="py-pop-up">
<div class="smooth spinner"></div>
<div id="pyscript-loading-label" class="label">
<div id="pyscript-operation-details">
</div>
</div>
</div>
</div>`;
this.mount_name = this.id.split('-').join('_');
this.operation = document.getElementById('pyscript-operation');
this.details = document.getElementById('pyscript-operation-details');
}
log(msg: string) {
const newLog = document.createElement('p');
newLog.innerText = msg;
this.details.appendChild(newLog);
}
close() {
logger.info('Closing');
this.remove();
}
}

View File

@@ -15,7 +15,6 @@ export interface AppConfig extends Record<string, any> {
author_name?: string;
author_email?: string;
license?: string;
autoclose_loader?: boolean;
runtimes?: RuntimeConfig[];
packages?: string[];
fetch?: FetchConfig[];
@@ -44,14 +43,12 @@ export type PyScriptMetadata = {
const allKeys = {
string: ['name', 'description', 'version', 'type', 'author_name', 'author_email', 'license'],
number: ['schema_version'],
boolean: ['autoclose_loader'],
array: ['runtimes', 'packages', 'fetch', 'plugins'],
};
export const defaultConfig: AppConfig = {
schema_version: 1,
type: 'app',
autoclose_loader: true,
runtimes: [
{
src: 'https://cdn.jsdelivr.net/pyodide/v0.21.3/full/pyodide.js',
@@ -163,7 +160,7 @@ function parseConfig(configText: string, configType = 'toml') {
throw new UserError(`The config supplied: ${configText} is an invalid JSON and cannot be parsed: ${errMessage}`);
}
} else {
throw new UserError(`<p>The type of config supplied'${configType}' is not supported, supported values are ["toml", "json"].</p>`);
throw new UserError(`The type of config supplied '${configType}' is not supported, supported values are ["toml", "json"]`);
}
return config;
}

View File

@@ -46,10 +46,8 @@ export function showWarning(msg: string, messageType: "text" | "html" = "text"):
}
export function handleFetchError(e: Error, singleFile: string) {
//Should we still export full error contents to console?
// XXX: What happens if I make a typo? i.e. a web server is being used but a file
// that doesn't exist is being accessed. We should cover this case as well.
console.warn(`Caught an error in fetchPaths:\r\n ${e.toString()}`);
// XXX: inspecting the error message to understand what happened is very
// fragile. We need a better solution.
let errorContent: string;
if (e.message.includes('Failed to fetch')) {
errorContent = `<p>PyScript: Access to local files
@@ -66,11 +64,7 @@ export function handleFetchError(e: Error, singleFile: string) {
} else {
errorContent = `<p>PyScript encountered an error while loading from file: ${e.message} </p>`;
}
// We need to create the banner because `handleFetchError` is called before we
// use withUserErrorHandler in main.js we are also disabling the log message
// because it will be logged by the uncaught exception in promise.
_createAlertBanner(errorContent, "error", "html", false);
throw new UserError(errorContent);
throw new UserError(errorContent, "html");
}
export function readTextFromPath(path: string) {

View File

@@ -77,6 +77,7 @@ class PyScriptTest:
# fixture, the server automatically starts in its own thread.
self.http_server = request.getfixturevalue("http_server")
self.router = None
self.is_fake_server = False
else:
# use the internal playwright routing
self.http_server = "http://fake_server"
@@ -87,6 +88,7 @@ class PyScriptTest:
usepdb=request.config.option.usepdb,
)
self.router.install(page)
self.is_fake_server = True
#
self.init_page(page)
#

View File

@@ -0,0 +1,65 @@
from .support import PyScriptTest
class TestImportmap(PyScriptTest):
def test_importmap(self):
src = """
export function say_hello(who) {
console.log("hello from", who);
}
"""
self.writefile("mymod.js", src)
#
self.pyscript_run(
"""
<script type="importmap">
{
"imports": {
"mymod": "/mymod.js"
}
}
</script>
<script type="module">
import { say_hello } from "mymod";
say_hello("JS");
</script>
<py-script>
import mymod
mymod.say_hello("Python")
</py-script>
"""
)
assert self.console.log.lines == [
"hello from JS",
self.PY_COMPLETE,
"hello from Python",
]
def test_invalid_json(self):
self.pyscript_run(
"""
<script type="importmap">
this is not valid JSON
</script>
<py-script>
print("hello world")
</py-script>
""",
wait_for_pyscript=False,
)
# this error is raised by the browser itself, when *it* tries to parse
# the import map
self.check_js_errors("Failed to parse import map")
self.wait_for_pyscript()
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello world",
]
# this warning is shown by pyscript, when *we* try to parse the import
# map
banner = self.page.locator(".py-warning")
assert "Failed to parse import map" in banner.inner_text()

View File

@@ -5,7 +5,7 @@ import tempfile
import pytest
import requests
from .support import JsErrors, PyScriptTest
from .support import PyScriptTest
URL = "https://github.com/pyodide/pyodide/releases/download/0.20.0/pyodide-build-0.20.0.tar.bz2"
TAR_NAME = "pyodide-build-0.20.0.tar.bz2"
@@ -245,30 +245,20 @@ class TestConfig(PyScriptTest):
wait_for_pyscript=False,
)
# This is expected if running pytest with --dev flag
localErrorContent = """PyScript: Access to local files
if self.is_fake_server:
expected = """PyScript: Access to local files
(using "Paths:" in &lt;py-config&gt;)
is not available when directly opening a HTML file;
you must use a webserver to serve the additional files."""
# This is expected if running a live server
serverErrorContent = (
else:
expected = (
"Loading from file <u>./f.py</u> failed with error 404 (File not Found). "
"Are your filename and path are correct?"
)
inner_html = self.page.locator(".py-error").inner_html()
assert localErrorContent in inner_html or serverErrorContent in inner_html
assert "Failed to load resource" in self.console.error.lines[0]
assert "Caught an error in fetchPaths" in self.console.warning.lines[0]
with pytest.raises(JsErrors) as exc:
self.check_js_errors()
received_error_msg = str(exc.value)
assert (
localErrorContent in received_error_msg
or serverErrorContent in received_error_msg
)
assert expected in inner_html
assert expected in self.console.error.lines[-1]
def test_paths_from_packages(self):
self.writefile("utils/__init__.py", "")

View File

@@ -0,0 +1,86 @@
from playwright.sync_api import expect
from .support import PyScriptTest
class TestSplashscreen(PyScriptTest):
def test_autoshow_and_autoclose(self):
"""
By default, we show the splashscreen and we close it when the loading is
complete.
XXX: this test is a bit fragile: now it works reliably because the
startup is so slow that when we do expect(div).to_be_visible(), the
splashscreen is still there. But in theory, if the startup become very
fast, it could happen that by the time we arrive in python lang, it
has already been removed.
"""
self.pyscript_run(
"""
<py-script>
print('hello pyscript')
</py-script>
""",
wait_for_pyscript=False,
)
div = self.page.locator("py-splashscreen > div")
expect(div).to_be_visible()
expect(div).to_contain_text("Python startup...")
assert "Python startup..." in self.console.info.text
#
# now we wait for the startup to complete
self.wait_for_pyscript()
#
# and now the splashscreen should have been removed
expect(div).to_be_hidden()
assert self.page.locator("py-locator").count() == 0
#
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello pyscript",
]
def test_autoclose_false(self):
self.pyscript_run(
"""
<py-config>
[splashscreen]
autoclose = false
</py-config>
<py-script>
print('hello pyscript')
</py-script>
""",
)
div = self.page.locator("py-splashscreen > div")
expect(div).to_be_visible()
expect(div).to_contain_text("Python startup...")
expect(div).to_contain_text("Startup complete")
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello pyscript",
]
def test_autoclose_loader_deprecated(self):
self.pyscript_run(
"""
<py-config>
autoclose_loader = false
</py-config>
<py-script>
print('hello pyscript')
</py-script>
""",
)
warning = self.page.locator(".py-warning")
inner_text = warning.inner_text()
assert "The setting autoclose_loader is deprecated" in inner_text
#
div = self.page.locator("py-splashscreen > div")
expect(div).to_be_visible()
expect(div).to_contain_text("Python startup...")
expect(div).to_contain_text("Startup complete")
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello pyscript",
]

View File

@@ -1,5 +1,5 @@
import { expect, it, jest } from "@jest/globals"
import { _createAlertBanner, withUserErrorHandler, UserError } from "../../src/exceptions"
import { _createAlertBanner, UserError } from "../../src/exceptions"
describe("Test _createAlertBanner", () => {
@@ -80,41 +80,8 @@ describe("Test _createAlertBanner", () => {
_createAlertBanner("Test warning", "warning", "text", false);
expect(warnLogSpy).not.toHaveBeenCalledWith("Test warning");
})
})
describe("Test withUserErrorHandler", () => {
afterEach(() => {
// Ensure we always have a clean body
document.body.innerHTML = `<div>Hello World</div>`;
})
it("userError doesn't stop execution", async () => {
function exception() {
throw new UserError("Computer says no");
}
function func() {
withUserErrorHandler(exception);
return "Hello, world";
}
const returnValue = func();
const banners = document.getElementsByClassName("alert-banner");
expect(banners.length).toBe(1);
expect(banners[0].innerHTML).toBe("Computer says no");
expect(returnValue).toBe("Hello, world");
})
it("any other exception should stop execution and raise", async () => {
function exception() {
throw new Error("Explosions!");
}
expect(() => withUserErrorHandler(exception)).toThrow(new Error("Explosions!"))
})
it('_createAlertbanner messageType text writes message to content', async () => {
let banner = document.getElementsByClassName("alert-banner");
expect(banner.length).toBe(0);

View File

@@ -0,0 +1,69 @@
import { jest } from "@jest/globals"
import { UserError } from "../../src/exceptions"
import { PyScriptApp } from "../../src/main"
describe("Test withUserErrorHandler", () => {
class MyApp extends PyScriptApp {
myRealMain: any;
constructor(myRealMain) {
super();
this.myRealMain = myRealMain;
}
_realMain() {
this.myRealMain();
}
}
beforeEach(() => {
// Ensure we always have a clean body
document.body.innerHTML = `<div>Hello World</div>`;
});
it("userError doesn't stop execution", () => {
function myRealMain() {
throw new UserError("Computer says no");
}
const app = new MyApp(myRealMain);
app.main();
const banners = document.getElementsByClassName("alert-banner");
expect(banners.length).toBe(1);
expect(banners[0].innerHTML).toBe("Computer says no");
});
it("userError escapes by default", () => {
function myRealMain() {
throw new UserError("hello <br>");
}
const app = new MyApp(myRealMain);
app.main();
const banners = document.getElementsByClassName("alert-banner");
expect(banners.length).toBe(1);
expect(banners[0].innerHTML).toBe("hello &lt;br&gt;");
});
it("userError messageType=html don't escape", () => {
function myRealMain() {
throw new UserError("hello <br>", "html");
}
const app = new MyApp(myRealMain);
app.main();
const banners = document.getElementsByClassName("alert-banner");
expect(banners.length).toBe(1);
expect(banners[0].innerHTML).toBe("hello <br>");
});
it("any other exception should stop execution and raise", () => {
function myRealMain() {
throw new Error("Explosions!");
}
const app = new MyApp(myRealMain);
expect(() => app.main()).toThrow(new Error("Explosions!"))
});
});

View File

@@ -1,52 +0,0 @@
import { jest } from '@jest/globals';
import { PyLoader } from "../../src/components/pyloader"
import { getLogger } from "../../src/logger"
customElements.define('py-loader', PyLoader);
describe('PyLoader', () => {
let instance: PyLoader;
const logger = getLogger("py-loader")
beforeEach(() => {
instance = new PyLoader();
logger.info = jest.fn()
})
it('PyLoader instantiates correctly', async () => {
expect (instance).toBeInstanceOf(PyLoader);
})
it('connectedCallback adds splash screen', async () => {
// innerHTML should be empty
expect(instance.innerHTML).toBe("")
instance.connectedCallback();
// This is just checking that we have some ids or class names
expect(instance.innerHTML).toContain('pyscript_loading_splash')
expect(instance.innerHTML).toContain("spinner")
expect(instance.mount_name).toBe("")
})
it('confirm calling log will log to console and page', () => {
const element = document.createElement('div')
element.setAttribute("id", "pyscript-operation-details")
instance.details = element
instance.log("Hello, world!")
const printedLog = element.getElementsByTagName('p')
expect(logger.info).toHaveBeenCalledWith("Hello, world!")
expect(printedLog[0].innerText).toBe("Hello, world!")
})
it('confirm that calling close removes element', async () => {
instance.remove = jest.fn()
instance.close()
expect(logger.info).toHaveBeenCalledWith("Closing")
expect(instance.remove).toHaveBeenCalled()
})
})