Skip to content

Commit fe035bc

Browse files
authored
Run tests in the browser (#172)
* add html page to run tests in the browser * add todo * alter setrecursionlimit * cleanup file fetching * tweaking error output * rearrange and add dedicated upload script * panic abort and update browser tests * add header * use githubproxy * prevent caching of worker.js and run_tests.py * pin tests * set html url to main
1 parent e4cf2e2 commit fe035bc

File tree

8 files changed

+295
-2
lines changed

8 files changed

+295
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ package-lock.json
3030
/pytest-speed/
3131
/src/self_schema.py
3232
/worktree/
33+
/wasm-preview/tests.zip

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ name = "pydantic_core._pydantic_core"
3838
[profile.release]
3939
lto = "fat"
4040
codegen-units = 1
41+
panic = "abort"
4142

4243
[build-dependencies]
4344
version_check = "0.9.4"

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.DEFAULT_GOAL := all
22
isort = isort pydantic_core tests generate_self_schema.py
3-
black = black pydantic_core tests generate_self_schema.py
3+
black = black pydantic_core tests generate_self_schema.py wasm-preview/upload.py
44

55
.PHONY: install
66
install:
@@ -63,7 +63,7 @@ format:
6363

6464
.PHONY: lint-python
6565
lint-python:
66-
flake8 --max-line-length 120 pydantic_core tests
66+
flake8 --max-line-length 120 pydantic_core tests generate_self_schema.py wasm-preview/upload.py
6767
$(isort) --check-only --df
6868
$(black) --check --diff
6969

wasm-preview/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Demonstration of pydantic-core unit tests running in the browser
2+
3+
To run tests in your browser, go
4+
[here](https://githubproxy.samuelcolvin.workers.dev/samuelcolvin/pydantic-core/blob/main/wasm-preview/index.html).
5+
6+
If the output appears to stop prematurely, try looking in the developer console for more details.

wasm-preview/index.html

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<!DOCTYPE html>
2+
<title>pydantic-core unit tests</title>
3+
<style>
4+
html, body {
5+
height: 100%;
6+
background: rgb(30, 31, 46);
7+
color: white;
8+
font-family: monospace;
9+
overflow: hidden;
10+
}
11+
main {
12+
max-width: 800px;
13+
height: 100%;
14+
margin: 10px auto;
15+
}
16+
section {
17+
margin-top: 20px;
18+
padding: 10px 15px;
19+
height: calc(100% - 160px);
20+
overflow-y: scroll;
21+
overflow-x: hidden;
22+
border: 1px solid #aaa;
23+
border-radius: 5px;
24+
}
25+
pre {
26+
margin: 0;
27+
padding: 0;
28+
white-space: pre-wrap;
29+
}
30+
a {
31+
color: #58a6ff;
32+
text-decoration: none;
33+
}
34+
</style>
35+
<main>
36+
<h1>
37+
<a href="https://github.com/samuelcolvin/pydantic-core/tree/main/wasm-preview">pydantic-core</a> unit tests
38+
</h1>
39+
<aside>
40+
pydantic-core is compiled to webassembly and run in the browser using
41+
<a href="https://pyodide.org/en/stable/">pyodide</a>.
42+
</aside>
43+
<section>
44+
<pre id="output">loading...</pre>
45+
</section>
46+
</main>
47+
48+
<script src="https://smokeshow.helpmanual.io/2d1p666q3x5f0j26223o/ansi-to-html.browser.js"></script>
49+
<script>
50+
const output_el = document.getElementById('output')
51+
const decoder = new TextDecoder()
52+
const Convert = require('ansi-to-html')
53+
const ansi_converter = new Convert()
54+
let terminal_output = ''
55+
56+
output_el.innerText = 'Starting worker...'
57+
const worker = new Worker(`./worker.js?v=${Date.now()}`)
58+
worker.onmessage = ({data}) => {
59+
if (typeof data == 'string') {
60+
terminal_output += data
61+
} else {
62+
for (let chunk of data) {
63+
let arr = new Uint8Array(chunk)
64+
let extra = decoder.decode(arr)
65+
terminal_output += extra
66+
}
67+
}
68+
output_el.innerHTML = ansi_converter.toHtml(terminal_output)
69+
// scrolls to the bottom of the div
70+
output_el.scrollIntoView(false)
71+
}
72+
worker.postMessage({})
73+
</script>

wasm-preview/run_tests.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import base64
2+
import re
3+
import sys
4+
import importlib
5+
import traceback
6+
from io import BytesIO
7+
from pathlib import Path
8+
from zipfile import ZipFile
9+
10+
import micropip
11+
import pytest
12+
13+
# this seems to be required for me on M1 Mac
14+
sys.setrecursionlimit(200)
15+
16+
# compiled manually an uploaded to smokeshow, there seems to be no nice way of getting a file from a CI build
17+
pydantic_core_wheel = (
18+
'https://smokeshow.helpmanual.io'
19+
'/4o4l4x0t2m6z1w4n6u4b/pydantic_core-0.0.1-cp310-cp310-emscripten_3_1_14_wasm32.whl'
20+
)
21+
22+
23+
async def main(tests_zip: str):
24+
print(f'Extracting test files (size: {len(tests_zip):,})...')
25+
zip_file = ZipFile(BytesIO(base64.b64decode(tests_zip)))
26+
count = 0
27+
for name in zip_file.namelist():
28+
if name.endswith('.py'):
29+
path, subs = re.subn(r'^pydantic-core-main/tests/', 'tests/', name)
30+
if subs:
31+
count += 1
32+
path = Path(path)
33+
path.parent.mkdir(parents=True, exist_ok=True)
34+
with zip_file.open(name, 'r') as f:
35+
path.write_bytes(f.read())
36+
37+
print(f'Mounted {count} test files, installing dependencies...')
38+
39+
await micropip.install(['dirty-equals', 'hypothesis', 'pytest-speed', pydantic_core_wheel])
40+
importlib.invalidate_caches()
41+
42+
# print('installed packages:')
43+
# print(micropip.list())
44+
print('Running tests...')
45+
pytest.main()
46+
47+
try:
48+
await main(tests_zip)
49+
except Exception as e:
50+
traceback.print_exc()
51+
raise

wasm-preview/upload.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import os
2+
import sys
3+
from pathlib import Path
4+
5+
6+
def error(msg: str):
7+
print(f'ERROR: {msg}', file=sys.stderr)
8+
exit(1)
9+
10+
11+
try:
12+
import requests
13+
except ImportError:
14+
error("requests not installed, you'll need to run `pip install requests`")
15+
16+
17+
def main():
18+
root_dir = Path(__file__).parent.parent
19+
try:
20+
wheel_file = next(p for p in (root_dir / 'dist').iterdir() if p.name.endswith('wasm32.whl'))
21+
except StopIteration:
22+
error('No wheel found in "dist" directory')
23+
else:
24+
uploader = Uploader()
25+
26+
wheel_url = uploader.upload_file(wheel_file)
27+
print(f'Wheel uploaded ✓, URL: "{wheel_url}"')
28+
29+
30+
class Uploader:
31+
def __init__(self):
32+
try:
33+
auth_key = os.environ['SMOKESHOW_AUTH_KEY']
34+
except KeyError:
35+
raise RuntimeError('No auth key provided, please set SMOKESHOW_AUTH_KEY')
36+
else:
37+
self.client = requests.Session()
38+
r = self.client.post('https://smokeshow.helpmanual.io/create/', headers={'Authorisation': auth_key})
39+
if r.status_code != 200:
40+
raise ValueError(f'Error creating ephemeral site {r.status_code}, response:\n{r.text}')
41+
42+
obj = r.json()
43+
self.secret_key: str = obj['secret_key']
44+
self.url: str = obj['url']
45+
assert self.url.endswith('/'), self.url
46+
47+
def upload_file(self, file: Path) -> str:
48+
headers = {'Authorisation': self.secret_key, 'Response-Header-Access-Control-Allow-Origin': '*'}
49+
50+
url_path = file.name
51+
url = self.url + file.name
52+
r = self.client.post(url, data=file.read_bytes(), headers=headers)
53+
if r.status_code == 200:
54+
upload_info = r.json()
55+
print(f' uploaded {url_path} size={upload_info["size"]:,}')
56+
else:
57+
print(f' ERROR! {url_path} status={r.status_code} response={r.text}')
58+
error(f'invalid response from "{url_path}" status={r.status_code} response={r.text}')
59+
return url
60+
61+
62+
if __name__ == '__main__':
63+
main()

wasm-preview/worker.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
let chunks = []
2+
let last_post = 0
3+
4+
function print(tty) {
5+
if (tty.output && tty.output.length > 0) {
6+
chunks.push(tty.output)
7+
tty.output = []
8+
const now = performance.now()
9+
if (now - last_post > 100) {
10+
post()
11+
last_post = now
12+
}
13+
}
14+
}
15+
16+
function post() {
17+
self.postMessage(chunks)
18+
chunks = []
19+
}
20+
21+
function make_tty_ops() {
22+
return {
23+
put_char(tty, val) {
24+
if (val !== null) {
25+
tty.output.push(val)
26+
}
27+
if (val === null || val === 10) {
28+
print(tty)
29+
}
30+
},
31+
flush(tty) {
32+
print(tty)
33+
},
34+
}
35+
}
36+
37+
function setupStreams(FS, TTY) {
38+
let mytty = FS.makedev(FS.createDevice.major++, 0)
39+
let myttyerr = FS.makedev(FS.createDevice.major++, 0)
40+
TTY.register(mytty, make_tty_ops())
41+
TTY.register(myttyerr, make_tty_ops())
42+
FS.mkdev('/dev/mytty', mytty)
43+
FS.mkdev('/dev/myttyerr', myttyerr)
44+
FS.unlink('/dev/stdin')
45+
FS.unlink('/dev/stdout')
46+
FS.unlink('/dev/stderr')
47+
FS.symlink('/dev/mytty', '/dev/stdin')
48+
FS.symlink('/dev/mytty', '/dev/stdout')
49+
FS.symlink('/dev/myttyerr', '/dev/stderr')
50+
FS.closeStream(0)
51+
FS.closeStream(1)
52+
FS.closeStream(2)
53+
FS.open('/dev/stdin', 0)
54+
FS.open('/dev/stdout', 1)
55+
FS.open('/dev/stderr', 1)
56+
}
57+
58+
async function get(url, mode) {
59+
const r = await fetch(url)
60+
if (r.ok) {
61+
if (mode === 'text') {
62+
return await r.text()
63+
} else {
64+
const blob = await r.blob();
65+
let buffer = await blob.arrayBuffer();
66+
return btoa(new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), ''))
67+
}
68+
} else {
69+
let text = await r.text()
70+
console.error('unexpected response', r, text)
71+
throw new Error(`${r.status}: ${text}`)
72+
}
73+
}
74+
75+
self.onmessage = async () => {
76+
self.postMessage('Downloading repo archive to get tests...\n')
77+
const [python_code, tests_zip,] = await Promise.all([
78+
get(`./run_tests.py?v=${Date.now()}`, 'text'),
79+
// 95c4f56 commit matches the pydantic-core wheel being used, so tests should pass
80+
get('https://githubproxy.samuelcolvin.workers.dev/samuelcolvin/pydantic-core/archive/refs/95c4f56/main.zip', 'blob'),
81+
importScripts('https://cdn.jsdelivr.net/pyodide/v0.21.0a3/full/pyodide.js')
82+
])
83+
84+
const pyodide = await loadPyodide()
85+
const {FS} = pyodide
86+
setupStreams(FS, pyodide._module.TTY)
87+
FS.mkdir('/test_dir')
88+
FS.chdir('/test_dir')
89+
await pyodide.loadPackage(['micropip', 'pytest', 'pytz'])
90+
try {
91+
await pyodide.runPythonAsync(python_code, {globals: pyodide.toPy({tests_zip})})
92+
} catch (err) {
93+
console.error(err)
94+
const raw_error = Array.from(new TextEncoder().encode(err.toString()))
95+
self.postMessage(raw_error)
96+
}
97+
post()
98+
}

0 commit comments

Comments
 (0)