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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ The one automated check is `test:browser` (`scripts/check-ide-browser.mjs`): `pl
- `src/i18n.ts` — only the shell's own strings (`ShellStrings`, de/en). The workspace's strings come from glpython-editor's `coreStrings`, so don't copy them here. A new workspace string goes into glpython-editor's `src/i18n.ts`.
- `src/tabProjects.ts`: per-tab projects (P25). The tab's id is in sessionStorage and `history.state` (never in the address); projects are in localStorage, one entry per id. Nothing is deleted when a tab goes away: a page can't tell a close from iPad Safari unloading it (only `history.state` survives that). Open tabs answer over a BroadcastChannel, which finds duplicated tabs and the projects for «Zuletzt geschlossen».
- `src/projectStorage.ts` (folder/ZIP) and `src/projectShare.ts` (share links: written as `?p=`, raw deflate (fflate, synchronous so «Teilen» copies within the tap on an iPad) in base64url without padding; `?project=` (lz-string) read forever; versioned payload: v1 every file's code, v2 also libraries by URL, fetched by `src/libraries.ts`; optional `openFile`, the tab to open with (a library by URL once fetched, unless the learner chose a tab: `tabChosenRef`), kept on the `TabEntry`, in «Teilen» links and in the ZIP; the tabs in the link's order (`fileOrder`), which «Teilen» keeps; a link that can't be read opens an empty project with a notice).
- `src/features.ts`: what a tab shows (P34 C): the embed's `features=` vocabulary, the IDE's defaults (`DEFAULT_FEATURES`: everything except `listtree` and `tree`), and `capabilitiesOf` for the workspace. From the payload's `features` or `?features=`, kept on the `TabEntry`, switched in ⋮ «Optionen», written by «Teilen» once the tab has settings.
- `src/markdown.ts`: the workspace's `renderMarkdown` (marked + DOMPurify, both pinned; HTML in the Markdown becomes text, links get `target="_blank"` and `rel="noopener noreferrer"`).
- `src/auth.ts` — MSAL. Only active with `VITE_ENTRA_CLIENT_ID` in `.env.local` (README «Microsoft sign-in»).
- `vite.config.ts` — `optimizeDeps.exclude: ['glpython-editor']` keeps the editor's worker next to its `dist/index.js`. Don't remove it.
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,20 @@ so `https://glpython-edu.github.io/?p=VcxBCoMw…` opens it (`test:browser`
checks this). The IDE's own encoder may write other bytes for the same
payload; both open.

**What a tab shows.** A link can set which parts the IDE shows, in the
vocabulary of the embed's `features=`: `run` (▶), `tests`, `visualize`, `repl`
(the console's `>>>` input line; the console stays, and `input()` still asks
there), `export` (the visualizer's «→-Kette kopieren»), `coverage` (after
Tests, the lines no example ran), `listtree` (lists as trees) and `tree` (the
visualizer's mode «Baum»). Other names (`ide`, `reset`, unknown ones) are
ignored. It goes into the payload as `"features": ["run", "repl", …]` (in
either encoding), or as `?features=run,repl` on a link without a project. It
is the tab's starting state, not a lock: ⋮ **Optionen** has a switch for each
part; the tab keeps them across a reload, and **Teilen** writes its current
settings (a tab that never had any writes none). Without settings the IDE
shows what it always did: everything except `listtree` and `tree`. Unlike
the embed, that includes `coverage`.

**Libraries by URL.** A share link (version 2) can name a library by its URL
instead of carrying its code, e.g. a course's `parser.py`:
`{"path": "parser.py", "url": "https://…/parser.py", "readOnly": true}`. The
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@azure/msal-browser": "^5.22.0",
"dompurify": "3.4.17",
"fflate": "0.8.3",
"glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.29",
"glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.30",
"jszip": "^3.10.2",
"lucide-react": "^1.47.0",
"lz-string": "^1.5.0",
Expand Down
95 changes: 94 additions & 1 deletion scripts/check-ide-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
// it; an old link as before; the page's HTML sanitized;
// - compact links (P46): «Teilen» writes `?p=` (raw deflate, base64url),
// which opens the same project; zlib's test vector, old `?project=` and
// `#project=` links open; a cut `?p=` link shows the notice.
// `#project=` links open; a cut `?p=` link shows the notice;
// - settings through the link (P34 C, `features`): without `repl` no input
// line, but input() asks; ⋮ «Optionen» switches it back, a reload and
// «Teilen» keep the tab's settings; `listtree` and `tree`; ▶ without Tests
// and Tests without ▶; a link without settings as before.
// The browser's «leave the page» question and the iPad cases stay manual:
// headless Chrome can't confirm them.
//
Expand Down Expand Up @@ -478,6 +482,95 @@ try {
await context.close()
})

// P34 C: what a tab shows. The buttons in the top bar, the REPL line.
const settingsPayload = (features, files = [file('main.py', 'name = input("Name: ")\nprint("Hallo", name)\n')]) => ({ v: 1, name: 'Check', mainFile: 'main.py', ...(features ? { features } : {}), files: Object.fromEntries(files.map((entry) => [entry.path, entry])) })
const barButtons = (page) => page.locator('.ide-actions .editor-actions > button').evaluateAll((buttons) => buttons.map((button) => button.textContent.replace(/\s+/g, ' ').trim().replace(/ ?Stop$/, '').replace(/^Ausführen.*/, 'Ausführen')))
const replLine = (page) => page.locator('.repl-line:not(.repl-line--program)').count()
const options = async (page) => {
await page.locator('.ide-menu-button').click()
await page.getByRole('menuitem', { name: 'Optionen' }).click()
return page.getByRole('dialog', { name: 'Optionen' })
}

