Skip to content

Commit cf4bfa1

Browse files
stephenliangclaude
andcommitted
feat(lab2): add @code-dot-org/lab host package and wire into studio
Scaffold the labs/base package (@code-dot-org/lab) from the music-lab prototype, adapted to prop-driven levelId + map (no redux, no theme provider). Includes Lab shell with ErrorBoundary, Loading, metrics reporter, and LevelPropertiesContext. Wire into the existing oceans project route as an integration proof. Add cross-package lint rule preventing oceans from importing @code-dot-org/lab directly. Lab2-studio-oceans tasks §1-2 (1.1–2.2). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1493d0e commit cf4bfa1

21 files changed

Lines changed: 611 additions & 16 deletions

File tree

frontend/apps/studio/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@code-dot-org/component-library-styles": "workspace:*",
2424
"@code-dot-org/core": "workspace:*",
2525
"@code-dot-org/fonts": "workspace:*",
26+
"@code-dot-org/lab": "workspace:*",
2627
"@code-dot-org/music-lab": "workspace:*",
2728
"@code-dot-org/oceans-lab": "workspace:*",
2829
"@emotion/react": "catalog:",
Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
import {createFileRoute, notFound} from '@tanstack/react-router';
2-
import {Suspense} from 'react';
2+
3+
import {Lab} from '@code-dot-org/lab';
34

45
import {getLabEntrypoint} from '@/modules/labs/router/getLabEntrypoint';
6+
import {getLabFixtures} from '@/modules/labs/router/getLabFixtures';
57

