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
8 changes: 8 additions & 0 deletions docs/PRODUCT_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ 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 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

Hafa Code currently supports:
Expand Down
26 changes: 26 additions & 0 deletions web/e2e/languages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
33 changes: 30 additions & 3 deletions web/src/components/RunnerPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)!
Expand All @@ -248,14 +248,41 @@ 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',
advice: expect.objectContaining({ guideTopicId: 'python-loops' }),
}))
})

it('lets a cold Python runtime finish after the default startup limit without extending execution time', () => {
vi.useFakeTimers()
const onRunComplete = vi.fn()
render(<RunnerPanel project={project} entryFile={project.files[0]} onRunComplete={onRunComplete} />)

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()
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/codeRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
PROJECT_KINDS,
PROJECT_KIND_DEFINITIONS,
RUNNER_STARTUP_TIMEOUT_MS,
PYTHON_STARTUP_TIMEOUT_MS,
RUNNER_TIMEOUT_MS,
fileLanguageDefinition,
isProjectFileLanguage,
Expand Down
3 changes: 3 additions & 0 deletions web/src/lib/languageRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("'", "'\\''")}'`
Expand Down Expand Up @@ -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: {
Expand Down
Loading