diff --git a/CLAUDE.md b/CLAUDE.md index 6bb19ad..f2d1ad3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ npm run lint # oxlint; 7 known warnings, all in the vendored public/coi-servi npm run test:browser # after a build: scripts/check-ide-browser.mjs, the IDE in headless Chrome ``` -The one automated check is `test:browser` (`scripts/check-ide-browser.mjs`): `playwright-core` drives the installed Chrome (GitHub's runner has one) against `vite preview` of `dist/`, with Pyodide from its CDN and its own fixtures (it serves a library by URL itself). It covers tab switches with a read-only file, per-tab projects, share links (library by URL, 404, cut and long links), ▶ and Tests, and ⋮ «Info»; add regression cases there. The browser's «leave the page» question and the iPad stay manual. `.github/workflows/ci.yml` runs `npm ci`, the build (tsc catches type changes in a new editor tag), lint and `test:browser` on every pull request. Pushes to `main` deploy to GitHub Pages (`deploy-pages.yml`). To check a change in the browser, use `npm run dev`; `npm run build && npm run preview` serves the production layout. The embed is a separate project site under the same origin, https://glpython-edu.github.io/glpython-editor/. The IDE's `coi-serviceworker.js` registers for scope `/`, which includes it, so after a change to that worker check the embed too: its demo page opened directly, and a LiaScript course. +The one automated check is `test:browser` (`scripts/check-ide-browser.mjs`): `playwright-core` drives the installed Chrome (GitHub's runner has one) against `vite preview` of `dist/`, with Pyodide from its CDN and its own fixtures (it serves a library by URL itself). It covers tab switches with a read-only file, per-tab projects, share links (library by URL, 404, cut and long links), ▶ and Tests, ⋮ «Info», and `input()` in the console (a recursive REPL, Stop → `EOFError`); add regression cases there. The browser's «leave the page» question and the iPad stay manual. `.github/workflows/ci.yml` runs `npm ci`, the build (tsc catches type changes in a new editor tag), lint and `test:browser` on every pull request. Pushes to `main` deploy to GitHub Pages (`deploy-pages.yml`). To check a change in the browser, use `npm run dev`; `npm run build && npm run preview` serves the production layout. The embed is a separate project site under the same origin, https://glpython-edu.github.io/glpython-editor/. The IDE's `coi-serviceworker.js` registers for scope `/`, which includes it, so after a change to that worker check the embed too: its demo page opened directly, and a LiaScript course. ## Layout diff --git a/package-lock.json b/package-lock.json index 9ce2d85..1bd5316 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.21", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.22", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", @@ -805,7 +805,7 @@ }, "node_modules/glpython-editor": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#95eb1c6c104f02ff738355743507e9129dc3283f", + "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#9b734fe41722b36f2e455c16af610ad49082ce9a", "license": "MIT", "peerDependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/package.json b/package.json index 2d14e37..b61a052 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.21", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.22", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", diff --git a/scripts/check-ide-browser.mjs b/scripts/check-ide-browser.mjs index 3681128..681dbd7 100644 --- a/scripts/check-ide-browser.mjs +++ b/scripts/check-ide-browser.mjs @@ -7,7 +7,9 @@ // closed tab's project offered again; // - share links: a library by URL (served here), a library that gives 404, // a link cut at 2,083 characters, the note for a long link; -// - ▶ and «Tests» on a project of two files; ⋮ «Info» and its update check. +// - ▶ and «Tests» on a project of two files; ⋮ «Info» and its update check; +// - input() in the console's input line: ▶, a recursive REPL called in the +// REPL, Stop → EOFError. // The browser's «leave the page» question and the iPad cases stay manual: // headless Chrome can't confirm them. // @@ -51,6 +53,8 @@ const link = (files, mainFile, version = 1) => { const helfer = 'def doppelt(x):\n return 2 * x\n' const main = 'from helfer import doppelt\n\n\ndef viermal(x):\n """\n >>> viermal(2)\n 8\n """\n return doppelt(doppelt(x))\n\n\nprint(viermal(21))\n' const twoFiles = link([file('helfer.py', helfer), file('main.py', main)], 'main.py') +const greeting = link([file('main.py', 'name = input("Name: ")\nprint("Hallo", name)\n')], 'main.py') +const language = link([file('main.py', 'def berechnen(text):\n return eval(text)\n\ndef repl():\n text = input("> ")\n if text == "ende":\n return\n print(berechnen(text))\n repl()\n')], 'main.py') const withReadOnly = link([file('bib.py', library, { readOnly: true }), file('main.py', 'from bib import gross\nprint(gross("hallo"))\n')], 'main.py') const byUrl = (url) => link([{ path: 'bib.py', url, readOnly: true }, file('main.py', 'from bib import gross\nprint(gross("hallo"))\n')], 'main.py', 2) // A file that compresses badly, for a link longer than 2,083 characters. @@ -228,6 +232,65 @@ try { await context.close() }) + // input(): the console's >>> line becomes the program's input line, with + // the prompt in front; prompt and answer stay in the transcript. + const programLine = (page) => page.locator('.repl-line--program .repl-input') + const answer = async (page, text, prompt) => { + await programLine(page).waitFor({ timeout: 60000 }) + assert.equal(await page.locator('.repl-line--program .repl-prompt').textContent(), prompt) + assert.ok(await programLine(page).evaluate((element) => element === document.activeElement), 'the input line has the focus') + await programLine(page).fill(text) + await programLine(page).press('Enter') + } + // A REPL input, once the REPL is free again (until then it keeps the text). + const enterRepl = async (page, text) => { + const line = page.locator('.repl-line .repl-input') + await page.getByRole('textbox', { name: 'Python-Eingabe' }).fill(text) + for (let attempt = 0; attempt < 100 && await line.inputValue() === text; attempt++) { + await line.press('Enter') + await page.waitForTimeout(100) + } + } + const replBack = (page) => page.locator('.repl-line:not(.repl-line--program)').waitFor({ timeout: 60000 }) + + await check('input(): ▶ asks in the console, no dialog', async () => { + const context = await newContext() + const page = await open(context, greeting) + await pythonReady(page) + await page.getByRole('button', { name: 'Ausführen' }).click() + await answer(page, 'Zoë', 'Name: ') + assert.equal(await page.locator('.input-dialog').count(), 0) + await page.waitForFunction(() => document.querySelector('.console')?.textContent.includes('Hallo Zoë'), null, { timeout: 60000 }) + assert.match(await consoleText(page), /Name: Zoë\nHallo Zoë/) + await context.close() + }) + + await check('input(): a recursive REPL called in the REPL, then Stop → EOFError', async () => { + const context = await newContext() + const page = await open(context, language) + await pythonReady(page) + await page.getByRole('button', { name: 'Ausführen' }).click() + await page.locator('.console').getByText('(Keine Ausgabe)').waitFor({ timeout: 60000 }) + await enterRepl(page, 'repl()') + await answer(page, '1 + 2', '> ') + await answer(page, '2 * (3 + 4)', '> ') + await answer(page, 'ende', '> ') + await replBack(page) + assert.match(await consoleText(page), />>> repl\(\)\n(Visualisieren\n)?> 1 \+ 2\n3\n> 2 \* \(3 \+ 4\)\n14\n> ende/) + // Stop while it waits: EOFError, and the session stays (no restart). + await enterRepl(page, 'repl()') + await answer(page, '6 * 7', '> ') + await programLine(page).waitFor({ timeout: 60000 }) + await page.getByRole('button', { name: 'Stop' }).click() + await page.locator('.console').getByText('EOFError: EOF when reading a line').waitFor({ timeout: 60000 }) + const text = await consoleText(page) + assert.match(text, /> 6 \* 7\n42\n> \n[\s\S]*EOFError: EOF when reading a line/) + assert.doesNotMatch(text, /neu gestartet/) + await enterRepl(page, 'berechnen("2 ** 10")') + await page.locator('.console').getByText('1024').waitFor({ timeout: 60000 }) + await context.close() + }) + await check('⋮ «Info»: the version from version.json, and the update check', async () => { const { version } = JSON.parse(readFileSync(new URL('../dist/version.json', import.meta.url), 'utf8')) const context = await newContext()