├── .eslintrc.json ├── src ├── lessons │ ├── lessons │ │ └── courses │ │ │ ├── options │ │ │ ├── 0.md │ │ │ └── root.ts │ │ │ ├── assign │ │ │ ├── 1.md │ │ │ ├── 3.md │ │ │ ├── 0.md │ │ │ ├── 5.md │ │ │ ├── 2.md │ │ │ ├── 4.md │ │ │ └── root.ts │ │ │ └── meta.ts │ ├── toMachine.ts │ ├── LessonType.ts │ └── lessonRunner.machine.ts ├── font.css ├── env.d.ts ├── editorTheme.ts ├── pages │ ├── _app.tsx │ ├── api │ │ └── compile.ts │ ├── index.tsx │ └── lessons │ │ └── [lessonId].tsx ├── makeChakraOverride.tsx ├── theme.ts ├── prettier.ts ├── parseMachine.ts ├── EditorWithXStateImports.tsx ├── monacoPatch.ts └── typeAcquisition.ts ├── tailwind.config.js ├── public ├── favicon.ico ├── ttcommons.woff2 ├── vercel.svg └── ts-worker.js ├── next.config.js ├── next-env.d.ts ├── .vscode └── launch.json ├── .gitignore ├── tsconfig.json ├── README.md └── package.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /src/lessons/lessons/courses/options/0.md: -------------------------------------------------------------------------------- 1 | # Options 2 | 3 | You can 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], 3 | }; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statelyai/stately-tutorials/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/ttcommons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statelyai/stately-tutorials/HEAD/public/ttcommons.woff2 -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | } 5 | -------------------------------------------------------------------------------- /src/font.css: -------------------------------------------------------------------------------- 1 | /* Include the Stately font */ 2 | @font-face { 3 | font-family: "TTCommons"; 4 | src: url("/ttcommons.woff2") format("woff2"); 5 | } 6 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module "realms-shim" { 2 | class Realm { 3 | evaluate(code: string, endowments: any): T; 4 | } 5 | 6 | const exportObj: { 7 | makeRootRealm(): Realm; 8 | }; 9 | 10 | export default exportObj; 11 | } 12 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "configurations": [ 4 | { 5 | "command": "yarn dev", 6 | "name": "Start dev server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | }, 10 | ] 11 | } -------------------------------------------------------------------------------- /src/lessons/lessons/courses/assign/1.md: -------------------------------------------------------------------------------- 1 | # Using the 'assign' function 2 | 3 | Nice work on that bugfix. The count is now being incremented by 1. 4 | 5 | Let's add some new functionality to the machine. Now, we also want to `DECREMENT` the state. That means you'll need to write a new assign function to decrement the state by 1, when the machine receives the `DECREMENT` event. 6 | -------------------------------------------------------------------------------- /src/editorTheme.ts: -------------------------------------------------------------------------------- 1 | import type { editor } from "monaco-editor"; 2 | 3 | export type EditorThemeDefinition = editor.IStandaloneThemeData & { 4 | name: string; 5 | }; 6 | 7 | export const editorTheme: EditorThemeDefinition = { 8 | base: "vs-dark", 9 | inherit: true, 10 | name: "XState Viz", 11 | rules: [], 12 | colors: { "editor.background": "#151618" }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../font.css"; 2 | import type { AppProps } from "next/app"; 3 | import { ChakraProvider } from "@chakra-ui/react"; 4 | import { theme } from "../theme"; 5 | import "../monacoPatch"; 6 | 7 | function MyApp({ Component, pageProps }: AppProps) { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | export default MyApp; 15 | -------------------------------------------------------------------------------- /src/makeChakraOverride.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentProps, PropsWithChildren } from 'react'; 2 | 3 | export function makeChakraOverride( 4 | Component: Comp, 5 | name: string, 6 | defaultProps: ComponentProps, 7 | ) { 8 | const WrappedComponent = (props: ComponentProps) => { 9 | return ; 10 | }; 11 | 12 | WrappedComponent.displayName = name; 13 | 14 | return WrappedComponent; 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /src/lessons/lessons/courses/assign/3.md: -------------------------------------------------------------------------------- 1 | # Extracting an assign function - part 1 2 | 3 | Great stuff - our new requirements have been met. 4 | 5 | But there's an opportunity to tidy things up here. We've got some duplicated code inside our assign functions - both of them increment the `pressCount` by one. 6 | 7 | We can extract this to its own assign action, and reuse it wherever we need to in the machine. 8 | 9 | Let's start by removing any `pressCount` stuff from the assign functions we've already declared. For now, let's declare the `pressCount` as `0` and never increment it. 10 | -------------------------------------------------------------------------------- /src/lessons/lessons/courses/assign/0.md: -------------------------------------------------------------------------------- 1 | # Storing values in state 2 | 3 | When we think of 'state management', the first thing we think of is storing stuff. It could be: 4 | 5 | - The name of the logged-in user 6 | - Data you receive from a fetch request 7 | - The value of a form input 8 | 9 | Every machine in XState comes with a value store called `context` which you can assign items to. 10 | 11 | This example represents a 'counter'. The machine can receive a `INCREMENT` event, which increments the `count` value in context by 1. 12 | 13 | But there's a bug - the `INCREMENT` event isn't making the count go up. Can you fix the bug? 14 | -------------------------------------------------------------------------------- /src/lessons/lessons/courses/assign/5.md: -------------------------------------------------------------------------------- 1 | # Action execution order 2 | 3 | Great job! 4 | 5 | Assign actions are executed in the order they're passed in, so you can do some clever stuff by combining actions together. 6 | 7 | As an experiment, let's try running the `incrementPressCount` twice on each `INCREMENT` to see if they combine together. 8 | 9 | It should look like this: 10 | 11 | ```ts 12 | on: { 13 | INCREMENT: { 14 | actions: [ 15 | 'incrementPressCount', 16 | 'incrementPressCount', 17 | assign({ 18 | count: (context) => context.count + 1, 19 | }), 20 | ]; 21 | } 22 | } 23 | ``` 24 | -------------------------------------------------------------------------------- /src/pages/api/compile.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from 'next'; 2 | import * as esbuild from 'esbuild'; 3 | 4 | export interface CompileHandlerResponse { 5 | didItWork: boolean; 6 | result?: string; 7 | } 8 | 9 | const handler: NextApiHandler = async (req, res) => { 10 | const fileToCompile = JSON.parse(req.body).file; 11 | 12 | try { 13 | const transformResult = await esbuild.transform(fileToCompile, { 14 | sourcefile: 'test.ts', 15 | loader: 'ts', 16 | }); 17 | res.json({ 18 | didItWork: true, 19 | result: transformResult.code, 20 | }); 21 | } catch (e) { 22 | console.log(e); 23 | res.json({ 24 | didItWork: false, 25 | }); 26 | } 27 | }; 28 | 29 | export default handler; 30 | -------------------------------------------------------------------------------- /src/lessons/lessons/courses/meta.ts: -------------------------------------------------------------------------------- 1 | import { CourseType } from "../../LessonType"; 2 | import assignCourse from "./assign/root"; 3 | import optionsCourse from "./options/root"; 4 | 5 | interface CourseMeta { 6 | title: string; 7 | description: string; 8 | course: CourseType; 9 | /** 10 | * The folder name where the course is stored 11 | */ 12 | id: string; 13 | } 14 | 15 | export const courseMeta: CourseMeta[] = [ 16 | { 17 | course: assignCourse, 18 | title: "Assign", 19 | description: 20 | "This course teaches you the basics of assign, the way XState handles storing values.", 21 | id: "assign", 22 | }, 23 | { 24 | course: optionsCourse, 25 | title: "Options", 26 | description: "Learn how to handle options, like actions and services", 27 | id: "options", 28 | }, 29 | ]; 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | Run `yarn dev` to start the project 4 | 5 | # Important Files 6 | 7 | ## `pages/lessons/[lessonId].tsx` 8 | 9 | Where the lessons get implemented. Very messy. Visit `/lessons/assign` in localhost:3000 to try it 10 | 11 | ## `lessonRunner.machine.ts` 12 | 13 | Where all the logic for the lesson runner lives 14 | 15 | ## `lessons/lessons/courses` 16 | 17 | Where all of the course information lives. Add courses to this folder. 18 | 19 | # TODO 20 | 21 | - Strip out tailwind and replace with Chakra UI 22 | - Make the lesson runner look pretty 23 | - Strip out the esbuild-on-API setup and use Monaco editor to compile 24 | - Set up Monaco editor to use imports, in the same way as we have it in Stately viz 25 | - Build a page to display all the courses 26 | - Go on to the next course when you're done with the first one. 27 | -------------------------------------------------------------------------------- /src/lessons/lessons/courses/options/root.ts: -------------------------------------------------------------------------------- 1 | import { CourseType } from "../../../LessonType"; 2 | 3 | const optionsCourse: CourseType = { 4 | initialMachineText: `createMachine({ 5 | entry: ['sayHello'], 6 | }, { 7 | actions: {} 8 | })`, 9 | lessons: [ 10 | { 11 | acceptanceCriteria: { 12 | cases: [ 13 | { 14 | steps: [ 15 | { 16 | type: "OPTIONS_ASSERTION", 17 | assertion: (options) => Boolean(options.actions.sayHello), 18 | description: `sayHello should be defined in options.actions`, 19 | }, 20 | { 21 | type: "CONSOLE_ASSERTION", 22 | expectedText: "Hello", 23 | }, 24 | ], 25 | }, 26 | ], 27 | }, 28 | }, 29 | ], 30 | }; 31 | 32 | export default optionsCourse; 33 | -------------------------------------------------------------------------------- /src/lessons/lessons/courses/assign/2.md: -------------------------------------------------------------------------------- 1 | # Assigning multiple fields at once 2 | 3 | Good stuff, the machine is looking good. 4 | 5 | A new requirement has come in - we want to keep a running tally of how many times the counter has been incremented or decremented. 6 | 7 | That means every time we `DECREMENT` or `INCREMENT`, we also want to increment a `pressCount` item in context. 8 | 9 | That means we need to: 10 | 11 | 1. Update the initial context value to contain a `pressCount: 0`. 12 | 2. On both the `INCREMENT` and `DECREMENT` events, assign `pressCount` to `context.pressCount + 1` 13 | 14 | There are two ways of doing this in the assign function. Either: 15 | 16 | ```ts 17 | assign({ 18 | count: (context) => context.count - 1, 19 | pressCount: (context) => context.count + 1, 20 | }); 21 | ``` 22 | 23 | Or: 24 | 25 | ```ts 26 | assign((context) => { 27 | return { 28 | count: context.count - 1, 29 | pressCount: context.count + 1, 30 | }; 31 | }); 32 | ``` 33 | -------------------------------------------------------------------------------- /src/lessons/lessons/courses/assign/4.md: -------------------------------------------------------------------------------- 1 | # Extracting an assign function - part 2 2 | 3 | OK, now let's add a named action called `incrementPressCount` into the `actions` array in `INCREMENT` and `DECREMENT`. 4 | 5 | The syntax looks like this: 6 | 7 | ```ts 8 | on: { 9 | INCREMENT: { 10 | actions: [ 11 | 'incrementPressCount', 12 | assign({ 13 | count: (context) => context.count + 1, 14 | }), 15 | ]; 16 | }, 17 | DECREMENT: { 18 | actions: [ 19 | 'incrementPressCount', 20 | assign({ 21 | count: (context) => context.count - 1, 22 | }), 23 | ]; 24 | } 25 | } 26 | ``` 27 | 28 | Now, in the second argument of `createMachine`, let's add the implementation of the assign action: 29 | 30 | ```ts 31 | createMachine( 32 | { 33 | // configuration goes in here 34 | }, 35 | { 36 | actions: { 37 | incrementPressCount: assign({ 38 | pressCount: (context) => context.pressCount + 1, 39 | }), 40 | }, 41 | }, 42 | ); 43 | ``` 44 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stately-tutorials", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@chakra-ui/react": "^1.6.10", 13 | "@emotion/react": "^11", 14 | "@emotion/styled": "^11", 15 | "@monaco-editor/react": "^4.2.1", 16 | "@types/lz-string": "^1.3.34", 17 | "@types/prettier": "^2.4.1", 18 | "@xstate/inspect": "^0.5.0", 19 | "@xstate/react": "^1.6.1", 20 | "classnames": "^2.3.1", 21 | "esbuild": "^0.12.25", 22 | "framer-motion": "^4", 23 | "lz-string": "^1.4.4", 24 | "memory-web-storage": "^1.0.0", 25 | "monaco-editor": "^0.25.2", 26 | "next": "11.1.2", 27 | "party-js": "^2.0.1", 28 | "react": "17.0.2", 29 | "react-dom": "17.0.2", 30 | "react-markdown": "^7.0.1", 31 | "realms-shim": "^1.2.2", 32 | "xstate": "^4.23.4" 33 | }, 34 | "devDependencies": { 35 | "@types/react": "17.0.20", 36 | "eslint": "7.32.0", 37 | "eslint-config-next": "11.1.2", 38 | "typescript": "4.4.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lessons/toMachine.ts: -------------------------------------------------------------------------------- 1 | import { 2 | actions, 3 | interpret, 4 | Machine, 5 | sendParent, 6 | spawn, 7 | StateNode, 8 | } from 'xstate'; 9 | import { raise, assign, send } from 'xstate/lib/actions'; 10 | 11 | export function toMachine(machineText: string): StateNode { 12 | if (typeof machineText !== 'string') { 13 | return machineText; 14 | } 15 | 16 | let makeMachine: Function; 17 | try { 18 | makeMachine = new Function( 19 | 'Machine', 20 | 'createMachine', 21 | 'interpret', 22 | 'assign', 23 | 'send', 24 | 'sendParent', 25 | 'spawn', 26 | 'raise', 27 | 'actions', 28 | machineText, 29 | ); 30 | } catch (e) { 31 | throw e; 32 | } 33 | 34 | const machines: Array> = []; 35 | 36 | const machineProxy = (config: any, options: any) => { 37 | const machine = Machine(config, options); 38 | machines.push(machine); 39 | return machine; 40 | }; 41 | 42 | try { 43 | makeMachine( 44 | machineProxy, 45 | machineProxy, 46 | interpret, 47 | assign, 48 | send, 49 | sendParent, 50 | spawn, 51 | raise, 52 | actions, 53 | ); 54 | } catch (e) { 55 | throw e; 56 | } 57 | 58 | return machines[machines.length - 1]! as StateNode; 59 | } 60 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { courseMeta } from "../lessons/lessons/courses/meta"; 3 | 4 | const Home = () => { 5 | return ( 6 |
7 | {courseMeta.map((course) => { 8 | return ( 9 |
13 |
14 |

15 | {course.title} 16 |

17 |

18 | {course.course.lessons.length} Lessons 19 |

20 |
21 |

22 | {course.description} 23 |

24 |
25 | 26 | 27 | Learn 28 | 29 | 30 |
31 |
32 | ); 33 | })} 34 |
35 | ); 36 | }; 37 | 38 | export default Home; 39 | -------------------------------------------------------------------------------- /src/lessons/LessonType.ts: -------------------------------------------------------------------------------- 1 | import { EventObject, MachineOptions, State } from "xstate"; 2 | 3 | export interface CourseType { 4 | initialMachineText: string; 5 | lessons: LessonType[]; 6 | } 7 | export interface LessonType { 8 | acceptanceCriteria: AcceptanceCriteria; 9 | mergeWithPreviousCriteria?: boolean; 10 | title?: string; 11 | } 12 | 13 | export interface AcceptanceCriteria { 14 | cases: AcceptanceCriteriaCase[]; 15 | } 16 | 17 | export interface AcceptanceCriteriaCase< 18 | TContext = any, 19 | TEvent extends EventObject = any, 20 | > { 21 | // initialContext?: TContext; 22 | steps: AcceptanceCriteriaStep[]; 23 | } 24 | 25 | export type AcceptanceCriteriaStep = 26 | | { 27 | type: "ASSERTION"; 28 | check: (state: State) => string | number | boolean; 29 | expectedValue: string | number | boolean; 30 | } 31 | | { 32 | type: "OPTIONS_ASSERTION"; 33 | description: string; 34 | assertion: (options: MachineOptions) => boolean; 35 | } 36 | | { 37 | type: "SEND_EVENT"; 38 | event: TEvent; 39 | } 40 | | { 41 | type: "WAIT"; 42 | durationInMs: number; 43 | } 44 | | { 45 | type: "CONSOLE_ASSERTION"; 46 | expectedText: string; 47 | }; 48 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | // 1. import `extendTheme` function 2 | import { extendTheme, ThemeConfig } from "@chakra-ui/react"; 3 | // 2. Add your color mode config 4 | const config: ThemeConfig = { 5 | initialColorMode: "dark", 6 | useSystemColorMode: false, 7 | }; 8 | 9 | // 3. extend the theme 10 | export const theme = extendTheme({ 11 | config, 12 | fonts: { 13 | heading: "TTCommons", 14 | body: "TTCommons", 15 | }, 16 | styles: { 17 | global: { 18 | "html, body": { 19 | backgroundColor: "gray.900", 20 | }, 21 | }, 22 | }, 23 | components: { 24 | // https://github.com/chakra-ui/chakra-ui/blob/main/packages/theme/src/components/switch.ts 25 | Switch: { 26 | baseStyle: { 27 | track: { 28 | _checked: { 29 | bg: "primary.500", 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | colors: { 36 | gray: { 37 | "50": "#EFEFF1", 38 | "100": "#E1E2E5", 39 | "200": "#C6C7CD", 40 | "300": "#ABACB5", 41 | "400": "#8F919D", 42 | "500": "#757785", 43 | "600": "#5D5F6A", 44 | "700": "#45464F", 45 | "800": "#2D2E34", 46 | "900": "#151618", 47 | }, 48 | primary: { 49 | 500: "#056DFF", 50 | 600: "#1956DD", 51 | }, 52 | danger: { 53 | 500: "#F44747", 54 | 600: "#EF0F0F", 55 | }, 56 | warning: { 57 | 500: "#FF9044", 58 | }, 59 | redAlpha: { 60 | 100: "rgba(244, 71, 71, 0.2)", 61 | }, 62 | }, 63 | }); 64 | -------------------------------------------------------------------------------- /src/prettier.ts: -------------------------------------------------------------------------------- 1 | const loadScript = (src: string) => { 2 | return new Promise((resolve, reject) => { 3 | const scriptElement = document.createElement("script"); 4 | scriptElement.src = src; 5 | 6 | const timeout = setTimeout(() => { 7 | reject("Timeout"); 8 | }, 15000); 9 | 10 | scriptElement.onload = () => { 11 | resolve(); 12 | clearTimeout(timeout); 13 | }; 14 | document.body.appendChild(scriptElement); 15 | }); 16 | }; 17 | 18 | declare global { 19 | export const prettier: typeof import("prettier"); 20 | export const prettierPlugins: (string | import("prettier").Plugin)[]; 21 | 22 | interface Window { 23 | define: any; 24 | } 25 | } 26 | 27 | const Prettier = () => { 28 | let hasLoaded = false; 29 | 30 | const loadPrettier = async () => { 31 | if (hasLoaded) return; 32 | 33 | /** 34 | * Helps Monaco editor get over a too-sensitive error 35 | * 36 | * https://stackoverflow.com/questions/55057425/can-only-have-one-anonymous-define-call-per-script-file 37 | */ 38 | const define = window.define; 39 | window.define = () => {}; 40 | 41 | await Promise.all([ 42 | loadScript("https://unpkg.com/prettier@2.3.2/standalone.js"), 43 | loadScript("https://unpkg.com/prettier@2.3.2/parser-typescript.js"), 44 | ]); 45 | 46 | window.define = define; 47 | 48 | hasLoaded = true; 49 | }; 50 | 51 | const format = async (code: string) => { 52 | try { 53 | await loadPrettier(); 54 | return prettier.format(code, { 55 | parser: "typescript", 56 | plugins: prettierPlugins, 57 | }); 58 | } catch (e) { 59 | console.error(e); 60 | } 61 | /** 62 | * If loading prettier fails, just 63 | * load the code 64 | */ 65 | return code; 66 | }; 67 | 68 | return { 69 | format, 70 | }; 71 | }; 72 | 73 | export const prettierLoader = Prettier(); 74 | -------------------------------------------------------------------------------- /src/parseMachine.ts: -------------------------------------------------------------------------------- 1 | import * as XState from "xstate"; 2 | import * as XStateModel from "xstate/lib/model"; 3 | import * as XStateActions from "xstate/lib/actions"; 4 | import { StateNode } from "xstate"; 5 | import realmsShim from "realms-shim"; 6 | 7 | const realm = realmsShim.makeRootRealm(); 8 | 9 | const wrapCallbackToPreventThis = 10 | (callback: (...args: any[]) => void) => 11 | (...args: any[]) => { 12 | return callback(...args); 13 | }; 14 | 15 | const windowShim = { 16 | setInterval: (callback: (...args: any[]) => void, ...args: any[]) => { 17 | return setInterval(wrapCallbackToPreventThis(callback), ...args); 18 | }, 19 | setTimeout: (callback: (...args: any[]) => void, ...args: any[]) => { 20 | return setTimeout(wrapCallbackToPreventThis(callback), ...args); 21 | }, 22 | clearTimeout: (...args: any[]) => { 23 | return clearTimeout(...args); 24 | }, 25 | clearInterval: (...args: any[]) => { 26 | return clearInterval(...args); 27 | }, 28 | }; 29 | 30 | export function parseMachines(sourceJs: string): Array { 31 | const machines: Array = []; 32 | 33 | const createMachineCapturer = 34 | (machineFactory: any) => 35 | (...args: any[]) => { 36 | const machine = machineFactory(...args); 37 | machines.push(machine); 38 | return machine; 39 | }; 40 | 41 | realm.evaluate(sourceJs, { 42 | // we just allow for export statements to be used in the source code 43 | // we don't have any use for the exported values so we just mock the `exports` object 44 | exports: {}, 45 | require: (sourcePath: string) => { 46 | switch (sourcePath) { 47 | case "xstate": 48 | return { 49 | ...XState, 50 | createMachine: createMachineCapturer(XState.createMachine), 51 | Machine: createMachineCapturer(XState.Machine), 52 | }; 53 | case "xstate/lib/actions": 54 | return XStateActions; 55 | case "xstate/lib/model": 56 | const { createModel } = XStateModel; 57 | return { 58 | ...XStateModel, 59 | createModel(initialContext: any, creators: any) { 60 | const model = createModel(initialContext, creators); 61 | return { 62 | ...model, 63 | createMachine: createMachineCapturer(model.createMachine), 64 | }; 65 | }, 66 | }; 67 | default: 68 | throw new Error(`External module ("${sourcePath}") can't be used.`); 69 | } 70 | }, 71 | // users might want to access `console` in the sandboxed env 72 | console: { 73 | error: console.error, 74 | info: console.info, 75 | log: console.log, 76 | warn: console.warn, 77 | }, 78 | ...windowShim, 79 | }); 80 | 81 | return machines; 82 | } 83 | -------------------------------------------------------------------------------- /public/ts-worker.js: -------------------------------------------------------------------------------- 1 | // codes taken from the TS codebase, this ts-worker is not bundled right now so we can't easily use any module system here to import these 2 | // https://github.com/microsoft/TypeScript/blob/1aac3555f7ebbfc10515d2ba28f041e03e75d885/src/compiler/diagnosticMessages.json#L1457-L1460 3 | const CANNOT_FIND_NAME_CODE = 2304; 4 | // https://github.com/microsoft/TypeScript/blob/1aac3555f7ebbfc10515d2ba28f041e03e75d885/src/compiler/diagnosticMessages.json#L2409-L2412 5 | const CANNOT_FIND_NAME_DID_YOU_MEAN_CODE = 2552; 6 | // https://github.com/microsoft/TypeScript/blob/1aac3555f7ebbfc10515d2ba28f041e03e75d885/src/compiler/diagnosticMessages.json#L7110-L7113 7 | const NO_VALUE_EXISTS_IN_SCOPE_FOR_THE_SHORTHAND_PROPERTY_CODE = 18004; 8 | 9 | const uniq = (arr) => Array.from(new Set(arr)); 10 | 11 | // eslint-disable-next-line no-restricted-globals 12 | self.customTSWorkerFactory = (TypeScriptWorker) => { 13 | return class extends TypeScriptWorker { 14 | getCompletionsAtPosition(fileName, position, options) { 15 | return this._languageService.getCompletionsAtPosition( 16 | fileName, 17 | position, 18 | { 19 | ...options, 20 | // enable auto-imports to be included in the completion list 21 | // https://github.com/microsoft/TypeScript/blob/1e2c77e728a601b92f18a7823412466fea1be913/lib/protocol.d.ts#L2619-L2623 22 | includeCompletionsForModuleExports: true, 23 | }, 24 | ); 25 | } 26 | getCompletionEntryDetails( 27 | fileName, 28 | position, 29 | entryName, 30 | formatOptions, 31 | source, 32 | preferences, 33 | data, 34 | ) { 35 | return this._languageService.getCompletionEntryDetails( 36 | fileName, 37 | position, 38 | entryName, 39 | formatOptions, 40 | source, 41 | preferences, 42 | data, 43 | ); 44 | } 45 | // lists what has been used from the list of things initially exposed here: 46 | // https://github.com/statecharts/xstate-viz/blob/87e87c8610ed84d5d61975c8451be7aba21ca18a/src/StateChart.tsx#L143-L151 47 | queryXStateGistIdentifiers(fileName) { 48 | const exposedToGists = new Set([ 49 | 'Machine', 50 | 'interpret', 51 | 'assign', 52 | 'send', 53 | 'sendParent', 54 | 'spawn', 55 | 'raise', 56 | 'actions', 57 | 'XState', 58 | ]); 59 | const program = this._languageService.getProgram(); 60 | const sourceFile = program.getSourceFile(fileName); 61 | const diagnostics = program.getSemanticDiagnostics(sourceFile); 62 | 63 | return uniq( 64 | diagnostics 65 | .filter((diagnostic) => { 66 | switch (diagnostic.code) { 67 | case CANNOT_FIND_NAME_CODE: 68 | case CANNOT_FIND_NAME_DID_YOU_MEAN_CODE: 69 | case NO_VALUE_EXISTS_IN_SCOPE_FOR_THE_SHORTHAND_PROPERTY_CODE: 70 | return true; 71 | default: 72 | return false; 73 | } 74 | }) 75 | // the missing name is always quoted in the message text and it's the first quoted thing for all the filtered codes 76 | .map((diagnostic) => diagnostic.messageText.match(/["'](.+?)["']/)[1]) 77 | .filter((name) => exposedToGists.has(name)), 78 | ); 79 | } 80 | }; 81 | }; 82 | -------------------------------------------------------------------------------- /src/pages/lessons/[lessonId].tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/button"; 2 | import { 3 | Box, 4 | Code, 5 | Divider, 6 | Heading, 7 | List, 8 | ListItem, 9 | Text, 10 | UnorderedList, 11 | } from "@chakra-ui/layout"; 12 | import { useMachine } from "@xstate/react"; 13 | import classNames from "classnames"; 14 | import type * as monaco from "monaco-editor"; 15 | import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next"; 16 | import { useRouter } from "next/dist/client/router"; 17 | import dynamic from "next/dynamic"; 18 | import { useRef } from "react"; 19 | import ReactMarkdown from "react-markdown"; 20 | import { lessonMachine } from "../../lessons/lessonRunner.machine"; 21 | import { courseMeta } from "../../lessons/lessons/courses/meta"; 22 | import { CourseType } from "../../lessons/LessonType"; 23 | 24 | const EditorWithXStateImports = dynamic( 25 | () => import("../../EditorWithXStateImports"), 26 | ); 27 | 28 | export const getStaticPaths: GetStaticPaths = async (context) => { 29 | const fs = await import("fs"); 30 | const path = await import("path"); 31 | 32 | const lessonFolderPath = path.resolve( 33 | process.cwd(), 34 | "src/lessons/lessons/courses", 35 | ); 36 | 37 | const dirs = fs.readdirSync(lessonFolderPath); 38 | 39 | return { 40 | paths: dirs.map((dir) => { 41 | return { 42 | params: { 43 | lessonId: dir, 44 | }, 45 | }; 46 | }), 47 | fallback: false, 48 | }; 49 | }; 50 | 51 | export const getStaticProps: GetStaticProps = async (context) => { 52 | const lessonDir = context.params?.lessonId as string; 53 | 54 | const fs = await import("fs"); 55 | const path = await import("path"); 56 | 57 | const lessonFolderPath = path.resolve( 58 | process.cwd(), 59 | "src/lessons/lessons/courses", 60 | lessonDir, 61 | ); 62 | 63 | const markdownFiles: string[] = fs 64 | .readdirSync(lessonFolderPath) 65 | .filter((filePath) => filePath.endsWith(".md")) 66 | .map((file) => 67 | fs.readFileSync(path.resolve(lessonFolderPath, file)).toString(), 68 | ); 69 | 70 | return { 71 | props: { 72 | markdownFiles, 73 | id: lessonDir, 74 | }, 75 | }; 76 | }; 77 | 78 | const LessonDemo = (props: InferGetStaticPropsType) => { 79 | const course = courseMeta.find((course) => course.id === props.id)?.course; 80 | 81 | if (!course) return null; 82 | 83 | return ; 84 | }; 85 | 86 | export default LessonDemo; 87 | 88 | const LessonInner = (props: { 89 | course: CourseType; 90 | markdownFiles: string[]; 91 | }) => { 92 | const editorRef = useRef(null); 93 | const router = useRouter(); 94 | 95 | const [state, send] = useMachine(lessonMachine, { 96 | context: { 97 | course: props.course, 98 | fileText: props.course.initialMachineText, 99 | }, 100 | actions: { 101 | autoFormatEditor: () => { 102 | editorRef.current?.getAction("editor.action.formatDocument").run(); 103 | }, 104 | goToIndexPage: () => { 105 | router.push("/"); 106 | }, 107 | }, 108 | }); 109 | 110 | return ( 111 | 112 | 121 | ( 124 | 125 | 126 | 127 | 128 | ), 129 | p: (props) => ( 130 | 131 | ), 132 | code: (props) => , 133 | ul: (props) => ( 134 | 135 | ), 136 | li: (props) => , 137 | }} 138 | >{`${props.markdownFiles[state.context.lessonIndex]}`} 139 | 140 | 141 | {}} 144 | onCompile={(nodes) => { 145 | send({ 146 | type: "MACHINES_COMPILED", 147 | nodes, 148 | }); 149 | }} 150 | language="typescript" 151 | value={state.context.fileText} 152 | /> 153 | 154 | {state.context.terminalLines.map((line, index) => { 155 | return ( 156 | 171 | {line.icon === "check" && ( 172 | 173 | ✔️ 174 | 175 | )} 176 | {line.icon === "arrow-right" && ( 177 | 178 | ➡ 179 | 180 | )} 181 | {line.icon === "clock" && ( 182 | 183 | ⏱️ 184 | 185 | )} 186 | {line.icon === "cross" && ( 187 | 188 | ✖️ 189 | 190 | )} 191 | {`${line.text}`} 192 | 193 | ); 194 | })} 195 | {state.hasTag("testsPassed") && ( 196 | 207 | )} 208 | {/* {state.hasTag("testsNotPassed") && ( 209 | 219 | )} */} 220 | 221 | 222 | 223 | ); 224 | }; 225 | -------------------------------------------------------------------------------- /src/EditorWithXStateImports.tsx: -------------------------------------------------------------------------------- 1 | import Editor, { EditorProps, Monaco, OnMount } from "@monaco-editor/react"; 2 | import { useInterpret } from "@xstate/react"; 3 | import type { editor } from "monaco-editor"; 4 | import { useRef } from "react"; 5 | import { assign, StateNode } from "xstate"; 6 | import { createModel } from "xstate/lib/model"; 7 | import { editorTheme } from "./editorTheme"; 8 | import { parseMachines } from "./parseMachine"; 9 | import { prettierLoader } from "./prettier"; 10 | import { detectNewImportsToAcquireTypeFor } from "./typeAcquisition"; 11 | 12 | /** 13 | * CtrlCMD + Enter => format => update chart 14 | * Click on update chart button => update chart 15 | * Click on save/update => save/update to registry 16 | * CtrlCMD + S => format => save/update to registry 17 | */ 18 | 19 | interface EditorWithXStateImportsProps { 20 | onChange?: (text: string) => void; 21 | onMount?: OnMount; 22 | onSave?: () => void; 23 | onFormat?: () => void; 24 | onCompile?: (stateNodes: StateNode[]) => void; 25 | // value: string; 26 | } 27 | 28 | // based on the logic here: https://github.com/microsoft/TypeScript-Website/blob/103f80e7490ad75c34917b11e3ebe7ab9e8fc418/packages/sandbox/src/index.ts 29 | const withTypeAcquisition = ( 30 | editor: editor.IStandaloneCodeEditor, 31 | monaco: Monaco, 32 | ): editor.IStandaloneCodeEditor => { 33 | const addLibraryToRuntime = (code: string, path: string) => { 34 | monaco.languages.typescript.typescriptDefaults.addExtraLib(code, path); 35 | const uri = monaco.Uri.file(path); 36 | if (monaco.editor.getModel(uri) === null) { 37 | monaco.editor.createModel(code, "javascript", uri); 38 | } 39 | }; 40 | 41 | const textUpdated = (code = editor.getModel()!.getValue()) => { 42 | detectNewImportsToAcquireTypeFor( 43 | code, 44 | addLibraryToRuntime, 45 | window.fetch.bind(window), 46 | { 47 | logger: { 48 | log: process.env.NODE_ENV !== "production" ? console.log : () => {}, 49 | error: console.error, 50 | warn: console.warn, 51 | }, 52 | }, 53 | ); 54 | }; 55 | 56 | // to enable type acquisition for any module we can introduce a deboouncer like here: 57 | // https://github.com/microsoft/TypeScript-Website/blob/97a97d460d64c3c363878f11db198d0027885d8d/packages/sandbox/src/index.ts#L204-L213 58 | 59 | textUpdated( 60 | // those are modules that we actually allow when we load the code at runtime 61 | // this "prefetches" the types for those modules so the autoimport feature can kick in asap for them 62 | ["xstate", "xstate/lib/model", "xstate/lib/actions"] 63 | .map((specifier) => `import '${specifier}'`) 64 | .join("\n"), 65 | ); 66 | 67 | return editor; 68 | }; 69 | 70 | const editorCompileModel = createModel( 71 | { 72 | monacoRef: null as Monaco | null, 73 | standaloneEditorRef: null as editor.IStandaloneCodeEditor | null, 74 | mainFile: "main.ts", 75 | }, 76 | { 77 | events: { 78 | EDITOR_TEXT_CHANGED: () => ({}), 79 | EDITOR_READY: ( 80 | monacoRef: Monaco, 81 | standaloneEditorRef: editor.IStandaloneCodeEditor, 82 | ) => ({ monacoRef, standaloneEditorRef }), 83 | "done.invoke.compile": (data: Array) => ({ data }), 84 | }, 85 | }, 86 | ); 87 | 88 | const machine = editorCompileModel.createMachine( 89 | { 90 | initial: "waitingForEditorToBeReady", 91 | states: { 92 | waitingForEditorToBeReady: { 93 | on: { 94 | EDITOR_READY: { 95 | target: "editorReady", 96 | actions: [ 97 | assign({ 98 | monacoRef: (_, e) => e.monacoRef, 99 | standaloneEditorRef: (_, e) => e.standaloneEditorRef, 100 | }), 101 | ], 102 | }, 103 | }, 104 | }, 105 | editorReady: { 106 | initial: "compiling", 107 | on: { 108 | EDITOR_TEXT_CHANGED: { 109 | target: ".throttling", 110 | internal: false, 111 | }, 112 | }, 113 | states: { 114 | idle: {}, 115 | throttling: { 116 | after: { 117 | 200: { 118 | target: "compiling", 119 | }, 120 | }, 121 | }, 122 | compiling: { 123 | invoke: { 124 | src: "compile", 125 | onDone: { 126 | target: "idle", 127 | actions: "onCompile", 128 | }, 129 | onError: { 130 | target: "idle", 131 | }, 132 | }, 133 | }, 134 | }, 135 | }, 136 | }, 137 | }, 138 | { 139 | services: { 140 | compile: async (ctx) => { 141 | const monaco = ctx.monacoRef!; 142 | const uri = monaco.Uri.parse(ctx.mainFile); 143 | const tsWoker = await monaco.languages.typescript 144 | .getTypeScriptWorker() 145 | .then((worker) => worker(uri)); 146 | 147 | const syntaxErrors = await tsWoker.getSyntacticDiagnostics( 148 | uri.toString(), 149 | ); 150 | 151 | if (syntaxErrors.length > 0) { 152 | return; 153 | } 154 | 155 | const compiledSource = await tsWoker 156 | .getEmitOutput(uri.toString()) 157 | .then((result) => result.outputFiles[0].text); 158 | 159 | return parseMachines(compiledSource); 160 | }, 161 | }, 162 | }, 163 | ); 164 | 165 | export const EditorWithXStateImports = ( 166 | props: EditorWithXStateImportsProps & EditorProps, 167 | ) => { 168 | const editorRef = useRef(null); 169 | 170 | const service = useInterpret(machine, { 171 | actions: { 172 | onCompile: (context, e) => { 173 | if (e.type !== "done.invoke.compile") return; 174 | 175 | props.onCompile?.(e.data); 176 | }, 177 | }, 178 | }); 179 | 180 | return ( 181 | { 192 | if (typeof text === "string") { 193 | props.onChange?.(text); 194 | } 195 | service.send({ 196 | type: "EDITOR_TEXT_CHANGED", 197 | }); 198 | }} 199 | theme="vs-dark" 200 | onMount={async (editor, monaco) => { 201 | editorRef.current = monaco.editor; 202 | monaco.editor.defineTheme("xstate-viz", editorTheme); 203 | monaco.editor.setTheme("xstate-viz"); 204 | 205 | service.send({ 206 | type: "EDITOR_READY", 207 | monacoRef: monaco, 208 | standaloneEditorRef: editor, 209 | }); 210 | 211 | monaco.languages.typescript.typescriptDefaults.setWorkerOptions({ 212 | customWorkerPath: `${new URL(window.location.origin)}ts-worker.js`, 213 | }); 214 | 215 | monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ 216 | ...monaco.languages.typescript.typescriptDefaults.getCompilerOptions(), 217 | module: monaco.languages.typescript.ModuleKind.CommonJS, 218 | moduleResolution: 219 | monaco.languages.typescript.ModuleResolutionKind.NodeJs, 220 | strict: true, 221 | }); 222 | 223 | // Prettier to format 224 | // Ctrl/CMD + Enter to visualize 225 | editor.addAction({ 226 | id: "format", 227 | label: "Format", 228 | keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], 229 | run: (editor) => { 230 | editor.getAction("editor.action.formatDocument").run(); 231 | }, 232 | }); 233 | 234 | monaco.languages.registerDocumentFormattingEditProvider("typescript", { 235 | provideDocumentFormattingEdits: async (model) => { 236 | try { 237 | return [ 238 | { 239 | text: await prettierLoader.format(editor.getValue()), 240 | range: model.getFullModelRange(), 241 | }, 242 | ]; 243 | } catch (err) { 244 | console.error(err); 245 | } finally { 246 | props.onFormat?.(); 247 | } 248 | }, 249 | }); 250 | 251 | const wrappedEditor = withTypeAcquisition(editor, monaco); 252 | props.onMount?.(wrappedEditor, monaco); 253 | }} 254 | /> 255 | ); 256 | }; 257 | 258 | export default EditorWithXStateImports; 259 | -------------------------------------------------------------------------------- /src/lessons/lessons/courses/assign/root.ts: -------------------------------------------------------------------------------- 1 | import { CourseType } from "../../../LessonType"; 2 | 3 | const assignCourse: CourseType = { 4 | initialMachineText: `import { assign, createMachine } from 'xstate'; 5 | 6 | interface Context { 7 | count: number; 8 | } 9 | 10 | createMachine({ 11 | context: { 12 | count: 0, 13 | }, 14 | on: { 15 | INCREMENT: { 16 | actions: [assign({ 17 | count: (context) => context.count - 1 18 | })] 19 | } 20 | } 21 | })`, 22 | lessons: [ 23 | { 24 | acceptanceCriteria: { 25 | cases: [ 26 | { 27 | steps: [ 28 | { 29 | type: "ASSERTION", 30 | check: (state) => state.context.count, 31 | expectedValue: 0, 32 | }, 33 | { 34 | type: "SEND_EVENT", 35 | event: { 36 | type: "INCREMENT", 37 | }, 38 | }, 39 | { 40 | type: "ASSERTION", 41 | check: (state) => state.context.count, 42 | expectedValue: 1, 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | }, 49 | { 50 | mergeWithPreviousCriteria: true, 51 | acceptanceCriteria: { 52 | cases: [ 53 | { 54 | steps: [ 55 | { 56 | type: "ASSERTION", 57 | check: (state) => state.context.count, 58 | expectedValue: 0, 59 | }, 60 | { 61 | type: "SEND_EVENT", 62 | event: { 63 | type: "DECREMENT", 64 | }, 65 | }, 66 | { 67 | type: "ASSERTION", 68 | check: (state) => state.context.count, 69 | expectedValue: -1, 70 | }, 71 | ], 72 | }, 73 | ], 74 | }, 75 | }, 76 | { 77 | acceptanceCriteria: { 78 | cases: [ 79 | { 80 | steps: [ 81 | { 82 | type: "ASSERTION", 83 | check: (state) => state.context.count, 84 | expectedValue: 0, 85 | }, 86 | { 87 | type: "ASSERTION", 88 | check: (state) => state.context.pressCount, 89 | expectedValue: 0, 90 | }, 91 | { 92 | type: "SEND_EVENT", 93 | event: { 94 | type: "DECREMENT", 95 | }, 96 | }, 97 | { 98 | type: "ASSERTION", 99 | check: (state) => state.context.count, 100 | expectedValue: -1, 101 | }, 102 | { 103 | type: "ASSERTION", 104 | check: (state) => state.context.pressCount, 105 | expectedValue: 1, 106 | }, 107 | { 108 | type: "SEND_EVENT", 109 | event: { 110 | type: "INCREMENT", 111 | }, 112 | }, 113 | { 114 | type: "ASSERTION", 115 | check: (state) => state.context.count, 116 | expectedValue: 0, 117 | }, 118 | { 119 | type: "ASSERTION", 120 | check: (state) => state.context.pressCount, 121 | expectedValue: 2, 122 | }, 123 | ], 124 | }, 125 | ], 126 | }, 127 | }, 128 | { 129 | acceptanceCriteria: { 130 | cases: [ 131 | { 132 | steps: [ 133 | { 134 | type: "ASSERTION", 135 | check: (state) => state.context.count, 136 | expectedValue: 0, 137 | }, 138 | { 139 | type: "ASSERTION", 140 | check: (state) => state.context.pressCount, 141 | expectedValue: 0, 142 | }, 143 | { 144 | type: "SEND_EVENT", 145 | event: { 146 | type: "DECREMENT", 147 | }, 148 | }, 149 | { 150 | type: "ASSERTION", 151 | check: (state) => state.context.count, 152 | expectedValue: -1, 153 | }, 154 | { 155 | type: "ASSERTION", 156 | check: (state) => state.context.pressCount, 157 | expectedValue: 0, 158 | }, 159 | { 160 | type: "SEND_EVENT", 161 | event: { 162 | type: "INCREMENT", 163 | }, 164 | }, 165 | { 166 | type: "ASSERTION", 167 | check: (state) => state.context.count, 168 | expectedValue: 0, 169 | }, 170 | { 171 | type: "ASSERTION", 172 | check: (state) => state.context.pressCount, 173 | expectedValue: 0, 174 | }, 175 | ], 176 | }, 177 | ], 178 | }, 179 | }, 180 | { 181 | acceptanceCriteria: { 182 | cases: [ 183 | { 184 | steps: [ 185 | { 186 | type: "OPTIONS_ASSERTION", 187 | assertion: (options) => 188 | Boolean(options.actions.incrementPressCount), 189 | description: `Must have an action called 'incrementPressCount' defined`, 190 | }, 191 | ], 192 | }, 193 | { 194 | steps: [ 195 | { 196 | type: "ASSERTION", 197 | check: (state) => state.context.count, 198 | expectedValue: 0, 199 | }, 200 | { 201 | type: "ASSERTION", 202 | check: (state) => state.context.pressCount, 203 | expectedValue: 0, 204 | }, 205 | { 206 | type: "SEND_EVENT", 207 | event: { 208 | type: "DECREMENT", 209 | }, 210 | }, 211 | { 212 | type: "ASSERTION", 213 | check: (state) => state.context.count, 214 | expectedValue: -1, 215 | }, 216 | { 217 | type: "ASSERTION", 218 | check: (state) => state.context.pressCount, 219 | expectedValue: 1, 220 | }, 221 | { 222 | type: "SEND_EVENT", 223 | event: { 224 | type: "INCREMENT", 225 | }, 226 | }, 227 | { 228 | type: "ASSERTION", 229 | check: (state) => state.context.count, 230 | expectedValue: 0, 231 | }, 232 | { 233 | type: "ASSERTION", 234 | check: (state) => state.context.pressCount, 235 | expectedValue: 2, 236 | }, 237 | ], 238 | }, 239 | ], 240 | }, 241 | }, 242 | { 243 | acceptanceCriteria: { 244 | cases: [ 245 | { 246 | steps: [ 247 | { 248 | type: "OPTIONS_ASSERTION", 249 | assertion: (options) => 250 | Boolean(options.actions.incrementPressCount), 251 | description: `Must have an action called 'incrementPressCount' defined`, 252 | }, 253 | ], 254 | }, 255 | { 256 | steps: [ 257 | { 258 | type: "ASSERTION", 259 | check: (state) => state.context.count, 260 | expectedValue: 0, 261 | }, 262 | { 263 | type: "ASSERTION", 264 | check: (state) => state.context.pressCount, 265 | expectedValue: 0, 266 | }, 267 | { 268 | type: "SEND_EVENT", 269 | event: { 270 | type: "DECREMENT", 271 | }, 272 | }, 273 | { 274 | type: "ASSERTION", 275 | check: (state) => state.context.count, 276 | expectedValue: -1, 277 | }, 278 | { 279 | type: "ASSERTION", 280 | check: (state) => state.context.pressCount, 281 | expectedValue: 1, 282 | }, 283 | { 284 | type: "SEND_EVENT", 285 | event: { 286 | type: "INCREMENT", 287 | }, 288 | }, 289 | { 290 | type: "ASSERTION", 291 | check: (state) => state.context.count, 292 | expectedValue: 0, 293 | }, 294 | { 295 | type: "ASSERTION", 296 | check: (state) => state.context.pressCount, 297 | expectedValue: 3, 298 | }, 299 | ], 300 | }, 301 | ], 302 | }, 303 | }, 304 | ], 305 | }; 306 | 307 | export default assignCourse; 308 | -------------------------------------------------------------------------------- /src/monacoPatch.ts: -------------------------------------------------------------------------------- 1 | // This file exists to configure and patch the Monaco editor 2 | import monacoLoader from "@monaco-editor/loader"; 3 | import type { languages } from "monaco-editor"; 4 | 5 | // should be in sync with the "modules" allowed by our eval Function 6 | import * as XState from "xstate"; 7 | import * as XStateModel from "xstate/lib/model"; 8 | import * as XStateActions from "xstate/lib/actions"; 9 | 10 | // dont hate the player, hate the game 11 | // https://github.com/microsoft/vscode-loader/issues/33 12 | if (typeof document !== "undefined") { 13 | const { insertBefore } = document.head; 14 | document.head.insertBefore = function ( 15 | newChild: Node, 16 | refChild: Node | null, 17 | ): any { 18 | // @ts-ignore 19 | const self: HTMLHeadElement = this; 20 | 21 | if (newChild.nodeType !== Node.ELEMENT_NODE) { 22 | return insertBefore.call(self, newChild, refChild); 23 | } 24 | 25 | const newElement = newChild as Element; 26 | 27 | if ( 28 | newElement.tagName === "LINK" && 29 | (newElement as HTMLLinkElement).href.includes("/editor/") 30 | ) { 31 | return self.appendChild(newElement); 32 | } 33 | 34 | return insertBefore.call(self, newChild, refChild); 35 | } as any; 36 | } 37 | 38 | const MONACO_LOCATION = `https://unpkg.com/monaco-editor@0.25.2/min/vs`; 39 | 40 | monacoLoader.config({ 41 | paths: { 42 | vs: MONACO_LOCATION, 43 | }, 44 | }); 45 | 46 | const fixupXStateSpecifier = (specifier: string) => 47 | specifier 48 | .replace(/\.\/node_modules\//, "") 49 | // redirect 'xstate/lib' to 'xstate' 50 | .replace(/xstate\/lib(?!\/)/, "xstate"); 51 | 52 | function toTextEdit(provider: any, textChange: any): languages.TextEdit { 53 | return { 54 | // if there is no existing xstate import in the file then a new import has to be created 55 | // in such a situation TS most likely fails to compute the proper module specifier for this "node module" 56 | // because it exits its `tryGetModuleNameAsNodeModule` when it doesn't have fs layer installed: 57 | // https://github.com/microsoft/TypeScript/blob/328e888a9d0a11952f4ff949848d4336bce91b18/src/compiler/moduleSpecifiers.ts#L553 58 | // it then generates a relative path which we just patch here 59 | text: fixupXStateSpecifier(textChange.newText), 60 | range: (provider as any)._textSpanToRange( 61 | provider.__model, 62 | textChange.span, 63 | ), 64 | }; 65 | } 66 | 67 | type AutoImport = { detailText: string; textEdits: languages.TextEdit[] }; 68 | 69 | function getAutoImport(provider: any, details: any): AutoImport | undefined { 70 | const codeAction = details.codeActions?.[0]; 71 | 72 | if (!codeAction) { 73 | return; 74 | } 75 | 76 | const { textChanges } = codeAction.changes[0]; 77 | 78 | if ( 79 | textChanges.every((textChange: any) => !/import/.test(textChange.newText)) 80 | ) { 81 | // if the new text doesn't start with an import it means that it's going to be added to the existing import 82 | // it can be safely (for the most part) accepted as is 83 | 84 | // example description: 85 | // Add 'createMachine' to existing import declaration from "xstate" 86 | const specifier = codeAction.description.match(/from ["'](.+)["']/)![1]; 87 | return { 88 | detailText: `Auto import from '${specifier}'`, 89 | textEdits: textChanges.map((textChange: any) => 90 | toTextEdit(provider, textChange), 91 | ), 92 | }; 93 | } 94 | 95 | if (details.kind === "interface" || details.kind === "type") { 96 | const specifier = codeAction.description.match( 97 | /from module ["'](.+)["']/, 98 | )![1]; 99 | return { 100 | detailText: `Auto import from '${fixupXStateSpecifier(specifier)}'`, 101 | textEdits: textChanges.map((textChange: any) => 102 | toTextEdit(provider, { 103 | ...textChange, 104 | // make type-related **new** imports safe 105 | // the resolved specifier might be internal 106 | // we don't have an easy way to remap it to a more public one that we actually allow when we load the code at runtime 107 | // 108 | // this kind should work out of the box with `isolatedModules: true` but for some reason it didn't when I've tried it 109 | newText: textChange.newText.replace(/import/, "import type"), 110 | }), 111 | ), 112 | }; 113 | } 114 | 115 | let specifier = ""; 116 | 117 | // fortunately auto-imports are not suggested for types 118 | if (details.name in XState) { 119 | specifier = "xstate"; 120 | } else if (details.name in XStateModel) { 121 | specifier = "xstate/lib/model"; 122 | } else if (details.name in XStateActions) { 123 | specifier = "xstate/lib/actions"; 124 | } 125 | 126 | if (!specifier) { 127 | return; 128 | } 129 | 130 | return { 131 | detailText: `Auto import from '${specifier}'`, 132 | textEdits: textChanges.map((textChange: any) => 133 | toTextEdit(provider, textChange), 134 | ), 135 | }; 136 | } 137 | 138 | const initLoader = monacoLoader.init; 139 | monacoLoader.init = function (...args) { 140 | const cancelable = initLoader.apply(this, ...args); 141 | 142 | cancelable.then( 143 | (monaco) => { 144 | const { registerCompletionItemProvider } = monaco.languages; 145 | 146 | monaco.languages.registerCompletionItemProvider = function ( 147 | language, 148 | provider, 149 | ) { 150 | if (language !== "typescript") { 151 | return registerCompletionItemProvider(language, provider); 152 | } 153 | 154 | // those are mostly copied from https://github.com/microsoft/monaco-typescript/blob/17582eaec0872b9a311ec0dee2853e4fc6ba6cf2/src/languageFeatures.ts#L434 155 | // it has some minor adjustments (& hacks) to enable auto-imports 156 | provider.provideCompletionItems = async function ( 157 | model, 158 | position, 159 | _context, 160 | token, 161 | ) { 162 | (this as any).__model = model; 163 | 164 | const wordInfo = model.getWordUntilPosition(position); 165 | const wordRange = new monaco.Range( 166 | position.lineNumber, 167 | wordInfo.startColumn, 168 | position.lineNumber, 169 | wordInfo.endColumn, 170 | ); 171 | const resource = model.uri; 172 | const offset = model.getOffsetAt(position); 173 | 174 | const worker = await (this as any)._worker(resource); 175 | 176 | if (model.isDisposed()) { 177 | return; 178 | } 179 | 180 | const info = await worker.getCompletionsAtPosition( 181 | resource.toString(), 182 | offset, 183 | ); 184 | 185 | if (!info || model.isDisposed()) { 186 | return; 187 | } 188 | 189 | const suggestions = info.entries.map((entry: any) => { 190 | let range = wordRange; 191 | if (entry.replacementSpan) { 192 | const p1 = model.getPositionAt(entry.replacementSpan.start); 193 | const p2 = model.getPositionAt( 194 | entry.replacementSpan.start + entry.replacementSpan.length, 195 | ); 196 | 197 | range = new monaco.Range( 198 | p1.lineNumber, 199 | p1.column, 200 | p2.lineNumber, 201 | p2.column, 202 | ); 203 | } 204 | 205 | const tags = []; 206 | if (entry.kindModifiers?.indexOf("deprecated") !== -1) { 207 | tags.push(monaco.languages.CompletionItemTag.Deprecated); 208 | } 209 | 210 | return { 211 | uri: resource, 212 | position: position, 213 | offset: offset, 214 | range: range, 215 | label: entry.name, 216 | insertText: entry.name, 217 | sortText: entry.sortText, 218 | kind: (provider.constructor as any).convertKind(entry.kind), 219 | tags, 220 | // properties below were added here 221 | data: entry.data, 222 | source: entry.source, 223 | hasAction: entry.hasAction, 224 | }; 225 | }); 226 | 227 | return { 228 | suggestions, 229 | }; 230 | }; 231 | 232 | provider.resolveCompletionItem = function ( 233 | item: any /*, token which stays unused */, 234 | ) { 235 | return (this as any) 236 | ._worker(item.uri) 237 | .then((worker: any) => { 238 | return worker.getCompletionEntryDetails( 239 | (item as any).uri.toString(), 240 | item.offset, 241 | item.label, 242 | /* formatOptions */ {}, 243 | (item as any).uri.toString(), // this could potentially be `item.source`, according to some API docs, didn't have time to test this out yet 244 | /* userPreferences */ undefined, 245 | item.data, 246 | ); 247 | }) 248 | .then((details: any) => { 249 | if (!details) { 250 | return item; 251 | } 252 | 253 | const autoImport = getAutoImport(provider, details); 254 | 255 | return { 256 | uri: item.uri, 257 | position: item.position, 258 | label: details.name, 259 | kind: (provider.constructor as any).convertKind(details.kind), 260 | detail: 261 | autoImport?.detailText || 262 | (details.displayParts 263 | ?.map((displayPart: any) => displayPart.text) 264 | .join("") ?? 265 | ""), 266 | 267 | // properties below were added here 268 | additionalTextEdits: autoImport?.textEdits, 269 | documentation: { 270 | value: ( 271 | provider.constructor as any 272 | ).createDocumentationString(details), 273 | }, 274 | }; 275 | }); 276 | }; 277 | 278 | return registerCompletionItemProvider(language, provider); 279 | }; 280 | }, 281 | () => {}, 282 | ); 283 | 284 | return cancelable; 285 | }; 286 | -------------------------------------------------------------------------------- /src/typeAcquisition.ts: -------------------------------------------------------------------------------- 1 | // almost identical to https://github.com/microsoft/TypeScript-Website/blob/23cd21aec8ef8284d3cb89fad6607e839c2f97e8/packages/sandbox/src/typeAcquisition.ts 2 | import lzstring from "lz-string"; 3 | 4 | //// PATCH-START 5 | //// 6 | //// let's use a safe variant throughout the file 7 | import { createStorage, testStorageSupport } from "memory-web-storage"; 8 | 9 | const storage = testStorageSupport() ? window.localStorage : createStorage(); 10 | //// PATCH-END 11 | 12 | const globalishObj: any = 13 | typeof globalThis !== "undefined" ? globalThis : window || {}; 14 | globalishObj.typeDefinitions = {}; 15 | 16 | /** 17 | * Type Defs we've already got, and nulls when something has failed. 18 | * This is to make sure that it doesn't infinite loop. 19 | */ 20 | export const acquiredTypeDefs: { [name: string]: string | null } = 21 | globalishObj.typeDefinitions; 22 | 23 | export type AddLibToRuntimeFunc = (code: string, path: string) => void; 24 | 25 | // eslint-disable-next-line 26 | const moduleJSONURL = (name: string) => 27 | // prettier-ignore 28 | `https://ofcncog2cu-dsn.algolia.net/1/indexes/npm-search/${encodeURIComponent(name)}?attributes=types&x-algolia-agent=Algolia%20for%20vanilla%20JavaScript%20(lite)%203.27.1&x-algolia-application-id=OFCNCOG2CU&x-algolia-api-key=f54e21fa3a2a0160595bb058179bfb1e`; 29 | 30 | const unpkgURL = (name: string, path: string) => { 31 | if (!name) { 32 | const actualName = path.substring(0, path.indexOf("/")); 33 | const actualPath = path.substring(path.indexOf("/") + 1); 34 | return `https://www.unpkg.com/${encodeURIComponent( 35 | actualName, 36 | )}/${encodeURIComponent(actualPath)}`; 37 | } 38 | return `https://www.unpkg.com/${encodeURIComponent( 39 | name, 40 | )}/${encodeURIComponent(path)}`; 41 | }; 42 | 43 | const packageJSONURL = (name: string) => unpkgURL(name, "package.json"); 44 | 45 | const errorMsg = (msg: string, response: any, config: ATAConfig) => { 46 | config.logger.error( 47 | `${msg} - will not try again in this session`, 48 | response.status, 49 | response.statusText, 50 | response, 51 | ); 52 | }; 53 | 54 | /** 55 | * Grab any import/requires from inside the code and make a list of 56 | * its dependencies 57 | */ 58 | const parseFileForModuleReferences = (sourceCode: string) => { 59 | // https://regex101.com/r/Jxa3KX/4 60 | const requirePattern = 61 | /(const|let|var)(.|\n)*? require\(('|")(.*)('|")\);?$/gm; 62 | // this handle ths 'from' imports https://regex101.com/r/hdEpzO/4 63 | const es6Pattern = 64 | /(import|export)((?!from)(?!require)(.|\n))*?(from|require\()\s?('|")(.*)('|")\)?;?$/gm; 65 | // https://regex101.com/r/hdEpzO/8 66 | const es6ImportOnly = /import\s+?\(?('|")(.*)('|")\)?;?/gm; 67 | 68 | const foundModules = new Set(); 69 | var match; 70 | 71 | while ((match = es6Pattern.exec(sourceCode)) !== null) { 72 | if (match[6]) foundModules.add(match[6]); 73 | } 74 | 75 | while ((match = requirePattern.exec(sourceCode)) !== null) { 76 | if (match[5]) foundModules.add(match[5]); 77 | } 78 | 79 | while ((match = es6ImportOnly.exec(sourceCode)) !== null) { 80 | if (match[2]) foundModules.add(match[2]); 81 | } 82 | 83 | return Array.from(foundModules); 84 | }; 85 | 86 | /** Converts some of the known global imports to node so that we grab the right info */ 87 | const mapModuleNameToModule = (name: string) => { 88 | // in node repl: 89 | // > require("module").builtinModules 90 | const builtInNodeMods = [ 91 | "assert", 92 | "async_hooks", 93 | "buffer", 94 | "child_process", 95 | "cluster", 96 | "console", 97 | "constants", 98 | "crypto", 99 | "dgram", 100 | "dns", 101 | "domain", 102 | "events", 103 | "fs", 104 | "fs/promises", 105 | "http", 106 | "http2", 107 | "https", 108 | "inspector", 109 | "module", 110 | "net", 111 | "os", 112 | "path", 113 | "perf_hooks", 114 | "process", 115 | "punycode", 116 | "querystring", 117 | "readline", 118 | "repl", 119 | "stream", 120 | "string_decoder", 121 | "sys", 122 | "timers", 123 | "tls", 124 | "trace_events", 125 | "tty", 126 | "url", 127 | "util", 128 | "v8", 129 | "vm", 130 | "wasi", 131 | "worker_threads", 132 | "zlib", 133 | ]; 134 | 135 | if (builtInNodeMods.includes(name)) { 136 | return "node"; 137 | } 138 | return name; 139 | }; 140 | 141 | //** A really simple version of path.resolve */ 142 | const mapRelativePath = (moduleDeclaration: string, currentPath: string) => { 143 | // https://stackoverflow.com/questions/14780350/convert-relative-path-to-absolute-using-javascript 144 | function absolute(base: string, relative: string) { 145 | if (!base) return relative; 146 | 147 | const stack = base.split("/"); 148 | const parts = relative.split("/"); 149 | stack.pop(); // remove current file name (or empty string) 150 | 151 | for (var i = 0; i < parts.length; i++) { 152 | // eslint-disable-next-line eqeqeq 153 | if (parts[i] == ".") continue; 154 | // eslint-disable-next-line eqeqeq 155 | if (parts[i] == "..") stack.pop(); 156 | else stack.push(parts[i]); 157 | } 158 | return stack.join("/"); 159 | } 160 | 161 | return absolute(currentPath, moduleDeclaration); 162 | }; 163 | 164 | const convertToModuleReferenceID = ( 165 | outerModule: string, 166 | moduleDeclaration: string, 167 | currentPath: string, 168 | ) => { 169 | const modIsScopedPackageOnly = 170 | moduleDeclaration.indexOf("@") === 0 && 171 | moduleDeclaration.split("/").length === 2; 172 | const modIsPackageOnly = 173 | moduleDeclaration.indexOf("@") === -1 && 174 | moduleDeclaration.split("/").length === 1; 175 | const isPackageRootImport = modIsPackageOnly || modIsScopedPackageOnly; 176 | 177 | if (isPackageRootImport) { 178 | return moduleDeclaration; 179 | } else { 180 | return `${outerModule}-${mapRelativePath(moduleDeclaration, currentPath)}`; 181 | } 182 | }; 183 | 184 | /** 185 | * Takes an initial module and the path for the root of the typings and grab it and start grabbing its 186 | * dependencies then add those the to runtime. 187 | */ 188 | const addModuleToRuntime = async ( 189 | mod: string, 190 | path: string, 191 | config: ATAConfig, 192 | ) => { 193 | const isDeno = path && path.indexOf("https://") === 0; 194 | 195 | let actualMod = mod; 196 | let actualPath = path; 197 | 198 | if (!mod) { 199 | actualMod = path.substring(0, path.indexOf("/")); 200 | actualPath = path.substring(path.indexOf("/") + 1); 201 | } 202 | 203 | const dtsFileURL = isDeno ? path : unpkgURL(actualMod, actualPath); 204 | 205 | let content = await getCachedDTSString(config, dtsFileURL); 206 | if (!content) { 207 | const isDeno = actualPath && actualPath.indexOf("https://") === 0; 208 | const indexPath = `${actualPath.replace(".d.ts", "")}/index.d.ts`; 209 | 210 | const dtsFileURL = isDeno ? actualPath : unpkgURL(actualMod, indexPath); 211 | content = await getCachedDTSString(config, dtsFileURL); 212 | 213 | if (!content) { 214 | return errorMsg( 215 | `Could not get root d.ts file for the module '${actualMod}' at ${actualPath}`, 216 | {}, 217 | config, 218 | ); 219 | } 220 | 221 | if (!isDeno) { 222 | actualPath = indexPath; 223 | } 224 | } 225 | 226 | // Now look and grab dependent modules where you need the 227 | await getDependenciesForModule(content, actualMod, actualPath, config); 228 | 229 | if (isDeno) { 230 | const wrapped = `declare module "${actualPath}" { ${content} }`; 231 | config.addLibraryToRuntime(wrapped, actualPath); 232 | } else { 233 | config.addLibraryToRuntime( 234 | content, 235 | `file:///node_modules/${actualMod}/${actualPath}`, 236 | ); 237 | } 238 | }; 239 | 240 | /** 241 | * Takes a module import, then uses both the algolia API and the the package.json to derive 242 | * the root type def path. 243 | * 244 | * @param {string} packageName 245 | * @returns {Promise<{ mod: string, path: string, packageJSON: any }>} 246 | */ 247 | const getModuleAndRootDefTypePath = async ( 248 | packageName: string, 249 | config: ATAConfig, 250 | ) => { 251 | //// PATCH-START 252 | //// 253 | //// we don't want to hit Algolia endpoint since we don't need a generic solution at the moment 254 | //// it's also uncertain if that endpoint is considered to be "public" 255 | //// 256 | // const url = moduleJSONURL(packageName); 257 | 258 | // const response = await config.fetcher(url); 259 | 260 | // if (!response.ok) { 261 | // return errorMsg( 262 | // `Could not get Algolia JSON for the module '${packageName}'`, 263 | // response, 264 | // config, 265 | // ); 266 | // } 267 | 268 | // const responseJSON = await response.json(); 269 | // if (!responseJSON) { 270 | // return errorMsg( 271 | // `Could the Algolia JSON was un-parsable for the module '${packageName}'`, 272 | // response, 273 | // config, 274 | // ); 275 | // } 276 | 277 | // if (!responseJSON.types) { 278 | // return config.logger.log( 279 | // `There were no types for '${packageName}' - will not try again in this session`, 280 | // ); 281 | // } 282 | // if (!responseJSON.types.ts) { 283 | // return config.logger.log( 284 | // `There were no types for '${packageName}' - will not try again in this session`, 285 | // ); 286 | // } 287 | 288 | // acquiredTypeDefs has a wrong type annotation in the original 289 | const responseJSON: any = { 290 | types: { ts: "included" }, 291 | objectID: packageName, 292 | }; 293 | 294 | acquiredTypeDefs[packageName] = responseJSON; 295 | 296 | //// PATCH-END 297 | 298 | if (responseJSON.types.ts === "included") { 299 | const modPackageURL = packageJSONURL(packageName); 300 | 301 | const response = await config.fetcher(modPackageURL); 302 | if (!response.ok) { 303 | return errorMsg( 304 | `Could not get Package JSON for the module '${packageName}'`, 305 | response, 306 | config, 307 | ); 308 | } 309 | 310 | const responseJSON = await response.json(); 311 | if (!responseJSON) { 312 | return errorMsg( 313 | `Could not get Package JSON for the module '${packageName}'`, 314 | response, 315 | config, 316 | ); 317 | } 318 | 319 | config.addLibraryToRuntime( 320 | JSON.stringify(responseJSON, null, " "), 321 | `file:///node_modules/${packageName}/package.json`, 322 | ); 323 | 324 | // Get the path of the root d.ts file 325 | 326 | // non-inferred route 327 | let rootTypePath = 328 | responseJSON.typing || responseJSON.typings || responseJSON.types; 329 | 330 | // package main is custom 331 | if ( 332 | !rootTypePath && 333 | typeof responseJSON.main === "string" && 334 | responseJSON.main.indexOf(".js") > 0 335 | ) { 336 | rootTypePath = responseJSON.main.replace(/js$/, "d.ts"); 337 | } 338 | 339 | // Final fallback, to have got here it must have passed in algolia 340 | if (!rootTypePath) { 341 | rootTypePath = "index.d.ts"; 342 | } 343 | 344 | return { mod: packageName, path: rootTypePath, packageJSON: responseJSON }; 345 | } else if (responseJSON.types.ts === "definitely-typed") { 346 | return { 347 | mod: responseJSON.types.definitelyTyped, 348 | path: "index.d.ts", 349 | packageJSON: responseJSON, 350 | }; 351 | } else { 352 | // eslint-disable-next-line no-throw-literal 353 | throw "This shouldn't happen"; 354 | } 355 | }; 356 | 357 | const getCachedDTSString = async (config: ATAConfig, url: string) => { 358 | const cached = storage.getItem(url); 359 | if (cached) { 360 | const [dateString, text] = cached.split("-=-^-=-"); 361 | const cachedDate = new Date(dateString); 362 | const now = new Date(); 363 | 364 | const cacheTimeout = 604800000; // 1 week 365 | // const cacheTimeout = 60000 // 1 min 366 | 367 | if (now.getTime() - cachedDate.getTime() < cacheTimeout) { 368 | return lzstring.decompressFromUTF16(text); 369 | } else { 370 | config.logger.log("Skipping cache for ", url); 371 | } 372 | } 373 | 374 | const response = await config.fetcher(url); 375 | if (!response.ok) { 376 | return errorMsg( 377 | `Could not get DTS response for the module at ${url}`, 378 | response, 379 | config, 380 | ); 381 | } 382 | 383 | // TODO: handle checking for a resolve to index.d.ts whens someone imports the folder 384 | let content = await response.text(); 385 | if (!content) { 386 | return errorMsg( 387 | `Could not get text for DTS response at ${url}`, 388 | response, 389 | config, 390 | ); 391 | } 392 | 393 | const now = new Date(); 394 | const cacheContent = `${now.toISOString()}-=-^-=-${lzstring.compressToUTF16( 395 | content, 396 | )}`; 397 | storage.setItem(url, cacheContent); 398 | return content; 399 | }; 400 | 401 | const getReferenceDependencies = async ( 402 | sourceCode: string, 403 | mod: string, 404 | path: string, 405 | config: ATAConfig, 406 | ) => { 407 | var match; 408 | if (sourceCode.indexOf("reference path") > 0) { 409 | // https://regex101.com/r/DaOegw/1 410 | const referencePathExtractionPattern = //gm; 411 | while ((match = referencePathExtractionPattern.exec(sourceCode)) !== null) { 412 | const relativePath = match[1]; 413 | if (relativePath) { 414 | let newPath = mapRelativePath(relativePath, path); 415 | if (newPath) { 416 | const dtsRefURL = unpkgURL(mod, newPath); 417 | 418 | const dtsReferenceResponseText = await getCachedDTSString( 419 | config, 420 | dtsRefURL, 421 | ); 422 | if (!dtsReferenceResponseText) { 423 | return errorMsg( 424 | `Could not get root d.ts file for the module '${mod}' at ${path}`, 425 | {}, 426 | config, 427 | ); 428 | } 429 | 430 | await getDependenciesForModule( 431 | dtsReferenceResponseText, 432 | mod, 433 | newPath, 434 | config, 435 | ); 436 | const representationalPath = `file:///node_modules/${mod}/${newPath}`; 437 | config.addLibraryToRuntime( 438 | dtsReferenceResponseText, 439 | representationalPath, 440 | ); 441 | } 442 | } 443 | } 444 | } 445 | }; 446 | 447 | type Logger = Record<"log" | "warn" | "error", (...args: any[]) => void>; 448 | 449 | interface ATAConfig { 450 | sourceCode: string; 451 | addLibraryToRuntime: AddLibToRuntimeFunc; 452 | fetcher: typeof fetch; 453 | logger: Logger; 454 | } 455 | 456 | /** 457 | * Pseudo in-browser type acquisition tool, uses a 458 | */ 459 | export const detectNewImportsToAcquireTypeFor = async ( 460 | sourceCode: string, 461 | userAddLibraryToRuntime: AddLibToRuntimeFunc, 462 | fetcher = fetch, 463 | playgroundConfig: { 464 | logger: Logger; 465 | }, 466 | ) => { 467 | // Wrap the runtime func with our own side-effect for visibility 468 | const addLibraryToRuntime = (code: string, path: string) => { 469 | globalishObj.typeDefinitions[path] = code; 470 | userAddLibraryToRuntime(code, path); 471 | }; 472 | 473 | // Basically start the recursion with an undefined module 474 | const config: ATAConfig = { 475 | sourceCode, 476 | addLibraryToRuntime, 477 | fetcher, 478 | logger: playgroundConfig.logger, 479 | }; 480 | const results = getDependenciesForModule( 481 | sourceCode, 482 | undefined, 483 | "playground.ts", 484 | config, 485 | ); 486 | return results; 487 | }; 488 | 489 | /** 490 | * Looks at a JS/DTS file and recurses through all the dependencies. 491 | * It avoids 492 | */ 493 | const getDependenciesForModule = ( 494 | sourceCode: string, 495 | moduleName: string | undefined, 496 | path: string, 497 | config: ATAConfig, 498 | ) => { 499 | // Get all the import/requires for the file 500 | const filteredModulesToLookAt = parseFileForModuleReferences(sourceCode); 501 | filteredModulesToLookAt 502 | //// PATCH-START 503 | //// 504 | //// this filter is added by us, 505 | //// for the top-level file (when there is no `moduleName`) we only allow for xstate types to be acquired 506 | //// for other cases we allow everything since those will be dependencies (relative or not) of xstate 507 | .filter( 508 | (moduleToLookAt) => moduleName || /^xstate($|\/)/.test(moduleToLookAt), 509 | ) 510 | //// PATCH-END 511 | .forEach(async (name) => { 512 | // Support grabbing the hard-coded node modules if needed 513 | const moduleToDownload = mapModuleNameToModule(name); 514 | 515 | if (!moduleName && moduleToDownload.startsWith(".")) { 516 | return config.logger.log( 517 | "[ATA] Can't resolve relative dependencies from the playground root", 518 | ); 519 | } 520 | 521 | const moduleID = convertToModuleReferenceID( 522 | moduleName!, 523 | moduleToDownload, 524 | moduleName!, 525 | ); 526 | if (acquiredTypeDefs[moduleID] || acquiredTypeDefs[moduleID] === null) { 527 | return; 528 | } 529 | 530 | process.env.NODE_ENV !== "production" && 531 | config.logger.log(`[ATA] Looking at ${moduleToDownload}`); 532 | 533 | const modIsScopedPackageOnly = 534 | moduleToDownload.indexOf("@") === 0 && 535 | moduleToDownload.split("/").length === 2; 536 | const modIsPackageOnly = 537 | moduleToDownload.indexOf("@") === -1 && 538 | moduleToDownload.split("/").length === 1 && 539 | //// PATCH-START 540 | //// 541 | //// this is a bug fix for the logic in the original type acquisition code 542 | moduleToDownload !== "." && 543 | moduleToDownload !== ".."; 544 | //// PATCH-END 545 | const isPackageRootImport = modIsPackageOnly || modIsScopedPackageOnly; 546 | const isDenoModule = moduleToDownload.indexOf("https://") === 0; 547 | 548 | if (isPackageRootImport) { 549 | // So it doesn't run twice for a package 550 | acquiredTypeDefs[moduleID] = null; 551 | 552 | // E.g. import danger from "danger" 553 | const packageDef = await getModuleAndRootDefTypePath( 554 | moduleToDownload, 555 | config, 556 | ); 557 | 558 | if (packageDef) { 559 | acquiredTypeDefs[moduleID] = packageDef.packageJSON; 560 | await addModuleToRuntime(packageDef.mod, packageDef.path, config); 561 | } 562 | } else if (isDenoModule) { 563 | // E.g. import { serve } from "https://deno.land/std@v0.12/http/server.ts"; 564 | await addModuleToRuntime(moduleToDownload, moduleToDownload, config); 565 | } else { 566 | // E.g. import {Component} from "./MyThing" 567 | if (!moduleToDownload || !path) { 568 | // eslint-disable-next-line no-throw-literal 569 | throw `No outer module or path for a relative import: ${moduleToDownload}`; 570 | } 571 | 572 | const absolutePathForModule = mapRelativePath(moduleToDownload, path); 573 | 574 | // So it doesn't run twice for a package 575 | acquiredTypeDefs[moduleID] = null; 576 | 577 | const resolvedFilepath = absolutePathForModule.endsWith(".ts") 578 | ? absolutePathForModule 579 | : absolutePathForModule + ".d.ts"; 580 | 581 | await addModuleToRuntime(moduleName!, resolvedFilepath, config); 582 | } 583 | }); 584 | 585 | // Also support the 586 | getReferenceDependencies(sourceCode, moduleName!, path!, config); 587 | }; 588 | -------------------------------------------------------------------------------- /src/lessons/lessonRunner.machine.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assign, 3 | createMachine, 4 | EventObject, 5 | interpret, 6 | Interpreter, 7 | InterpreterStatus, 8 | StateNode, 9 | StateNodeConfig, 10 | } from "xstate"; 11 | import { 12 | AcceptanceCriteriaStep, 13 | CourseType, 14 | LessonType, 15 | AcceptanceCriteriaCase, 16 | } from "./LessonType"; 17 | import { toMachine } from "./toMachine"; 18 | import type { CompileHandlerResponse } from "../pages/api/compile"; 19 | 20 | interface Context { 21 | course: CourseType; 22 | service?: Interpreter; 23 | stateNodes: StateNode[]; 24 | fileText: string; 25 | lessonIndex: number; 26 | stepCursor: { 27 | case: number; 28 | step: number; 29 | }; 30 | lastErroredStep: 31 | | { 32 | case: number; 33 | step: number; 34 | } 35 | | undefined; 36 | terminalLines: TerminalLine[]; 37 | } 38 | 39 | interface TerminalLine { 40 | color: "red" | "white" | "blue" | "green" | "gray" | "yellow"; 41 | icon?: "check" | "cross" | "arrow-right" | "clock"; 42 | text: string; 43 | bold?: boolean; 44 | fromUser?: boolean; 45 | } 46 | 47 | type Event = 48 | | { type: "TEXT_EDITED"; text: string } 49 | | { type: "MACHINES_COMPILED"; nodes: StateNode[] } 50 | | { 51 | type: "STEP_DONE"; 52 | } 53 | | { 54 | type: "GO_TO_NEXT_LESSON"; 55 | } 56 | | { 57 | type: "CLEAR_TERMINAL"; 58 | } 59 | | { 60 | type: "LOG_TO_CONSOLE"; 61 | args: any[]; 62 | consoleType: "log" | "warn" | "error"; 63 | }; 64 | 65 | const startingService: StateNodeConfig = { 66 | entry: ["stopRunningService"], 67 | invoke: { 68 | src: async (context, event) => { 69 | const machine = context.stateNodes[0]; 70 | 71 | if (!machine) throw new Error("Could not parse file"); 72 | // Tests the machine to see if it fails compilation 73 | interpret(machine).start().stop(); 74 | 75 | await new Promise((res) => waitFor(600, res as any)); 76 | 77 | return interpret(machine, { 78 | devTools: true, 79 | }); 80 | }, 81 | onDone: { 82 | actions: assign((context, event) => { 83 | return { 84 | service: event.data, 85 | }; 86 | }), 87 | target: "runningTests", 88 | }, 89 | onError: { 90 | target: "idle.machineCouldNotCompile", 91 | actions: [ 92 | console.log, 93 | assign({ 94 | terminalLines: (context, event) => [ 95 | ...context.terminalLines, 96 | { 97 | color: "red", 98 | text: "Could not run tests: an error occurred", 99 | bold: true, 100 | }, 101 | { 102 | color: "red", 103 | text: event.data.message || "An unknown error occurred", 104 | }, 105 | ], 106 | }), 107 | ], 108 | }, 109 | }, 110 | }; 111 | 112 | export const lessonMachine = createMachine( 113 | { 114 | initial: "firstLoad", 115 | context: { 116 | fileText: "", 117 | course: {} as any, 118 | lessonIndex: 0, 119 | stateNodes: [], 120 | stepCursor: { 121 | case: 0, 122 | step: 0, 123 | }, 124 | lastErroredStep: undefined, 125 | terminalLines: [], 126 | }, 127 | on: { 128 | CLEAR_TERMINAL: { 129 | actions: "clearTerminalLines", 130 | }, 131 | TEXT_EDITED: { 132 | actions: assign((context, event) => { 133 | return { 134 | fileText: event.text, 135 | }; 136 | }), 137 | }, 138 | MACHINES_COMPILED: { 139 | target: ".throttling", 140 | actions: assign((context, event) => { 141 | return { 142 | stateNodes: event.nodes, 143 | }; 144 | }), 145 | internal: false, 146 | }, 147 | }, 148 | states: { 149 | firstLoad: {}, 150 | idle: { 151 | id: "idle", 152 | initial: "machineValid", 153 | states: { 154 | machineValid: { 155 | initial: "testsNotPassed", 156 | states: { 157 | testsNotPassed: { 158 | tags: "testsNotPassed", 159 | }, 160 | testsPassed: { 161 | tags: "testsPassed", 162 | on: { 163 | GO_TO_NEXT_LESSON: [ 164 | { 165 | cond: "thereIsANextLesson", 166 | target: "#movingToNextLesson", 167 | }, 168 | { 169 | actions: "goToIndexPage", 170 | }, 171 | ], 172 | }, 173 | }, 174 | }, 175 | }, 176 | machineCouldNotCompile: {}, 177 | }, 178 | }, 179 | throttling: { 180 | entry: ["clearTerminalLines"], 181 | after: { 182 | 100: "startingService", 183 | }, 184 | }, 185 | startingService, 186 | movingToNextLesson: { 187 | entry: ["clearTerminalLines"], 188 | id: "movingToNextLesson", 189 | always: { 190 | actions: [ 191 | "stopRunningService", 192 | assign((context, event) => { 193 | return { 194 | stepCursor: { 195 | case: 0, 196 | step: 0, 197 | }, 198 | lessonIndex: context.lessonIndex + 1, 199 | lastErroredStep: undefined, 200 | }; 201 | }), 202 | "autoFormatEditor", 203 | ], 204 | target: "startingService", 205 | }, 206 | }, 207 | runningTests: { 208 | entry: ["resetLessonCursor", "startService", "logTestStartToTerminal"], 209 | initial: "runningStep", 210 | onDone: { 211 | target: "idle.machineValid.testsPassed", 212 | }, 213 | invoke: { 214 | src: "overrideConsoleLog", 215 | }, 216 | on: { 217 | LOG_TO_CONSOLE: { 218 | actions: assign((context, event) => { 219 | return { 220 | terminalLines: [ 221 | ...context.terminalLines, 222 | { 223 | color: 224 | event.consoleType === "log" 225 | ? "gray" 226 | : event.consoleType === "error" 227 | ? "red" 228 | : "yellow", 229 | text: `${event.args 230 | .map((elem) => JSON.stringify(elem, null, 2)) 231 | .join(" ") 232 | .slice(1, -1)}`, 233 | fromUser: true, 234 | }, 235 | ], 236 | }; 237 | }), 238 | }, 239 | }, 240 | states: { 241 | runningStep: { 242 | entry: ["reportStepStartedInTerminal"], 243 | on: { 244 | STEP_DONE: { 245 | target: "checkingIfThereIsAnIncompleteStep", 246 | actions: ["reportStepSucceededInTerminal"], 247 | }, 248 | }, 249 | invoke: { 250 | src: "runTestStep", 251 | onError: { 252 | target: "#idle.machineValid.testsNotPassed", 253 | actions: ["reportStepFailedInTerminal", "markStepAsErrored"], 254 | }, 255 | }, 256 | }, 257 | checkingIfThereIsAnIncompleteStep: { 258 | always: [ 259 | { 260 | cond: "isThereAnIncompleteStepInThisCase", 261 | actions: "incrementToNextStep", 262 | target: "runningStep", 263 | }, 264 | { 265 | cond: "isThereAnIncompleteStep", 266 | actions: [ 267 | "reportCaseSucceededInTerminal", 268 | "restartService", 269 | "incrementToNextStep", 270 | ], 271 | target: "runningStep", 272 | }, 273 | { 274 | target: "complete", 275 | actions: [ 276 | "reportCaseSucceededInTerminal", 277 | "reportAllTestsPassedInTerminal", 278 | ], 279 | }, 280 | ], 281 | }, 282 | complete: { 283 | type: "final", 284 | }, 285 | }, 286 | }, 287 | }, 288 | }, 289 | { 290 | services: { 291 | overrideConsoleLog: () => (send) => { 292 | const tempLog = console.log; 293 | const tempWarn = console.warn; 294 | const tempError = console.error; 295 | 296 | console.log = (...args: any[]) => { 297 | send({ 298 | type: "LOG_TO_CONSOLE", 299 | args, 300 | consoleType: "log", 301 | }); 302 | }; 303 | console.warn = (...args: any[]) => { 304 | send({ 305 | type: "LOG_TO_CONSOLE", 306 | args, 307 | consoleType: "warn", 308 | }); 309 | }; 310 | console.error = (...args: any[]) => { 311 | send({ 312 | type: "LOG_TO_CONSOLE", 313 | args, 314 | consoleType: "error", 315 | }); 316 | }; 317 | 318 | return () => { 319 | console.log = tempLog; 320 | console.warn = tempWarn; 321 | console.error = tempError; 322 | }; 323 | }, 324 | runTestStep: (context, event) => (send) => { 325 | const cases = getCurrentLessonCases(context); 326 | const currentStep = 327 | cases[context.stepCursor.case].steps[context.stepCursor.step]; 328 | 329 | if (!currentStep || !context.service) return; 330 | 331 | runTestStep( 332 | currentStep, 333 | context.service, 334 | () => send("STEP_DONE"), 335 | context.terminalLines, 336 | ); 337 | }, 338 | }, 339 | guards: { 340 | isThereAnIncompleteStepInThisCase: (context) => { 341 | const cases = getCurrentLessonCases(context); 342 | const nextStep = getNextSteps(cases, context.stepCursor); 343 | 344 | return Boolean(nextStep && nextStep.case === context.stepCursor.case); 345 | }, 346 | isThereAnIncompleteStep: (context) => { 347 | const cases = getCurrentLessonCases(context); 348 | const nextStep = getNextSteps(cases, context.stepCursor); 349 | return Boolean(nextStep); 350 | }, 351 | thereIsANextLesson: (context) => { 352 | return Boolean(context.course.lessons[context.lessonIndex + 1]); 353 | }, 354 | }, 355 | actions: { 356 | clearTerminalLines: assign((context) => { 357 | return { 358 | terminalLines: [], 359 | }; 360 | }), 361 | reportAllTestsPassedInTerminal: assign((context) => { 362 | const successMessage = "All tests passed!"; 363 | return { 364 | terminalLines: [ 365 | ...context.terminalLines, 366 | { 367 | color: "green", 368 | text: new Array(successMessage.length + 2).fill("-").join(""), 369 | }, 370 | { 371 | color: "green", 372 | text: successMessage, 373 | bold: true, 374 | icon: "check", 375 | }, 376 | { 377 | color: "green", 378 | text: new Array(successMessage.length + 2).fill("-").join(""), 379 | }, 380 | ], 381 | }; 382 | }), 383 | logTestStartToTerminal: assign((context) => { 384 | return { 385 | terminalLines: [ 386 | ...context.terminalLines, 387 | { 388 | color: "blue", 389 | bold: true, 390 | text: `Starting new test`, 391 | }, 392 | ], 393 | }; 394 | }), 395 | reportCaseSucceededInTerminal: assign((context) => { 396 | return { 397 | terminalLines: [ 398 | ...context.terminalLines, 399 | { 400 | text: `Test #${context.stepCursor.case + 1} passed`, 401 | color: "white", 402 | bold: true, 403 | icon: "check", 404 | }, 405 | ], 406 | }; 407 | }), 408 | reportStepSucceededInTerminal: assign((context) => { 409 | const currentStep = getCurrentLessonStep(context); 410 | 411 | let text: TerminalLine[] = []; 412 | 413 | if (currentStep.type === "ASSERTION") { 414 | text = [ 415 | { 416 | text: `Expected ${currentStep.check 417 | .toString() 418 | .slice(43, -13)} to equal ${ 419 | currentStep.expectedValue 420 | } and received ${currentStep.check(context.service!.state)}`, 421 | color: "gray", 422 | icon: "check", 423 | }, 424 | ]; 425 | } else if (currentStep.type === "OPTIONS_ASSERTION") { 426 | text = [ 427 | { 428 | text: `Expected: ${currentStep.description}`, 429 | color: "gray", 430 | icon: "check", 431 | }, 432 | ]; 433 | } else if (currentStep.type === "CONSOLE_ASSERTION") { 434 | text = [ 435 | { 436 | text: `Expected "${currentStep.expectedText}" to have been logged to the console`, 437 | color: "gray", 438 | icon: "check", 439 | }, 440 | ]; 441 | } 442 | 443 | if (!text) return {}; 444 | 445 | return { 446 | terminalLines: [...context.terminalLines, ...text], 447 | }; 448 | }), 449 | reportStepFailedInTerminal: assign((context) => { 450 | const currentStep = getCurrentLessonStep(context); 451 | 452 | let text: TerminalLine[] = []; 453 | 454 | if (currentStep.type === "ASSERTION") { 455 | const errorText = `Expected ${currentStep.check 456 | .toString() 457 | .slice(43, -13)} to equal ${ 458 | currentStep.expectedValue 459 | }, instead received ${currentStep.check(context.service!.state)}`; 460 | text = [ 461 | { 462 | color: "red", 463 | text: new Array(errorText.length + 2).fill("-").join(""), 464 | }, 465 | { 466 | color: "red", 467 | bold: true, 468 | text: `Test #${context.stepCursor.case + 1} failed`, 469 | }, 470 | { 471 | text: errorText, 472 | color: "red", 473 | icon: "cross", 474 | }, 475 | { 476 | color: "red", 477 | text: new Array(errorText.length + 2).fill("-").join(""), 478 | }, 479 | ]; 480 | } else if (currentStep.type === "OPTIONS_ASSERTION") { 481 | const errorText = `Check failed: ${currentStep.description}`; 482 | text = [ 483 | { 484 | color: "red", 485 | text: new Array(errorText.length + 2).fill("-").join(""), 486 | }, 487 | { 488 | color: "red", 489 | bold: true, 490 | text: `Test #${context.stepCursor.case + 1} failed`, 491 | }, 492 | { 493 | text: errorText, 494 | color: "red", 495 | icon: "cross", 496 | }, 497 | { 498 | color: "red", 499 | text: new Array(errorText.length + 2).fill("-").join(""), 500 | }, 501 | ]; 502 | } else if (currentStep.type === "CONSOLE_ASSERTION") { 503 | const errorText = `Check failed: Expected "${currentStep.expectedText}" to have been logged to the console`; 504 | text = [ 505 | { 506 | color: "red", 507 | text: new Array(errorText.length + 2).fill("-").join(""), 508 | }, 509 | { 510 | color: "red", 511 | bold: true, 512 | text: `Test #${context.stepCursor.case + 1} failed`, 513 | }, 514 | { 515 | text: errorText, 516 | color: "red", 517 | icon: "cross", 518 | }, 519 | { 520 | color: "red", 521 | text: new Array(errorText.length + 2).fill("-").join(""), 522 | }, 523 | ]; 524 | } 525 | 526 | if (!text) return {}; 527 | 528 | return { 529 | terminalLines: [...context.terminalLines, ...text], 530 | }; 531 | }), 532 | reportStepStartedInTerminal: assign((context) => { 533 | const currentStep = getCurrentLessonStep(context); 534 | 535 | let text: TerminalLine[] = []; 536 | 537 | if (currentStep.type === "WAIT") { 538 | text = [ 539 | { 540 | text: `Waiting for ${currentStep.durationInMs}ms...`, 541 | icon: "clock", 542 | color: "white", 543 | }, 544 | ]; 545 | } else if (currentStep.type === "SEND_EVENT") { 546 | text = [ 547 | { 548 | text: `Sending an event of type ${currentStep.event.type} to the machine...`, 549 | color: "white", 550 | icon: "arrow-right", 551 | }, 552 | ]; 553 | } 554 | 555 | if (!text) return {}; 556 | 557 | return { 558 | terminalLines: [...context.terminalLines, ...text], 559 | }; 560 | }), 561 | startService: (context) => { 562 | if ( 563 | context.service && 564 | context.service.status !== InterpreterStatus.Running 565 | ) { 566 | context.service.start(); 567 | } 568 | }, 569 | restartService: (context) => { 570 | if ( 571 | context.service && 572 | context.service.status === InterpreterStatus.Running 573 | ) { 574 | context.service.stop(); 575 | context.service.start(); 576 | } 577 | }, 578 | stopRunningService: (context) => { 579 | if ( 580 | context.service && 581 | context.service.status === InterpreterStatus.Running 582 | ) { 583 | context.service.stop(); 584 | } 585 | }, 586 | incrementToNextStep: assign((context) => { 587 | const cases = getCurrentLessonCases(context); 588 | const nextStep = getNextSteps(cases, context.stepCursor); 589 | if (!nextStep) return {}; 590 | 591 | return { 592 | stepCursor: nextStep, 593 | }; 594 | }), 595 | markStepAsErrored: assign((context) => { 596 | return { 597 | lastErroredStep: context.stepCursor, 598 | }; 599 | }), 600 | resetLessonCursor: assign((context) => ({ 601 | stepCursor: { 602 | case: 0, 603 | step: 0, 604 | }, 605 | lastErroredStep: undefined, 606 | })), 607 | }, 608 | }, 609 | ); 610 | 611 | const getNextSteps = ( 612 | cases: AcceptanceCriteriaCase[], 613 | stepCursor: { case: number; step: number }, 614 | ): { case: number; step: number } | null => { 615 | const currentCursor = stepCursor; 616 | const currentCase = cases[currentCursor.case]; 617 | 618 | if (currentCase && currentCase.steps[currentCursor.step + 1]) { 619 | return { 620 | case: currentCursor.case, 621 | step: currentCursor.step + 1, 622 | }; 623 | } 624 | 625 | const nextCase = cases[currentCursor.case + 1]; 626 | 627 | if (nextCase) { 628 | return { 629 | case: currentCursor.case + 1, 630 | step: 0, 631 | }; 632 | } 633 | 634 | return null; 635 | }; 636 | 637 | const runTestStep = ( 638 | step: AcceptanceCriteriaStep, 639 | service: Interpreter, 640 | callback: () => void, 641 | terminalLines: TerminalLine[], 642 | ) => { 643 | let state = service.state; 644 | const unsubscribeHandlers: (() => void)[] = []; 645 | 646 | const unsub = service.subscribe((newState) => { 647 | state = newState; 648 | }); 649 | 650 | unsubscribeHandlers.push(unsub.unsubscribe); 651 | 652 | switch (step.type) { 653 | case "ASSERTION": 654 | { 655 | const value = step.check(state); 656 | 657 | if (value !== step.expectedValue) { 658 | throw new Error("Assertion failed"); 659 | } 660 | callback(); 661 | } 662 | break; 663 | case "OPTIONS_ASSERTION": 664 | { 665 | const succeeded = step.assertion(service.machine.options); 666 | 667 | if (!succeeded) { 668 | throw new Error("Assertion failed"); 669 | } 670 | callback(); 671 | } 672 | break; 673 | case "SEND_EVENT": 674 | { 675 | service.send(step.event); 676 | callback(); 677 | } 678 | break; 679 | case "WAIT": 680 | { 681 | const unwait = waitFor(step.durationInMs, callback); 682 | unsubscribeHandlers.push(unwait); 683 | } 684 | break; 685 | case "CONSOLE_ASSERTION": 686 | { 687 | const regex = new RegExp(step.expectedText, "i"); 688 | const succeeded = terminalLines.some((line) => { 689 | return line.fromUser && regex.test(line.text); 690 | }); 691 | 692 | if (!succeeded) { 693 | throw new Error("Assertion failed"); 694 | } 695 | callback(); 696 | } 697 | break; 698 | } 699 | 700 | return () => { 701 | unsubscribeHandlers.forEach((func) => func()); 702 | }; 703 | }; 704 | 705 | const waitFor = (ms: number, callback: () => void): (() => void) => { 706 | let timeout = setTimeout(callback, ms); 707 | 708 | return () => { 709 | clearTimeout(timeout); 710 | }; 711 | }; 712 | 713 | const getCurrentLesson = (context: Context): LessonType => { 714 | return context.course.lessons[context.lessonIndex]; 715 | }; 716 | 717 | export const getCurrentLessonCases = ( 718 | context: Context, 719 | ): AcceptanceCriteriaCase[] => { 720 | if (context.lessonIndex === 0) { 721 | return context.course.lessons[0].acceptanceCriteria.cases; 722 | } 723 | 724 | const currentLesson = getCurrentLesson(context); 725 | 726 | if (!currentLesson.mergeWithPreviousCriteria) { 727 | return currentLesson.acceptanceCriteria.cases; 728 | } 729 | 730 | const cases: AcceptanceCriteriaCase[] = []; 731 | 732 | let cursorIndex = context.lessonIndex; 733 | 734 | while (cursorIndex >= 0) { 735 | const targetLesson = context.course.lessons[cursorIndex]; 736 | 737 | cases.unshift(...targetLesson.acceptanceCriteria.cases); 738 | 739 | if (!targetLesson.mergeWithPreviousCriteria) { 740 | break; 741 | } 742 | 743 | cursorIndex--; 744 | } 745 | 746 | return cases; 747 | }; 748 | 749 | export const getCurrentLessonStep = ( 750 | context: Context, 751 | ): AcceptanceCriteriaStep => { 752 | const cases = getCurrentLessonCases(context); 753 | const currentStep = 754 | cases[context.stepCursor.case].steps[context.stepCursor.step]; 755 | return currentStep; 756 | }; 757 | --------------------------------------------------------------------------------