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
10 changes: 5 additions & 5 deletions docs/Playground-Developer-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -207,7 +207,7 @@ const LANGUAGE_MAP: Record<PlaygroundLanguage, string> = {

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.

Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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 `&quot;` 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).
Expand Down
65 changes: 39 additions & 26 deletions src/features/playground/PlaygroundApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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]);

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 =
Expand All @@ -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) {
Expand Down Expand Up @@ -246,7 +259,7 @@ const PlaygroundApp: React.FC = () => {
onRun={handleRun}
onStop={handleStop}
onReset={handleReset}
onLoadSnippet={handleLoadSnippet}
onLoadExample={handleLoadExample}
onOpenSettings={() => setSettingsOpen((prev) => !prev)}
/>
{settingsOpen && (
Expand Down Expand Up @@ -297,7 +310,7 @@ const PlaygroundApp: React.FC = () => {
aria-label="Resize editor and visualization panes"
/>

{/* Visualization / Timeline Pane */}
{/* Visualization / Steps Pane */}
<div
className="flex flex-col rounded-lg overflow-hidden"
style={{
Expand All @@ -315,7 +328,7 @@ const PlaygroundApp: React.FC = () => {
previousSnapshot={timeline.previousSnapshot}
/>
</div>
{/* Timeline controls (bottom) */}
{/* Step controls (bottom) */}
<div
style={{
borderTop: '1px solid var(--pg-border)',
Expand Down Expand Up @@ -347,7 +360,7 @@ const PlaygroundApp: React.FC = () => {
aria-label="Resize visualization and console panes"
/>

{/* Output Pane */}
{/* Console Pane */}
<div
className="flex flex-col rounded-lg overflow-hidden"
style={{
Expand Down
64 changes: 28 additions & 36 deletions src/features/playground/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* These tests verify multi-module interactions:
* - JS instrumenter → sandbox → console output
* - Python instrumenter → snapshot parsing
* - Example snippets → language switching → code loading
* - Examples → language switching → code loading
* - Instrumentation → timeline data generation
*
* Since jsdom cannot execute real iframe code or load Pyodide WASM,
Expand All @@ -19,13 +19,8 @@ import {
} from '../instrumentation/PythonInstrumenter';
import { safeDeepClone, diffSnapshots } from '../instrumentation/StateSnapshot';
import { codeTemplates } from '../utils/codeTemplates';
import { exampleSnippets } from '../utils/exampleSnippets';
import {
escapeHtml,
sanitizeOutput,
enforceEntryLimit,
MAX_CONSOLE_ENTRIES,
} from '../utils/sanitize';
import { examples } from '../utils/examples';
import { sanitizeOutput, enforceEntryLimit, MAX_CONSOLE_ENTRIES } from '../utils/sanitize';
import type { StateSnapshot } from '../types';

describe('Integration: JS instrumentation pipeline', () => {
Expand All @@ -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);
}
});
Expand Down Expand Up @@ -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');
}
});
Expand Down Expand Up @@ -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 = '<img src=x onerror=alert(1)>';
const sanitized = escapeHtml(String(malicious));
expect(sanitized).not.toContain('<img');
expect(sanitized).toContain('&lt;img');
expect(sanitizeOutput(malicious)).toBe(malicious);
});

it('large output arrays are capped by enforceEntryLimit', () => {
Expand Down Expand Up @@ -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);
}
});
});
23 changes: 16 additions & 7 deletions src/features/playground/components/editor/LanguagePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<LanguagePickerProps> = ({ language, onLanguageChange }) => {
return (
<div className="flex items-center gap-1" role="radiogroup" aria-label="Language selector">
{LANGUAGES.map(({ id, label, icon: Icon }) => {
<div
className="flex items-center gap-1 shrink-0"
role="radiogroup"
aria-label="Language selector"
>
{LANGUAGES.map(({ id, label, shortLabel, icon: Icon }) => {
const isActive = language === id;
return (
<button
key={id}
role="radio"
aria-checked={isActive}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
aria-label={label}
title={label}
className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded-md transition-colors lg:px-3"
style={{
color: isActive ? 'var(--pg-accent-blue)' : 'var(--pg-text-secondary)',
background: isActive ? 'rgba(0, 212, 255, 0.1)' : 'transparent',
Expand All @@ -38,7 +46,8 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({ language, onLanguageCha
onClick={() => onLanguageChange(id)}
>
<Icon className="w-3.5 h-3.5" />
{label}
<span className="hidden lg:inline">{label}</span>
<span className="lg:hidden">{shortLabel}</span>
</button>
);
})}
Expand Down
Loading
Loading