Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ jobs:
with:
node-version: 22
- run: node scripts/check-macro.mjs
- run: npm ci
# Chrome is installed on GitHub's runners; playwright-core drives it.
- run: npm run check:ready
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.DS_Store
node_modules/
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ The macro renders a button row below the block's LiaScript code editor and an if

```bash
node scripts/check-macro.mjs # node --check on every <script> block of README.md
npm ci && npm run check:ready # the first program on "ready", in Chrome: @onload on a test page with a stand-in lia-editor and a stub embed
npm run check:player [visits] # the same with the real LiaScript player and the live embed (scripts/player/course.md); not in CI
```

That is the only automated check, and CI runs it on pull requests and on `main`. LiaScript replaces placeholders (`@0`, `@'input`, …) before running a block, so keep every placeholder inside a string literal, or the check fails, as it would in most courses.
CI runs `check-macro` and `check:ready` on pull requests and on `main`. LiaScript replaces placeholders (`@0`, `@'input`, …) before running a block, so keep every placeholder inside a string literal, or the check fails, as it would in most courses. `check:ready` serves everything through Playwright routes, so it needs no external site; add cases for what the macro sends there. `check:player` depends on liascript.github.io, the embed and the CDNs, so it must never block a merge; run it before a release.

## Testing in LiaScript

Expand All @@ -32,6 +34,7 @@ Nothing runs LiaScript automatically, so test a change in the hosted player:
- **Before/after:** compare with `git show main:README.md` served next to it.
- **Selecting the editors:** use `.lia-code--block lia-editor`. LiaScript also has a hidden chat `lia-editor`. Ace is reachable as `element.env.editor`.
- **Re-renders:** LiaScript recreates a slide's editors when the slide is shown again (next slide and back).
- **A new editor's text arrives late:** Elm sets `value` on a new `lia-editor`, which waits for its Ace editor (`getSession`, polling every 100 ms) and sets the text with its own events blocked: no `editorUpdate` for it, only Ace's `change`. The embed's iframe (`loading="lazy"`) loads when its fragment appears, and a cached embed says `ready` in a few milliseconds, before the text. So the first program waits while the block's editors are all empty (`sendOnceBlockHasText`).
- **Without hosting:** the README's «Development» section has the LiveEditor link. For a local embed, point `embedOrigin`/`embedUrl` at glpython-editor's `npm run build:embed && npm run preview:embed`. That README still says `dev:embed`, which 404s (see glpython-editor's CLAUDE.md).

## How LiaScript behaves (source: github.com/LiaScript/LiaScript)
Expand Down
83 changes: 76 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,11 @@ window.__glpythonMount = (args, send) => {
}
}

