From f9ef5f87aca8d4d387fe6b7a7c5328a3c0264291 Mon Sep 17 00:00:00 2001 From: leonshimizu Date: Sat, 26 Sep 2026 13:26:32 +1000 Subject: [PATCH 1/3] Give cold Python runtimes more time to start --- web/e2e/languages.spec.ts | 26 +++++++++++++++++++ web/src/components/RunnerPanel.test.tsx | 33 ++++++++++++++++++++++--- web/src/lib/codeRunner.ts | 1 + web/src/lib/languageRegistry.ts | 3 +++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/web/e2e/languages.spec.ts b/web/e2e/languages.spec.ts index 4837acc..972027d 100644 --- a/web/e2e/languages.spec.ts +++ b/web/e2e/languages.spec.ts @@ -52,6 +52,32 @@ test('TypeScript stays usable without horizontal overflow on a phone viewport', expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) }) +test('Python survives a slow first runtime download and runs again from the warm workspace', async ({ page }) => { + test.setTimeout(120_000) + await openStudent(page) + await page.locator('.sidebar-content').getByRole('button', { name: 'Python', exact: true }).click() + await expect(page.getByLabel('Project name')).toHaveValue('Python Playground') + + let delayedRuntime = false + await page.route('**/assets/pyodide/pyodide.asm.wasm', async (route) => { + delayedRuntime = true + await new Promise((resolve) => setTimeout(resolve, 31_000)) + await route.continue() + }) + + await page.getByRole('button', { name: 'Run Python' }).click() + await expect(page.getByText('Loading runtime')).toBeVisible() + await expect(page.getByText(/first Python run downloads a larger browser runtime/i)).toBeVisible() + await expect(page.locator('.terminal')).toContainText('Hafa adai, Python!', { timeout: 90_000 }) + expect(delayedRuntime).toBe(true) + await expect(page.locator('.terminal-footer')).toContainText('success') + + expect(await page.evaluate(() => window.__HAFA_E2E_EDITOR__?.setValue('print("Warm Python run")'))).toBe(true) + await page.getByRole('button', { name: 'Run again' }).click() + await expect(page.locator('.terminal')).toContainText('Warm Python run', { timeout: 15_000 }) + await expect(page.locator('.terminal-footer')).toContainText('success') +}) + test('TypeScript and SQL expose complete guides and three-tier practice catalogs', async ({ page }) => { await openStudent(page) diff --git a/web/src/components/RunnerPanel.test.tsx b/web/src/components/RunnerPanel.test.tsx index 441547b..6c1d220 100644 --- a/web/src/components/RunnerPanel.test.tsx +++ b/web/src/components/RunnerPanel.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RUNNER_STARTUP_TIMEOUT_MS, RUNNER_TIMEOUT_MS, projectKindDefinition } from '../lib/codeRunner' +import { PYTHON_STARTUP_TIMEOUT_MS, RUNNER_STARTUP_TIMEOUT_MS, RUNNER_TIMEOUT_MS, projectKindDefinition } from '../lib/codeRunner' import type { RunnerRequest, RunnerResponse } from '../workers/runnerProtocol' import { RunnerPanel } from './RunnerPanel' @@ -237,7 +237,7 @@ describe('RunnerPanel', () => { act(() => { window.dispatchEvent(new Event('hafa-code-run-active-project')) - vi.advanceTimersByTime(RUNNER_STARTUP_TIMEOUT_MS) + vi.advanceTimersByTime(PYTHON_STARTUP_TIMEOUT_MS) }) const worker = FakeWorker.instances.at(-1)! @@ -248,7 +248,7 @@ describe('RunnerPanel', () => { status: 'timeout', stdout: '', stderr: 'The browser runtime took too long to load. Check your connection, then try again.', - durationMs: RUNNER_STARTUP_TIMEOUT_MS, + durationMs: PYTHON_STARTUP_TIMEOUT_MS, }) expect(onErrorAdviceChange).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'python', @@ -256,6 +256,33 @@ describe('RunnerPanel', () => { })) }) + it('lets a cold Python runtime finish after the default startup limit without extending execution time', () => { + vi.useFakeTimers() + const onRunComplete = vi.fn() + render() + + act(() => window.dispatchEvent(new Event('hafa-code-run-active-project'))) + const worker = FakeWorker.instances.at(-1)! + const run = worker.messages.find((message) => message.type === 'run') + if (!run || run.type !== 'run') throw new Error('Expected a Python run request') + expect(run.startupTimeoutMs).toBe(PYTHON_STARTUP_TIMEOUT_MS) + expect(screen.getByText(/first Python run downloads a larger browser runtime/i)).toBeTruthy() + + act(() => vi.advanceTimersByTime(RUNNER_STARTUP_TIMEOUT_MS + 1_000)) + expect(worker.terminated).toBe(false) + expect(screen.getByText('Loading runtime')).toBeTruthy() + expect(onRunComplete).not.toHaveBeenCalled() + + act(() => { + worker.respond({ id: run.id, type: 'started' }) + worker.respond({ id: run.id, type: 'result', stdout: 'Ready\n', stderr: '', exitCode: 0, durationMs: 25 }) + vi.advanceTimersByTime(PYTHON_STARTUP_TIMEOUT_MS) + }) + expect(screen.getByText('Ready')).toBeTruthy() + expect(worker.terminated).toBe(false) + expect(onRunComplete).toHaveBeenCalledOnce() + }) + it('reports an execution timeout with streamed output exactly once', () => { vi.useFakeTimers() const onRunComplete = vi.fn() diff --git a/web/src/lib/codeRunner.ts b/web/src/lib/codeRunner.ts index 4ced7ba..a19e15e 100644 --- a/web/src/lib/codeRunner.ts +++ b/web/src/lib/codeRunner.ts @@ -10,6 +10,7 @@ export { PROJECT_KINDS, PROJECT_KIND_DEFINITIONS, RUNNER_STARTUP_TIMEOUT_MS, + PYTHON_STARTUP_TIMEOUT_MS, RUNNER_TIMEOUT_MS, fileLanguageDefinition, isProjectFileLanguage, diff --git a/web/src/lib/languageRegistry.ts b/web/src/lib/languageRegistry.ts index c5c008b..34cb3c2 100644 --- a/web/src/lib/languageRegistry.ts +++ b/web/src/lib/languageRegistry.ts @@ -3,6 +3,7 @@ import type { ProjectFile, ProjectFileLanguage, ProjectKind, RunnerLanguage } fr export const RUNNER_TIMEOUT_MS = 3_000 export const RUNNER_STARTUP_TIMEOUT_MS = 30_000 +export const PYTHON_STARTUP_TIMEOUT_MS = 90_000 function shellArgument(value: string) { return /^[a-zA-Z0-9_./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'` @@ -201,6 +202,8 @@ export const PROJECT_KIND_DEFINITIONS = { runLabel: 'Python', terminalCommand: (entryPath) => `python ${entryPath}`, createWorker: () => new Worker(new URL('../workers/pythonRunner.worker.ts', import.meta.url), { type: 'module' }), + startupTimeoutMs: PYTHON_STARTUP_TIMEOUT_MS, + startupNote: 'The first Python run downloads a larger browser runtime and may take longer on a mobile connection. Later runs are faster.', }, }, java: { From 558f194da571623fac9ff48a1278796874897b6e Mon Sep 17 00:00:00 2001 From: leonshimizu Date: Sun, 27 Sep 2026 06:42:16 +1000 Subject: [PATCH 2/3] Document Hafa role in focused course path --- docs/PRODUCT_ROADMAP.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PRODUCT_ROADMAP.md b/docs/PRODUCT_ROADMAP.md index 4427eff..f83ca50 100644 --- a/docs/PRODUCT_ROADMAP.md +++ b/docs/PRODUCT_ROADMAP.md @@ -26,6 +26,12 @@ The product is intentionally not a general cloud IDE or a replacement for the sc 6. Treat mobile, keyboard, zoom, reduced motion, and screen-reader use as release criteria. 7. Require school approval before collecting optional analytics or enabling external sharing for classroom work. +## Code School focused courses + +Hafa Code is the browser coding workspace for CSG's invited December 2026 Python Fundamentals pilot. Learners can work from Guam or elsewhere without local Python setup. CSG Learn remains the record for lessons, submissions, feedback, and private meeting bookings. A Hafa Code snapshot link currently imports a copy into the recipient's workspace; instructors need the submitted code in Learn or another reviewable record until a safe read-only sharing flow is ready. + +The longer-term course plan includes independent practice and limited guided runs with private instructor support. Hafa Code should serve both formats without tying a project to one instructor or cohort. Before opening courses to minors, review account access, sharing, retention, guardian workflows, and instructor visibility for each age group. The first pilot is for invited adults; no public youth enrollment is implied by this roadmap. + ## Current baseline Hafa Code currently supports: From cfccb6991b8c065d6c9c7f7d101ee4d847b3371a Mon Sep 17 00:00:00 2001 From: leonshimizu Date: Mon, 28 Sep 2026 14:06:50 +1000 Subject: [PATCH 3/3] Clarify Hafa role in agent-assisted course extensions --- docs/PRODUCT_ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRODUCT_ROADMAP.md b/docs/PRODUCT_ROADMAP.md index f83ca50..5a78f62 100644 --- a/docs/PRODUCT_ROADMAP.md +++ b/docs/PRODUCT_ROADMAP.md @@ -30,6 +30,8 @@ The product is intentionally not a general cloud IDE or a replacement for the sc Hafa Code is the browser coding workspace for CSG's invited December 2026 Python Fundamentals pilot. Learners can work from Guam or elsewhere without local Python setup. CSG Learn remains the record for lessons, submissions, feedback, and private meeting bookings. A Hafa Code snapshot link currently imports a copy into the recipient's workspace; instructors need the submitted code in Learn or another reviewable record until a safe read-only sharing flow is ready. +The focused-course teaching sequence is fundamentals, a small project built and explained independently, then an optional agent-assisted extension of the same ideas with human diff and test review. Hafa Code should make the independent project and the saved extension examples easy to run, but it is not required to host the coding agent. No AI subscription is necessary to complete a fundamentals course. Keep the exercise submission and final review copy in CSG Learn; an editable Hafa import link alone does not establish which code was assessed. For the Python pilot, rehearse both the original six-expense $118 report and the optional two-month example in the deployed Python runner before recording claims about them. + The longer-term course plan includes independent practice and limited guided runs with private instructor support. Hafa Code should serve both formats without tying a project to one instructor or cohort. Before opening courses to minors, review account access, sharing, retention, guardian workflows, and instructor visibility for each age group. The first pilot is for invited adults; no public youth enrollment is implied by this roadmap. ## Current baseline