diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b9d69c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + # The Python version Pyodide 0.26 brings (see src/pyodide-worker.js), so + # the runtime checks run the same language version as the browser. + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + # npm ci also builds the library once (the "prepare" script). + - run: npm ci + - run: npm run lint + - run: npm run build:embed + # Builds the library again and checks the embed protocol against dist/. + - run: npm run test:protocol + # The runtime under CPython: the visualizer's columns, docstrings, + # condition comments and readable types, and the doctest runner. + - run: npm run test:runtime + - name: Not checked here + run: | + echo "::notice title=Not run in CI::Needs Pyodide in a browser, so it isn't checked here: the worker (run, REPL, Tests in the browser, input() bridge, gturtle) and the UI. Check these in the embed preview (npm run build:embed && npm run preview:embed)." diff --git a/CLAUDE.md b/CLAUDE.md index 98fbbfc..85b7dbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,13 +13,14 @@ npm run build # tsc -b && vite build && copy src/style.css -> dist/ (li npm run dev # vite build --watch, for use with npm link/yalc in a host shell npm run lint # oxlint npm run test:protocol # builds the library, then runs scripts/check-embed-protocol.mjs against dist/ +npm run test:runtime # the runtime under CPython: scripts/check-runtime-{columns,values,doctests}.py npm run build:embed # builds the iframe app + demo page into dist-embed/ npm run preview:embed # serves dist-embed/ with COOP/COEP headers; open /demo.html ``` `npm run dev:embed` currently 404s: `embed/index.html` loads `../src/embed/main.tsx`, which lies outside the Vite root, so only the build works. Use `build:embed` + `preview:embed`. -There is no unit test framework; `test:protocol` (plain `node:assert` against the built `dist/index.js`) is the only automated check. Add protocol validation cases there. `npm run lint` has one known warning in `GlPythonWorkspace.tsx` (`react(immutability)` on the `cursor` variable in `Frame`). +There is no unit test framework. The automated checks are `test:protocol` (plain `node:assert` against the built `dist/index.js`; add protocol validation cases there) and `test:runtime` (plain Python scripts that exec `src/runtime/` under CPython 3.12, Pyodide's version; add runtime regression cases there). `.github/workflows/ci.yml` runs lint, both builds and both checks on every push and pull request. Nothing that needs Pyodide in a browser (the worker, the UI) is checked automatically. `npm run lint` has one known warning in `GlPythonWorkspace.tsx` (`react(immutability)` on the `cursor` variable in `Frame`). **Releases.** - The package is consumed from git: the glpython IDE (`../glpython`) pins `glpython-editor` by tag in its `package.json`. The `prepare` script builds `dist/` on install. @@ -42,6 +43,8 @@ There is no unit test framework; `test:protocol` (plain `node:assert` against th - A call into a function from another file switches `_glpython_current_path` while its body runs. - `_glpython_short_form` marks functions whose body is a single `return ` (docstring allowed). Their call-enter steps carry `shortForm` for the UI's substitution mode. - `_glpython_run_visual_repl` traces a REPL input as the pseudo-file `` against the REPL namespace. The file's functions are swapped for traced versions of their `def`s, taken from the sources of the last Run. It's a dry run that leaves the namespace unchanged. + - Columns: `ast` counts UTF-8 bytes, `tokenize` characters, the UI UTF-16 code units. Every offset into a line goes through `_glpython_text_before` (the text in front of it, for slicing) or `_glpython_ui_column` (a column for the UI); never slice a line by an `ast` offset directly. + - Values are shown through `_glpython_safe_repr`, which writes types as learners do (`int`, not ``); the program's own output keeps Python's repr. Worker messages: - `run` → `result`/`error` diff --git a/package.json b/package.json index 20d86ee..11c1cb4 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "lint": "oxlint", "preview:embed": "vite preview --config vite.embed.config.ts", "test:protocol": "npm run build && node scripts/check-embed-protocol.mjs", - "test:runtime": "python3 -B scripts/check-runtime-columns.py", + "test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py", "prepare": "npm run build" }, "peerDependencies": { diff --git a/scripts/check-runtime-columns.py b/scripts/check-runtime-columns.py index 74b9d43..32f5a06 100644 --- a/scripts/check-runtime-columns.py +++ b/scripts/check-runtime-columns.py @@ -123,6 +123,26 @@ def walk(node_dict): check_trace("REPL", "", {"main.py": PROGRAM, "": "länge(3) + 1"}, lambda: namespace["_glpython_run_visual_repl"]("länge(3) + 1", json.dumps({"main.py": PROGRAM}), "main.py")) +# A docstring, and the evaluation chain's condition comment («# 'ä🙂' == ... +# ist True»), on lines with ä and 🙂: both slice lines through the same +# conversion as the columns above. +DOC_PROGRAM = '''def prüfe(wört, zähler): + """Prüft, ob wört «ä🙂» ist.""" + if "🙂" in wört and zähler > 2: # Kommentar mit ö + return "ja" + return "nein" + +prüfe("ä🙂", 3) +''' +result = json.loads(namespace["_glpython_trace_program"]("main.py", {"main.py": DOC_PROGRAM}, None)) +hidden = result["hidden"]["main.py"] +if [2, 0, 3, 0] not in hidden: + failures.append(f"docstring: line 2 not hidden as a docstring, hidden ranges are {hidden}") +conditions = [step.get("conditionText") for step in result["trace"] if step["kind"] == "branch"] +expected_condition = """"🙂" in 'ä🙂' and 3 > 2""" +if conditions != [expected_condition]: + failures.append(f"condition comment: got {conditions}, expected {[expected_condition]}") + if failures: print("\n".join(failures)) sys.exit(f"{len(failures)} column check(s) failed.") diff --git a/scripts/check-runtime-doctests.py b/scripts/check-runtime-doctests.py new file mode 100644 index 0000000..861d36e --- /dev/null +++ b/scripts/check-runtime-doctests.py @@ -0,0 +1,131 @@ +"""Regression test: the Tests button's doctest runner (src/runtime/doctests.py) +- the summary, each failing example with doctest's own report and the texts it +compared, the print hint, and a file that doesn't load. + +Runs the runtime with CPython (no Pyodide needed): + python3 -B scripts/check-runtime-doctests.py +""" +import contextlib +import io +import json +import os +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RUNTIME = os.path.join(ROOT, "src", "runtime") + +namespace = {"__name__": "glpython_runtime"} +with open(os.path.join(RUNTIME, "doctests.py"), encoding="utf-8") as handle: + exec(compile(handle.read(), "doctests.py", "exec"), namespace) +run_doctests = namespace["_glpython_run_doctests"] + +failures = [] +folder = tempfile.mkdtemp(prefix="glpython-doctests-") +sys.path.insert(0, folder) # the worker mounts a project's files next to each other + + +def run(source, name="main.py"): + """The report for one file, and what the run printed outside the examples.""" + path = os.path.join(folder, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(source) + printed = io.StringIO() + with contextlib.redirect_stdout(printed): + report = json.loads(run_doctests(path)) + return report, printed.getvalue() + + +def expect(label, got, expected): + if got != expected: + failures.append(f"{label}: got {got!r}, expected {expected!r}") + + +PROGRAM = '''def doppelt(x): + """ + >>> doppelt(2) + 4 + >>> doppelt(3) + 6 + """ + return x * x + +def zeige(x): + """ + >>> zeige(3) + 9 + """ + print(x * x) + +def teile(a, b): + """ + >>> teile(1, 0) + 0 + """ + return a / b + +def länge(text): + """ + >>> länge("ä🙂") + 2 + """ + return len(text) + +if __name__ == "__main__": + print("main läuft") +''' +report, printed = run(PROGRAM) +expect("attempted", report.get("attempted"), 5) +expect("failed", report.get("failed"), 2) +expect("main block", printed, "") +failed = report.get("failures", []) +expect("failing examples", [(f["name"], f["line"], f["source"]) for f in failed], + [("main.doppelt", 5, "doppelt(3)"), ("main.teile", 19, "teile(1, 0)")]) +if len(failed) == 2: + # A wrong result: doctest's own words, and both texts for «Erhalten · Erwartet». + expect("want", failed[0].get("want"), "6\n") + expect("got", failed[0].get("got"), "9\n") + for part in ("Failed example:", "doppelt(3)", "Expected:", "Got:"): + if part not in failed[0]["text"]: + failures.append(f"report of doppelt(3) lacks {part!r}: {failed[0]['text']!r}") + # An exception: no texts to compare. + expect("exception want/got", ("want" in failed[1], "got" in failed[1]), (False, False)) + for part in ("Exception raised:", "ZeroDivisionError"): + if part not in failed[1]["text"]: + failures.append(f"report of teile(1, 0) lacks {part!r}: {failed[1]['text']!r}") +# Passes in doctest, but printed instead of returning. +expect("print hints", report.get("printHints"), [{"source": "zeige(3)", "output": "9"}]) +# The file's definitions stay available to visualize a failing example. +expect("test namespace", sorted(n for n in ("doppelt", "zeige", "teile", "länge") if n in namespace["_glpython_test_namespace"]), + ["doppelt", "länge", "teile", "zeige"]) +# doctest swaps sys.displayhook per example; the runner restores it. +expect("displayhook restored", sys.__displayhook__ is sys.displayhook, True) + +# Importing another file of the project. +run("def halb(x):\n return x / 2\n", "hilfe.py") +report, _ = run('''from hilfe import halb + +def viertel(x): + """ + >>> viertel(8) + 2.0 + """ + return halb(halb(x)) +''') +expect("import: attempted/failed", (report.get("attempted"), report.get("failed")), (1, 0)) + +# No examples at all. +report, _ = run("def f(x):\n return x\n") +expect("no examples", (report.get("attempted"), report.get("failed"), report.get("failures"), report.get("printHints")), (0, 0, [], [])) + +# A file that doesn't load: Python's error (for a syntax error with the line +# and caret above it). +report, _ = run("def f(x)\n return x\n") +expect("syntax error", str(report.get("error", "")).splitlines()[-1:], ["SyntaxError: expected ':'"]) +report, _ = run("x = 1 / 0\n") +expect("error while loading", report.get("error"), "ZeroDivisionError: division by zero") + +if failures: + print("\n".join(failures)) + sys.exit(f"{len(failures)} doctest check(s) failed.") +print("Runtime doctest checks passed.") diff --git a/scripts/check-runtime-values.py b/scripts/check-runtime-values.py new file mode 100644 index 0000000..3be566b --- /dev/null +++ b/scripts/check-runtime-values.py @@ -0,0 +1,50 @@ +"""Regression test: values as the visualizer shows them. A type reads the way +learners write it ("int", "list", "NoneType", not ""), in the +steps the UI substitutes and in the condition text the → chain uses, while the +program's own output keeps Python's repr. + +Runs the visualizer runtime with CPython (no Pyodide needed): + python3 -B scripts/check-runtime-values.py +""" +import json +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RUNTIME = os.path.join(ROOT, "src", "runtime") +sys.path.insert(0, RUNTIME) # for "import gturtle" + +namespace = {"__name__": "glpython_runtime"} +for name in ("session.py", "visualizer.py"): + with open(os.path.join(RUNTIME, name), encoding="utf-8") as handle: + exec(compile(handle.read(), name, "exec"), namespace) + +failures = [] + +# The course's evaluator (Etappen 4-5) asks "type(e) != list". +PROGRAM = '''def ist_blatt(e): + return type(e) != list + +a = 3 +if type(2.5) != str: + print(ist_blatt(a), ist_blatt([1]), type(None), type(True)) +''' +result = json.loads(namespace["_glpython_trace_program"]("main.py", {"main.py": PROGRAM}, None)) +steps = [(step["label"], step.get("valueText")) for step in result["trace"] if step["kind"] == "eval"] +for expected in [("type(...)", "int"), ("int != list", "True"), ("type(...)", "list"), ("list != list", "False"), + ("type(...)", "NoneType"), ("type(...)", "bool"), ("type(...)", "float"), ("float != str", "True")]: + if expected not in steps: + failures.append(f"no step {expected[0]!r} -> {expected[1]!r}; eval steps: {steps}") +# The → chain's reason comment for that if: the condition before it runs. +conditions = [step.get("conditionText") for step in result["trace"] if step["kind"] == "branch"] +if conditions != ["type(2.5) != str"]: + failures.append(f"condition text {conditions}, expected ['type(2.5) != str']") +if any(": {steps}") +if result["output"].strip() != "True False ": + failures.append(f"output changed: {result['output']!r}") + +if failures: + print("\n".join(failures)) + sys.exit(f"{len(failures)} value check(s) failed.") +print("Runtime value checks passed.") diff --git a/src/runtime/visualizer.py b/src/runtime/visualizer.py index 13aed25..6aae2b2 100644 --- a/src/runtime/visualizer.py +++ b/src/runtime/visualizer.py @@ -53,9 +53,13 @@ def _glpython_is_uninteresting_for_substitution(value): # enclosing Call node's own eval step show the real result instead. return callable(value) or isinstance(value, _glpython_types.ModuleType) +# A value as the visualizer shows it: Python's repr, except that a type reads +# the way learners write it ("int", "list", "NoneType" instead of +# ""), so "type(a) != list" steps to "int != list". The program's +# own output (print, the REPL transcript) keeps Python's repr. def _glpython_safe_repr(value, limit=160): try: - text = repr(value) + text = value.__name__ if isinstance(value, type) else repr(value) except Exception: text = "" if len(text) > limit: @@ -163,11 +167,15 @@ class _GlpythonCallDepthExceeded(Exception): # The UI indexes text in UTF-16 code units (JavaScript strings), but Python's # ast reports columns in UTF-8 bytes and tokenize in characters - they only # agree on plain ASCII lines, not with "ü" (2 bytes, 1 unit) or "🙂" (4 bytes, -# 1 character, 2 units). Every column sent to the UI goes through here; `unit` -# says what `offset` counts ("utf8" bytes or "chars"). +# 1 character, 2 units). Every offset into a line goes through these two: +# _glpython_text_before for the text in front of it (to slice a Python +# string), _glpython_ui_column for a column sent to the UI. `unit` says what +# `offset` counts ("utf8" bytes or "chars"). +def _glpython_text_before(line, offset, unit="utf8"): + return line.encode("utf-8")[:offset].decode("utf-8", "replace") if unit == "utf8" else line[:offset] + def _glpython_ui_column(line, offset, unit="utf8"): - prefix = line.encode("utf-8")[:offset].decode("utf-8", "replace") if unit == "utf8" else line[:offset] - return len(prefix.encode("utf-16-le")) // 2 + return len(_glpython_text_before(line, offset, unit).encode("utf-16-le")) // 2 # A node's position for the UI: lines as Python counts them, columns in UTF-16 # code units (set by _glpython_preprocess; nodes it never saw keep Python's). @@ -834,7 +842,7 @@ def _glpython_source_with_values(expr, scope): source = getattr(expr, "_glpython_source", None) if source is None or getattr(expr, "lineno", None) is None: return None - lines = [line.encode("utf-8") for line in source.split("\n")] + lines = source.split("\n") replacements = [] for name in _glpython_names_read(expr): try: @@ -849,10 +857,10 @@ def _glpython_source_with_values(expr, scope): shift = expr.col_offset if row == 0 else 0 if not 0 <= row < len(lines): continue - start = len(lines[row][:name.col_offset - shift].decode("utf-8", "replace")) - end = len(lines[row][:name.end_col_offset - shift].decode("utf-8", "replace")) + start = len(_glpython_text_before(lines[row], name.col_offset - shift)) + end = len(_glpython_text_before(lines[row], name.end_col_offset - shift)) replacements.append((row, start, end, _glpython_safe_repr(value))) - text_lines = [line.decode("utf-8") for line in lines] + text_lines = list(lines) for row, start, end, value_text in sorted(replacements, reverse=True): text_lines[row] = text_lines[row][:start] + value_text + text_lines[row][end:] return " ".join(line.strip() for line in text_lines) @@ -1376,7 +1384,7 @@ def _glpython_hidden_ranges(source, tree): if token.type != tokenize.COMMENT: continue (row, col), (end_row, end_col) = token.start, token.end - before = lines[row - 1][:col] + before = _glpython_text_before(lines[row - 1], col, "chars") if before.strip(): # A comment after code: drop it together with the spaces before it. # (tokenize counts characters; the UI needs UTF-16 code units.) @@ -1392,7 +1400,7 @@ def _glpython_hidden_ranges(source, tree): if not (isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) and isinstance(first.value.value, str)): continue # Only a docstring on lines of its own (not "def f(): \"\"\"doc\"\"\"; ..."). - if lines[first.lineno - 1][:first.col_offset].strip() or first.lineno == node.lineno: + if _glpython_text_before(lines[first.lineno - 1], first.col_offset).strip() or first.lineno == node.lineno: continue ranges.append([first.lineno, 0, first.end_lineno + 1, 0]) return ranges