mirror of
https://github.com/pyscript/pyscript.git
synced 2026-02-21 11:01:26 -05:00
Move pyodide to a web worker (#1333)
This PR adds support for optionally running pyodide in a web worker: - add a new option config.execution_thread, which can be `main` or `worker`. The default is `main` - improve the test machinery so that we run all tests twice, once for `main` and once for `worker` - add a new esbuild target which builds the code for the worker The support for workers is not complete and many features are still missing: there are 71 tests which are marked as `@skip_worker`, but we can fix them in subsequent PRs. The vast majority of tests fail because js.document is unavailable: for it to run transparently, we need the "auto-syncify" feature of synclink. Co-authored-by: Hood Chatham <roberthoodchatham@gmail.com> Co-authored-by: Madhur Tandon <20173739+madhur-tandon@users.noreply.github.com>
This commit is contained in:
@@ -26,6 +26,10 @@ Features
|
||||
### Plugins
|
||||
- Plugins may now implement the `beforePyReplExec()` and `afterPyReplExec()` hooks, which are called immediately before and after code in a `py-repl` tag is executed. ([#1106](https://github.com/pyscript/pyscript/pull/1106))
|
||||
|
||||
### Web worker support
|
||||
- introduced the new experimental `execution_thread` config option: if you set `execution_thread = "worker"`, the python interpreter runs inside a web worker
|
||||
- worker support is still **very** experimental: not everything works, use it at your own risk
|
||||
|
||||
Bug fixes
|
||||
---------
|
||||
|
||||
|
||||
@@ -190,6 +190,16 @@ $ pytest test_01_basic.py -k test_pyscript_hello -s --dev
|
||||
`--dev` implies `--headed --no-fake-server`. In addition, it also
|
||||
automatically open chrome dev tools.
|
||||
|
||||
#### To run only main thread or worker tests
|
||||
|
||||
By default, we run each test twice: one with `execution_thread = "main"` and
|
||||
one with `execution_thread = "worker"`. If you want to run only half of them,
|
||||
you can use `-m`:
|
||||
|
||||
```
|
||||
$ pytest -m main # run only the tests in the main thread
|
||||
$ pytest -m worker # ron only the tests in the web worker
|
||||
```
|
||||
|
||||
## Fake server, HTTP cache
|
||||
|
||||
|
||||
@@ -16,20 +16,18 @@ module.exports = {
|
||||
browser: true,
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
ignorePatterns: ['node_modules'],
|
||||
ignorePatterns: ['node_modules', 'src/interpreter_worker/*'],
|
||||
rules: {
|
||||
// ts-ignore is already an explicit override, no need to have a second lint
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
|
||||
// any-related lints
|
||||
// These two come up a lot, so they probably aren't worth it
|
||||
// any related lints
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
// encourage people to cast "any" to a more specific type before using it
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||
'@typescript-eslint/no-unsafe-argument': 'error',
|
||||
'@typescript-eslint/no-unsafe-return': 'error',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
|
||||
// other rules
|
||||
'no-prototype-builtins': 'error',
|
||||
|
||||
@@ -59,7 +59,7 @@ build:
|
||||
npm run build
|
||||
|
||||
build-fast:
|
||||
node esbuild.js
|
||||
node esbuild.mjs
|
||||
|
||||
# use the following rule to do all the checks done by precommit: in
|
||||
# particular, use this if you want to run eslint.
|
||||
|
||||
@@ -14,6 +14,7 @@ dependencies:
|
||||
- pillow
|
||||
- numpy
|
||||
- markdown
|
||||
- toml
|
||||
- pip:
|
||||
- playwright
|
||||
- pytest-playwright
|
||||
|
||||
@@ -61,6 +61,14 @@ const pyScriptConfig = {
|
||||
plugins: [bundlePyscriptPythonPlugin()],
|
||||
};
|
||||
|
||||
const interpreterWorkerConfig = {
|
||||
entryPoints: ['src/interpreter_worker/worker.ts'],
|
||||
loader: { '.py': 'text' },
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
plugins: [bundlePyscriptPythonPlugin()],
|
||||
};
|
||||
|
||||
const copyPath = (source, dest, ...rest) => cp(join(__dirname, source), join(__dirname, dest), ...rest);
|
||||
|
||||
const esbuild = async () => {
|
||||
@@ -80,6 +88,14 @@ const esbuild = async () => {
|
||||
minify: true,
|
||||
outfile: 'build/pyscript.min.js',
|
||||
}),
|
||||
// XXX I suppose we should also build a minified version
|
||||
// TODO (HC): Simplify config a bit
|
||||
build({
|
||||
...interpreterWorkerConfig,
|
||||
sourcemap: false,
|
||||
minify: false,
|
||||
outfile: 'build/interpreter_worker.js',
|
||||
}),
|
||||
]);
|
||||
|
||||
const copy = [];
|
||||
|
||||
@@ -40,7 +40,6 @@ export class InterpreterClient extends Object {
|
||||
*/
|
||||
async initializeRemote(): Promise<void> {
|
||||
await this._remote.loadInterpreter(this.config, Synclink.proxy(this.stdio));
|
||||
// await this._remote.loadInterpreter(this.config, Synclink.proxy(this.stdio));
|
||||
this.globals = this._remote.globals;
|
||||
}
|
||||
|
||||
|
||||
26
pyscriptjs/src/interpreter_worker/worker.ts
Normal file
26
pyscriptjs/src/interpreter_worker/worker.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// XXX: what about code duplications?
|
||||
// With the current build configuration, the code for logger,
|
||||
// remote_interpreter and everything which is included from there is
|
||||
// bundled/fetched/executed twice, once in pyscript.js and once in
|
||||
// worker_interpreter.js.
|
||||
|
||||
import { getLogger } from '../logger';
|
||||
import { RemoteInterpreter } from '../remote_interpreter';
|
||||
import * as Synclink from 'synclink';
|
||||
|
||||
const logger = getLogger('worker');
|
||||
logger.info('Interpreter worker starting...');
|
||||
|
||||
async function worker_initialize(cfg) {
|
||||
const remote_interpreter = new RemoteInterpreter(cfg.src);
|
||||
// this is the equivalent of await import(interpreterURL)
|
||||
logger.info(`Downloading ${cfg.name}...`); // XXX we should use logStatus
|
||||
importScripts(cfg.src);
|
||||
|
||||
logger.info('worker_initialize() complete');
|
||||
return Synclink.proxy(remote_interpreter);
|
||||
}
|
||||
|
||||
Synclink.expose(worker_initialize);
|
||||
|
||||
export type { worker_initialize };
|
||||
@@ -1,7 +1,7 @@
|
||||
import './styles/pyscript_base.css';
|
||||
|
||||
import { loadConfigFromElement } from './pyconfig';
|
||||
import type { AppConfig } from './pyconfig';
|
||||
import type { AppConfig, InterpreterConfig } from './pyconfig';
|
||||
import { InterpreterClient } from './interpreter_client';
|
||||
import { PluginManager, Plugin, PythonPlugin } from './plugin';
|
||||
import { make_PyScript, initHandlers, mountElements } from './components/pyscript';
|
||||
@@ -59,16 +59,6 @@ throwHandler.serialize = new_error_transfer_handler;
|
||||
user scripts
|
||||
|
||||
8. initialize the rest of web components such as py-button, py-repl, etc.
|
||||
|
||||
More concretely:
|
||||
|
||||
- Points 1-4 are implemented sequentially in PyScriptApp.main().
|
||||
|
||||
- PyScriptApp.loadInterpreter adds a <script> tag to the document to initiate
|
||||
the download, and then adds an event listener for the 'load' event, which
|
||||
in turns calls PyScriptApp.afterInterpreterLoad().
|
||||
|
||||
- PyScriptApp.afterInterpreterLoad() implements all the points >= 5.
|
||||
*/
|
||||
|
||||
export let interpreter;
|
||||
@@ -173,6 +163,52 @@ export class PyScriptApp {
|
||||
logger.info('config loaded:\n' + JSON.stringify(this.config, null, 2));
|
||||
}
|
||||
|
||||
_get_base_url(): string {
|
||||
// Note that this requires that pyscript is loaded via a <script>
|
||||
// tag. If we want to allow loading via an ES6 module in the future,
|
||||
// we need to think about some other strategy
|
||||
const elem = document.currentScript as HTMLScriptElement;
|
||||
const slash = elem.src.lastIndexOf('/');
|
||||
return elem.src.slice(0, slash);
|
||||
}
|
||||
|
||||
async _startInterpreter_main(interpreter_cfg: InterpreterConfig) {
|
||||
logger.info('Starting the interpreter in the main thread');
|
||||
// this is basically equivalent to worker_initialize()
|
||||
const remote_interpreter = new RemoteInterpreter(interpreter_cfg.src);
|
||||
const { port1, port2 } = new Synclink.FakeMessageChannel() as unknown as MessageChannel;
|
||||
port1.start();
|
||||
port2.start();
|
||||
Synclink.expose(remote_interpreter, port2);
|
||||
const wrapped_remote_interpreter = Synclink.wrap(port1);
|
||||
|
||||
this.logStatus(`Downloading ${interpreter_cfg.name}...`);
|
||||
/* Dynamically download and import pyodide: the import() puts a
|
||||
loadPyodide() function into globalThis, which is later called by
|
||||
RemoteInterpreter.
|
||||
|
||||
This is suboptimal: ideally, we would like to import() a module
|
||||
which exports loadPyodide(), but this plays badly with workers
|
||||
because at the moment of writing (2023-03-24) Firefox does not
|
||||
support ES modules in workers:
|
||||
https://caniuse.com/mdn-api_worker_worker_ecmascript_modules
|
||||
*/
|
||||
const interpreterURL = interpreter_cfg.src;
|
||||
await import(interpreterURL);
|
||||
return { remote_interpreter, wrapped_remote_interpreter };
|
||||
}
|
||||
|
||||
async _startInterpreter_worker(interpreter_cfg: InterpreterConfig) {
|
||||
logger.warn('execution_thread = "worker" is still VERY experimental, use it at your own risk');
|
||||
logger.info('Starting the interpreter in a web worker');
|
||||
const base_url = this._get_base_url();
|
||||
const worker = new Worker(base_url + '/interpreter_worker.js');
|
||||
const worker_initialize: any = Synclink.wrap(worker);
|
||||
const wrapped_remote_interpreter = await worker_initialize(interpreter_cfg);
|
||||
const remote_interpreter = undefined; // this is _unwrapped_remote
|
||||
return { remote_interpreter, wrapped_remote_interpreter };
|
||||
}
|
||||
|
||||
// lifecycle (4)
|
||||
async loadInterpreter() {
|
||||
logger.info('Initializing interpreter');
|
||||
@@ -184,35 +220,21 @@ export class PyScriptApp {
|
||||
showWarning('Multiple interpreters are not supported yet.<br />Only the first will be used', 'html');
|
||||
}
|
||||
|
||||
const interpreter_cfg = this.config.interpreters[0];
|
||||
const cfg = this.config.interpreters[0];
|
||||
let x;
|
||||
if (this.config.execution_thread == 'worker') {
|
||||
x = await this._startInterpreter_worker(cfg);
|
||||
} else {
|
||||
x = await this._startInterpreter_main(cfg);
|
||||
}
|
||||
const { remote_interpreter, wrapped_remote_interpreter } = x;
|
||||
|
||||
const remote_interpreter = new RemoteInterpreter(interpreter_cfg.src);
|
||||
const { port1, port2 } = new Synclink.FakeMessageChannel() as unknown as MessageChannel;
|
||||
port1.start();
|
||||
port2.start();
|
||||
Synclink.expose(remote_interpreter, port2);
|
||||
const wrapped_remote_interpreter = Synclink.wrap(port1);
|
||||
this.interpreter = new InterpreterClient(
|
||||
this.config,
|
||||
this._stdioMultiplexer,
|
||||
wrapped_remote_interpreter as Synclink.Remote<RemoteInterpreter>,
|
||||
remote_interpreter,
|
||||
);
|
||||
|
||||
this.logStatus(`Downloading ${interpreter_cfg.name}...`);
|
||||
|
||||
/* Dynamically download and import pyodide: the import() puts a
|
||||
loadPyodide() function into globalThis, which is later called by
|
||||
RemoteInterpreter.
|
||||
|
||||
This is suboptimal: ideally, we would like to import() a module
|
||||
which exports loadPyodide(), but this plays badly with workers
|
||||
because at the moment of writing (2023-03-24) Firefox does not
|
||||
support ES modules in workers:
|
||||
https://caniuse.com/mdn-api_worker_worker_ecmascript_modules
|
||||
*/
|
||||
const interpreterURL = await this.interpreter._remote.src;
|
||||
await import(interpreterURL);
|
||||
await this.afterInterpreterLoad(this.interpreter);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface AppConfig extends Record<string, any> {
|
||||
fetch?: FetchConfig[];
|
||||
plugins?: string[];
|
||||
pyscript?: PyScriptMetadata;
|
||||
execution_thread?: string; // "main" or "worker"
|
||||
}
|
||||
|
||||
export type FetchConfig = {
|
||||
@@ -43,7 +44,7 @@ export type PyScriptMetadata = {
|
||||
};
|
||||
|
||||
const allKeys = Object.entries({
|
||||
string: ['name', 'description', 'version', 'type', 'author_name', 'author_email', 'license'],
|
||||
string: ['name', 'description', 'version', 'type', 'author_name', 'author_email', 'license', 'execution_thread'],
|
||||
number: ['schema_version'],
|
||||
array: ['runtimes', 'interpreters', 'packages', 'fetch', 'plugins'],
|
||||
});
|
||||
@@ -63,6 +64,7 @@ export const defaultConfig: AppConfig = {
|
||||
packages: [],
|
||||
fetch: [],
|
||||
plugins: [],
|
||||
execution_thread: 'main',
|
||||
};
|
||||
|
||||
export function loadConfigFromElement(el: Element): AppConfig {
|
||||
@@ -237,6 +239,15 @@ function validateConfig(configText: string, configType = 'toml') {
|
||||
}
|
||||
finalConfig[item].push(eachFetchConfig);
|
||||
});
|
||||
} else if (item == 'execution_thread') {
|
||||
const value = config[item];
|
||||
if (value !== 'main' && value !== 'worker') {
|
||||
throw new UserError(
|
||||
ErrorCode.BAD_CONFIG,
|
||||
`"${value}" is not a valid value for the property "execution_thread". The only valid values are "main" and "worker"`,
|
||||
);
|
||||
}
|
||||
finalConfig[item] = value;
|
||||
} else {
|
||||
finalConfig[item] = config[item];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
from textwrap import dedent
|
||||
|
||||
from js import console, document
|
||||
import js
|
||||
|
||||
from . import _internal
|
||||
from ._mime import format_mime as _format_mime
|
||||
@@ -22,7 +22,7 @@ class HTML:
|
||||
def write(element_id, value, append=False, exec_id=0):
|
||||
"""Writes value to the element with id "element_id"""
|
||||
Element(element_id).write(value=value, append=append)
|
||||
console.warn(
|
||||
js.console.warn(
|
||||
dedent(
|
||||
"""PyScript Deprecation Warning: PyScript.write is
|
||||
marked as deprecated and will be removed sometime soon. Please, use
|
||||
@@ -55,7 +55,7 @@ class Element:
|
||||
def element(self):
|
||||
"""Return the dom element"""
|
||||
if not self._element:
|
||||
self._element = document.querySelector(f"#{self._id}")
|
||||
self._element = js.document.querySelector(f"#{self._id}")
|
||||
return self._element
|
||||
|
||||
@property
|
||||
@@ -72,7 +72,7 @@ class Element:
|
||||
return
|
||||
|
||||
if append:
|
||||
child = document.createElement("div")
|
||||
child = js.document.createElement("div")
|
||||
self.element.appendChild(child)
|
||||
|
||||
if append and self.element.children:
|
||||
@@ -81,7 +81,7 @@ class Element:
|
||||
out_element = self.element
|
||||
|
||||
if mime_type in ("application/javascript", "text/html"):
|
||||
script_element = document.createRange().createContextualFragment(html)
|
||||
script_element = js.document.createRange().createContextualFragment(html)
|
||||
out_element.appendChild(script_element)
|
||||
else:
|
||||
out_element.innerHTML = html
|
||||
@@ -102,7 +102,7 @@ class Element:
|
||||
if _el:
|
||||
return Element(_el.id, _el)
|
||||
else:
|
||||
console.warn(f"WARNING: can't find element matching query {query}")
|
||||
js.console.warn(f"WARNING: can't find element matching query {query}")
|
||||
|
||||
def clone(self, new_id=None, to=None):
|
||||
if new_id is None:
|
||||
@@ -142,7 +142,7 @@ def add_classes(element, class_list):
|
||||
|
||||
|
||||
def create(what, id_=None, classes=""):
|
||||
element = document.createElement(what)
|
||||
element = js.document.createElement(what)
|
||||
if id_:
|
||||
element.id = id_
|
||||
add_classes(element, classes)
|
||||
@@ -256,7 +256,7 @@ class PyListTemplate:
|
||||
Element(new_id).element.onclick = foo
|
||||
|
||||
def connect(self):
|
||||
self.md = main_div = document.createElement("div")
|
||||
self.md = main_div = js.document.createElement("div")
|
||||
main_div.id = self._id + "-list-tasks-container"
|
||||
|
||||
if self.theme:
|
||||
|
||||
@@ -105,7 +105,6 @@ export class RemoteInterpreter extends Object {
|
||||
this.interface = Synclink.proxy(
|
||||
await loadPyodide({
|
||||
stdout: (msg: string) => {
|
||||
// TODO: add syncify when moved to worker
|
||||
stdio.stdout_writeline(msg).syncify();
|
||||
},
|
||||
stderr: (msg: string) => {
|
||||
@@ -114,6 +113,8 @@ export class RemoteInterpreter extends Object {
|
||||
fullStdLib: false,
|
||||
}),
|
||||
);
|
||||
this.interface.registerComlink(Synclink);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
this.FS = this.interface.FS;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
this.PATH = (this.interface as any)._module.PATH;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import dataclasses
|
||||
import functools
|
||||
import math
|
||||
import os
|
||||
import pdb
|
||||
@@ -11,13 +12,100 @@ from dataclasses import dataclass
|
||||
|
||||
import py
|
||||
import pytest
|
||||
import toml
|
||||
from playwright.sync_api import Error as PlaywrightError
|
||||
|
||||
ROOT = py.path.local(__file__).dirpath("..", "..", "..")
|
||||
BUILD = ROOT.join("pyscriptjs", "build")
|
||||
|
||||
|
||||
def params_with_marks(params):
|
||||
"""
|
||||
Small helper to automatically apply to each param a pytest.mark with the
|
||||
same name of the param itself. E.g.:
|
||||
|
||||
params_with_marks(['aaa', 'bbb'])
|
||||
|
||||
is equivalent to:
|
||||
|
||||
[pytest.param('aaa', marks=pytest.mark.aaa),
|
||||
pytest.param('bbb', marks=pytest.mark.bbb)]
|
||||
|
||||
This makes it possible to use 'pytest -m aaa' to run ONLY the tests which
|
||||
uses the param 'aaa'.
|
||||
"""
|
||||
return [pytest.param(name, marks=getattr(pytest.mark, name)) for name in params]
|
||||
|
||||
|
||||
def with_execution_thread(*values):
|
||||
"""
|
||||
Class decorator to override config.execution_thread.
|
||||
|
||||
By default, we run each test twice:
|
||||
- execution_thread = 'main'
|
||||
- execution_thread = 'worker'
|
||||
|
||||
If you want to execute certain tests with only one specific values of
|
||||
execution_thread, you can use this class decorator. For example:
|
||||
|
||||
@with_execution_thread('main')
|
||||
class TestOnlyMainThread:
|
||||
...
|
||||
|
||||
@with_execution_thread('worker')
|
||||
class TestOnlyWorker:
|
||||
...
|
||||
|
||||
If you use @with_execution_thread(None), the logic to inject the
|
||||
execution_thread config is disabled.
|
||||
"""
|
||||
|
||||
if values == (None,):
|
||||
|
||||
@pytest.fixture
|
||||
def execution_thread(self, request):
|
||||
return None
|
||||
|
||||
else:
|
||||
for value in values:
|
||||
assert value in ("main", "worker")
|
||||
|
||||
@pytest.fixture(params=params_with_marks(values))
|
||||
def execution_thread(self, request):
|
||||
return request.param
|
||||
|
||||
def with_execution_thread_decorator(cls):
|
||||
cls.execution_thread = execution_thread
|
||||
return cls
|
||||
|
||||
return with_execution_thread_decorator
|
||||
|
||||
|
||||
def skip_worker(reason):
|
||||
"""
|
||||
Decorator to skip a test if self.execution_thread == 'worker'
|
||||
"""
|
||||
if callable(reason):
|
||||
# this happens if you use @skip_worker instead of @skip_worker("bla bla bla")
|
||||
raise Exception(
|
||||
"You need to specify a reason for skipping, "
|
||||
"please use: @skip_worker('...')"
|
||||
)
|
||||
|
||||
def decorator(fn):
|
||||
@functools.wraps(fn)
|
||||
def decorated(self, *args):
|
||||
if self.execution_thread == "worker":
|
||||
pytest.skip(reason)
|
||||
return fn(self, *args)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init")
|
||||
@with_execution_thread("main", "worker")
|
||||
class PyScriptTest:
|
||||
"""
|
||||
Base class to write PyScript integration tests, based on playwright.
|
||||
@@ -47,7 +135,7 @@ class PyScriptTest:
|
||||
"""
|
||||
|
||||
@pytest.fixture()
|
||||
def init(self, request, tmpdir, logger, page):
|
||||
def init(self, request, tmpdir, logger, page, execution_thread):
|
||||
"""
|
||||
Fixture to automatically initialize all the tests in this class and its
|
||||
subclasses.
|
||||
@@ -69,6 +157,7 @@ class PyScriptTest:
|
||||
tmpdir.join("build").mksymlinkto(BUILD)
|
||||
self.tmpdir.chdir()
|
||||
self.logger = logger
|
||||
self.execution_thread = execution_thread
|
||||
|
||||
if request.config.option.no_fake_server:
|
||||
# use a real HTTP server. Note that as soon as we request the
|
||||
@@ -78,7 +167,7 @@ class PyScriptTest:
|
||||
self.is_fake_server = False
|
||||
else:
|
||||
# use the internal playwright routing
|
||||
self.http_server = "http://fake_server"
|
||||
self.http_server = "https://fake_server"
|
||||
self.router = SmartRouter(
|
||||
"fake_server",
|
||||
cache=request.config.cache,
|
||||
@@ -215,12 +304,16 @@ class PyScriptTest:
|
||||
url = f"{self.http_server}/{path}"
|
||||
self.page.goto(url, timeout=0)
|
||||
|
||||
def wait_for_console(self, text, *, timeout=None, check_js_errors=True):
|
||||
def wait_for_console(
|
||||
self, text, *, match_substring=False, timeout=None, check_js_errors=True
|
||||
):
|
||||
"""
|
||||
Wait until the given message appear in the console. If the message was
|
||||
already printed in the console, return immediately.
|
||||
|
||||
Note: it must be the *exact* string as printed by e.g. console.log.
|
||||
By default "text" must be the *exact* string as printed by a single
|
||||
call to e.g. console.log. If match_substring is True, it is enough
|
||||
that the console contains the given text anywhere.
|
||||
|
||||
timeout is expressed in milliseconds. If it's None, it will use
|
||||
the same default as playwright, which is 30 seconds.
|
||||
@@ -230,6 +323,16 @@ class PyScriptTest:
|
||||
|
||||
Return the elapsed time in ms.
|
||||
"""
|
||||
if match_substring:
|
||||
|
||||
def find_text():
|
||||
return text in self.console.all.text
|
||||
|
||||
else:
|
||||
|
||||
def find_text():
|
||||
return text in self.console.all.lines
|
||||
|
||||
if timeout is None:
|
||||
timeout = 30 * 1000
|
||||
# NOTE: we cannot use playwright's own page.expect_console_message(),
|
||||
@@ -242,7 +345,7 @@ class PyScriptTest:
|
||||
if elapsed_ms > timeout:
|
||||
raise TimeoutError(f"{elapsed_ms:.2f} ms")
|
||||
#
|
||||
if text in self.console.all.lines:
|
||||
if find_text():
|
||||
# found it!
|
||||
return elapsed_ms
|
||||
#
|
||||
@@ -279,6 +382,69 @@ class PyScriptTest:
|
||||
# events aren't being triggered in the tests.
|
||||
self.page.wait_for_timeout(100)
|
||||
|
||||
def _parse_py_config(self, doc):
|
||||
configs = re.findall("<py-config>(.*?)</py-config>", doc, flags=re.DOTALL)
|
||||
configs = [cfg.strip() for cfg in configs]
|
||||
if len(configs) == 0:
|
||||
return None
|
||||
elif len(configs) == 1:
|
||||
return toml.loads(configs[0])
|
||||
else:
|
||||
raise AssertionError("Too many <py-config>")
|
||||
|
||||
def _inject_execution_thread_config(self, snippet, execution_thread):
|
||||
"""
|
||||
If snippet already contains a py-config, let's try to inject
|
||||
execution_thread automatically. Note that this works only for plain
|
||||
<py-config> with inline config: type="json" and src="..." are not
|
||||
supported by this logic, which should remain simple.
|
||||
"""
|
||||
cfg = self._parse_py_config(snippet)
|
||||
if cfg is None:
|
||||
# we don't have any <py-config>, let's add one
|
||||
py_config_maybe = f"""
|
||||
<py-config>
|
||||
execution_thread = "{execution_thread}"
|
||||
</py-config>
|
||||
"""
|
||||
else:
|
||||
cfg["execution_thread"] = execution_thread
|
||||
dumped_cfg = toml.dumps(cfg)
|
||||
new_py_config = f"""
|
||||
<py-config>
|
||||
{dumped_cfg}
|
||||
</py-config>
|
||||
"""
|
||||
snippet = re.sub(
|
||||
"<py-config>.*</py-config>", new_py_config, snippet, flags=re.DOTALL
|
||||
)
|
||||
# no need for extra config, it's already in the snippet
|
||||
py_config_maybe = ""
|
||||
#
|
||||
return snippet, py_config_maybe
|
||||
|
||||
def _pyscript_format(self, snippet, *, execution_thread, extra_head=""):
|
||||
if execution_thread is None:
|
||||
py_config_maybe = ""
|
||||
else:
|
||||
snippet, py_config_maybe = self._inject_execution_thread_config(
|
||||
snippet, execution_thread
|
||||
)
|
||||
doc = f"""
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="{self.http_server}/build/pyscript.css" />
|
||||
<script defer src="{self.http_server}/build/pyscript.js"></script>
|
||||
{extra_head}
|
||||
</head>
|
||||
<body>
|
||||
{py_config_maybe}
|
||||
{snippet}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return doc
|
||||
|
||||
def pyscript_run(
|
||||
self, snippet, *, extra_head="", wait_for_pyscript=True, timeout=None
|
||||
):
|
||||
@@ -295,18 +461,9 @@ class PyScriptTest:
|
||||
- open a playwright page for it
|
||||
- wait until pyscript has been fully loaded
|
||||
"""
|
||||
doc = f"""
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="{self.http_server}/build/pyscript.css" />
|
||||
<script defer src="{self.http_server}/build/pyscript.js"></script>
|
||||
{extra_head}
|
||||
</head>
|
||||
<body>
|
||||
{snippet}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
doc = self._pyscript_format(
|
||||
snippet, execution_thread=self.execution_thread, extra_head=extra_head
|
||||
)
|
||||
if not wait_for_pyscript and timeout is not None:
|
||||
raise ValueError("Cannot set a timeout if wait_for_pyscript=False")
|
||||
filename = f"{self.testname}.html"
|
||||
@@ -746,7 +903,11 @@ class SmartRouter:
|
||||
assert url.path[0] == "/"
|
||||
relative_path = url.path[1:]
|
||||
if os.path.exists(relative_path):
|
||||
route.fulfill(status=200, path=relative_path)
|
||||
headers = {
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
}
|
||||
route.fulfill(status=200, headers=headers, path=relative_path)
|
||||
else:
|
||||
route.fulfill(status=404)
|
||||
return
|
||||
|
||||
@@ -3,9 +3,10 @@ import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from .support import JsErrors, JsErrorsDidNotRaise, PyScriptTest
|
||||
from .support import JsErrors, JsErrorsDidNotRaise, PyScriptTest, with_execution_thread
|
||||
|
||||
|
||||
@with_execution_thread(None)
|
||||
class TestSupport(PyScriptTest):
|
||||
"""
|
||||
These are NOT tests about PyScript.
|
||||
@@ -183,9 +184,9 @@ class TestSupport(PyScriptTest):
|
||||
"""
|
||||
JS errors found: 2
|
||||
Error: error 1
|
||||
at http://fake_server/mytest.html:.*
|
||||
at https://fake_server/mytest.html:.*
|
||||
Error: error 2
|
||||
at http://fake_server/mytest.html:.*
|
||||
at https://fake_server/mytest.html:.*
|
||||
"""
|
||||
).strip()
|
||||
assert re.search(expected, msg)
|
||||
@@ -214,9 +215,9 @@ class TestSupport(PyScriptTest):
|
||||
"""
|
||||
JS errors found: 2
|
||||
Error: NOT expected 2
|
||||
at http://fake_server/mytest.html:.*
|
||||
at https://fake_server/mytest.html:.*
|
||||
Error: NOT expected 4
|
||||
at http://fake_server/mytest.html:.*
|
||||
at https://fake_server/mytest.html:.*
|
||||
"""
|
||||
).strip()
|
||||
assert re.search(expected, msg)
|
||||
@@ -243,9 +244,9 @@ class TestSupport(PyScriptTest):
|
||||
---
|
||||
The following JS errors were raised but not expected:
|
||||
Error: error 1
|
||||
at http://fake_server/mytest.html:.*
|
||||
at https://fake_server/mytest.html:.*
|
||||
Error: error 2
|
||||
at http://fake_server/mytest.html:.*
|
||||
at https://fake_server/mytest.html:.*
|
||||
"""
|
||||
).strip()
|
||||
assert re.search(expected, msg)
|
||||
@@ -391,6 +392,24 @@ class TestSupport(PyScriptTest):
|
||||
# clear the errors, else the test fails at teardown
|
||||
self.clear_js_errors()
|
||||
|
||||
def test_wait_for_console_match_substring(self):
|
||||
doc = """
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
console.log('Foo Bar Baz');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.writefile("mytest.html", doc)
|
||||
self.goto("mytest.html")
|
||||
with pytest.raises(TimeoutError):
|
||||
self.wait_for_console("Bar", timeout=200)
|
||||
#
|
||||
self.wait_for_console("Bar", timeout=200, match_substring=True)
|
||||
assert self.console.log.lines[-1] == "Foo Bar Baz"
|
||||
|
||||
def test_iter_locator(self):
|
||||
doc = """
|
||||
<html>
|
||||
@@ -427,7 +446,7 @@ class TestSupport(PyScriptTest):
|
||||
self.router.clear_cache(URL)
|
||||
self.goto("mytest.html")
|
||||
assert self.router.requests == [
|
||||
(200, "fake_server", "http://fake_server/mytest.html"),
|
||||
(200, "fake_server", "https://fake_server/mytest.html"),
|
||||
(200, "NETWORK", URL),
|
||||
]
|
||||
#
|
||||
@@ -435,10 +454,10 @@ class TestSupport(PyScriptTest):
|
||||
self.goto("mytest.html")
|
||||
assert self.router.requests == [
|
||||
# 1st visit
|
||||
(200, "fake_server", "http://fake_server/mytest.html"),
|
||||
(200, "fake_server", "https://fake_server/mytest.html"),
|
||||
(200, "NETWORK", URL),
|
||||
# 2nd visit
|
||||
(200, "fake_server", "http://fake_server/mytest.html"),
|
||||
(200, "fake_server", "https://fake_server/mytest.html"),
|
||||
(200, "CACHED", URL),
|
||||
]
|
||||
|
||||
@@ -450,3 +469,22 @@ class TestSupport(PyScriptTest):
|
||||
assert [
|
||||
"Failed to load resource: the server responded with a status of 404 (Not Found)"
|
||||
] == self.console.all.lines
|
||||
|
||||
def test__pyscript_format_inject_execution_thread(self):
|
||||
"""
|
||||
This is slightly different than other tests: it doesn't use playwright, it
|
||||
just tests that our own internal helper works
|
||||
"""
|
||||
doc = self._pyscript_format("<b>Hello</b>", execution_thread="main")
|
||||
cfg = self._parse_py_config(doc)
|
||||
assert cfg == {"execution_thread": "main"}
|
||||
|
||||
def test__pyscript_format_modify_existing_py_config(self):
|
||||
src = """
|
||||
<py-config>
|
||||
hello = 42
|
||||
</py-config>
|
||||
"""
|
||||
doc = self._pyscript_format(src, execution_thread="main")
|
||||
cfg = self._parse_py_config(doc)
|
||||
assert cfg == {"execution_thread": "main", "hello": 42}
|
||||
|
||||
@@ -2,11 +2,37 @@ import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .support import JsErrors, PyScriptTest
|
||||
from .support import JsErrors, PyScriptTest, skip_worker
|
||||
|
||||
|
||||
class TestBasic(PyScriptTest):
|
||||
def test_pyscript_hello(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script>
|
||||
import js
|
||||
js.console.log('hello pyscript')
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
assert self.console.log.lines == ["hello pyscript"]
|
||||
|
||||
def test_execution_thread(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<!-- we don't really need anything here, we just want to check that
|
||||
pyscript starts -->
|
||||
"""
|
||||
)
|
||||
assert self.execution_thread in ("main", "worker")
|
||||
if self.execution_thread == "main":
|
||||
where = "the main thread"
|
||||
elif self.execution_thread == "worker":
|
||||
where = "a web worker"
|
||||
expected = f"[pyscript/main] Starting the interpreter in {where}"
|
||||
assert expected in self.console.info.lines
|
||||
|
||||
def test_print(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script>
|
||||
@@ -53,6 +79,9 @@ class TestBasic(PyScriptTest):
|
||||
)
|
||||
|
||||
self.page.locator("button").click()
|
||||
self.wait_for_console(
|
||||
"Exception: this is an error inside handler", match_substring=True
|
||||
)
|
||||
|
||||
## error in console
|
||||
tb_lines = self.console.error.lines[-1].splitlines()
|
||||
@@ -120,6 +149,7 @@ class TestBasic(PyScriptTest):
|
||||
"hello asciitree", # printed by us
|
||||
]
|
||||
|
||||
@skip_worker("FIXME: the banner doesn't appear")
|
||||
def test_non_existent_package(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -139,6 +169,7 @@ class TestBasic(PyScriptTest):
|
||||
alert_banner = self.page.wait_for_selector(".alert-banner")
|
||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
||||
|
||||
@skip_worker("FIXME: the banner doesn't appear")
|
||||
def test_no_python_wheel(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -240,6 +271,7 @@ class TestBasic(PyScriptTest):
|
||||
is not None
|
||||
)
|
||||
|
||||
@skip_worker("FIXME: showWarning()")
|
||||
def test_assert_no_banners(self):
|
||||
"""
|
||||
Test that the DOM doesn't contain error/warning banners
|
||||
|
||||
@@ -7,10 +7,11 @@ import re
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .support import PyScriptTest, wait_for_render
|
||||
from .support import PyScriptTest, skip_worker, wait_for_render
|
||||
|
||||
|
||||
class TestOutput(PyScriptTest):
|
||||
class TestDisplay(PyScriptTest):
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_simple_display(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -24,6 +25,7 @@ class TestOutput(PyScriptTest):
|
||||
assert re.search(pattern, node_list[0].inner_html())
|
||||
assert len(node_list) == 1
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_consecutive_display(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -41,6 +43,7 @@ class TestOutput(PyScriptTest):
|
||||
lines = [line for line in lines if line != ""] # remove empty lines
|
||||
assert lines == ["hello 1", "hello 2", "hello 3"]
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_target_attribute(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -53,6 +56,7 @@ class TestOutput(PyScriptTest):
|
||||
mydiv = self.page.locator("#mydiv")
|
||||
assert mydiv.inner_text() == "hello world"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_consecutive_display_target(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -73,6 +77,7 @@ class TestOutput(PyScriptTest):
|
||||
lines = [line for line in lines if line != ""] # remove empty lines
|
||||
assert lines == ["hello 1", "hello in between 1 and 2", "hello 2", "hello 3"]
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_multiple_display_calls_same_tag(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -86,6 +91,7 @@ class TestOutput(PyScriptTest):
|
||||
lines = tag.inner_text().splitlines()
|
||||
assert lines == ["hello", "world"]
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_implicit_target_from_a_different_tag(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -104,6 +110,7 @@ class TestOutput(PyScriptTest):
|
||||
assert py1.inner_text() == ""
|
||||
assert py2.inner_text() == "hello"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_no_implicit_target(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -129,6 +136,7 @@ class TestOutput(PyScriptTest):
|
||||
text = self.page.text_content("body")
|
||||
assert "hello world" not in text
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_explicit_target_pyscript_tag(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -144,6 +152,7 @@ class TestOutput(PyScriptTest):
|
||||
text = self.page.locator("id=second-pyscript-tag").inner_text()
|
||||
assert text == "hello"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_explicit_target_on_button_tag(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -158,6 +167,7 @@ class TestOutput(PyScriptTest):
|
||||
text = self.page.locator("id=my-button").inner_text()
|
||||
assert "hello" in text
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_explicit_different_target_from_call(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -176,6 +186,7 @@ class TestOutput(PyScriptTest):
|
||||
text = self.page.locator("id=second-pyscript-tag").all_inner_texts()
|
||||
assert "hello" in text
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_append_true(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -189,6 +200,7 @@ class TestOutput(PyScriptTest):
|
||||
assert re.search(pattern, node_list[0].inner_html())
|
||||
assert len(node_list) == 1
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_append_false(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -201,6 +213,7 @@ class TestOutput(PyScriptTest):
|
||||
pattern = r'<py-script id="py-.*">hello world</py-script>'
|
||||
assert re.search(pattern, inner_html)
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_display_multiple_values(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -214,6 +227,7 @@ class TestOutput(PyScriptTest):
|
||||
inner_text = self.page.inner_text("html")
|
||||
assert inner_text == "hello\nworld"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_display_multiple_append_false(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -227,6 +241,7 @@ class TestOutput(PyScriptTest):
|
||||
pattern = r'<py-script id="py-.*">world</py-script>'
|
||||
assert re.search(pattern, inner_html)
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_display_multiple_append_false_with_target(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -255,6 +270,7 @@ class TestOutput(PyScriptTest):
|
||||
== '<svg height="20" width="20"><circle cx="10" cy="10" r="10" fill="red"></circle></svg>' # noqa: E501
|
||||
)
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_display_list_dict_tuple(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -273,6 +289,7 @@ class TestOutput(PyScriptTest):
|
||||
== "['A', 1, '!']\n{'B': 2, 'List': ['A', 1, '!']}\n('C', 3, '!')"
|
||||
)
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_display_should_escape(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -285,6 +302,7 @@ class TestOutput(PyScriptTest):
|
||||
assert out.inner_html() == html.escape("<p>hello world</p>")
|
||||
assert out.inner_text() == "<p>hello world</p>"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_display_HTML(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -297,6 +315,7 @@ class TestOutput(PyScriptTest):
|
||||
assert out.inner_html() == "<p>hello world</p>"
|
||||
assert out.inner_text() == "hello world"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_image_display(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -325,6 +344,7 @@ class TestOutput(PyScriptTest):
|
||||
assert deviation == 0.0
|
||||
self.assert_no_banners()
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_empty_HTML_and_console_output(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -342,6 +362,7 @@ class TestOutput(PyScriptTest):
|
||||
assert "print from js" in console_text
|
||||
assert "error from js" in console_text
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_text_HTML_and_console_output(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -362,6 +383,7 @@ class TestOutput(PyScriptTest):
|
||||
print(self.console.error.lines)
|
||||
assert self.console.error.lines[-1] == "error from js"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_console_line_break(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -375,6 +397,7 @@ class TestOutput(PyScriptTest):
|
||||
assert console_text.index("1print") == (console_text.index("2print") - 1)
|
||||
assert console_text.index("1console") == (console_text.index("2console") - 1)
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_image_renders_correctly(self):
|
||||
"""This is just a sanity check to make sure that images are rendered correctly."""
|
||||
buffer = io.BytesIO()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .support import PyScriptTest
|
||||
from .support import PyScriptTest, skip_worker
|
||||
|
||||
|
||||
class TestElement(PyScriptTest):
|
||||
@@ -21,6 +21,7 @@ class TestElement(PyScriptTest):
|
||||
py_terminal = self.page.wait_for_selector("py-terminal")
|
||||
assert "foo" in py_terminal.inner_text()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_value(self):
|
||||
"""Test the element value"""
|
||||
self.pyscript_run(
|
||||
@@ -38,6 +39,7 @@ class TestElement(PyScriptTest):
|
||||
py_terminal = self.page.wait_for_selector("py-terminal")
|
||||
assert "bar" in py_terminal.inner_text()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_innerHtml(self):
|
||||
"""Test the element innerHtml"""
|
||||
self.pyscript_run(
|
||||
@@ -55,6 +57,7 @@ class TestElement(PyScriptTest):
|
||||
py_terminal = self.page.wait_for_selector("py-terminal")
|
||||
assert "bar" in py_terminal.inner_text()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_write_no_append(self):
|
||||
"""Test the element write"""
|
||||
self.pyscript_run(
|
||||
@@ -71,6 +74,7 @@ class TestElement(PyScriptTest):
|
||||
div = self.page.wait_for_selector("#foo")
|
||||
assert "World!" in div.inner_text()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_write_append(self):
|
||||
"""Test the element write"""
|
||||
self.pyscript_run(
|
||||
@@ -90,6 +94,7 @@ class TestElement(PyScriptTest):
|
||||
# confirm that the second write was appended
|
||||
assert "Hello!<div>World!</div>" in parent_div.inner_html()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_clear_div(self):
|
||||
"""Test the element clear"""
|
||||
self.pyscript_run(
|
||||
@@ -105,6 +110,7 @@ class TestElement(PyScriptTest):
|
||||
div = self.page.locator("#foo")
|
||||
assert div.inner_text() == ""
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_clear_input(self):
|
||||
"""Test the element clear"""
|
||||
self.pyscript_run(
|
||||
@@ -120,6 +126,7 @@ class TestElement(PyScriptTest):
|
||||
input = self.page.wait_for_selector("#foo")
|
||||
assert input.input_value() == ""
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_select(self):
|
||||
"""Test the element select"""
|
||||
self.pyscript_run(
|
||||
@@ -137,6 +144,7 @@ class TestElement(PyScriptTest):
|
||||
select = self.page.wait_for_selector("#foo")
|
||||
assert select.inner_text() == "Bar"
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_clone_no_id(self):
|
||||
"""Test the element clone"""
|
||||
self.pyscript_run(
|
||||
@@ -154,6 +162,7 @@ class TestElement(PyScriptTest):
|
||||
assert divs.first.inner_text() == "Hello!"
|
||||
assert divs.last.inner_text() == "Hello!"
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_clone_with_id(self):
|
||||
"""Test the element clone"""
|
||||
self.pyscript_run(
|
||||
@@ -173,6 +182,7 @@ class TestElement(PyScriptTest):
|
||||
clone = self.page.locator("#bar")
|
||||
assert clone.inner_text() == "Hello!"
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_clone_to_other_element(self):
|
||||
"""Test the element clone"""
|
||||
self.pyscript_run(
|
||||
@@ -207,6 +217,7 @@ class TestElement(PyScriptTest):
|
||||
# Make sure that the clones are rendered in the right order
|
||||
assert container_div.inner_text() == "Bond\nJames\nBond"
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_remove_single_class(self):
|
||||
"""Test the element remove_class"""
|
||||
self.pyscript_run(
|
||||
@@ -222,6 +233,7 @@ class TestElement(PyScriptTest):
|
||||
div = self.page.locator("#foo")
|
||||
assert div.get_attribute("class") == "baz"
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_remove_multiple_classes(self):
|
||||
"""Test the element remove_class"""
|
||||
self.pyscript_run(
|
||||
@@ -237,6 +249,7 @@ class TestElement(PyScriptTest):
|
||||
div = self.page.locator("#foo")
|
||||
assert div.get_attribute("class") == ""
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_add_single_class(self):
|
||||
"""Test the element add_class"""
|
||||
self.pyscript_run(
|
||||
@@ -253,6 +266,7 @@ class TestElement(PyScriptTest):
|
||||
div = self.page.locator("#foo")
|
||||
assert div.get_attribute("class") == "red"
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_element_add_multiple_class(self):
|
||||
"""Test the element add_class"""
|
||||
self.pyscript_run(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .support import PyScriptTest
|
||||
from .support import PyScriptTest, skip_worker
|
||||
|
||||
|
||||
class TestAsync(PyScriptTest):
|
||||
@@ -87,6 +87,7 @@ class TestAsync(PyScriptTest):
|
||||
"b func done",
|
||||
]
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_multiple_async_multiple_display_targeted(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -119,6 +120,7 @@ class TestAsync(PyScriptTest):
|
||||
inner_text = self.page.inner_text("html")
|
||||
assert "A0\nA1\nB0\nB1" in inner_text
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_async_display_untargeted(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .support import PyScriptTest
|
||||
from .support import PyScriptTest, skip_worker
|
||||
|
||||
# Source code of a simple plugin that creates a Custom Element for testing purposes
|
||||
CE_PLUGIN_CODE = """
|
||||
@@ -194,6 +194,7 @@ def prepare_test(
|
||||
|
||||
|
||||
class TestPlugin(PyScriptTest):
|
||||
@skip_worker("FIXME: relative paths")
|
||||
@prepare_test("py-upper", CE_PLUGIN_CODE, tagname="py-up", html="Hello World")
|
||||
def test_py_plugin_inline(self):
|
||||
"""Test that a regular plugin that returns new HTML content from connected works"""
|
||||
@@ -209,6 +210,7 @@ class TestPlugin(PyScriptTest):
|
||||
rendered_text = self.page.locator("py-up").inner_text()
|
||||
assert rendered_text == "HELLO WORLD"
|
||||
|
||||
@skip_worker("FIXME: relative paths")
|
||||
@prepare_test("hooks_logger", HOOKS_PLUGIN_CODE, template=HTML_TEMPLATE_NO_TAG)
|
||||
def test_execution_hooks(self):
|
||||
"""Test that a Plugin that hooks into the PyScript App events, gets called
|
||||
@@ -241,6 +243,7 @@ class TestPlugin(PyScriptTest):
|
||||
|
||||
# TODO: It'd be actually better to check that the events get called in order
|
||||
|
||||
@skip_worker("FIXME: relative paths")
|
||||
@prepare_test(
|
||||
"exec_test_logger",
|
||||
PYSCRIPT_HOOKS_PLUGIN_CODE,
|
||||
@@ -263,6 +266,7 @@ class TestPlugin(PyScriptTest):
|
||||
assert "after_id:pyid" in log_lines
|
||||
assert "result:2" in log_lines
|
||||
|
||||
@skip_worker("FIXME: relative paths")
|
||||
@prepare_test(
|
||||
"pyrepl_test_logger",
|
||||
PYREPL_HOOKS_PLUGIN_CODE,
|
||||
@@ -287,6 +291,7 @@ class TestPlugin(PyScriptTest):
|
||||
assert "after_id:pyid" in log_lines
|
||||
assert "result:2" in log_lines
|
||||
|
||||
@skip_worker("FIXME: relative paths")
|
||||
@prepare_test("no_plugin", NO_PLUGIN_CODE)
|
||||
def test_no_plugin_attribute_error(self):
|
||||
"""
|
||||
@@ -301,6 +306,7 @@ class TestPlugin(PyScriptTest):
|
||||
# EXPECT an error for the missing attribute
|
||||
assert error_msg in self.console.error.lines
|
||||
|
||||
@skip_worker("FIXME: relative paths")
|
||||
def test_fetch_python_plugin(self):
|
||||
"""
|
||||
Test that we can fetch a plugin from a remote URL. Note we need to use
|
||||
@@ -355,7 +361,7 @@ class TestPlugin(PyScriptTest):
|
||||
"""
|
||||
<py-config>
|
||||
plugins = [
|
||||
"http://non-existent.blah/hello-world"
|
||||
"https://non-existent.blah/hello-world"
|
||||
]
|
||||
</py-config>
|
||||
""",
|
||||
@@ -364,7 +370,7 @@ class TestPlugin(PyScriptTest):
|
||||
|
||||
expected_msg = (
|
||||
"(PY2000): Unable to load plugin from "
|
||||
"'http://non-existent.blah/hello-world'. Plugins "
|
||||
"'https://non-existent.blah/hello-world'. Plugins "
|
||||
"need to contain a file extension and be either a "
|
||||
"python or javascript file."
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from .support import PyScriptTest
|
||||
from .support import PyScriptTest, with_execution_thread
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -35,6 +35,15 @@ def unzip(location, extract_to="."):
|
||||
file.extractall(path=extract_to)
|
||||
|
||||
|
||||
# Disable the main/worker dual testing, for two reasons:
|
||||
#
|
||||
# 1. the <py-config> logic happens before we start the worker, so there is
|
||||
# no point in running these tests twice
|
||||
#
|
||||
# 2. the logic to inject execution_thread into <py-config> works only with
|
||||
# plain <py-config> tags, but here we want to test all weird combinations
|
||||
# of config
|
||||
@with_execution_thread(None)
|
||||
class TestConfig(PyScriptTest):
|
||||
def test_py_config_inline(self):
|
||||
self.pyscript_run(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import platform
|
||||
|
||||
from .support import PyScriptTest
|
||||
from .support import PyScriptTest, skip_worker
|
||||
|
||||
|
||||
class TestPyRepl(PyScriptTest):
|
||||
@@ -79,6 +79,7 @@ class TestPyRepl(PyScriptTest):
|
||||
# Shift-enter should not add a newline to the editor
|
||||
assert self.page.locator(".cm-line").count() == 1
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_display(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -92,6 +93,7 @@ class TestPyRepl(PyScriptTest):
|
||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
||||
assert out_div.inner_text() == "hello world"
|
||||
|
||||
@skip_worker("TIMEOUT")
|
||||
def test_show_last_expression(self):
|
||||
"""
|
||||
Test that we display() the value of the last expression, as you would
|
||||
@@ -109,6 +111,7 @@ class TestPyRepl(PyScriptTest):
|
||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
||||
assert out_div.inner_text() == "42"
|
||||
|
||||
@skip_worker("TIMEOUT")
|
||||
def test_show_last_expression_with_output(self):
|
||||
"""
|
||||
Test that we display() the value of the last expression, as you would
|
||||
@@ -130,6 +133,7 @@ class TestPyRepl(PyScriptTest):
|
||||
out_div = self.page.wait_for_selector("#repl-target")
|
||||
assert out_div.inner_text() == "42"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_run_clears_previous_output(self):
|
||||
"""
|
||||
Check that we clear the previous output of the cell before executing it
|
||||
@@ -180,6 +184,7 @@ class TestPyRepl(PyScriptTest):
|
||||
assert tb_lines[0] == "Traceback (most recent call last):"
|
||||
assert tb_lines[-1] == "Exception: this is an error"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_multiple_repls(self):
|
||||
"""
|
||||
Multiple repls showing in the correct order in the page
|
||||
@@ -202,6 +207,7 @@ class TestPyRepl(PyScriptTest):
|
||||
self.page.wait_for_selector("#py-internal-1-repl-output")
|
||||
assert self.page.inner_text("#py-internal-1-repl-output") == "second"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_python_exception_after_previous_output(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -223,6 +229,7 @@ class TestPyRepl(PyScriptTest):
|
||||
assert "hello world" not in out_div.inner_text()
|
||||
assert "ZeroDivisionError" in out_div.inner_text()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_hide_previous_error_after_successful_run(self):
|
||||
"""
|
||||
this tests the fact that a new error div should be created once there's an
|
||||
@@ -270,6 +277,7 @@ class TestPyRepl(PyScriptTest):
|
||||
)
|
||||
assert banner_content == expected
|
||||
|
||||
@skip_worker("TIMEOUT")
|
||||
def test_auto_generate(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -300,6 +308,7 @@ class TestPyRepl(PyScriptTest):
|
||||
out_texts = [el.inner_text() for el in self.iter_locator(outputs)]
|
||||
assert out_texts == ["hello", "world", ""]
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_multiple_repls_mixed_display_order(self):
|
||||
"""
|
||||
Displaying several outputs that don't obey the order in which the original
|
||||
@@ -331,6 +340,7 @@ class TestPyRepl(PyScriptTest):
|
||||
assert self.page.inner_text("#py-internal-1-1-repl-output") == "second children"
|
||||
assert self.page.inner_text("#py-internal-0-1-repl-output") == "first children"
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_repl_output_attribute(self):
|
||||
# Test that output attribute sends stdout to the element
|
||||
# with the given ID, but not display()
|
||||
@@ -356,6 +366,7 @@ class TestPyRepl(PyScriptTest):
|
||||
|
||||
self.assert_no_banners()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_repl_output_display_async(self):
|
||||
# py-repls running async code are not expected to
|
||||
# send display to element element
|
||||
@@ -396,6 +407,7 @@ class TestPyRepl(PyScriptTest):
|
||||
assert self.page.locator("#repl-target").text_content() == ""
|
||||
self.assert_no_banners()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_repl_stdio_dynamic_tags(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -498,6 +510,7 @@ class TestPyRepl(PyScriptTest):
|
||||
assert self.page.wait_for_selector("#stderr-div").inner_text() == "one.\n"
|
||||
self.assert_no_banners()
|
||||
|
||||
@skip_worker("TIMEOUT")
|
||||
def test_repl_output_attribute_change(self):
|
||||
# If the user changes the 'output' attribute of a <py-repl> tag mid-execution,
|
||||
# Output should no longer go to the selected div and a warning should appear
|
||||
@@ -535,6 +548,7 @@ class TestPyRepl(PyScriptTest):
|
||||
alert_banner = self.page.wait_for_selector(".alert-banner")
|
||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
||||
|
||||
@skip_worker("TIMEOUT")
|
||||
def test_repl_output_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
|
||||
@@ -590,6 +604,7 @@ class TestPyRepl(PyScriptTest):
|
||||
code = py_repl.inner_text()
|
||||
assert "print('1')" in code
|
||||
|
||||
@skip_worker("TIMEOUT")
|
||||
def test_repl_src_change(self):
|
||||
self.writefile("loadReplSrc2.py", "2")
|
||||
self.writefile("loadReplSrc3.py", "print('3')")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from .support import PyScriptTest
|
||||
from .support import PyScriptTest, skip_worker
|
||||
|
||||
|
||||
class TestPyTerminal(PyScriptTest):
|
||||
@@ -35,6 +35,7 @@ class TestPyTerminal(PyScriptTest):
|
||||
"this goes to stdout",
|
||||
]
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_two_terminals(self):
|
||||
"""
|
||||
Multiple <py-terminal>s can cohexist.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from .support import PyScriptTest
|
||||
from .support import PyScriptTest, skip_worker
|
||||
|
||||
|
||||
class TestSplashscreen(PyScriptTest):
|
||||
@@ -96,6 +96,7 @@ class TestSplashscreen(PyScriptTest):
|
||||
py_terminal = self.page.wait_for_selector("py-terminal")
|
||||
assert py_terminal.inner_text() == "Hello pyscript!\n"
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_splashscreen_custom_message(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .support import PyScriptTest
|
||||
from .support import PyScriptTest, skip_worker
|
||||
|
||||
|
||||
class TestOutputHandling(PyScriptTest):
|
||||
@@ -183,6 +183,7 @@ class TestOutputHandling(PyScriptTest):
|
||||
|
||||
self.assert_no_banners()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_targeted_stdio_dynamic_tags(self):
|
||||
# Test that creating py-script tags via Python still leaves
|
||||
# stdio targets working
|
||||
@@ -292,6 +293,7 @@ class TestOutputHandling(PyScriptTest):
|
||||
assert self.page.locator("#stderr-div").text_content() == "one."
|
||||
self.assert_no_banners()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
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
|
||||
@@ -325,6 +327,7 @@ class TestOutputHandling(PyScriptTest):
|
||||
alert_banner = self.page.locator(".alert-banner")
|
||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
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
|
||||
|
||||
@@ -8,9 +8,10 @@ import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from .support import ROOT, PyScriptTest, wait_for_render
|
||||
from .support import ROOT, PyScriptTest, wait_for_render, with_execution_thread
|
||||
|
||||
|
||||
@with_execution_thread(None)
|
||||
@pytest.mark.usefixtures("chdir")
|
||||
class TestExamples(PyScriptTest):
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import re
|
||||
|
||||
from .support import PyScriptTest
|
||||
from .support import PyScriptTest, skip_worker
|
||||
|
||||
|
||||
class TestDocsSnippets(PyScriptTest):
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_tutorials_py_click(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -65,6 +66,7 @@ class TestDocsSnippets(PyScriptTest):
|
||||
assert "userId" in py_terminal.inner_text()
|
||||
self.assert_no_banners()
|
||||
|
||||
@skip_worker("FIXME: js.document")
|
||||
def test_tutorials_py_config_fetch(self):
|
||||
# flake8: noqa
|
||||
self.pyscript_run(
|
||||
@@ -147,6 +149,7 @@ class TestDocsSnippets(PyScriptTest):
|
||||
assert "0.22.0a3" in py_terminal.inner_text()
|
||||
self.assert_no_banners()
|
||||
|
||||
@skip_worker("FIXME: display()")
|
||||
def test_tutorials_writing_to_page(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
|
||||
@@ -2,8 +2,9 @@ import sys
|
||||
import textwrap
|
||||
from unittest.mock import Mock
|
||||
|
||||
import js
|
||||
import pyscript
|
||||
from pyscript import HTML, Element, _html
|
||||
from pyscript import HTML, Element
|
||||
from pyscript._deprecated_globals import DeprecatedGlobal
|
||||
from pyscript._internal import set_version_info, uses_top_level_await
|
||||
from pyscript._mime import format_mime
|
||||
@@ -19,7 +20,7 @@ class TestElement:
|
||||
document = Mock()
|
||||
call_result = "some_result"
|
||||
document.querySelector = Mock(return_value=call_result)
|
||||
monkeypatch.setattr(_html, "document", document)
|
||||
monkeypatch.setattr(js, "document", document)
|
||||
assert not el._element
|
||||
real_element = el.element
|
||||
assert real_element
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"_version": "3.0.0",
|
||||
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules/*", "__sapper__/*", "public/*"],
|
||||
"exclude": ["node_modules/*", "__sapper__/*", "public/*", "src/interpreter_worker/*"],
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2020",
|
||||
|
||||
Reference in New Issue
Block a user