await check('settings: without repl no input line, input() still asks; ⋮ Optionen, reload and Teilen', async () => {
const context = await newContext()
const page = await open(context, compactLink(settingsPayload(['run', 'tests', 'visualize', 'ide'])))
assert.deepEqual(await barButtons(page), ['Ausführen', 'Tests', 'Visualisieren'])
assert.equal(await replLine(page), 0, 'no REPL line')
await pythonReady(page)
await page.getByRole('button', { name: 'Ausführen' }).click()
await answer(page, 'Ada', 'Name: ')
await page.waitForFunction(() => document.querySelector('.console')?.textContent.includes('Hallo Ada'), null, { timeout: 30000 })
assert.equal(await replLine(page), 0, 'still no REPL line')
// ⋮ Optionen: the REPL line back on.
const dialog = await options(page)
assert.deepEqual(await dialog.locator('label').evaluateAll((labels) => labels.map((label) => [label.textContent.trim(), label.querySelector('input').checked])), [['▶ Ausführen', true], ['Tests', true], ['Visualisieren', true], ['Eingabezeile in der Konsole (>>>)', false], ['→-Kette kopieren (im Visualisierer)', false], ['Nach Tests: Zeilen, die kein Beispiel ausführt', false], ['Listen als Baum', false], ['Visualisierer-Modus «Baum»', false]])
await dialog.getByLabel('Eingabezeile in der Konsole (>>>)').check()
await page.keyboard.press('Escape')
assert.equal(await replLine(page), 1, 'the REPL line is back')
await page.reload()
await page.waitForFunction(() => window.monaco?.editor.getEditors().length > 0, null, { timeout: 60000 })
assert.equal(await replLine(page), 1, 'after a reload')
await page.getByRole('button', { name: 'Teilen' }).click()
await page.waitForTimeout(500)
assert.deepEqual(payloadOf(await page.evaluate(() => navigator.clipboard.readText())).features, ['run', 'tests', 'visualize', 'repl'])
await context.close()
})

await check('settings: listtree and tree - «Baum» in the visualizer, trees in the console', async () => {
const context = await newContext()
const page = await open(context, compactLink(settingsPayload(['run', 'repl', 'visualize', 'listtree', 'tree'], [file('main.py', 'baum = ["+", 2, ["*", 3, 4]]\nx = 2 + 3 * 4\n')])))
assert.deepEqual(await barButtons(page), ['Ausführen', 'Visualisieren'])
await pythonReady(page)
await page.getByRole('button', { name: 'Ausführen' }).click()
await page.waitForTimeout(1500)
await enterRepl(page, 'baum')
await page.locator('.repl-entry-tree').first().waitFor({ timeout: 30000 })
await page.locator('.ide-actions').getByRole('button', { name: 'Visualisieren' }).click()
await page.locator('.visualizer-mode').waitFor({ timeout: 60000 })
assert.deepEqual(await page.locator('.visualizer-mode button').allInnerTexts(), ['Kasten', 'Ersetzung', 'Baum'])
await page.locator('.visualizer-mode button', { hasText: 'Baum' }).click()
await page.getByRole('button', { name: 'Letzter Schritt' }).click()
await page.locator('.locals-row[data-name="baum"] .value-tree').first().waitFor({ timeout: 10000 })
await context.close()
})

await check('settings: ▶ without Tests, Tests without ▶', async () => {
const context = await newContext()
const runOnly = await open(context, compactLink(settingsPayload(['run', 'repl'])))
assert.deepEqual(await barButtons(runOnly), ['Ausführen'])
const testsOnly = await open(context, compactLink(settingsPayload(['tests', 'repl'], [file('main.py', 'def f():\n """\n >>> f()\n 1\n """\n return 1\n')])))
assert.deepEqual(await barButtons(testsOnly), ['Tests'])
await pythonReady(testsOnly)
await testsOnly.getByRole('button', { name: 'Tests', exact: true }).click()
await testsOnly.locator('.test-summary').waitFor({ timeout: 60000 })
await context.close()
})

await check('settings: a link without features, old and new form, as before', async () => {
const context = await newContext()
for (const url of [link([file('main.py', 'print(1)\n')], 'main.py'), compactLink(settingsPayload(null))]) {
const page = await open(context, url)
assert.deepEqual(await barButtons(page), ['Ausführen', 'Tests', 'Visualisieren'])
assert.equal(await replLine(page), 1)
const dialog = await options(page)
assert.deepEqual(await dialog.locator('input').evaluateAll((inputs) => inputs.map((input) => input.checked)), [true, true, true, true, true, true, false, false], 'the defaults')
await page.keyboard.press('Escape')
await page.getByRole('button', { name: 'Teilen' }).click()
await page.waitForTimeout(500)
assert.equal(payloadOf(await page.evaluate(() => navigator.clipboard.readText())).features, undefined, 'no settings written')
}
await context.close()
})

await check('settings: ?features= on a link without a project', async () => {
const context = await newContext()
const page = await open(context, `${base}?features=run,visualize`)
assert.deepEqual(await barButtons(page), ['Ausführen', 'Visualisieren'])
assert.equal(await replLine(page), 0)
await context.close()
})

await check('an old link: opens on the file that runs, Markdown as text', async () => {
const context = await newContext()
const page = await open(context, link([file('main.py', 'print(1)\n'), { path: 'notizen.md', label: 'notizen', kind: 'text', code: '# Notizen\n' }], 'main.py', 1, { openFile: 'fehlt.md' }))
Expand Down
Loading
Loading