refactor: move challenge build outside client (#65513)

This commit is contained in:
Oliver Eyton-Williams
2026-01-27 05:53:51 +01:00
committed by GitHub
parent c8b21dfc4a
commit 5ff971687c
20 changed files with 766 additions and 329 deletions
+3 -4
View File
@@ -47,14 +47,13 @@
"@babel/preset-env": "7.23.7", "@babel/preset-env": "7.23.7",
"@babel/preset-react": "7.23.3", "@babel/preset-react": "7.23.3",
"@babel/preset-typescript": "7.23.3", "@babel/preset-typescript": "7.23.3",
"@babel/standalone": "7.23.7",
"@codesandbox/sandpack-react": "2.6.9", "@codesandbox/sandpack-react": "2.6.9",
"@codesandbox/sandpack-themes": "2.0.21", "@codesandbox/sandpack-themes": "2.0.21",
"@fortawesome/fontawesome-svg-core": "6.7.1", "@fortawesome/fontawesome-svg-core": "6.7.1",
"@fortawesome/free-brands-svg-icons": "6.7.1", "@fortawesome/free-brands-svg-icons": "6.7.1",
"@fortawesome/free-solid-svg-icons": "6.7.1", "@fortawesome/free-solid-svg-icons": "6.7.1",
"@fortawesome/react-fontawesome": "0.2.2", "@fortawesome/react-fontawesome": "0.2.2",
"@freecodecamp/loop-protect": "3.0.0", "@freecodecamp/challenge-builder": "workspace:*",
"@freecodecamp/ui": "5.0.1", "@freecodecamp/ui": "5.0.1",
"@gatsbyjs/reach-router": "1.3.9", "@gatsbyjs/reach-router": "1.3.9",
"@growthbook/growthbook-react": "1.6.0", "@growthbook/growthbook-react": "1.6.0",
@@ -141,10 +140,10 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-syntax-dynamic-import": "7.8.3", "@babel/plugin-syntax-dynamic-import": "7.8.3",
"@freecodecamp/browser-scripts": "workspace:*",
"@freecodecamp/curriculum": "workspace:*",
"@freecodecamp/eslint-config": "workspace:*", "@freecodecamp/eslint-config": "workspace:*",
"@freecodecamp/shared": "workspace:*", "@freecodecamp/shared": "workspace:*",
"@freecodecamp/curriculum": "workspace:*",
"@freecodecamp/browser-scripts": "workspace:*",
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "12.1.5", "@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1", "@testing-library/react-hooks": "^8.0.1",
@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { runSaga } from 'redux-saga'; import { runSaga } from 'redux-saga';
import { describe, test, it, expect, beforeEach, vi, Mock } from 'vitest'; import { describe, test, it, expect, beforeEach, vi, Mock } from 'vitest';
import { buildChallenge } from '@freecodecamp/challenge-builder/build';
import { render } from '../../../../utils/test-utils'; import { render } from '../../../../utils/test-utils';
import { getCompletedPercentage } from '../../../utils/get-completion-percentage'; import { getCompletedPercentage } from '../../../utils/get-completion-percentage';
@@ -14,7 +16,7 @@ import {
isBuildEnabledSelector, isBuildEnabledSelector,
isBlockNewlyCompletedSelector isBlockNewlyCompletedSelector
} from '../redux/selectors'; } from '../redux/selectors';
import { buildChallenge, getTestRunner } from '../utils/build'; import { getTestRunner } from '../utils/build';
import CompletionModal, { combineFileData } from './completion-modal'; import CompletionModal, { combineFileData } from './completion-modal';
vi.mock('../../../analytics'); vi.mock('../../../analytics');
vi.mock('../../../utils/fire-confetti'); vi.mock('../../../utils/fire-confetti');
@@ -22,6 +24,7 @@ vi.mock('../../../components/Progress');
vi.mock('../redux/selectors'); vi.mock('../redux/selectors');
vi.mock('../utils/build'); vi.mock('../utils/build');
vi.mock('../../../utils/get-words'); vi.mock('../../../utils/get-words');
vi.mock('@freecodecamp/challenge-builder/build');
const mockFireConfetti = fireConfetti as Mock; const mockFireConfetti = fireConfetti as Mock;
const mockTestRunner = vi.fn().mockReturnValue({ pass: true }); const mockTestRunner = vi.fn().mockReturnValue({ pass: true });
const mockBuildEnabledSelector = isBuildEnabledSelector as Mock; const mockBuildEnabledSelector = isBuildEnabledSelector as Mock;
@@ -15,6 +15,8 @@ import {
} from 'redux-saga/effects'; } from 'redux-saga/effects';
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types'; import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import { buildChallenge } from '@freecodecamp/challenge-builder/build';
import { createFlashMessage } from '../../../components/Flash/redux'; import { createFlashMessage } from '../../../components/Flash/redux';
import { FlashMessages } from '../../../components/Flash/redux/flash-messages'; import { FlashMessages } from '../../../components/Flash/redux/flash-messages';
import { import {
@@ -25,7 +27,6 @@ import {
} from '../../../utils/challenge-request-helpers'; } from '../../../utils/challenge-request-helpers';
import { playTone } from '../../../utils/tone'; import { playTone } from '../../../utils/tone';
import { import {
buildChallenge,
canBuildChallenge, canBuildChallenge,
challengeHasPreview, challengeHasPreview,
getTestRunner, getTestRunner,
+5 -53
View File
@@ -1,13 +1,15 @@
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types'; import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import type { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl';
import type { ChallengeFile } from '../../../redux/prop-types';
import { concatHtml } from '../rechallenge/builders';
import { import {
getTransformers, getTransformers,
embedFilesInHtml, embedFilesInHtml,
getPythonTransformers, getPythonTransformers,
getMultifileJSXTransformers getMultifileJSXTransformers
} from '../rechallenge/transformers'; } from '@freecodecamp/challenge-builder/transformers';
import { concatHtml } from '@freecodecamp/challenge-builder/builders';
import { runnerTypes } from '@freecodecamp/challenge-builder/build';
import { import {
runTestsInTestFrame, runTestsInTestFrame,
createMainPreviewFramer, createMainPreviewFramer,
@@ -97,56 +99,6 @@ export function canBuildChallenge(challengeData: BuildChallengeData): boolean {
return Object.prototype.hasOwnProperty.call(buildFunctions, challengeType); return Object.prototype.hasOwnProperty.call(buildFunctions, challengeType);
} }
export async function buildChallenge(
challengeData: BuildChallengeData,
options: BuildOptions
) {
const { challengeType } = challengeData;
const build = buildFunctions[challengeType];
if (build) {
return build(challengeData, options);
}
throw new Error(`Cannot build challenge of type ${challengeType}`);
}
export const runnerTypes: Record<
(typeof challengeTypes)[keyof typeof challengeTypes],
'javascript' | 'dom' | 'python'
> = {
[challengeTypes.html]: 'dom',
[challengeTypes.js]: 'javascript',
[challengeTypes.backend]: 'dom',
[challengeTypes.zipline]: 'dom',
[challengeTypes.frontEndProject]: 'dom',
[challengeTypes.backEndProject]: 'dom',
[challengeTypes.pythonProject]: 'python',
[challengeTypes.jsProject]: 'javascript',
[challengeTypes.modern]: 'dom',
[challengeTypes.step]: 'dom',
[challengeTypes.quiz]: 'dom',
[challengeTypes.invalid]: 'dom',
[challengeTypes.video]: 'dom',
[challengeTypes.codeAllyPractice]: 'dom',
[challengeTypes.codeAllyCert]: 'dom',
[challengeTypes.multifileCertProject]: 'dom',
[challengeTypes.theOdinProject]: 'dom',
[challengeTypes.colab]: 'dom',
[challengeTypes.exam]: 'dom',
[challengeTypes.msTrophy]: 'dom',
[challengeTypes.multipleChoice]: 'dom',
[challengeTypes.python]: 'python',
[challengeTypes.dialogue]: 'dom',
[challengeTypes.fillInTheBlank]: 'dom',
[challengeTypes.multifilePythonCertProject]: 'python',
[challengeTypes.generic]: 'dom',
[challengeTypes.lab]: 'dom',
[challengeTypes.jsLab]: 'javascript',
[challengeTypes.pyLab]: 'python',
[challengeTypes.dailyChallengeJs]: 'javascript',
[challengeTypes.dailyChallengePy]: 'python',
[challengeTypes.review]: 'dom'
};
export async function getTestRunner(buildData: BuildChallengeData) { export async function getTestRunner(buildData: BuildChallengeData) {
const { challengeType } = buildData; const { challengeType } = buildData;
// TODO: Fully type BuildChallengeData // TODO: Fully type BuildChallengeData
+2 -1
View File
@@ -48,9 +48,10 @@
"devDependencies": { "devDependencies": {
"@babel/core": "7.23.7", "@babel/core": "7.23.7",
"@babel/register": "7.23.7", "@babel/register": "7.23.7",
"@freecodecamp/browser-scripts": "workspace:*",
"@freecodecamp/challenge-builder": "workspace:*",
"@freecodecamp/eslint-config": "workspace:*", "@freecodecamp/eslint-config": "workspace:*",
"@freecodecamp/shared": "workspace:*", "@freecodecamp/shared": "workspace:*",
"@freecodecamp/browser-scripts": "workspace:*",
"@total-typescript/ts-reset": "^0.6.1", "@total-typescript/ts-reset": "^0.6.1",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/js-yaml": "4.0.5", "@types/js-yaml": "4.0.5",
+5 -7
View File
@@ -26,16 +26,16 @@ import { sortChallenges } from './utils/sort-challenges.js';
const { flatten, isEmpty, cloneDeep } = lodash; const { flatten, isEmpty, cloneDeep } = lodash;
vi.mock( vi.mock(
'../../../client/src/templates/Challenges/utils/typescript-worker-handler', '@freecodecamp/challenge-builder/typescript-worker-handler',
async importOriginal => { async importOriginal => {
const actual = await importOriginal(); const actual = await importOriginal();
// ts and tsvfs must match the versions used in the typescript-worker. // ts and tsvfs must match the versions used in the typescript-worker.
const tsvfs = await import('@typescript/vfs-1.6.1'); const tsvfs = await import('@typescript/vfs-1.6.1');
const ts = await import('typescript-5.9.2'); const ts = await import('typescript-5.9.2');
// use the same TS compiler as the client // use the same TS compiler as the challenge-builder
const tsCompiler = await import( const tsCompiler = await import(
'../../../tools/client-plugins/browser-scripts/modules/typescript-compiler' '@freecodecamp/browser-scripts/ts-compiler'
); );
const compiler = new tsCompiler.Compiler(ts, tsvfs); const compiler = new tsCompiler.Compiler(ts, tsvfs);
await compiler.setup({ useNodeModules: true }); await compiler.setup({ useNodeModules: true });
@@ -151,7 +151,7 @@ async function populateTestsForLang({ lang, challenges, meta }) {
// Presumably this is because we import from_this file in the generated block // Presumably this is because we import from_this file in the generated block
// test files and that happens before the mock is applied. // test files and that happens before the mock is applied.
const { buildChallenge } = await import( const { buildChallenge } = await import(
'../../../client/src/templates/Challenges/utils/build' '@freecodecamp/challenge-builder/build'
); );
const validateChallenge = challengeSchemaValidator(); const validateChallenge = challengeSchemaValidator();
@@ -370,9 +370,7 @@ async function createTestRunner(
buildChallenge, buildChallenge,
solutionFromNext solutionFromNext
) { ) {
const { runnerTypes } = await import( const { runnerTypes } = await import('@freecodecamp/challenge-builder/build');
'../../../client/src/templates/Challenges/utils/build'
);
const challengeFiles = replaceChallengeFilesContentsWithSolutions( const challengeFiles = replaceChallengeFilesContentsWithSolutions(
challenge.challengeFiles, challenge.challengeFiles,
+1
View File
@@ -0,0 +1 @@
dist
@@ -0,0 +1,4 @@
/* eslint-disable filenames-simple/naming-convention */
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
export default createLintStagedConfig(import.meta.dirname);
@@ -0,0 +1,18 @@
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
import globals from 'globals';
import { defineConfig } from 'eslint/config';
const baseLanguageOptions = {
globals: {
...globals.browser,
...globals.node // TODO: necessary?
}
};
export default defineConfig({
extends: [configTypeChecked],
languageOptions: {
...baseLanguageOptions
}
});
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@freecodecamp/challenge-builder",
"version": "0.0.1",
"author": "freeCodeCamp <team@freecodecamp.org>",
"license": "BSD-3-Clause",
"description": "Builds challenges for testing and rendering",
"private": false,
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"exports": {
"./build": "./dist/build.js",
"./transformers": "./dist/transformers.js",
"./typescript-worker-handler": "./dist/typescript-worker-handler.js",
"./builders": "./dist/builders.js"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit",
"compile": "tsc",
"lint": "eslint --max-warnings 0"
},
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
},
"bugs": {
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
},
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
"devDependencies": {
"@freecodecamp/eslint-config": "workspace:*",
"@types/lodash-es": "4.17.12",
"@vitest/ui": "^3.2.4",
"eslint": "^9.39.1",
"vitest": "^3.2.4"
},
"dependencies": {
"@babel/preset-env": "7.23.7",
"@babel/preset-react": "7.28.5",
"@babel/standalone": "7.23.7",
"@freecodecamp/browser-scripts": "workspace:*",
"@freecodecamp/loop-protect": "3.0.0",
"@freecodecamp/shared": "workspace:*",
"lodash-es": "4.17.23"
}
}
+269
View File
@@ -0,0 +1,269 @@
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import type { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl';
import { concatHtml } from './builders.js';
import {
getTransformers,
embedFilesInHtml,
getPythonTransformers,
getMultifileJSXTransformers
} from './transformers.js';
interface Source {
index: string;
contents?: string;
editableContents: string;
}
interface BuildChallengeData {
challengeType: number;
challengeFiles?: ChallengeFile[];
required: { src?: string }[];
template: string;
url: string;
}
interface BuildOptions {
preview: boolean;
disableLoopProtectTests: boolean;
disableLoopProtectPreview: boolean;
usesTestRunner?: boolean;
}
type ApplyFunctionProps = (
file: ChallengeFile
) => Promise<ChallengeFile> | ChallengeFile;
const applyFunction =
(fn: ApplyFunctionProps) => async (file: ChallengeFile) => {
try {
if (file.error) {
return file;
}
const newFile = await fn.call(this, file);
if (typeof newFile !== 'undefined') {
return newFile;
}
return file;
} catch (error) {
return { ...file, error };
}
};
const composeFunctions = (...fns: ApplyFunctionProps[]) =>
fns.map(applyFunction).reduce((f, g) => x => f(x).then(g));
function buildSourceMap(challengeFiles: ChallengeFile[]): Source | undefined {
// TODO: rename sources.index to sources.contents.
const source: Source | undefined = challengeFiles?.reduce(
(sources, challengeFile) => {
sources.index += challengeFile.source || '';
sources.contents = sources.index;
sources.editableContents += challengeFile.editableContents || '';
return sources;
},
{
index: '',
editableContents: ''
} as Source
);
return source;
}
export const buildFunctions = {
[challengeTypes.js]: buildJSChallenge,
[challengeTypes.jsProject]: buildJSChallenge,
[challengeTypes.html]: buildDOMChallenge,
[challengeTypes.modern]: buildDOMChallenge,
[challengeTypes.backend]: buildBackendChallenge,
[challengeTypes.backEndProject]: buildBackendChallenge,
[challengeTypes.pythonProject]: buildBackendChallenge,
[challengeTypes.multifileCertProject]: buildDOMChallenge,
[challengeTypes.colab]: buildBackendChallenge,
[challengeTypes.python]: buildPythonChallenge,
[challengeTypes.multifilePythonCertProject]: buildPythonChallenge,
[challengeTypes.lab]: buildDOMChallenge,
[challengeTypes.jsLab]: buildJSChallenge,
[challengeTypes.pyLab]: buildPythonChallenge,
[challengeTypes.dailyChallengeJs]: buildJSChallenge,
[challengeTypes.dailyChallengePy]: buildPythonChallenge
};
export function canBuildChallenge(challengeData: BuildChallengeData): boolean {
const { challengeType } = challengeData;
return Object.prototype.hasOwnProperty.call(buildFunctions, challengeType);
}
export async function buildChallenge(
challengeData: BuildChallengeData,
options: BuildOptions
) {
const { challengeType } = challengeData;
const build = buildFunctions[challengeType];
if (build) {
return build(challengeData, options);
}
throw new Error(`Cannot build challenge of type ${challengeType}`);
}
export const runnerTypes: Record<
(typeof challengeTypes)[keyof typeof challengeTypes],
'javascript' | 'dom' | 'python'
> = {
[challengeTypes.html]: 'dom',
[challengeTypes.js]: 'javascript',
[challengeTypes.backend]: 'dom',
[challengeTypes.zipline]: 'dom',
[challengeTypes.frontEndProject]: 'dom',
[challengeTypes.backEndProject]: 'dom',
[challengeTypes.pythonProject]: 'python',
[challengeTypes.jsProject]: 'javascript',
[challengeTypes.modern]: 'dom',
[challengeTypes.step]: 'dom',
[challengeTypes.quiz]: 'dom',
[challengeTypes.invalid]: 'dom',
[challengeTypes.video]: 'dom',
[challengeTypes.codeAllyPractice]: 'dom',
[challengeTypes.codeAllyCert]: 'dom',
[challengeTypes.multifileCertProject]: 'dom',
[challengeTypes.theOdinProject]: 'dom',
[challengeTypes.colab]: 'dom',
[challengeTypes.exam]: 'dom',
[challengeTypes.msTrophy]: 'dom',
[challengeTypes.multipleChoice]: 'dom',
[challengeTypes.python]: 'python',
[challengeTypes.dialogue]: 'dom',
[challengeTypes.fillInTheBlank]: 'dom',
[challengeTypes.multifilePythonCertProject]: 'python',
[challengeTypes.generic]: 'dom',
[challengeTypes.lab]: 'dom',
[challengeTypes.jsLab]: 'javascript',
[challengeTypes.pyLab]: 'python',
[challengeTypes.dailyChallengeJs]: 'javascript',
[challengeTypes.dailyChallengePy]: 'python',
[challengeTypes.review]: 'dom'
};
type BuildResult = {
challengeType: number;
build?: string;
sources: Source | undefined;
loadEnzyme?: boolean;
error?: unknown;
};
// TODO: All the buildXChallenge files have a similar structure, so make that
// abstraction (function, class, whatever) and then create the various functions
// out of it.
export async function buildDOMChallenge(
{
challengeFiles,
required = [],
template = '',
challengeType
}: BuildChallengeData,
options?: BuildOptions
): Promise<BuildResult> {
// TODO: make this required in the schema.
if (!challengeFiles) throw Error('No challenge files provided');
const hasJsx = challengeFiles.some(
challengeFile => challengeFile.ext === 'jsx' || challengeFile.ext === 'tsx'
);
const isMultifile = challengeFiles.length > 1;
const requiresReact16 = required.some(({ src }) =>
src?.includes('https://cdnjs.cloudflare.com/ajax/libs/react/16.')
);
// I'm reasonably sure this is fine, but we need to migrate transformers to
// TypeScript to be sure.
const transformers: ApplyFunctionProps[] = (isMultifile && hasJsx
? getMultifileJSXTransformers(options)
: getTransformers(options)) as unknown as ApplyFunctionProps[];
const pipeLine = composeFunctions(...transformers);
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const contents = (await embedFilesInHtml(finalFiles)) as string;
// if there is an error, we just build the test runner so that it can be
// used to run tests against the code without actually running the code.
const toBuild = error
? {}
: {
required,
template,
contents
};
return {
challengeType,
build: concatHtml(toBuild),
sources: buildSourceMap(finalFiles),
loadEnzyme: requiresReact16,
error
};
}
export async function buildJSChallenge(
{
challengeFiles,
challengeType
}: { challengeFiles?: ChallengeFile[]; challengeType: number },
options: BuildOptions
): Promise<BuildResult> {
if (!challengeFiles) throw Error('No challenge files provided');
const pipeLine = composeFunctions(
...(getTransformers(options) as unknown as ApplyFunctionProps[])
);
const finalFiles = await Promise.all(challengeFiles?.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const toBuild = error ? [] : finalFiles;
return {
challengeType,
build: toBuild
.reduce(
(body, challengeFile) => [
...body,
challengeFile.head,
challengeFile.contents,
challengeFile.tail
],
[] as string[]
)
.join('\n'),
sources: buildSourceMap(finalFiles),
error
};
}
function buildBackendChallenge({ url, challengeType }: BuildChallengeData) {
return {
challengeType,
build: '',
sources: { contents: url }
};
}
export async function buildPythonChallenge({
challengeFiles,
challengeType
}: BuildChallengeData): Promise<BuildResult> {
if (!challengeFiles) throw new Error('No challenge files provided');
const pipeLine = composeFunctions(
...(getPythonTransformers() as unknown as ApplyFunctionProps[])
);
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const sources = buildSourceMap(finalFiles);
return {
challengeType,
sources,
build: sources?.contents,
error
};
}
@@ -17,11 +17,11 @@ import {
} from '@freecodecamp/shared/utils/polyvinyl'; } from '@freecodecamp/shared/utils/polyvinyl';
import { version } from '@freecodecamp/browser-scripts/package.json'; import { version } from '@freecodecamp/browser-scripts/package.json';
import { WorkerExecutor } from '../utils/worker-executor'; import { WorkerExecutor } from './worker-executor';
import { import {
compileTypeScriptCode, compileTypeScriptCode,
checkTSServiceIsReady checkTSServiceIsReady
} from '../utils/typescript-worker-handler'; } from './typescript-worker-handler';
const protectTimeout = 100; const protectTimeout = 100;
const testProtectTimeout = 1500; const testProtectTimeout = 1500;
@@ -1,8 +1,8 @@
import { version } from '@freecodecamp/browser-scripts/package.json'; import browserScripts from '@freecodecamp/browser-scripts/package.json';
import { awaitResponse } from './awaitable-messenger'; import { awaitResponse } from './awaitable-messenger.js';
const typeScriptWorkerSrc = `/js/workers/${version}/typescript-worker.js`; const typeScriptWorkerSrc = `/js/workers/${browserScripts.version}/typescript-worker.js`;
let worker: Worker | null = null; let worker: Worker | null = null;
+12
View File
@@ -0,0 +1,12 @@
{
"include": ["src"],
"extends": "../../tsconfig-base.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"module": "es2020",
"moduleResolution": "bundler",
"outDir": "dist",
"noEmit": false
}
}
+383 -257
View File
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,8 @@
"dist" "dist"
], ],
"exports": { "exports": {
".": "./index.d.ts",
"./ts-compiler": "./modules/typescript-compiler.ts",
"./test-runner": "./test-runner.ts", "./test-runner": "./test-runner.ts",
"./package.json": "./package.json" "./package.json": "./package.json"
}, },