// Every program sent counts, so a first program still waiting for the
// block's text (see sendOnceBlockHasText) knows it was overtaken.
let programsSent = 0
const postProgram = async (type, fallbackSource) => {
programsSent += 1
const program = await readProgram(fallbackSource)
if (!program || !frame?.contentWindow) return
const main = program.files.find((file) => file.path === program.mainFile)
Expand All @@ -523,6 +527,54 @@ window.__glpythonMount = (args, send) => {
postProgram(type, fallbackSource)
}

// The first program, sent on "ready", may find the block without its text:
// when a LiaScript fragment appears, LiaScript creates the block's editor
// and this iframe together, and fills the editor a few milliseconds later
// (once its Ace editor exists), while a cached embed can be ready sooner.
// So while all of the block's editors are still empty, it waits for their
// text: for Ace's change event (LiaScript's own editorUpdate doesn't come
// for the text it sets itself) and for attach(), which runs on every DOM
// change (an editor not yet connected). At most editorTextTimeout ms, as
// a block may be empty on purpose. Nothing else waits: a program sent
// meanwhile (▶, Tests, a file chosen) carries the block as it is then, and
// the first one is dropped.
const editorTextTimeout = 2000
let textWait = null
const blockHasText = () => (readCodeFiles() ?? []).some((file) => file.source !== "")
const sendOnceBlockHasText = (type) => {
textWait?.stop()
const sentBefore = programsSent
const listeners = []
const stop = () => {
window.clearTimeout(timer)
for (const remove of listeners) remove()
if (textWait === wait) textWait = null
}
const check = () => {
if (programsSent !== sentBefore) return stop()
if (blockHasText()) {
stop()
sendCommand(type)
return
}
for (const editor of codeBlock?.querySelectorAll("lia-editor") ?? []) {
const ace = editor.env?.editor
if (!ace || listeners.some((listener) => listener.ace === ace)) continue
ace.on("change", check)
const remove = () => ace.off("change", check)
remove.ace = ace
listeners.push(remove)
}
}
const timer = window.setTimeout(() => {
stop()
if (programsSent === sentBefore) sendCommand(type)
}, editorTextTimeout)
const wait = { check, stop }
textWait = wait
check()
}

// The command a button asked for when it went through LiaScript's run (see
// throughLiaScript): LiaScript's per-run script then calls run, which sends
// that command instead of a run - so Python runs once. A request older than
Expand Down Expand Up @@ -558,11 +610,9 @@ window.__glpythonMount = (args, send) => {
stopSupported = message.capabilities?.stop === true
pythonBusy = false
frame.dataset.inputAvailable = String(inputAvailable)
sendCommand(mode === "visual" ? "glpython:visualize" : "glpython:load")
if (pendingRun) {
sendCommand("glpython:run")
pendingRun = false
}
// A run asked for before (pendingRun) carries the program itself.
sendOnceBlockHasText(pendingRun ? "glpython:run" : mode === "visual" ? "glpython:visualize" : "glpython:load")
pendingRun = false
for (const id of ["visualize", "reset", "run", "run-menu", "tests"]) {
const button = document.getElementById(`glpython-${id}-${sessionId}`)
if (button) button.disabled = false
Expand Down Expand Up @@ -673,6 +723,8 @@ window.__glpythonMount = (args, send) => {
if (frame !== nextFrame) {
runtimeReady = false
pythonBusy = false
// The new iframe says "ready" again.
textWait?.stop()
}
frame = nextFrame
error = document.getElementById(`glpython-error-${sessionId}`)
Expand Down Expand Up @@ -715,6 +767,7 @@ window.__glpythonMount = (args, send) => {
for (const editor of codeBlock?.querySelectorAll("lia-editor") ?? []) indentWithSpaces(editor)
updateRunButton(readCodeFiles() ?? [])
updateIdeButton()
textWait?.check()
libraries.then(showLibraries)
if (frame.dataset.glpythonInitialized === "true") return
frame.dataset.glpythonInitialized = "true"
Expand Down Expand Up @@ -875,6 +928,13 @@ one. The post-run REPL below the output uses this per-block worker lifecycle: Ru
keeps the program's namespace while the worker survives, and Reset discards it
without changing LiaScript's source.

When the iframe is ready, the macro sends it the block's source - for
`@GLPython.visual` that starts the visualization. A block inside a fragment
(`{{1}}`) gets its editor only when the fragment appears, and LiaScript fills
the editor a moment later; so while the block's code is still empty, the macro
waits for it, at most 2 seconds (a block may be empty on purpose). Run, Tests
and the REPL don't wait.

## Turtle graphics

Use `@GLPython.turtle` for a graphics-focused layout:
Expand Down Expand Up @@ -1143,8 +1203,17 @@ https://liascript.github.io/LiveEditor/?/show/file/https://raw.githubusercontent
```

`node scripts/check-macro.mjs` checks the syntax of the template's `<script>`
blocks with `node --check`; CI runs it on every pull request and on `main`,
since a merge to `main` reaches every course.
blocks with `node --check`. `npm run check:ready` (after `npm ci`; it needs
Chrome) runs the `@onload` code on a test page with stand-ins for LiaScript's
editor and the embed, and checks the first program sent on "ready": with text
that arrives after "ready", with an empty block, with several files. CI runs
both on every pull request and on `main`, since a merge to `main` reaches every
course.

`npm run check:player` checks the same with the real LiaScript player, the live
embed and a small course (`scripts/player/course.md`: a visualization in a
fragment, visited several times so the embed comes from the cache). It needs
those sites, so it isn't part of CI; run it before a release.

For local embed development, change `embedOrigin` and `embedUrl` in the template
header to the origin printed by `npm run dev:embed` in `glpython-editor`.
Expand Down
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "glpython-liascript",
"private": true,
"type": "module",
"scripts": {
"check": "node scripts/check-macro.mjs",
"check:ready": "node scripts/check-ready.mjs",
"check:player": "node scripts/check-player.mjs"
},
"devDependencies": {
"playwright-core": "^1.48.2"
}
}
82 changes: 82 additions & 0 deletions scripts/check-player.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Check with the real LiaScript player (not in CI: it needs
// liascript.github.io, the live embed and the CDNs, which can fail for
// reasons outside this repo). Run it before a macro release:
//
// npm run check:player [visits] (needs Chrome installed)
//
// The course (scripts/player/course.md) has a visualization-only block in a
// fragment, which is hidden when its slide opens. In presentation mode
// LiaScript creates the block's editor and the embed's iframe only when the
// fragment appears, and fills the editor a few milliseconds later; once the
// embed is cached, its "ready" can come first. Every visit must still show
// the program, the first one (embed loaded from the network) and the later
// ones (cached). The macro is this repo's README.md, served in place of the
// course's import.
import { readFileSync } from 'node:fs'
import { chromium } from 'playwright-core'

const visits = Number(process.argv[2] ?? 5)
const courseOrigin = 'https://course.example'
const course = readFileSync(new URL('player/course.md', import.meta.url), 'utf8')
const macro = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
const player = `https://liascript.github.io/course/?${courseOrigin}/course.md`
// The slide with the block, and its program (as LiaScript's editor has it).
const slide = 3
const programLength = course.match(/```python\n([\s\S]*?)\n```/)[1].length

const browser = await chromium.launch({ channel: 'chrome', headless: true })
const results = []
try {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } })
await context.route(`${courseOrigin}/**`, (route) => {
const body = new URL(route.request().url()).pathname === '/macro.md' ? macro : course
route.fulfill({ contentType: 'text/plain; charset=utf-8', headers: { 'Access-Control-Allow-Origin': '*' }, body })
})
await context.addInitScript(() => {
// In the embed: the length of each program it gets.
if (location.origin === 'https://glpython-edu.github.io') {
window.addEventListener('message', (event) => {
if (event.data?.type === 'glpython:visualize') console.log(`glpython-check: visualize ${(event.data.source ?? '').length}`)
})
return
}
// The player in presentation mode, where fragments start hidden.
if (window.top === window && !localStorage.getItem('settings')) localStorage.setItem('settings', JSON.stringify({ mode: 'Presentation', table_of_contents: false }))
})
const page = await context.newPage()
let lengths = []
page.on('console', (message) => {
const match = message.text().match(/^glpython-check: visualize (\d+)$/)
if (match) lengths.push(Number(match[1]))
})
await page.goto(`${player}#1`)
await page.waitForSelector('.lia-slide__content', { timeout: 60000 })
for (let visit = 0; visit < visits; visit++) {
lengths = []
await page.evaluate((hash) => { location.hash = hash }, `#${slide - 1}`)
await page.waitForTimeout(1500)
await page.keyboard.press('ArrowRight')
await page.waitForTimeout(1000)
await page.keyboard.press('ArrowRight')
// The visualizer, once Pyodide has traced the program (the first visit
// loads it).
let steps = null
for (let tries = 0; tries < 120 && steps === null; tries++) {
await page.waitForTimeout(500)
for (const frame of page.frames().filter((frame) => frame.url().startsWith('https://glpython-edu.github.io/'))) {
const count = await frame.evaluate(() => document.querySelector('.visualizer-view') ? document.querySelector('.visualizer-step-count')?.textContent ?? '' : null).catch(() => null)
if (count !== null) steps = count
}
}
const ok = lengths.length === 1 && lengths[0] === programLength && steps !== null && !/Keine Schritte|No steps/.test(steps)
results.push(ok)
console.log(`visit ${visit + 1}${visit === 0 ? ' (embed not cached)' : ''}: program sent ${JSON.stringify(lengths)} characters, visualizer «${steps ?? 'none'}»: ${ok ? 'ok' : 'FAILED'}`)
}
} finally {
await browser.close()
}
if (results.includes(false)) {
console.error(`Player check failed: ${results.filter((ok) => !ok).length} of ${results.length} visits.`)
process.exit(1)
}
console.log(`Player check passed: ${results.length} visits.`)
Loading
Loading