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
16 changes: 7 additions & 9 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
name: Test
name: CI

on:
push:
pull_request:

jobs:
test:
lint-and-typecheck:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Build Java sandbox image
run: |
docker build -t docker.bedson.tech/codeftc-java-sandbox:latest \
-f docker/java-sandbox/Dockerfile .

- uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install

- name: Run tests
run: bun test tests/
- name: Type check
run: npx tsc --noEmit

- name: Lint
run: bun run lint
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ next-env.d.ts
# FTC project template (download with: bun run setup:ftc-template)
/ftc-template/

# CheerpJ build artifacts (built with: bun run build:cheerpj)
/public/cheerpj/

# IDE
.idea
.vscode
40 changes: 40 additions & 0 deletions app/api/analytics/run/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { recordEvent } from "@/lib/analytics"

export async function POST(request: Request) {
const session = await auth()
if (!session) {
return NextResponse.json({ ok: false }, { status: 401 })
}

try {
const body: unknown = await request.json()
const { lessonId, allPassed } = body as {
lessonId: string
allPassed: boolean
}

if (!lessonId) {
return NextResponse.json({ ok: false }, { status: 400 })
}

await recordEvent({
type: "code_run",
lessonId,
userId: session.user.id,
})

if (allPassed) {
await recordEvent({
type: "exercise_complete",
lessonId,
userId: session.user.id,
})
}

return NextResponse.json({ ok: true })
} catch {
return NextResponse.json({ ok: false }, { status: 500 })
}
}
93 changes: 0 additions & 93 deletions app/api/execute/route.ts

This file was deleted.

51 changes: 36 additions & 15 deletions app/lessons/[moduleSlug]/[lessonSlug]/LessonPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import { CodeEditor } from "@/components/editor/CodeEditor"
import { EditorToolbar } from "@/components/editor/EditorToolbar"
import { OutputPanel } from "@/components/editor/OutputPanel"
import { HintAccordion } from "@/components/ui/HintAccordion"
import { useCheerpJ } from "@/lib/cheerpj-context"
import { executeInBrowser } from "@/lib/cheerpj-executor"
import type { LessonData, SidebarModule, ExecutionResult } from "@/lib/types"

interface LessonPageProps {
data: LessonData
modules: SidebarModule[]
moduleSlug: string
lessonSlug: string
isAuthenticated: boolean
userId: string | null
}

Expand All @@ -44,12 +45,13 @@ export function LessonPage({
modules,
moduleSlug,
lessonSlug,
isAuthenticated,
userId,
}: LessonPageProps) {
const lessonId = getLessonId(moduleSlug, lessonSlug)
const storageKey = getStorageKey(moduleSlug, lessonSlug)

const { status: cheerpjStatus } = useCheerpJ()

const [code, setCode] = useState(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(storageKey)
Expand Down Expand Up @@ -152,35 +154,55 @@ export function LessonPage({
}, [moduleSlug, lessonSlug])

const handleRun = useCallback(async () => {
if (cheerpjStatus !== "ready") {
setResult({
success: false,
runtimeError: cheerpjStatus === "loading"
? "Java runtime is still loading — please wait a moment and try again"
: "Java runtime failed to load. Try refreshing the page.",
testResults: [],
})
return
}

setIsRunning(true)
setResult(null)

try {
const response = await fetch("/api/execute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, lessonId }),
})
const data = await response.json() as ExecutionResult
setResult(data)
const execResult = await executeInBrowser(
code,
data.testCode,
)
setResult(execResult)

// Persist test progress to localStorage so sidebar can show it
if (data.testResults.length > 0) {
const passed = data.testResults.filter((t) => t.passed).length
const total = data.testResults.length
if (execResult.testResults.length > 0) {
const passed = execResult.testResults.filter((t) => t.passed).length
const total = execResult.testResults.length
localStorage.setItem(`ftc-tests:${lessonId}`, JSON.stringify({ passed, total }))
window.dispatchEvent(new Event("ftc-tests-updated"))
}

// Fire analytics (non-blocking)
fetch("/api/analytics/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lessonId,
allPassed: execResult.testResults.length > 0 && execResult.testResults.every((t) => t.passed),
}),
}).catch(() => {})
} catch (err) {
setResult({
success: false,
runtimeError:
err instanceof Error ? err.message : "Failed to connect to server",
err instanceof Error ? err.message : "Execution failed unexpectedly",
testResults: [],
})
} finally {
setIsRunning(false)
}
}, [code, lessonId])
}, [code, lessonId, data.testCode, cheerpjStatus])

const handleReset = useCallback(() => {
setCode(data.exercise.starterCode)
Expand Down Expand Up @@ -275,7 +297,6 @@ export function LessonPage({
onCollapse={() => setEditorCollapsed(true)}
showingSolution={showingSolution}
isRunning={isRunning}
isAuthenticated={isAuthenticated}
code={code}
/>
<div className="relative flex-1 overflow-hidden">
Expand Down
1 change: 0 additions & 1 deletion app/lessons/[moduleSlug]/[lessonSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export default async function LessonRoute({ params }: Props) {
modules={modules}
moduleSlug={moduleSlug}
lessonSlug={lessonSlug}
isAuthenticated={!!session}
userId={session?.user?.id ?? null}
/>
)
Expand Down
4 changes: 3 additions & 1 deletion app/lessons/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { CheerpJProvider } from "@/lib/cheerpj-context"

export default function LessonsLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
return <CheerpJProvider>{children}</CheerpJProvider>
}
Loading