From 0068c91940e43062bce33812dfd5daf53277e139 Mon Sep 17 00:00:00 2001 From: Mohammad Naim Faizy Date: Sun, 11 Oct 2026 22:45:14 +1100 Subject: [PATCH] fix(playground): TypeScript runs, timers are awaited, Console text is plain - TypeScript: types are stripped by Monaco's own TypeScript worker (no new dependency) and step and error lines are mapped back to the learner's source through the emit's source map - a run reports once no timer is pending, or at the execution timeout with a note in the Console, instead of 50 ms after the synchronous code - Console text is no longer HTML-escaped before React escapes it, and a string prints as written - the toolbar fits from 768px up; Run is always visible - "example snippet" becomes "example"; "timeline" wording becomes steps Co-Authored-By: Claude Opus 5.5 --- docs/Playground-Developer-Guide.md | 10 +- src/features/playground/PlaygroundApp.tsx | 65 ++-- .../playground/__tests__/integration.test.ts | 64 ++-- .../components/editor/LanguagePicker.tsx | 23 +- .../execution/ConsoleOutput.test.tsx | 30 +- .../components/execution/ConsoleOutput.tsx | 8 +- .../execution/ExecutionController.tsx | 2 +- .../components/execution/SandboxFrame.tsx | 206 +++---------- .../execution/sandboxRuntime.test.ts | 203 ++++++++++++ .../components/execution/sandboxRuntime.ts | 290 ++++++++++++++++++ .../instrumentation/TimelinePlayer.test.tsx | 12 +- .../instrumentation/TimelinePlayer.tsx | 14 +- .../components/layout/BackToSiteLink.tsx | 7 +- .../components/layout/PlaygroundToolbar.tsx | 279 +++++++++-------- .../visualizations/LensSelector.tsx | 8 +- .../playground/hooks/useSandbox.test.ts | 35 +++ src/features/playground/hooks/useSandbox.ts | 227 ++++++++------ .../services/sourceMapLines.test.ts | 125 ++++++++ .../playground/services/sourceMapLines.ts | 83 +++++ .../services/transpileTypeScript.ts | 160 ++++++++++ .../playground/types/playground-v2.ts | 2 + .../utils/{exampleSnippets.ts => examples.ts} | 4 +- .../playground/utils/sanitize.test.ts | 64 +--- src/features/playground/utils/sanitize.ts | 33 +- 24 files changed, 1372 insertions(+), 582 deletions(-) create mode 100644 src/features/playground/components/execution/sandboxRuntime.test.ts create mode 100644 src/features/playground/components/execution/sandboxRuntime.ts create mode 100644 src/features/playground/hooks/useSandbox.test.ts create mode 100644 src/features/playground/services/sourceMapLines.test.ts create mode 100644 src/features/playground/services/sourceMapLines.ts create mode 100644 src/features/playground/services/transpileTypeScript.ts rename src/features/playground/utils/{exampleSnippets.ts => examples.ts} (98%) diff --git a/docs/Playground-Developer-Guide.md b/docs/Playground-Developer-Guide.md index b388bb9..26aa3e0 100644 --- a/docs/Playground-Developer-Guide.md +++ b/docs/Playground-Developer-Guide.md @@ -4,7 +4,7 @@ > **This guide describes the code as it is today, and part of it will change.** [ADR 0004](adr/0004-playground-traces-share-the-snapshot.md) replaces the React Flow lenses with the 2D/3D views the stories use, and changes what a step records. [How to Add a New Visualization Lens](#how-to-add-a-new-visualization-lens) describes the current mechanism, which that work will replace. > -> **Words.** The prose follows the glossary in [CONTEXT.md](../CONTEXT.md): the record of a run is a **trace** made of **steps**, prebuilt code is an **example**, and the output pane is the **console**. The code has not been renamed yet, so file and identifier names keep their old words: `StateSnapshot` is the data of one step, `useTimeline.ts` and `TimelinePlayer.tsx` move through the steps of a trace, and `exampleSnippets.ts` holds the examples. +> **Words.** The prose follows the glossary in [CONTEXT.md](../CONTEXT.md): the record of a run is a **trace** made of **steps**, prebuilt code is an **example**, and the output pane is the **console**. The code has not been renamed yet, so file and identifier names keep their old words: `StateSnapshot` is the data of one step, and `useTimeline.ts` and `TimelinePlayer.tsx` move through the steps of a trace. --- @@ -81,7 +81,7 @@ src/features/playground/ │ └── pyodide-loader.ts # Loads Pyodide ├── utils/ │ ├── codeTemplates.ts # Default starter code per language -│ ├── exampleSnippets.ts # The examples +│ ├── examples.ts # The examples │ └── sanitize.ts # XSS prevention + output limits ├── components/ │ ├── editor/ @@ -207,7 +207,7 @@ const LANGUAGE_MAP: Record = { 5. **Wire it into `PlaygroundApp.tsx`** — add the language to the `consoleEntries` selection logic and execution handlers. -6. **Add examples** in `utils/exampleSnippets.ts`. +6. **Add examples** in `utils/examples.ts`. 7. **Update `LanguagePicker.tsx`** to show the new language in the dropdown. @@ -255,7 +255,7 @@ All playground styling is scoped under `[data-theme="playground"]` via CSS custo ## How to Add Examples -An example is prebuilt code the learner can load and run, with a suggested lens. Edit `utils/exampleSnippets.ts`: +An example is prebuilt code the learner can load and run, with a suggested lens. Edit `utils/examples.ts`: ```ts { @@ -295,7 +295,7 @@ Examples appear in the toolbar's "Examples" dropdown, filtered by the currently ### Output Sanitization -- Everything written to the console is HTML-escaped via `escapeHtml()` before rendering. +- Everything written to the console is rendered as a React text node, which React escapes. It is never HTML-escaped by hand first (that showed `"` to the learner) and never set through `dangerouslySetInnerHTML`. - `sanitizeOutput()` handles circular references, prototype pollution keys (`__proto__`, `constructor`), BigInt, Symbol, and deeply nested objects. - Entry counts capped at `MAX_CONSOLE_ENTRIES` (500). - Individual entries capped at `MAX_ENTRY_LENGTH` (10 KB). diff --git a/src/features/playground/PlaygroundApp.tsx b/src/features/playground/PlaygroundApp.tsx index a86306f..af26a8a 100644 --- a/src/features/playground/PlaygroundApp.tsx +++ b/src/features/playground/PlaygroundApp.tsx @@ -16,7 +16,8 @@ import { usePyodide } from './hooks/usePyodide'; import { useTimeline } from './hooks/useTimeline'; import { usePaneLayout } from './hooks/usePaneLayout'; import { prepareInstrumentedCode } from './instrumentation/JsInstrumenter'; -import type { ExampleSnippet } from './utils/exampleSnippets'; +import { transpileTypeScript } from './services/transpileTypeScript'; +import type { Example } from './utils/examples'; import type { VisualizationLens } from './types'; import { loadSettings, @@ -64,15 +65,10 @@ const PlaygroundApp: React.FC = () => { // Determine which console entries to show based on language const consoleEntries = language === 'python' ? pyodide.entries : sandbox.entries; - const isRunning = language === 'python' ? pyodide.isRunning : sandbox.isRunning; - - // Sync execution state - useEffect(() => { - if (isRunning) { - setExecutionState('running'); - } - }, [isRunning, setExecutionState]); + // handleRun sets the execution state itself. An effect that copied `isRunning` + // into it could run after a fast run had already completed (a TypeScript run + // starts outside a click event) and leave the state stuck on 'running'. const handleRun = useCallback((): void => { setExecutionState('running'); timeline.clear(); @@ -85,28 +81,45 @@ const PlaygroundApp: React.FC = () => { timeline.loadSnapshots(result.snapshots); } }); - } else { - // For JS/TS: instrument the code if mode is enabled + return; + } + + const runJavaScript = (js: string, mapLine?: (line: number) => number): void => { if (instrumentedMode) { - const prepared = prepareInstrumentedCode(code); + const prepared = prepareInstrumentedCode(js); if ('error' in prepared) { // Fall back to non-instrumented execution on parse error - sandbox.execute(code).then((result) => { + sandbox.execute(js, { mapLine }).then((result) => { setExecutionState(result.error ? 'error' : 'completed'); }); return; } - sandbox.execute(prepared.code).then((result) => { + sandbox.execute(prepared.code, { mapLine }).then((result) => { setExecutionState(result.error ? 'error' : 'completed'); if (result.snapshots.length > 0) { timeline.loadSnapshots(result.snapshots); } }); } else { - sandbox.execute(code).then((result) => { + sandbox.execute(js, { mapLine }).then((result) => { setExecutionState(result.error ? 'error' : 'completed'); }); } + }; + + if (language === 'typescript') { + // The sandbox and the instrumenter take JavaScript: strip the types first, + // and keep the way back to the lines of the TypeScript source + transpileTypeScript(code).then((transpiled) => { + if (!transpiled.ok) { + sandbox.reportError(transpiled.message, transpiled.line); + setExecutionState('error'); + return; + } + runJavaScript(transpiled.code, transpiled.mapLine); + }); + } else { + runJavaScript(code); } }, [language, code, sandbox, pyodide, setExecutionState, instrumentedMode, timeline]); @@ -137,11 +150,11 @@ const PlaygroundApp: React.FC = () => { } }, [language, sandbox, pyodide]); - const handleLoadSnippet = useCallback( - (snippet: ExampleSnippet): void => { - setLanguage(snippet.language); - setCode(snippet.code); - setActiveLens(snippet.suggestedLens); + const handleLoadExample = useCallback( + (example: Example): void => { + setLanguage(example.language); + setCode(example.code); + setActiveLens(example.suggestedLens); sandbox.clearEntries(); pyodide.clearEntries(); sandbox.clearSnapshots(); @@ -177,7 +190,7 @@ const PlaygroundApp: React.FC = () => { handleClearConsole(); return; } - // Left/Right arrow, Space → timeline stepping (when not in editor or any interactive element) + // Left/Right arrow, Space → stepping through the trace (when not in editor or any interactive element) const activeEl = document.activeElement; const target = e.target instanceof HTMLElement ? e.target : null; const inEditor = @@ -198,7 +211,7 @@ const PlaygroundApp: React.FC = () => { timeline.nextStep(); return; } - // Space → play/pause timeline + // Space → play/pause the steps if (e.key === ' ') { e.preventDefault(); if (timeline.isPlaying) { @@ -246,7 +259,7 @@ const PlaygroundApp: React.FC = () => { onRun={handleRun} onStop={handleStop} onReset={handleReset} - onLoadSnippet={handleLoadSnippet} + onLoadExample={handleLoadExample} onOpenSettings={() => setSettingsOpen((prev) => !prev)} /> {settingsOpen && ( @@ -297,7 +310,7 @@ const PlaygroundApp: React.FC = () => { aria-label="Resize editor and visualization panes" /> - {/* Visualization / Timeline Pane */} + {/* Visualization / Steps Pane */}
{ previousSnapshot={timeline.previousSnapshot} />
- {/* Timeline controls (bottom) */} + {/* Step controls (bottom) */}
{ aria-label="Resize visualization and console panes" /> - {/* Output Pane */} + {/* Console Pane */}
{ @@ -47,10 +42,10 @@ describe('Integration: JS instrumentation pipeline', () => { expect('error' in result).toBe(true); }); - it('instruments all JS example snippets without errors', () => { - const jsSnippets = exampleSnippets.filter((s) => s.language === 'javascript'); - for (const snippet of jsSnippets) { - const result = prepareInstrumentedCode(snippet.code); + it('instruments all JS examples without errors', () => { + const jsExamples = examples.filter((s) => s.language === 'javascript'); + for (const example of jsExamples) { + const result = prepareInstrumentedCode(example.code); expect('error' in result).toBe(false); } }); @@ -126,10 +121,10 @@ describe('Integration: Python instrumentation pipeline', () => { expect(wrapped).toContain('_instrumentation_result'); }); - it('wraps all Python example snippets without errors', () => { - const pySnippets = exampleSnippets.filter((s) => s.language === 'python'); - for (const snippet of pySnippets) { - const wrapped = wrapPythonCodeWithTrace(snippet.code); + it('wraps all Python examples without errors', () => { + const pyExamples = examples.filter((s) => s.language === 'python'); + for (const example of pyExamples) { + const wrapped = wrapPythonCodeWithTrace(example.code); expect(wrapped).toContain('base64.b64decode'); } }); @@ -157,12 +152,9 @@ describe('Integration: Python instrumentation pipeline', () => { }); describe('Integration: Sanitization + console output pipeline', () => { - it('console entries are sanitized before display', () => { - // Simulate a malicious console.log output going through the pipeline + it('console text reaches the display as written', () => { const malicious = ''; - const sanitized = escapeHtml(String(malicious)); - expect(sanitized).not.toContain(' { @@ -268,35 +260,35 @@ describe('Integration: StateSnapshot utilities', () => { }); }); -describe('Integration: Example snippets coverage', () => { - it('all languages have at least one example snippet', () => { - const languages = new Set(exampleSnippets.map((s) => s.language)); +describe('Integration: Examples coverage', () => { + it('all languages have at least one example', () => { + const languages = new Set(examples.map((s) => s.language)); expect(languages.has('javascript')).toBe(true); - // Python may or may not have snippets yet + // Python may or may not have examples yet }); - it('all snippets have non-empty code', () => { - for (const snippet of exampleSnippets) { - expect(snippet.code.trim().length).toBeGreaterThan(0); + it('all examples have non-empty code', () => { + for (const example of examples) { + expect(example.code.trim().length).toBeGreaterThan(0); } }); - it('all snippet IDs are unique', () => { - const ids = exampleSnippets.map((s) => s.id); + it('all example ids are unique', () => { + const ids = examples.map((s) => s.id); expect(new Set(ids).size).toBe(ids.length); }); - it('all snippets reference valid languages', () => { + it('all examples reference valid languages', () => { const validLanguages = ['javascript', 'typescript', 'python']; - for (const snippet of exampleSnippets) { - expect(validLanguages).toContain(snippet.language); + for (const example of examples) { + expect(validLanguages).toContain(example.language); } }); - it('all snippets reference valid visualization lenses', () => { + it('all examples reference valid visualization lenses', () => { const validLenses = ['event-loop', 'heap-stack', 'data-structure', 'stream', 'none']; - for (const snippet of exampleSnippets) { - expect(validLenses).toContain(snippet.suggestedLens); + for (const example of examples) { + expect(validLenses).toContain(example.suggestedLens); } }); }); diff --git a/src/features/playground/components/editor/LanguagePicker.tsx b/src/features/playground/components/editor/LanguagePicker.tsx index 3486c7c..497ff0e 100644 --- a/src/features/playground/components/editor/LanguagePicker.tsx +++ b/src/features/playground/components/editor/LanguagePicker.tsx @@ -10,26 +10,34 @@ interface LanguagePickerProps { interface LanguageOption { id: PlaygroundLanguage; label: string; + /** Shown instead of the label below lg, where the toolbar is tight */ + shortLabel: string; icon: React.ComponentType<{ className?: string }>; } const LANGUAGES: LanguageOption[] = [ - { id: 'javascript', label: 'JavaScript', icon: Code2 }, - { id: 'typescript', label: 'TypeScript', icon: FileCode2 }, - { id: 'python', label: 'Python', icon: Terminal }, + { id: 'javascript', label: 'JavaScript', shortLabel: 'JS', icon: Code2 }, + { id: 'typescript', label: 'TypeScript', shortLabel: 'TS', icon: FileCode2 }, + { id: 'python', label: 'Python', shortLabel: 'PY', icon: Terminal }, ]; const LanguagePicker: React.FC = ({ language, onLanguageChange }) => { return ( -
- {LANGUAGES.map(({ id, label, icon: Icon }) => { +
+ {LANGUAGES.map(({ id, label, shortLabel, icon: Icon }) => { const isActive = language === id; return ( ); })} diff --git a/src/features/playground/components/execution/ConsoleOutput.test.tsx b/src/features/playground/components/execution/ConsoleOutput.test.tsx index 8f26d43..14f3371 100644 --- a/src/features/playground/components/execution/ConsoleOutput.test.tsx +++ b/src/features/playground/components/execution/ConsoleOutput.test.tsx @@ -23,7 +23,7 @@ describe('ConsoleOutput', () => { it('renders empty state when no entries', () => { render(); - expect(screen.getByText(/Run code to see output/)).toBeInTheDocument(); + expect(screen.getByText(/Run code to see what it prints/)).toBeInTheDocument(); }); it('renders log entries', () => { @@ -94,11 +94,27 @@ describe('ConsoleOutput', () => { expect(screen.getByText('Hello World 123')).toBeInTheDocument(); }); - it('escapes HTML in output to prevent XSS', () => { - const entries = [makeEntry('log', [''], 'e1')]; - render(); - // The angle brackets should be escaped, not rendered as HTML - const pre = screen.getByText(/<script>/); - expect(pre).toBeInTheDocument(); + it('shows text as written, without escaping it twice', () => { + const text = `"Hello, World!" a & b it's`; + const entries = [makeEntry('log', [text], 'e1')]; + const { container } = render(); + const pre = container.querySelector('pre'); + expect(pre?.textContent).toBe(text); + expect(pre?.textContent).not.toMatch(/"|<|&|'/); + }); + + it('renders markup in console text as text, never as elements', () => { + const entries = [makeEntry('error', [''], 'e1')]; + const { container } = render(); + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('img')).toBeNull(); + }); + + it('appends the source line of an entry that has one', () => { + const entries: ConsoleEntry[] = [ + { ...makeEntry('error', ['x is not defined'], 'e1'), line: 7 }, + ]; + const { container } = render(); + expect(container.querySelector('pre')?.textContent).toBe('x is not defined (line 7)'); }); }); diff --git a/src/features/playground/components/execution/ConsoleOutput.tsx b/src/features/playground/components/execution/ConsoleOutput.tsx index ef02d1a..9ec626b 100644 --- a/src/features/playground/components/execution/ConsoleOutput.tsx +++ b/src/features/playground/components/execution/ConsoleOutput.tsx @@ -1,7 +1,6 @@ import React, { useRef, useEffect } from 'react'; import { Trash2 } from 'lucide-react'; import type { ConsoleEntry } from '../../types'; -import { escapeHtml } from '../../utils/sanitize'; interface ConsoleOutputProps { entries: ConsoleEntry[]; @@ -67,7 +66,7 @@ const ConsoleOutput: React.FC = ({ entries, onClear }) => { className="flex-1 overflow-y-auto p-2" role="log" aria-live="polite" - aria-label="Console output" + aria-label="Console" style={{ background: 'var(--pg-bg-primary)' }} > {entries.length === 0 ? ( @@ -75,7 +74,7 @@ const ConsoleOutput: React.FC = ({ entries, onClear }) => { className="flex items-center justify-center h-full text-xs" style={{ color: 'var(--pg-text-muted)', fontFamily: 'var(--pg-font-mono)' }} > - Run code to see output here... + Run code to see what it prints here...
) : ( entries.map((entry) => ( @@ -92,7 +91,8 @@ const ConsoleOutput: React.FC = ({ entries, onClear }) => { {TYPE_PREFIXES[entry.type]}
-                {entry.args.map((arg) => escapeHtml(String(arg))).join(' ')}
+                {entry.args.map((arg) => String(arg)).join(' ')}
+                {entry.line ? ` (line ${entry.line})` : ''}
               
)) diff --git a/src/features/playground/components/execution/ExecutionController.tsx b/src/features/playground/components/execution/ExecutionController.tsx index f27bc49..2243e1c 100644 --- a/src/features/playground/components/execution/ExecutionController.tsx +++ b/src/features/playground/components/execution/ExecutionController.tsx @@ -20,7 +20,7 @@ const ExecutionController: React.FC = ({ executionState === 'idle' || executionState === 'completed' || executionState === 'error'; return ( -
+
{/* Run / Stop button */} {isRunning ? (
); } return ( -
+
{/* Timeline slider + controls */}
= ({ className="text-xs font-medium" style={{ color: 'var(--pg-text-secondary)', fontFamily: 'var(--pg-font-mono)' }} > - Step {currentStep + 1} / {totalSteps} + Step {currentStep + 1} of {totalSteps} {currentSnapshot && ( = ({ accentColor: 'var(--pg-accent-blue)', background: 'var(--pg-bg-panel)', }} - aria-label={`Timeline step ${currentStep + 1} of ${totalSteps}`} + aria-label={`Step ${currentStep + 1} of ${totalSteps}`} /> {/* Playback buttons */} diff --git a/src/features/playground/components/layout/BackToSiteLink.tsx b/src/features/playground/components/layout/BackToSiteLink.tsx index de1bd93..0a4b519 100644 --- a/src/features/playground/components/layout/BackToSiteLink.tsx +++ b/src/features/playground/components/layout/BackToSiteLink.tsx @@ -15,7 +15,9 @@ const BackToSiteLink: React.FC = () => { { }} > - Code Executives + {/* The label is dropped below lg; the aria-label carries it */} + Code Executives ); }; diff --git a/src/features/playground/components/layout/PlaygroundToolbar.tsx b/src/features/playground/components/layout/PlaygroundToolbar.tsx index 7b4e3e1..7891178 100644 --- a/src/features/playground/components/layout/PlaygroundToolbar.tsx +++ b/src/features/playground/components/layout/PlaygroundToolbar.tsx @@ -4,7 +4,7 @@ import BackToSiteLink from './BackToSiteLink'; import LanguagePicker from '../editor/LanguagePicker'; import ExecutionController from '../execution/ExecutionController'; import LensSelector from '../visualizations/LensSelector'; -import { exampleSnippets, type ExampleSnippet } from '../../utils/exampleSnippets'; +import { examples, type Example } from '../../utils/examples'; import type { PlaygroundLanguage, VisualizationLens, @@ -24,7 +24,7 @@ interface PlaygroundToolbarProps { onRun: () => void; onStop: () => void; onReset: () => void; - onLoadSnippet: (snippet: ExampleSnippet) => void; + onLoadExample: (example: Example) => void; onOpenSettings: () => void; } @@ -40,38 +40,40 @@ const PlaygroundToolbar: React.FC = ({ onRun, onStop, onReset, - onLoadSnippet, + onLoadExample, onOpenSettings, }) => { - const [snippetMenuOpen, setSnippetMenuOpen] = useState(false); + const [exampleMenuOpen, setExampleMenuOpen] = useState(false); const menuRef = useRef(null); // Close menu on outside click useEffect(() => { - if (!snippetMenuOpen) return; + if (!exampleMenuOpen) return; const handler = (e: MouseEvent): void => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setSnippetMenuOpen(false); + setExampleMenuOpen(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); - }, [snippetMenuOpen]); + }, [exampleMenuOpen]); // Close on Escape const handleMenuKeyDown = useCallback((e: React.KeyboardEvent): void => { if (e.key === 'Escape') { - setSnippetMenuOpen(false); + setExampleMenuOpen(false); } }, []); - const filteredSnippets = exampleSnippets.filter((s) => s.language === language); + const languageExamples = examples.filter((s) => s.language === language); return ( + // Below 2xl the lens and the examples move to a second row, so the first row + // (with Run at its end) always fits; from 2xl everything is one row.
= ({ > -
+
= ({ {/* Trace toggle */} - {/* Separator */} -