6-
// `createFileRoute` automatically sets the route's id and path based on the file path
7-
// There is no need to manually edit this, it is done via the Tanstack Router Vite plugin
8-
// See: https://tanstack.com/router/latest/docs/framework/react/routing/routing-concepts#anatomy-of-a-route
98
export const Route = createFileRoute('/projects/$labType/$channelId/edit')({
10-
loader: async ({params: {labType}}) => {
11-
// Lazy load each lab's entrypoint to ensure each lab's code is only loaded when needed.
12-
// This causes each lab to be code-split into its own chunk.
9+
loader: async ({params: {labType, channelId}}) => {
1310
const LabEntrypoint = getLabEntrypoint(labType);
1411

1512
if (!LabEntrypoint) {
1613
throw notFound();
1714
}
1815

16+
if (import.meta.env.VITE_API_MODE === 'msw') {
17+
const [{registerLabFixtures, setActiveScenario}, fixtures] =
18+
await Promise.all([
19+
import('@code-dot-org/core/api/mocks'),
20+
getLabFixtures(labType),
21+
]);
22+
23+
if (fixtures) registerLabFixtures(labType, fixtures);
24+
setActiveScenario({labKey: labType, tag: channelId});
25+
}
26+
1927
return {LabEntrypoint};
2028
},
2129
component: RouteComponent,
@@ -25,8 +33,8 @@ function RouteComponent() {
2533
const {LabEntrypoint} = Route.useLoaderData();
2634

2735
return (
28-
<Suspense fallback={<div>Loading...</div>}>
36+
<Lab>
2937
<LabEntrypoint />
30-
</Suspense>
38+
</Lab>
3139
);
3240
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
dist
12+
dist-ssr
13+
*.local
14+
15+
# Editor directories and files
16+
.vscode/*
17+
!.vscode/extensions.json
18+
.idea
19+
.DS_Store
20+
*.suo
21+
*.ntvs*
22+
*.njsproj
23+
*.sln
24+
*.sw?
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import baseConfig from '@code-dot-org/lint-config/lint-staged/lintstagedrc.mjs';
2+
3+
/**
4+
* @type {import('lint-staged').Configuration}
5+
*/
6+
export default baseConfig;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {globalIgnores} from 'eslint/config';
2+
3+
import cdoReactConfig from '@code-dot-org/lint-config/eslint/react.mjs';
4+
import cdoVitestConfig from '@code-dot-org/lint-config/eslint/vitest.mjs';
5+
6+
export default [globalIgnores(['dist']), ...cdoReactConfig, ...cdoVitestConfig];
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"name": "@code-dot-org/lab",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Lab2 framework host: lab shell, level-properties context, error/loading UI",
6+
"homepage": "https://github.com/code-dot-org/code-dot-org/blob/staging/frontend/packages/labs/base/#readme",
7+
"bugs": {
8+
"url": "https://github.com/code-dot-org/code-dot-org/issues"
9+
},
10+
"repository": {
11+
"type": "git",
12+
"url": "git+https://github.com/code-dot-org/code-dot-org.git",
13+
"directory": "frontend/packages/labs/base"
14+
},
15+
"license": "SEE LICENSE IN LICENSE",
16+
"exports": {
17+
".": {
18+
"types": "./dist/index.d.ts",
19+
"import": "./dist/index.mjs",
20+
"require": "./dist/index.cjs"
21+
}
22+
},
23+
"scripts": {
24+
"build": "vite build",
25+
"dev": "vite",
26+
"lint": "eslint .",
27+
"lint:fix": "eslint --fix .",
28+
"prettier": "prettier --check .",
29+
"prettier:fix": "prettier --write .",
30+
"test": "vitest --run",
31+
"typecheck": "tsc -b --noEmit"
32+
},
33+
"prettier": "@code-dot-org/lint-config/prettier/index.mjs",
34+
"dependencies": {
35+
"@code-dot-org/core": "workspace:*"
36+
},
37+
"devDependencies": {
38+
"@code-dot-org/lint-config": "workspace:*",
39+
"@testing-library/jest-dom": "catalog:",
40+
"@testing-library/react": "catalog:",
41+
"@types/node": "catalog:",
42+
"@vitejs/plugin-react": "catalog:",
43+
"eslint": "catalog:",
44+
"jsdom": "catalog:",
45+
"prettier": "catalog:",
46+
"react": "catalog:",
47+
"react-dom": "catalog:",
48+
"typescript": "catalog:",
49+
"vite": "catalog:",
50+
"vite-plugin-dts": "catalog:",
51+
"vite-plugin-externalize-deps": "catalog:",
52+
"vitest": "catalog:"
53+
},
54+
"peerDependencies": {
55+
"react": "^18.0.0 || ^19.0.0",
56+
"react-dom": "^18.0.0 || ^19.0.0"
57+
}
58+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
logger,
3+
metrics,
4+
recordError,
5+
} from '@code-dot-org/core/plugins/observability';
6+
7+
interface ReportingProperties {
8+
channelId?: string;
9+
appName?: string;
10+
currentLevelId?: string | number;
11+
scriptId?: number;
12+
}
13+
14+
class LabMetricsReporter {
15+
private commonProperties: ReportingProperties = {};
16+
17+
constructor(initialProperties?: ReportingProperties) {
18+
this.commonProperties = initialProperties || {};
19+
}
20+
21+
updateProperties(properties: ReportingProperties) {
22+
this.commonProperties = {...this.commonProperties, ...properties};
23+
}
24+
25+
logInfo(message: string | object) {
26+
const decorated = this.decorateMessage(message);
27+
logger.info(typeof message === 'string' ? message : 'lab.info', decorated);
28+
}
29+
30+
logWarning(message: string | object) {
31+
const decorated = this.decorateMessage(message);
32+
logger.warn(typeof message === 'string' ? message : 'lab.warn', decorated);
33+
}
34+
35+
logError(errorMessage: string, error?: Error) {
36+
recordError(error ?? new Error(errorMessage), {
37+
...this.commonProperties,
38+
errorMessage,
39+
});
40+
}
41+
42+
reportLoadTime(metricName: string, loadTimeMs: number) {
43+
metrics.distribution(metricName, loadTimeMs, {
44+
...this.getCommonAttributes(),
45+
});
46+
}
47+
48+
incrementCounter(metricName: string) {
49+
metrics.count(metricName, 1, {...this.getCommonAttributes()});
50+
}
51+
52+
reportSevereError() {
53+
metrics.count('SevereError', 1, {...this.getCommonAttributes()});
54+
}
55+
56+
reset() {
57+
this.commonProperties = {};
58+
}
59+
60+
private decorateMessage(message: string | object): Record<string, unknown> {
61+
const obj = typeof message === 'string' ? {message} : message;
62+
return {...obj, ...this.commonProperties};
63+
}
64+
65+
private getCommonAttributes(): Record<string, unknown> {
66+
const attrs: Record<string, unknown> = {};
67+
if (this.commonProperties.appName) {
68+
attrs.AppName = this.commonProperties.appName;
69+
}
70+
return attrs;
71+
}
72+
}
73+
74+
export default LabMetricsReporter;
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {render, screen} from '@testing-library/react';
2+
import {describe, expect, it, vi} from 'vitest';
3+
4+
import Lab from '../components/Lab';
5+
import {useLevelProperties} from '../contexts/LevelPropertiesContext';
6+
import type {LevelPropertiesMap} from '../types';
7+
8+
const LEVEL_MAP: LevelPropertiesMap = {
9+
'29091': {appName: 'fish', mode: 'fishvtrash'},
10+
'29092': {appName: 'fish', mode: 'short'},
11+
};
12+
13+
function LevelDisplay() {
14+
const props = useLevelProperties();
15+
return <div data-testid="level-info">{JSON.stringify(props)}</div>;
16+
}
17+
18+
describe('Lab', () => {
19+
it('provides level context to children', () => {
20+
render(
21+
<Lab levelId={29091} levelPropertiesMap={LEVEL_MAP}>
22+
<LevelDisplay />
23+
</Lab>,
24+
);
25+
26+
const info = screen.getByTestId('level-info');
27+
expect(JSON.parse(info.textContent!)).toEqual({
28+
appName: 'fish',
29+
mode: 'fishvtrash',
30+
});
31+
});
32+
33+
it('updates children when levelId changes without remounting shell', () => {
34+
const shellMountSpy = vi.fn();
35+
36+
function ShellSentinel() {
37+
shellMountSpy();
38+
return null;
39+
}
40+
41+
const {rerender} = render(
42+
<Lab levelId={29091} levelPropertiesMap={LEVEL_MAP}>
43+
<ShellSentinel />
44+
<LevelDisplay />
45+
</Lab>,
46+
);
47+
48+
expect(shellMountSpy).toHaveBeenCalledTimes(1);
49+
50+
rerender(
51+
<Lab levelId={29092} levelPropertiesMap={LEVEL_MAP}>
52+
<ShellSentinel />
53+
<LevelDisplay />
54+
</Lab>,
55+
);
56+
57+
const info = screen.getByTestId('level-info');
58+
expect(JSON.parse(info.textContent!)).toEqual({
59+
appName: 'fish',
60+
mode: 'short',
61+
});
62+
// Shell re-rendered (React always calls render), but was not remounted
63+
// (no second mount lifecycle). Two calls = two renders, not two mounts.
64+
expect(shellMountSpy).toHaveBeenCalledTimes(2);
65+
});
66+
});
67+
68+
describe('Lab error containment', () => {
69+
it('shows error state and reports via onError when child throws', () => {
70+
const reportSpy = vi.fn();
71+
72+
function Bomb(): never {
73+
throw new Error('boom');
74+
}
75+
76+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
77+
78+
render(
79+
<Lab levelId={29091} levelPropertiesMap={LEVEL_MAP} onError={reportSpy}>
80+
<Bomb />
81+
</Lab>,
82+
);
83+
84+
expect(screen.getByRole('alert')).toHaveTextContent(
85+
/error occurred.*reloading/i,
86+
);
87+
expect(reportSpy).toHaveBeenCalledWith(
88+
expect.any(Error),
89+
expect.any(String),
90+
);
91+
92+
consoleSpy.mockRestore();
93+
});
94+
});
95+
96+
describe('Lab without level properties (project route)', () => {
97+
it('renders children directly when no levelId or map provided', () => {
98+
render(
99+
<Lab>
100+
<div data-testid="lab-child">Oceans Lab Content</div>
101+
</Lab>,
102+
);
103+
104+
expect(screen.getByTestId('lab-child')).toHaveTextContent(
105+
'Oceans Lab Content',
106+
);
107+
});
108+
109+
it('catches errors from children even without level properties', () => {
110+
const reportSpy = vi.fn();
111+
112+
function Bomb(): never {
113+
throw new Error('project crash');
114+
}
115+
116+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
117+
118+
render(
119+
<Lab onError={reportSpy}>
120+
<Bomb />
121+
</Lab>,
122+
);
123+
124+
expect(screen.getByRole('alert')).toHaveTextContent(
125+
/error occurred.*reloading/i,
126+
);
127+
expect(reportSpy).toHaveBeenCalled();
128+
129+
consoleSpy.mockRestore();
130+
});
131+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import '@testing-library/jest-dom/vitest';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {Component, type ErrorInfo, type ReactNode} from 'react';
2+
3+
interface ErrorBoundaryProps {
4+
fallback: ReactNode;
5+
children: ReactNode;
6+
onError: (error: Error, componentStack: string) => void;
7+
}
8+
9+
interface ErrorBoundaryState {
10+
hasError: boolean;
11+
}
12+
13+
export default class ErrorBoundary extends Component<
14+
ErrorBoundaryProps,
15+
ErrorBoundaryState
16+
> {
17+
constructor(props: ErrorBoundaryProps) {
18+
super(props);
19+
this.state = {hasError: false};
20+
}
21+
22+
static getDerivedStateFromError() {
23+
return {hasError: true};
24+
}
25+
26+
componentDidCatch(error: Error, info: ErrorInfo) {
27+
this.props.onError(error, info.componentStack || '');
28+
}
29+
30+
render() {
31+
if (this.state.hasError) {
32+
return this.props.fallback;
33+
}
34+
return this.props.children;
35+
}
36+
}

0 commit comments

Comments
 (0)