├── .bin └── export ├── .gitignore ├── .prettierrc ├── .vscode ├── general.code-snippets ├── react.code-snippets └── testing.code-snippets ├── README.md ├── assets ├── Pasted image 20230322042256.png ├── branch-protection-rules.png ├── build-and-test-with-names.png ├── caches-in-actions.png ├── enterprise-user-interface-development.png ├── jobs-with-a-dependency.png ├── parallel-jobs.png ├── playwright-vscode.png ├── reading-from-the-cache.png ├── running-your-unit-tests-on-github-actions.png ├── tale-of-two-caches.png ├── technologies.png ├── test-and-build-actions.png ├── writing-to-the-cache.png └── your-first-actions.png ├── babel.config.json ├── content ├── Adding a Step to Your Github Action.md ├── Asymmetric Matchers.md ├── Basic Component Testing Exercise.md ├── Basic Component Testing Solution.md ├── Beyond Strict Equality.md ├── Caching Assets Between Jobs.md ├── Caching Dependencies.md ├── Clearing, Restoring, and Reseting Mocks and Spies.md ├── Code Coverage.md ├── Component Testing Solution.md ├── Components of Large Application.md ├── Configuring Playwright.md ├── Configuring and Running ESLint.md ├── Create ESLint Rule (Solution).md ├── Creating Your Own Github Action.md ├── Creating a Helper for Rendering Components and User Events.md ├── Creating a Reusable Github Action.md ├── Custom Rules for ESLint.md ├── Danger Example and Exercise Ideas.md ├── Dealing with Authentication.md ├── Dependency Injection.md ├── Device Emultation in Playwright.md ├── Downloading and Uploading Files.md ├── EditorConfig.md ├── Evaluating Code in the Page Context.md ├── Events Supported by fireEvent.md ├── Extending ESLint.md ├── Extending Matchers for Testing Library.md ├── Factories.md ├── Faking Time.md ├── Filtering Tests.md ├── Formatting staged files with lint-staged.md ├── Generating Artifacts Using Github Actions.md ├── Generating Integration Tests with Playwright.md ├── Generating an Artifact for Your Code Coverage Report.md ├── Getting Set Up with Playwright.md ├── Getting Started with Github Actions.md ├── Github Actions Events.md ├── Github Actions Example Ideas.md ├── Globally Extending Matchers in the Test Setup for Component Testing.md ├── Helpful Resources and Tools.md ├── Hooking Up aXe with Testing Library.md ├── Husky, Lint-Staged, and Git Hooks.md ├── Input Obstacles.md ├── Interacting with the DOM Using Testing Library.md ├── Introduction to Integration Testing.md ├── Introduction.md ├── Labelling PRs.md ├── Mock Browser APIs in Playwright.md ├── Mock Service Worker.md ├── Mocking APIs.md ├── Mocking Environment Variables.md ├── Mocking Globals.md ├── Mocking Imports and Modules.md ├── Mocking Network Requests.md ├── Mocks Directory.md ├── Mocks.md ├── My Current Github Action for Testing and Linting.md ├── Navigating with Playwright.md ├── Obstacle Course.md ├── Packing List Component Testing Exercise.md ├── Parallelizing Tests.md ├── Parameterizing Playwright Tests.md ├── Parameterizing Tests.md ├── Playwright Assertions.md ├── Playwright Test Suites and Annotations.md ├── Prettier.md ├── Real World Use Case for Asymmetric Matching.md ├── Recording Network Requests with Playwright.md ├── Recording and Using Traces with Playwright.md ├── Recording with Playwright.md ├── Running Github Actions Locally.md ├── Running Your Tests with Vitest.md ├── Securiity and Dependencies.md ├── Setting Up Branch Protections.md ├── Setting Up Prettier Solution.md ├── Setting Up Prettier with Github Actions.md ├── Setting the Environment in Vitest.md ├── Snapshot Tests.md ├── Spies.md ├── Strategies for Testing with State from the Context API.md ├── Test Context.md ├── Test Runners and Assertion Libraries.md ├── Test Suites and Annotations.md ├── Testing Asynchronous Code.md ├── Testing TypeScript Types.md ├── Testing a Reducer Exercise.md ├── Testing a Reducer Solution.md ├── Testing the Accident Counter.md ├── Testing with Wrappers and Context.md ├── Unit Testing Exercise Ideas.md ├── Unit, Integration, and End-to-End Testing.md ├── Why Tests Pass and Fail.md ├── Working with Contexts and Redux.md ├── Working with Dialogs.md ├── Writing Some Simple Playwright Tests.md ├── git-blame-ignore-revs.md ├── orphaned files output.md └── user-event.md ├── index.html ├── jest.config.cjs ├── package-lock.json ├── package.json ├── plugins └── tailwind-buttons.cjs ├── postcss.config.cjs ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── sandbox.config.json ├── src ├── application.tsx ├── colors.json ├── components │ ├── button.tsx │ ├── frame.tsx │ └── input.tsx ├── examples │ ├── asynchronicity │ │ ├── anti-pattern.test.ts │ │ ├── async-add.test.ts │ │ └── package.json │ ├── character-search │ │ ├── character-card.tsx │ │ ├── index.tsx │ │ └── search-input.tsx │ ├── counter-context │ │ ├── context.tsx │ │ ├── counter.test.tsx │ │ ├── index.tsx │ │ ├── package.json │ │ ├── test │ │ │ ├── setup.ts │ │ │ ├── utilities.solution.ts │ │ │ └── utilities.ts │ │ └── vitest.config.ts │ ├── counter │ │ ├── counter.exercise.test.tsx │ │ ├── counter.extension.test.tsx │ │ ├── counter.solution.test.tsx │ │ ├── counter.svelte │ │ ├── counter.svelte.test.tsx │ │ ├── counter.test.tsx │ │ ├── index.tsx │ │ ├── package.json │ │ ├── test │ │ │ ├── setup.ts │ │ │ ├── utilities.solution.ts │ │ │ └── utilities.ts │ │ ├── vitest.config.solution.ts │ │ ├── vitest.config.svelte.ts │ │ └── vitest.config.ts │ ├── fizz-buzz │ │ ├── index.tsx │ │ ├── number-range.tsx │ │ ├── number.tsx │ │ └── numbers.tsx │ ├── getting-started │ │ ├── README.md │ │ ├── example.test.ts │ │ ├── exponent.test.ts │ │ ├── exponent.ts │ │ ├── math.test.ts │ │ ├── math.ts │ │ ├── package.json │ │ ├── vitest.config.ts │ │ ├── words.test.ts │ │ └── words.ts │ ├── great-expectations │ │ ├── asymmetric-matchers.complete.test.ts │ │ ├── asymmetric-matchers.test.ts │ │ ├── bonus-exercise.complete.test.ts │ │ ├── bonus-exercise.test.ts │ │ ├── exercise.solution.test.ts │ │ ├── exercise.test.ts │ │ ├── items-slice.solution.test.ts │ │ ├── items-slice.test.ts │ │ ├── items-slice.ts │ │ ├── objects.test.ts │ │ ├── package.json │ │ └── primitives.test.ts │ ├── logjam │ │ ├── arithmetic.ts │ │ ├── log.ts │ │ └── test.ts │ ├── obstacle-course │ │ ├── accessibility.svelte.test.tsx │ │ ├── accessibility.test.tsx │ │ ├── exercise.test.tsx │ │ ├── index.tsx │ │ ├── obstacle-course.svelte │ │ ├── package.json │ │ ├── vitest.config.svelte.ts │ │ └── vitest.config.ts │ ├── packing-list-revisited │ │ ├── index.tsx │ │ ├── item-list.test.tsx │ │ ├── item-list.tsx │ │ ├── item.tsx │ │ ├── mark-all-as-unpacked.tsx │ │ ├── new-item.tsx │ │ ├── package.json │ │ ├── packing-list.test.tsx │ │ ├── store │ │ │ ├── hooks.ts │ │ │ ├── index.tsx │ │ │ ├── items-slice.test.ts │ │ │ └── items-slice.ts │ │ └── test │ │ │ └── utilities.ts │ ├── packing-list │ │ ├── README.md │ │ ├── accessibility.test.solution.tsx │ │ ├── index.tsx │ │ ├── item-list.test.tsx │ │ ├── item-list.tsx │ │ ├── item.tsx │ │ ├── mark-all-as-unpacked.tsx │ │ ├── new-item.tsx │ │ ├── package.json │ │ ├── packing-list.solution.test.tsx │ │ ├── packing-list.test.tsx │ │ └── store │ │ │ ├── hooks.ts │ │ │ ├── index.tsx │ │ │ ├── items-slice.test.ts │ │ │ └── items-slice.ts │ ├── parallelizing-tests │ │ ├── package.json │ │ └── sleep.test.ts │ ├── parameterizing-tests │ │ ├── package.json │ │ ├── polygon.complete.test.ts │ │ └── polygon.test.ts │ ├── sign-up │ │ ├── index.tsx │ │ └── sign-up-form.tsx │ ├── snapshot-tests │ │ ├── __snapshots__ │ │ │ └── polygon.complete.test.ts.snap │ │ └── polygon.complete.test.ts │ ├── test-context │ │ ├── context.test.ts │ │ ├── extending-context.test.ts │ │ └── package.json │ └── time-zone │ │ ├── __snapshots__ │ │ └── time-zone.test.tsx.snap │ │ ├── get-tasks-from-api.ts │ │ ├── index.tsx │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── random.ts │ │ ├── tasks.tsx │ │ ├── test │ │ ├── setup.jest.ts │ │ ├── setup.ts │ │ └── utilities.ts │ │ ├── time-zone.test.tsx │ │ └── vitest.config.ts ├── global.d.ts ├── index.css ├── lib │ ├── __snapshots__ │ │ └── polygon.test.ts.snap │ ├── get-area.test.ts │ ├── get-area.ts │ ├── id.ts │ ├── kanban-board.ts │ ├── person.test.ts │ ├── person.ts │ ├── pluralize.ts │ ├── polygon.test.ts │ ├── polygon.ts │ ├── repeat.ts │ ├── to-fizz-buzz.ts │ └── to-kebab-case.ts └── main.tsx ├── tailwind.config.cjs ├── test ├── setup.jest.ts ├── setup.ts └── utilities.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts ├── vitest.config.svelte.ts └── vitest.config.ts /.bin/export: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/.bin/export -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .idea 4 | 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | pnpm-debug.log* 9 | lerna-debug.log* 10 | *.log 11 | *.swp 12 | /.history 13 | coverage 14 | /playwright-report/ 15 | /playwright/.cache/ 16 | /test-results/ 17 | 18 | dist 19 | dist-ssr 20 | *.local 21 | 22 | .vscode/* 23 | !.vscode/extensions.json 24 | !.vscode/*.code-snippets 25 | 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | 32 | .obsidian 33 | random 34 | content/orphaned files output.md 35 | 36 | html 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": true, 9 | "bracketSameLine": false 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/general.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "TODO": { 3 | "prefix": "todo", 4 | "body": ["$BLOCK_COMMENT_START TODO: ${0} $BLOCK_COMMENT_END"], 5 | "description": "TODO" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/react.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Create React Component": { 3 | "prefix": "crc", 4 | "scope": "javascriptreact,typescriptreact", 5 | "body": [ 6 | "type ${3} = {", 7 | " ${4}", 8 | "};", 9 | "", 10 | "const ${1:Component} = (${2:props}: ${3:PropType}) => {", 11 | " return (", 12 | " $0", 13 | " );", 14 | "};", 15 | "", 16 | "export default ${1};" 17 | ], 18 | "description": "Create React Component" 19 | }, 20 | "event.preventDefault()": { 21 | "prefix": "epd", 22 | "scope": "javascriptreact,typescriptreact", 23 | "body": ["(${1:e}) => { ${1}.preventDefault();${0} }"], 24 | "description": "Event: Prevent Default" 25 | }, 26 | "event.target.value": { 27 | "prefix": "etv", 28 | "scope": "javascriptreact,typescriptreact", 29 | "body": ["(${1:e}) => { ${2:console.log}(${1}.target.value);${0} }"], 30 | "description": "event.target.value" 31 | }, 32 | "useState()": { 33 | "prefix": "us", 34 | "scope": "javascriptreact,typescriptreact", 35 | "body": [ 36 | "const [${1}, set${1/(.*)/${1:/capitalize}/}] = useState($2);", 37 | "$0" 38 | ], 39 | "description": "useState()" 40 | }, 41 | "useReducer": { 42 | "prefix": "ur", 43 | "body": ["const [$1, dispatch] = useReducer($2${3:, initialState});"], 44 | "description": "useReducer" 45 | }, 46 | "Label and Input": { 47 | "prefix": "lai", 48 | "scope": "javascriptreact,typescriptreact", 49 | "body": [ 50 | "", 51 | " { ${4}(e.target.value) }} />" 52 | ], 53 | "description": "Label and Input" 54 | }, 55 | "Label and Input (Wrapped)": { 56 | "prefix": "lad", 57 | "scope": "javascriptreact,typescriptreact", 58 | "body": [ 59 | "<${5:div} ${6:className=\"${7:flex}\"}>", 60 | " ", 61 | " { ${4}(e.target.value) }} />", 62 | "<${5}>" 63 | ], 64 | "description": "Label and Input (Wrapped)" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.vscode/testing.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Test Suite": { 3 | "prefix": "dsc", 4 | "scope": "javascript,typescript,javascriptreact,typescriptreact", 5 | "body": ["describe('${1:test suite}', () => {", " $0", "});"], 6 | "description": "Describe" 7 | }, 8 | "Test Case": { 9 | "prefix": "tst", 10 | "scope": "javascript,typescript", 11 | "body": [ 12 | "${1|test,it|}('${2:description}', ${3:async }() => {", 13 | " $0", 14 | "});" 15 | ], 16 | "description": "Test Case" 17 | }, 18 | "Expect": { 19 | "prefix": "xp", 20 | "scope": "javascript,typescript,javascriptreact,typescriptreact", 21 | "body": ["expect($1).${2:toBe}($3);"], 22 | "description": "Expect" 23 | }, 24 | "Import": { 25 | "prefix": "imp", 26 | "scope": "javascript,typescript,javascriptreact,typescriptreact", 27 | "body": ["import { $0 } from '${1}';"], 28 | "description": "Set ups an import statement" 29 | }, 30 | "Import (Default)": { 31 | "prefix": "imd", 32 | "scope": "javascript,typescript,javascriptreact,typescriptreact", 33 | "body": ["import $0 from '${1}';"], 34 | "description": "Set ups an import statement" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /assets/Pasted image 20230322042256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/Pasted image 20230322042256.png -------------------------------------------------------------------------------- /assets/branch-protection-rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/branch-protection-rules.png -------------------------------------------------------------------------------- /assets/build-and-test-with-names.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/build-and-test-with-names.png -------------------------------------------------------------------------------- /assets/caches-in-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/caches-in-actions.png -------------------------------------------------------------------------------- /assets/enterprise-user-interface-development.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/enterprise-user-interface-development.png -------------------------------------------------------------------------------- /assets/jobs-with-a-dependency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/jobs-with-a-dependency.png -------------------------------------------------------------------------------- /assets/parallel-jobs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/parallel-jobs.png -------------------------------------------------------------------------------- /assets/playwright-vscode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/playwright-vscode.png -------------------------------------------------------------------------------- /assets/reading-from-the-cache.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/reading-from-the-cache.png -------------------------------------------------------------------------------- /assets/running-your-unit-tests-on-github-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/running-your-unit-tests-on-github-actions.png -------------------------------------------------------------------------------- /assets/tale-of-two-caches.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/tale-of-two-caches.png -------------------------------------------------------------------------------- /assets/technologies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/technologies.png -------------------------------------------------------------------------------- /assets/test-and-build-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/test-and-build-actions.png -------------------------------------------------------------------------------- /assets/writing-to-the-cache.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/writing-to-the-cache.png -------------------------------------------------------------------------------- /assets/your-first-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/assets/your-first-actions.png -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react", "@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /content/Adding a Step to Your Github Action.md: -------------------------------------------------------------------------------- 1 | Let's start by adding a build step after installing our dependencies and before running our test. 2 | 3 | Building the application is a verification step in-and-of itself since we're using TypeScript. (I'll leave it as an exercise to the reader if you want to run some other type checker like `tsc`, but this gets the jobs done.) 4 | 5 | ```yml 6 | name: Unit Tests 7 | 8 | on: 9 | push: 10 | branches: [main] 11 | pull_request: 12 | branches: [main] 13 | 14 | jobs: 15 | build-and-test: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout respository 20 | uses: actions/checkout@v3 21 | - name: Setup Node 22 | uses: actions/setup-node@v3 23 | - run: npm ci 24 | name: Install modules from npm 25 | - run: npm run build 26 | name: Build the application 27 | - run: npm test 28 | name: Run the tests 29 | ``` 30 | 31 | You'll notice that I also named the steps. This is totally optional. I like it, but it really doesn't matter much. 32 | 33 | ![](../assets/build-and-test-with-names.png) 34 | 35 | (No, I don't know what to do about the inconsistent capitalization.) 36 | 37 | # Adding a Second Job 38 | 39 | What if we want `build` and `test` to run in parallel? We _could_ try something like this: 40 | 41 | ```yml 42 | name: Unit Tests 43 | 44 | on: 45 | push: 46 | branches: [main] 47 | pull_request: 48 | branches: [main] 49 | 50 | jobs: 51 | test: 52 | runs-on: ubuntu-latest 53 | 54 | steps: 55 | - name: Checkout respository 56 | uses: actions/checkout@v3 57 | - name: Setup Node 58 | uses: actions/setup-node@v3 59 | - run: npm ci 60 | name: Install modules from npm 61 | - run: npm test 62 | name: Run the tests 63 | build: 64 | runs-on: ubuntu-latest 65 | 66 | steps: 67 | - name: Checkout respository 68 | uses: actions/checkout@v3 69 | - name: Setup Node 70 | uses: actions/setup-node@v3 71 | - run: npm ci 72 | name: Install modules from npm 73 | - run: npm run build 74 | name: Build the application 75 | ``` 76 | 77 | You'll now have to jobs running in parallel: 78 | 79 | ![](../assets/test-and-build-actions.png) 80 | 81 | There seems to be some amount of duplication though, right? Part of this is because they're running in parallel. You can actually see this in the **Actions** tab in your repository: 82 | 83 | ![](../assets/parallel-jobs.png) 84 | 85 | It doesn't look like much, but trust me, we're running in parallel. That said, we're still downloading and installing our assets twice. 86 | -------------------------------------------------------------------------------- /content/Basic Component Testing Exercise.md: -------------------------------------------------------------------------------- 1 | Theoretically, `counter.test.ts` should now look something like this: 2 | 3 | ```ts 4 | import { screen, render } from '@testing-library/react'; 5 | import userEvent from '@testing-library/user-event'; 6 | import Counter from '.'; 7 | 8 | test('it should render the component', () => { 9 | render(); 10 | const currentCount = screen.getByTestId('current-count'); 11 | expect(currentCount).toHaveTextContent('0'); 12 | }); 13 | 14 | test('it should increment with the "Increment" button is pressed', async () => { 15 | const user = userEvent.setup(); 16 | render(); 17 | 18 | const currentCount = screen.getByTestId('current-count'); 19 | const incrementButton = screen.getByRole('button', { name: 'Increment' }); 20 | 21 | await user.click(incrementButton); 22 | 23 | expect(currentCount).toHaveTextContent('1'); 24 | }); 25 | ``` 26 | 27 | # Your Mission 28 | 29 | 1. The `` component takes a prop called `initialCount`, which defaults to `0` but can be set to any integer. Write a test where you set it to some other value and verify that it did what you expected. 30 | 1. Write a test where you click the "Reset" button. Assert that the counter is set back to `0`. 31 | 32 | You can peek at a possible solution [here](Basic%20Component%20Testing%20Solution.md). 33 | -------------------------------------------------------------------------------- /content/Basic Component Testing Solution.md: -------------------------------------------------------------------------------- 1 | Here is a potential solution: 2 | 3 | ```ts 4 | test('it should render the component with an initial count', () => { 5 | render(); 6 | const currentCount = screen.getByTestId('current-count'); 7 | expect(currentCount).toHaveTextContent('400'); 8 | }); 9 | 10 | test('it should reset the count when the "Reset" button is pressed', async () => { 11 | const user = userEvent.setup(); 12 | render(); 13 | 14 | const currentCount = screen.getByTestId('current-count'); 15 | const resetButton = screen.getByRole('button', { name: 'Reset' }); 16 | 17 | await user.click(resetButton); 18 | 19 | expect(currentCount).toHaveTextContent('0'); 20 | }); 21 | ``` 22 | 23 | We can also [create a useful helper](Creating%20a%20Helper%20for%20Rendering%20Components%20and%20User%20Events.md) if we want to bundle this functionality together. 24 | 25 | # What About Other Frameworks? 26 | 27 | The really cool thing about `testing-library` is that other than some of the peculiarities around mounting the given components, it's somewhat framework agnostic. You can see the full example in `counter.svelte.test.tsx`, but let's look at the same solutions from up above—but using Svelte instead of React. 28 | 29 | **Nota bene**: I have a `vitest.config.svelte.ts` config at the top level that you can use to run this test if you'd like. 30 | 31 | ```ts 32 | test('it should render the component with an initial count', () => { 33 | render(Counter, { count: 400 }); 34 | const currentCount = screen.getByTestId('current-count'); 35 | expect(currentCount).toHaveTextContent('400'); 36 | }); 37 | 38 | test('it should reset the count when the "Reset" button is pressed', async () => { 39 | const user = userEvent.setup(); 40 | render(Counter, { count: 400 }); 41 | 42 | const currentCount = screen.getByTestId('current-count'); 43 | const resetButton = screen.getByRole('button', { name: 'Reset' }); 44 | 45 | await user.click(resetButton); 46 | 47 | expect(currentCount).toHaveTextContent('0'); 48 | }); 49 | ``` 50 | 51 | The `render` method is slightly different, but the rest of the test remains the same. 52 | -------------------------------------------------------------------------------- /content/Caching Assets Between Jobs.md: -------------------------------------------------------------------------------- 1 | # Caching Assets Between Jobs 2 | 3 | Let's _start_ by using `actions/cache@v3` to cache the npm cache between runs. 4 | 5 | ```yaml 6 | steps: 7 | - name: Checkout respository 8 | uses: actions/checkout@v3 9 | - name: Setup Node 10 | uses: actions/setup-node@v3 11 | - uses: actions/cache@v3 # 👀 12 | id: npm-cache # 👀 13 | with: # 👀 14 | path: ~/.npm # 👀 15 | key: npm-${{ hashFiles('**/package-lock.json') }} # 👀 16 | - run: npm ci 17 | name: Install modules from npm 18 | - run: npm test 19 | name: Run the tests 20 | ``` 21 | 22 | If we look at the Post Run for `actions/cache@v3`, we'll see that we wrote to the cache. 23 | 24 | ![Writing to the Cache](../assets/writing-to-the-cache.png) 25 | 26 | We can also navigate to it in the UI by going to the **Actions** tab and selecting **Caches** from the sidebar. 27 | 28 | ![Using the Cache](../assets/caches-in-actions.png) 29 | In my very silly example, we'll see that it cut the total time down by 4 seconds. This may not seem like a lot, but it's 33%. 30 | 31 | ![Reading from the Cache](../assets/reading-from-the-cache.png) 32 | 33 | ## Quick Exercise/Experiment 34 | 35 | Go ahead and add it to the `build` job too and observe what happens. 36 | 37 | ## Collapsing Our Code 38 | 39 | As you can imagine, this is a pretty common task. So common that `actions/setup-node@v3` has a little helper that will do this for you. 40 | 41 | ```yml 42 | - name: Setup Node 43 | uses: actions/setup-node@v3 44 | with: 45 | node-version: 18 46 | cache: 'npm' 47 | ``` 48 | 49 | `actions/setup-node@v3` names it a little differently, so you'll end up with a different cache, but that's okay. 50 | 51 | ![The Tale of Two Caches](../assets/tale-of-two-caches.png) 52 | 53 | But, like just because `actions/setup-node@v3` does it for you, it does't mean that you won't want this functionality elsewhere. Here are some use cases off the top of my mind: 54 | 55 | - You have a job or step that builds your assets and as long as something hasn't changed, then we want to just use whatever we had last time. 56 | - For example, we have a script that install Temporal CLI along with the rest of our dependencies. I might choose to cache so that I'm _not_ re-downloading it. 57 | -------------------------------------------------------------------------------- /content/Caching Dependencies.md: -------------------------------------------------------------------------------- 1 | # Further Reading 2 | 3 | - [Caching dependencies to speed up workflows - GitHub Docs](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows) 4 | -------------------------------------------------------------------------------- /content/Clearing, Restoring, and Reseting Mocks and Spies.md: -------------------------------------------------------------------------------- 1 | Generally speaking, you want to put stuff back the way you found it in order to make sure that you have good test isolation things don't get weird when tests have long-lasting side effects that cause other tests to fail for no particularly good reason. 2 | 3 | # Object Methods 4 | 5 | - `spy.mockClear()`: Clears out all of the information about how it was called and what it returned. This is effectively the same as setting `spy.mock.calls` and `spy.mock.results` back to empty arrays. 6 | - `spy.mockReset()`: In addition to doing what `spy.mockClear()`, this method replaces the inner implementation with an empty function. 7 | - `spy.mockRestore()`: In addition to doing what `spy.mockReset()` does, it replaces the implementation with the original functions. 8 | 9 | # Mock Lifecycle Methods 10 | 11 | You'd typically put these in an `afterEach` block within your test suite. 12 | 13 | - `vi.clearAllMocks`: Clears out the history of calls and return values on the spies, but does _not_ reset them to their default implementation. This is effectively the same as calling `.mockClear()` on each and every spy. 14 | - `vi.resetAllMocks`: Calls `.mockReset()` on all the spies. It will replace any mock implementations with an empty function. 15 | - `vi.restoreAllMocks`: Calls `.mockRestore()` on each and every mock. 16 | -------------------------------------------------------------------------------- /content/Component Testing Solution.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ```ts 4 | it('renders the Packing List application', () => { 5 | render(); 6 | }); 7 | 8 | it('has the correct title', async () => { 9 | render(); 10 | screen.getByText('Packing List'); 11 | }); 12 | 13 | it('has an input field for a new item', () => { 14 | render(); 15 | screen.getByLabelText('New Item Name'); 16 | }); 17 | 18 | it('has a "Add New Item" button that is disabled when the input is empty', () => { 19 | render(); 20 | const newItemInput = screen.getByLabelText('New Item Name'); 21 | const addNewItemButton = screen.getByRole('button', { name: 'Add New Item' }); 22 | 23 | expect(newItemInput).toHaveValue(''); 24 | expect(addNewItemButton).toBeDisabled(); 25 | }); 26 | 27 | it('enables the "Add New Item" button when there is text in the input field', async () => { 28 | const { user } = render(); 29 | const newItemInput = screen.getByLabelText('New Item Name'); 30 | const addNewItemButton = screen.getByRole('button', { name: 'Add New Item' }); 31 | 32 | await user.type(newItemInput, 'MacBook Pro'); 33 | 34 | expect(addNewItemButton).toBeEnabled(); 35 | }); 36 | 37 | it('adds a new item to the unpacked item list when the clicking "Add New Item"', async () => { 38 | const { user } = render(); 39 | const newItemInput = screen.getByLabelText('New Item Name'); 40 | const addNewItemButton = screen.getByRole('button', { 41 | name: 'Add New Item', 42 | }); 43 | 44 | await user.type(newItemInput, 'MacBook Pro'); 45 | await user.click(addNewItemButton); 46 | 47 | expect(screen.getByLabelText('MacBook Pro')).not.toBeChecked(); 48 | }); 49 | ``` 50 | 51 | # Removing an Item 52 | 53 | We have a little bit of an issue that I'm dancing around here. Notice how I'm _conveniently_ choosing to add an item with a different name? 54 | 55 | ```ts 56 | it('removes an item when the remove button is clicked', async () => { 57 | const { user } = render(); 58 | 59 | const newItemInput = screen.getByLabelText('New Item Name'); 60 | const addNewItemButton = screen.getByRole('button', { 61 | name: 'Add New Item', 62 | }); 63 | 64 | await user.type(newItemInput, 'iPad Pro'); 65 | await user.click(addNewItemButton); 66 | 67 | const item = screen.getByLabelText('iPad Pro'); 68 | const removeButton = screen.getByRole('button', { 69 | name: 'Remove iPad Pro', 70 | }); 71 | 72 | await user.click(removeButton); 73 | 74 | expect(item).not.toBeInTheDocument(); 75 | }); 76 | ``` 77 | -------------------------------------------------------------------------------- /content/Components of Large Application.md: -------------------------------------------------------------------------------- 1 | I'm going to argue that in addition to the code that actually creates any amount of customer value and makes you or your company money, there is code working behind the scenes to support it—and, that's what this course is all about. I 2 | 3 | - Unit testing. 4 | - Component testing (e.g. [Enzyme](https://enzymejs.github.io/enzyme/), [Testing Library](https://testing-library.com)). 5 | - Integration testing (e.g. [Cypress](https://www.cypress.io), [Playwright](https://playwright.dev)). 6 | - Static type-checking (e.g. [Flow](https://flow.org), [TypeScript](https://www.typescriptlang.org)). 7 | - Static analysis (e.g. [ESlint](https://eslint.org), [Prettier](https://prettier.io)). 8 | - Audits: Performance, accessibility, etc. (e.g. [aXe](https://www.deque.com/axe), [Lighthouse](https://developer.chrome.com/docs/lighthouse/overview/)) 9 | -------------------------------------------------------------------------------- /content/Configuring and Running ESLint.md: -------------------------------------------------------------------------------- 1 | You've probably at least heard of [ESLint](https://eslint.org) before, but just in case you haven't. ESLint is tool for performing static analysis on your code and finding places where you're code might have some unexpected edge-cases. On it's best days, it can be a powerful tool to help you enforce best practices in your codebase. On it's worst days, it can annoy your co-workers with pendantic formatting issues and other stylistic nitpicks. 2 | 3 | To install `eslint`: 4 | 5 | ``` 6 | npm install -D eslint 7 | ``` 8 | 9 | Next, you'll need to initialize a configuration file. 10 | 11 | ``` 12 | npm init @eslint/config 13 | ``` 14 | 15 | This will ask you a few questions: 16 | 17 | ``` 18 | ? How would you like to use ESLint? … 19 | To check syntax only 20 | ❯ To check syntax and find problems 21 | To check syntax, find problems, and enforce code style 22 | ``` 23 | 24 | I'll let you answer those questions as you see fit. It's your enterprise application—you do what you want. 25 | 26 | # Setting Up a Rule 27 | 28 | Let's take look at [this rule](https://eslint.org/docs/latest/rules/no-console). 29 | 30 | # Further Reading 31 | 32 | - [Command Line Interface Reference - ESLint - Pluggable JavaScript Linter](https://eslint.org/docs/latest/use/command-line-interface) 33 | - [How To Enable Linting on Save with Visual Studio Code and ESLint | DigitalOcean](https://www.digitalocean.com/community/tutorials/workflow-auto-eslinting) 34 | - [Auto-fix and format your JavaScript with ESLint - IBM Developer](https://developer.ibm.com/articles/auto-fix-and-format-your-javascript-with-eslint/) 35 | - [javascript - Is it safe to apply autofix from ESLint? - Stack Overflow](https://stackoverflow.com/questions/50289536/is-it-safe-to-apply-autofix-from-eslint) 36 | - [How to Set Up ESLint Autofix and Prettier on Save in WebStorm | The WebStorm Blog (jetbrains.com)](https://blog.jetbrains.com/webstorm/2016/08/using-external-tools/) 37 | -------------------------------------------------------------------------------- /content/Create ESLint Rule (Solution).md: -------------------------------------------------------------------------------- 1 | ```ts 2 | export const meta = { 3 | type: 'problem', 4 | hasSuggestions: true, 5 | fixable: true, 6 | }; 7 | 8 | export function create(context) { 9 | return { 10 | MemberExpression: (node) => { 11 | if (node.object.name === 'console' && node.property.name === 'error') { 12 | context.report({ 13 | message: 'Illegal use of the console.error. LogError.', 14 | node, 15 | fix(fixer) { 16 | return [fixer.replaceText(node, 'logError')]; 17 | }, 18 | }); 19 | } 20 | }, 21 | }; 22 | } 23 | ``` 24 | -------------------------------------------------------------------------------- /content/Creating Your Own Github Action.md: -------------------------------------------------------------------------------- 1 | - [Github Actions Toolkit](https://github.com/actions/toolkit) 2 | - [JavaScript Action Template](https://github.com/actions/javascript-action) 3 | - [Github Script](https://github.com/actions/github-script) 4 | -------------------------------------------------------------------------------- /content/Creating a Helper for Rendering Components and User Events.md: -------------------------------------------------------------------------------- 1 | Where you put this file is up to you, but we _can_ bundle together the act of rendering a component _and_ setting up `user-event`. For example, we could do something like this: 2 | 3 | ```ts 4 | import type { ReactElement } from 'react'; 5 | import { render as renderComponent } from '@testing-library/react'; 6 | import userEvent from '@testing-library/user-event'; 7 | 8 | type RenderOptions = Parameters[1]; 9 | 10 | export * from '@testing-library/react'; 11 | 12 | export const render = (ui: ReactElement, options?: RenderOptions) => { 13 | return { 14 | ...renderComponent(ui, options), 15 | user: userEvent.setup(), 16 | }; 17 | }; 18 | ``` 19 | 20 | As of this writing the type for the `options`, which is the optional second argument to `render` is `Omit`. I don't care to think about this ever again. So, I chose to use `Parameters[1]` to define a type that can best be summarized as, "Whatever the second argument to `render` is." 21 | -------------------------------------------------------------------------------- /content/Creating a Reusable Github Action.md: -------------------------------------------------------------------------------- 1 | Consider this action that I use [in my code](https://github.com/temporalio/ui/blob/9e063585055b561e40502759efe7182c3d4c2911/.github/actions/checkout-and-setup/action.yml) 2 | 3 | It looks just like a workflow, but it's actually a reusable action. 4 | 5 | ```yml 6 | name: 'Checkout, Install Node and Dependencies' 7 | description: 'Install dependencies' 8 | 9 | runs: 10 | using: 'composite' 11 | steps: 12 | - uses: actions/checkout@v3 13 | name: Checkout Repository 🫠 14 | - name: Setup Node 🏞️ 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | cache: 'npm' 19 | - run: npm ci 20 | ``` 21 | 22 | And then in your workflow: 23 | 24 | ```yml 25 | name: Run Unit Tests 26 | 27 | on: 28 | push: 29 | branches: [main] 30 | pull_request: 31 | branches: [main] 32 | 33 | jobs: 34 | unit-test: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout and Setup Node 38 | uses: ./.github/actions/checkout-and-setup 39 | - run: npm test 40 | build: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout and Setup Node 44 | uses: ./.github/actions/checkout-and-setup 45 | - run: npm run build 46 | ``` 47 | 48 | This feels like it should work, right? Why won't it? Why? Why? 49 | 50 | **Hint**: What's the order of operations here? 51 | -------------------------------------------------------------------------------- /content/Danger Example and Exercise Ideas.md: -------------------------------------------------------------------------------- 1 | - Check for an entry to the changelog in every pull request unless it has `#trivial` somwhere in the PR title or body. 2 | - Make sure a new test file (e.g. `do-something.test.ts`) is created for every new utility file (e.g. `do-something.ts`). 3 | -------------------------------------------------------------------------------- /content/Dealing with Authentication.md: -------------------------------------------------------------------------------- 1 | # Further Reading 2 | 3 | - [Authentication | Playwright](https://playwright.dev/docs/auth) 4 | -------------------------------------------------------------------------------- /content/Dependency Injection.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/content/Dependency Injection.md -------------------------------------------------------------------------------- /content/Device Emultation in Playwright.md: -------------------------------------------------------------------------------- 1 | - [ ] [Device Emulation in Playwright](https://playwright.dev/docs/emulation). 2 | -------------------------------------------------------------------------------- /content/Downloading and Uploading Files.md: -------------------------------------------------------------------------------- 1 | https://playwright.dev/docs/downloads 2 | -------------------------------------------------------------------------------- /content/EditorConfig.md: -------------------------------------------------------------------------------- 1 | EditorConfig is a standard for setting up consistent editor settings across your team. I'm going to lift a quote from [their website](https://editorconfig.org): 2 | 3 | > EditorConfig helps maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. The EditorConfig project consists of **a file format** for defining coding styles and a collection of **text editor plugins** that enable editors to read the file format and adhere to defined styles. EditorConfig files are easily readable and they work nicely with version control systems. 4 | 5 | Depending what editor you use, it might support reading the `.editorconfig` out of the box. Editors like Visual Studio Code, Visual Studio, and some of the assorted JetBrains editors (e.g. WebStorm, PyCharm, etc.) [support EditorConfig by default](https://editorconfig.org/#pre-installed). 6 | 7 | If you're using Atom, Vim, Emacs, SublimeText, TextMate, or one of the other editors that don't have support built in, then you can [download the appropriate plug-in](https://editorconfig.org/#download) and you should be good to go. 8 | 9 | There are certainly a number of other ways to enforce coding standards, but EditorConfig is a fairly lightweight and unobstrusive way to get everyone on the same page. This could be helpful for you if you're switching between mutliple projects that have different standards or for getting everyone on your team aligned regardless of the conventions of whatever operating system they happen to be using. 10 | 11 | Here is an example of a file: 12 | 13 | ```ini 14 | root = true 15 | 16 | [*] 17 | end_of_line = lf 18 | insert_final_newline = true 19 | 20 | [*.{js,ts,jsx,tsx}] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [*.{yml,yaml}] 25 | indent_style = space 26 | indent_size = 4 27 | trim_trailing_whitespace = true 28 | ``` 29 | 30 | You can see a list of all of the supported properties [here](https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties). Not every property is supported by every editor and/or plugin. 31 | -------------------------------------------------------------------------------- /content/Evaluating Code in the Page Context.md: -------------------------------------------------------------------------------- 1 | https://playwright.dev/docs/evaluating 2 | -------------------------------------------------------------------------------- /content/Events Supported by fireEvent.md: -------------------------------------------------------------------------------- 1 | In case you wanted to see a full list of the events that you can fire, I looked it up [in the source code for you](https://raw.githubusercontent.com/testing-library/dom-testing-library/main/src/event-map.js). 2 | 3 | - `copy` 4 | - `cut` 5 | - `paste` 6 | - `compositionEnd` 7 | - `compositionStart` 8 | - `compositionUpdate` 9 | - `keyDown` 10 | - `keyPress` 11 | - `keyUp` 12 | - `focus` 13 | - `blur` 14 | - `focusIn` 15 | - `focusOut` 16 | - `change` 17 | - `input` 18 | - `invalid` 19 | - `submit` 20 | - `reset` 21 | - `click` 22 | - `contextMenu` 23 | - `dblClick` 24 | - `drag` 25 | - `dragEnd` 26 | - `dragEnter` 27 | - `dragExit` 28 | - `dragLeave` 29 | - `dragOver` 30 | - `dragStart` 31 | - `drop` 32 | - `mouseDown` 33 | - `mouseEnter` 34 | - `mouseLeave` 35 | - `mouseMove` 36 | - `mouseOut` 37 | - `mouseOver` 38 | - `mouseUp` 39 | - `select` 40 | - `touchCancel` 41 | - `touchEnd` 42 | - `touchMove` 43 | - `touchStart` 44 | - `resize` 45 | - `scroll` 46 | - `wheel` 47 | - `abort` 48 | - `canPlay` 49 | - `canPlayThrough` 50 | - `durationChange` 51 | - `emptied` 52 | - `encrypted` 53 | - `ended` 54 | - `loadedData` 55 | - `loadedMetadata` 56 | - `loadStart` 57 | - `pause` 58 | - `play` 59 | - `playing` 60 | - `progress` 61 | - `rateChange` 62 | - `seeked` 63 | - `seeking` 64 | - `stalled` 65 | - `suspend` 66 | - `timeUpdate` 67 | - `volumeChange` 68 | - `waiting` 69 | - `load` 70 | - `error` 71 | - `animationStart` 72 | - `animationEnd` 73 | - `animationIteration` 74 | - `transitionCancel` 75 | - `transitionEnd` 76 | - `transitionRun` 77 | - `transitionStart` 78 | - `pointerOver` 79 | - `pointerEnter` 80 | - `pointerDown` 81 | - `pointerMove` 82 | - `pointerUp` 83 | - `pointerCancel` 84 | - `pointerOut` 85 | - `pointerLeave` 86 | - `gotPointerCapture` 87 | - `lostPointerCapture` 88 | - `popState` 89 | - `offline` 90 | - `online` 91 | -------------------------------------------------------------------------------- /content/Extending ESLint.md: -------------------------------------------------------------------------------- 1 | # Further Reading 2 | 3 | - [Ways to Extend ESLint - ESLint - Pluggable JavaScript Linter](https://eslint.org/docs/latest/extend/ways-to-extend) 4 | - [Custom Rules - ESLint - Pluggable JavaScript Linter](https://eslint.org/docs/latest/extend/custom-rules) 5 | - [Custom Processors - ESLint - Pluggable JavaScript Linter](https://eslint.org/docs/latest/extend/custom-processors) 6 | - [Custom Formatters - ESLint - Pluggable JavaScript Linter](https://eslint.org/docs/latest/extend/custom-formatters) 7 | - [Custom Parsers - ESLint - Pluggable JavaScript Linter](https://eslint.org/docs/latest/extend/custom-parsers) 8 | -------------------------------------------------------------------------------- /content/Factories.md: -------------------------------------------------------------------------------- 1 | ## Further Reading and Viewing 2 | 3 | - [Supercharge your Tests with Factories - Chris Ball | Prisma Day 2021 - YouTube](https://www.youtube.com/watch?v=a5S5thDd7Xg) 4 | -------------------------------------------------------------------------------- /content/Formatting staged files with lint-staged.md: -------------------------------------------------------------------------------- 1 | - [`lint-staged` examples](https://github.com/okonet/lint-staged#examples) 2 | -------------------------------------------------------------------------------- /content/Generating Artifacts Using Github Actions.md: -------------------------------------------------------------------------------- 1 | # Generating Artifacts Using Github Actions 2 | 3 | Github Actions allow you to store an artifact that was create by any of your build processes just by using their `actions/upload-artifact@v3` action. For example, if we wanted to save the output of our build, we could do so using the following: 4 | 5 | ```yml 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout respository 10 | uses: actions/checkout@v3 11 | - name: Setup Node 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | cache: 'npm' 16 | - run: npm ci 17 | name: Install modules from npm 18 | - run: npm run build 19 | name: Build the application 20 | - name: Archive build output 21 | uses: actions/upload-artifact@v3 22 | with: 23 | name: build-output 24 | path: dist 25 | ``` 26 | 27 | Now, we can push that up and see that it's ready and waiting for us when the action completes. 28 | 29 | # Exercise 30 | 31 | Can you add an job that generates the coverage report and makes it available as an artifact that we can download? 32 | -------------------------------------------------------------------------------- /content/Generating Integration Tests with Playwright.md: -------------------------------------------------------------------------------- 1 | # Further Reading 2 | 3 | - [Test Generator (Introduction) | Playwright](https://playwright.dev/docs/codegen-intro) 4 | - [Test Generator | Playwright](https://playwright.dev/docs/codegen) 5 | -------------------------------------------------------------------------------- /content/Generating an Artifact for Your Code Coverage Report.md: -------------------------------------------------------------------------------- 1 | ```yml 2 | test: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - name: Checkout respository 6 | uses: actions/checkout@v3 7 | - name: Setup Node 8 | uses: actions/setup-node@v3 9 | with: 10 | node-version: 18 11 | cache: 'npm' 12 | - name: Install modules from npm 13 | run: npm ci 14 | - name: Run coverage report 15 | run: npm run coverage 16 | - name: Generate coverage report 17 | uses: actions/upload-artifact@v3 18 | with: 19 | name: coverage-report 20 | path: coverage 21 | ``` 22 | -------------------------------------------------------------------------------- /content/Github Actions Events.md: -------------------------------------------------------------------------------- 1 | - `branch_protection_rule`: Runs whenever one of your [branch protection rules](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches) are changed. You can have it run when a rule is `created`, `changed`, or `deleted`. 2 | - `check_run`: Runs whenever a [check](https://docs.github.com/en/rest/guides/getting-started-with-the-checks-api) occurs. 3 | - `check_suite`: 4 | - `create`: Runs whenever some creates a branch or tag in your respository on Github. 5 | - `delete`: 6 | - `deployment`: 7 | - `deployment_status`: 8 | - `discussion`: 9 | - `discussion_comment`: 10 | - `fork`: Runs whenever someone forks your repository. 11 | - `gollum`: Runs whenever someone updates a page on the \[repository's Wiki\]([About wikis](https://docs.github.com/en/communities/documenting-your-project-with-wikis/about-wikis). (I don't make the rules here.) 12 | - `issue_comment`: 13 | - `issues`: 14 | - `label`: 15 | - `merge_group`: 16 | - `milestone`: 17 | - `page_build`: 18 | - `project`: 19 | - `project_card`: 20 | - `project_column`: 21 | - `public`: Runs whenever your repository changes from private to public. 22 | - `pull_request`: 23 | - `pull_request_comment`: 24 | - `pull_request_review`: 25 | - `pull_request_review_comment`: 26 | - `pull_request_target`: 27 | - `push`: Runs whenever a commit or tag is pushed to the repo. 28 | - `registry_package`: 29 | - `release`: 30 | - `repository_dispatch`: 31 | - `schedule`: Runs a workflow regularly on a schedule that you define. 32 | - `status`: 33 | - `watch`: Runs whenever someone stars your repository. 34 | - `workflow_call`: 35 | - `workflow_dispatch`: 36 | - `workflow_run`: 37 | -------------------------------------------------------------------------------- /content/Github Actions Example Ideas.md: -------------------------------------------------------------------------------- 1 | --- 2 | tags: github-actions, github, examples 3 | created: 2023-02-06T20:52:18-07:00 4 | updated: 2023-03-19T12:01:14-06:00 5 | --- 6 | 7 | - Run a code coverage report on a regular interval 8 | - Check Lighthouse performance on a weekly basis 9 | - Run the linter on every push to a PR 10 | - Send a notification to a Slack channel when a PR is opened 11 | -------------------------------------------------------------------------------- /content/Globally Extending Matchers in the Test Setup for Component Testing.md: -------------------------------------------------------------------------------- 1 | Let's work under the assumption that you're _not_ interested in doing this in every single test: 2 | 3 | ```ts 4 | expect.extend(matchers); 5 | ``` 6 | 7 | # Making `it`, `expect`, and Friends Globally Available 8 | 9 | We _can_ extend `expect` globally, with one caveat: You have to use the globally available version of `expect` in your tests. 10 | 11 | ```ts 12 | import { defineConfig } from 'vitest/config'; 13 | 14 | import configuration from './vite.config'; 15 | 16 | export default defineConfig({ 17 | ...configuration, 18 | 19 | test: { 20 | globals: true, 21 | setupFiles: './test/setup.ts', 22 | }, 23 | }); 24 | ``` 25 | 26 | # Creating a Test Setup File 27 | 28 | In the event you don't already have a test setup file, let's create one. You _could_ name this file whatever you. I'm going to name it `vitest.setup.ts` because that's my preference. 29 | 30 | In `vitest.setup.ts`, I can add the following: 31 | 32 | ```ts 33 | import '@testing-library/jest-dom'; 34 | ``` 35 | 36 | We can do something similar [for the environment as well](Setting%20the%20Environment%20in%20Vitest.md). 37 | -------------------------------------------------------------------------------- /content/Helpful Resources and Tools.md: -------------------------------------------------------------------------------- 1 | # Helpful Resources 2 | 3 | - [Vitest plugin for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=ZixuanChen.vitest-explorer) 4 | -------------------------------------------------------------------------------- /content/Hooking Up aXe with Testing Library.md: -------------------------------------------------------------------------------- 1 | I created more inputs than I know what to do with in `src/examples/obstacle-course`. Ideally, we'd love something that enforces whether or not or components are accessible. 2 | 3 | Lucky for us, we can hook up aXe to Testing Library and run automated tests that our elements are accessible. 4 | 5 | ```ts 6 | import { render } from 'test/utilities'; 7 | import { axe, toHaveNoViolations } from 'jest-axe'; 8 | import ObstacleCourse from '.'; 9 | 10 | expect.extend(toHaveNoViolations); // This *could* go in `test/setup`. 11 | 12 | it('should be accessible', async () => { 13 | const { container } = render(); 14 | const results = await axe(container); 15 | 16 | expect(results).toHaveNoViolations(); 17 | }); 18 | ``` 19 | 20 | aXe adds an additional matcher called `toHaveNoViolations`. 21 | 22 | # Experiment Time 23 | 24 | Can you set this up in the Packing List application? 25 | -------------------------------------------------------------------------------- /content/Husky, Lint-Staged, and Git Hooks.md: -------------------------------------------------------------------------------- 1 | # Husky, Lint-Staged, and Git Hooks 2 | 3 | **Important-ish**: Make sure to add this "prepare" script to your `package.json`. 4 | 5 | ``` 6 | npm install -D husky lint-stage commitlint 7 | ``` 8 | 9 | Here is what a Husky file might end up looking like: 10 | 11 | ```sh 12 | #!/usr/bin/env sh 13 | . "$(dirname -- "$0")/_/husky.sh" 14 | 15 | npx lint-staged 16 | ``` 17 | 18 | Here is an example of my `lint-staged` set up: 19 | 20 | ```json 21 | { 22 | "*.{ts,js}": ["pnpm lint:fix", "prettier --write"], 23 | "*.{css,postcss,svelte}": "stylelint --fix", 24 | "*.{json,md}": "prettier --write" 25 | } 26 | ``` 27 | 28 | ``` 29 | npx husky add .husky/precommit 30 | ``` 31 | 32 | ## Commitlint 33 | 34 | We can install and set up Commitlint: 35 | 36 | ``` 37 | # Install and configure if needed 38 | npm install --save-dev @commitlint/{cli,config-conventional} 39 | # For Windows: 40 | npm install --save-dev @commitlint/config-conventional @commitlint/cli 41 | 42 | # Configure commitlint to use conventional config 43 | echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js 44 | ``` 45 | 46 | And then we can add the hook to husky. 47 | 48 | ``` 49 | npx husky add .husky/commit-msg 'npx commitlint --edit ${1}' 50 | ``` 51 | 52 | Now, we can test the usage: 53 | 54 | ``` 55 | npx commitlint --from HEAD~1 --to HEAD --verbose 56 | ``` 57 | 58 | We can also take `@commitlint/prompt-cli`. 59 | 60 | Here is a script for validating that you have a Jira ticket (or whatever) number in your commit: 61 | 62 | ``` 63 | commitlint: { 64 | 'rules': { 65 | 'references-empty': [2, 'never'], 66 | }, 67 | parserPreset: { 68 | parserOpts: { 69 | issuePrefixes: ['PROJ-'] 70 | } 71 | }, 72 | } 73 | ``` 74 | 75 | ## In a Github Action 76 | 77 | You _could_ just use this on the PR title since that ends up being the commit if you use squash and commit. 78 | 79 | ``` 80 | ${{ github.event.pull_request.title }} 81 | ``` 82 | -------------------------------------------------------------------------------- /content/Input Obstacles.md: -------------------------------------------------------------------------------- 1 | # Exercise: Input Obstacles 2 | 3 | At another location, we have a series of input field that dynamically update the page through the magic of TypeScript. 4 | 5 | Pick five, change the values and then validate that you got what you expect. I'll live code some crowd favorites (and then I'll update this page later with my code). 6 | 7 | -------------------------------------------------------------------------------- /content/Interacting with the DOM Using Testing Library.md: -------------------------------------------------------------------------------- 1 | Okay, mounting a component is great and wonderful, but let's say we want to actually—you know—_do something_ to that component that we just mounted? (Wild, I know.) 2 | 3 | Let's look at an example where maybe we want to click the "Increment" button and verify that the counter incremented. 4 | 5 | ```ts 6 | test('it should increment with the "Increment" button is pressed', () => { 7 | render(); 8 | 9 | const currentCount = screen.getByTestId('current-count'); 10 | const incrementButton = screen.getByRole('button', { name: 'Increment' }); 11 | 12 | fireEvent.click(incrementButton); 13 | 14 | expect(currentCount).toHaveTextContent('1'); 15 | }); 16 | ``` 17 | 18 | Two tasting notes: 19 | 20 | 1. We were able to look for the button on the `document.body` by looking for it's `role`, which on `button` elements is determined by it's text content. 21 | 1. We used `fireEvent` to dispatch a `click` event to that button. 22 | 23 | [Here is a list](Events%20Supported%20by%20fireEvent.md) of _all_ of the events that you can fire with `fireEvent`. I looked it up in the source code. You can thank me later. 24 | 25 | It's useful to to use the `role` as opposed to the actual HTML element in the name of testing our application as a user might experience it. Most of the built-in HTML attributes have a pre-assigned role. So, this is not necessarily something that you need to to take on yourself. You can check out the full list [here](https://www.w3.org/TR/html-aria/#docconformance). You can also read more about the options that you can pass [here](https://testing-library.com/docs/queries/byrole#options). 26 | 27 | If you want to see _all of the events_ supported by `fireEvent`, I made a list for you: [Events Supported by `fireEvent`](Events%20Supported%20by%20fireEvent.md). 28 | 29 | It turns out that we can improve on this by using a companion library called [`@testing-library/user-event`](https://www.npmjs.com/package/@testing-library/user-event). Let's take [a look at how to do that](user-event.md). 30 | -------------------------------------------------------------------------------- /content/Introduction to Integration Testing.md: -------------------------------------------------------------------------------- 1 | We're going to use [Playwright](https://playwright.dev) for our integration tests. A lot of the ideas translate over to some of the other browser-testing frameworks. 2 | 3 | > \[!NOTE\] What about Cypress? 4 | > If you use [Cypress](https://cypress.io), I did [a whole course on Cypress](https://frontendmasters.com/courses/cypress/) not too long ago. Like I said, a lot of the concepts we're going to talk about in this section apply to Cypress as well, but if you want to go deeper into the specific of _how_ to write Cypress tests, you should totally check that course out—_after_ this one, of course. 5 | 6 | # Further Reading 7 | 8 | - [Best Practices | Playwright](https://playwright.dev/docs/best-practices) 9 | -------------------------------------------------------------------------------- /content/Labelling PRs.md: -------------------------------------------------------------------------------- 1 | - [Pull Request Labeller](https://github.com/actions/labeler) 2 | -------------------------------------------------------------------------------- /content/Mock Browser APIs in Playwright.md: -------------------------------------------------------------------------------- 1 | https://playwright.dev/docs/mock-browser-apis 2 | -------------------------------------------------------------------------------- /content/Mock Service Worker.md: -------------------------------------------------------------------------------- 1 | [MSW – Seamless API mocking library for browser and Node | Mock Service Worker (mswjs.io)](https://mswjs.io/) 2 | -------------------------------------------------------------------------------- /content/Mocking APIs.md: -------------------------------------------------------------------------------- 1 | A better alternative is just mock out the entire network layer—since you don't control it anyway. 2 | 3 | For this, we'll use a library called [Mock Service Worker](https://mswjs.io). 4 | 5 | ```ts 6 | import { setupWorker, rest } from 'msw'; 7 | 8 | const worker = setupWorker( 9 | rest.post('/login', async (req, res, ctx) => { 10 | const { username } = await req.json(); 11 | 12 | return res( 13 | ctx.json({ 14 | username, 15 | firstName: 'John', 16 | }), 17 | ); 18 | }), 19 | ); 20 | 21 | worker.start(); 22 | ``` 23 | 24 | ```ts 25 | import { afterAll, afterEach, beforeAll } from 'vitest'; 26 | import { setupServer } from 'msw/node'; 27 | import { graphql, rest } from 'msw'; 28 | 29 | const posts = [ 30 | { 31 | userId: 1, 32 | id: 1, 33 | title: 'first post title', 34 | body: 'first post body', 35 | }, 36 | // ... 37 | ]; 38 | 39 | export const restHandlers = [ 40 | rest.get('https://rest-endpoint.example/path/to/posts', (req, res, ctx) => { 41 | return res(ctx.status(200), ctx.json(posts)); 42 | }), 43 | ]; 44 | ]; 45 | 46 | const server = setupServer(...restHandlers, ...graphqlHandlers); 47 | 48 | // Start server before all tests 49 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); 50 | 51 | // Close server after all tests 52 | afterAll(() => server.close()); 53 | 54 | // Reset handlers after each test `important for test isolation` 55 | afterEach(() => server.resetHandlers()); 56 | ``` 57 | -------------------------------------------------------------------------------- /content/Mocking Environment Variables.md: -------------------------------------------------------------------------------- 1 | ```ts 2 | import { beforeEach, expect, it } from 'vitest'; 3 | 4 | // you can reset it in beforeEach hook manually 5 | const originalViteEnv = import.meta.env.VITE_ENV; 6 | 7 | beforeEach(() => { 8 | import.meta.env.VITE_ENV = originalViteEnv; 9 | }); 10 | 11 | it('changes value', () => { 12 | import.meta.env.VITE_ENV = 'staging'; 13 | expect(import.meta.env.VITE_ENV).toBe('staging'); 14 | }); 15 | ``` 16 | 17 | Here is a better way. 18 | 19 | ```ts 20 | import { expect, it, vi } from 'vitest'; 21 | 22 | // before running tests "VITE_ENV" is "test" 23 | import.meta.env.VITE_ENV === 'test'; 24 | 25 | it('changes value', () => { 26 | vi.stubEnv('VITE_ENV', 'staging'); 27 | expect(import.meta.env.VITE_ENV).toBe('staging'); 28 | }); 29 | 30 | it('the value is restored before running an other test', () => { 31 | expect(import.meta.env.VITE_ENV).toBe('test'); 32 | }); 33 | ``` 34 | -------------------------------------------------------------------------------- /content/Mocking Globals.md: -------------------------------------------------------------------------------- 1 | [`vi.stubGlobal`](https://vitest.dev/api/vi.html#vi-stubglobal) basically does what it says on the packaging. It takes two arguments: 2 | 3 | 1. The key on the global object that you want to stub. 4 | 1. An mock implementation to use. 5 | 6 | ```ts 7 | import { vi } from 'vitest'; 8 | 9 | const IntersectionObserverMock = vi.fn(() => ({ 10 | disconnect: vi.fn(), 11 | observe: vi.fn(), 12 | takeRecords: vi.fn(), 13 | unobserve: vi.fn(), 14 | })); 15 | 16 | vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); 17 | ``` 18 | -------------------------------------------------------------------------------- /content/Mocking Imports and Modules.md: -------------------------------------------------------------------------------- 1 | > \[!warning\] A Work on Module Systems 2 | > `vi.mock` works only for modules that were imported with the `import` keyword. It doesn't work with `require`. 3 | 4 | Vitest provides `vi.mock`, which allows you to mock any import that you provide a path for. It's go the following signature: 5 | 6 | ```ts 7 | (path: string, factory?: () => unknown) => void 8 | ``` 9 | 10 | The `factory` is a function that you provide as a substitute for whatever _really_ resides at the file path. You'll that it's optional. Here is how it goes down: 11 | 12 | 1. If you provided a `factory` function, it will use the return value of that function as the replacement for whatever module lives at `path`. 13 | 1. If you don't provide a factory, but you do have a `__mocks__ ` directory at the same location and an alternative file in that `__mocks__ ` directory, then it will substiture that in. 14 | 1. Vitest will use it's automocking algorithm. 15 | 16 | # Automocking Algorithm 17 | 18 | If you don't provide a factory, Vitest will employ its [automocking algorithm](https://vitest.dev/guide/mocking.html#automocking-algorithm): 19 | 20 | - All arrays will be emptied. 21 | - All primitives and collections will stay the same. 22 | - All objects will be deeply cloned. 23 | - All instances of classes and their prototypes will be deeply cloned. 24 | 25 | > \[!todo\] Explain the automocking algorithm 26 | > You'll probably want to add some kind of visual as well as an example in code and some tests that prove your assumptions. 27 | 28 | # `vi.doMock` 29 | 30 | [`vi.doMock`](https://vitest.dev/api/vi.html#vi-domock) is basically the same as `vi.mock` except for the fact that it's _not_ hoisted to the top, which means you have access to variables. The _next_ import of that module will be mocked. 31 | 32 | > \[!todo\] Explain `vi.mock` hoisting 33 | > Show with some concrete examples the difference between `vi.mock` and `vi.doMock`. 34 | -------------------------------------------------------------------------------- /content/Mocking Network Requests.md: -------------------------------------------------------------------------------- 1 | The easy way to do this is to use `page.route`. 2 | 3 | ```ts 4 | await page.route('https://dog.ceo/api/breeds/list/all', async (route) => { 5 | const json = { 6 | message: { test_breed: [] }, 7 | }; 8 | await route.fulfill({ json }); 9 | }); 10 | ``` 11 | 12 | This will set up Playwright to intercept any network requests that go to this route and replace it wiht the response that we provided. 13 | 14 | # Recording and Replaying a HAR 15 | -------------------------------------------------------------------------------- /content/Mocks Directory.md: -------------------------------------------------------------------------------- 1 | If you use `vi.mock` or `jest.mock`, you can specifiy an alternative implementation that should be used when mocked. 2 | -------------------------------------------------------------------------------- /content/Mocks.md: -------------------------------------------------------------------------------- 1 | The TL;DR of mocking is that sometimes we need to swap out things we don't control with things that we _do_. For example, it might be outside of the scope of our test to make sure that a third-party API goes down. Or, if that API isn't free, you don't necessarily want to run up a bill every time you run your test suite, right? 2 | 3 | You can create a mock function using `vi.fn()` (or, `jest.fn()`), which takes a callback function. If you you don't provide one, it'll just use an empty function as the implementation (e.g. `() => undefined`). 4 | 5 | ```ts 6 | const getNumber = vi.fn(() => 5000); 7 | 8 | const number = getNumber(); 9 | 10 | expect(number).toBe(5000); 11 | expect(getNumber).toHaveBeenCalled(); 12 | expect(number).toHaveReturnedWith(5000); 13 | ``` 14 | 15 | # Methods 16 | 17 | - `mockImplementation`: Takes a function that you want your mock function to call whenever it's called. 18 | - `mockImplementationOnce`: Accepts a function that will only be used the _next time_ a function is called. 19 | - `withImplementation`: Overrides the original mock implementation temporarily while the callback is being executed. Calls the function immediately. 20 | - `mockReturnValue`: Nevermind the implementation, we just know we want it to return whatever value. 21 | - `mockReturnValueOnce`: Set the return value—but only the _next time_ it's called. 22 | - `mockResolvedValue`: Sets the value of the promise when it resolves. 23 | - `mockResolvedValueOnce`: Set the resolved value of a promise _next time_ it resolves. 24 | - `mockRejectedValue`: Rejects a promise with the error provided. 25 | - `mockRejectedValueOnce`: Rejects a promise with the error provided _next time_. 26 | - `mockReturnThis`: Sets the value of `this`. 27 | -------------------------------------------------------------------------------- /content/My Current Github Action for Testing and Linting.md: -------------------------------------------------------------------------------- 1 | ```yaml 2 | name: Run Tests, Lint, and Check Types 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | paths-ignore: 10 | - '**.md' 11 | - 'LICENSE' 12 | - 'CODEOWNERS' 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | defaults: 19 | run: 20 | shell: bash 21 | 22 | jobs: 23 | unit-tests: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | - name: Checkout and Setup Node 28 | uses: ./.github/actions/checkout-and-setup 29 | - name: Run Unit Tests 30 | run: pnpm test 31 | ``` 32 | -------------------------------------------------------------------------------- /content/Navigating with Playwright.md: -------------------------------------------------------------------------------- 1 | # Further Reading 2 | 3 | - [Navigations | Playwright](https://playwright.dev/docs/navigations) 4 | -------------------------------------------------------------------------------- /content/Obstacle Course.md: -------------------------------------------------------------------------------- 1 | # Obstacle Course 2 | 3 | In `src/examples/obstacle-course/exercise.test.tsx` there are a set of skipped tests that you can use to target and manipulate any number of input fields. You can also choose whether or not you want to target the React or Svelte implementation. -------------------------------------------------------------------------------- /content/Packing List Component Testing Exercise.md: -------------------------------------------------------------------------------- 1 | # Packing List: Component Testing Exercise 2 | 3 | Take a look at our **Packing List** application in `src/examples/packing-list`. 4 | 5 | Can you add tests for the follow: 6 | 7 | - Verify that there is an input field for adding a new item. 8 | - Verify that the "Add New Item" button is disabled when the input field is empty. 9 | - Verify that the "Add New Item" button is enabled when there is content in the input field. 10 | - Verify that the new item is added to the page when the "Add New Item" button is clicked. 11 | 12 | If you want to go for a victory lap, you can add some tests for the following: 13 | 14 | - Verify that the input field is cleared out after clicking "Add New Item." 15 | - Verify that you can remove the item. 16 | 17 | **Next**: We'll talk about some [possible solutions](Component%20Testing%20Solution.md). 18 | -------------------------------------------------------------------------------- /content/Parameterizing Playwright Tests.md: -------------------------------------------------------------------------------- 1 | # Further Reading 2 | 3 | - [Parameterize tests | Playwright](https://playwright.dev/docs/test-parameterize) 4 | -------------------------------------------------------------------------------- /content/Playwright Test Suites and Annotations.md: -------------------------------------------------------------------------------- 1 | Just like [Vitest](Test%20Suites%20and%20Annotations.md), Playright also has some annotations for controlling which tests and suite should (or should _not_) run: 2 | 3 | - [`test.skip()`](https://playwright.dev/docs/api/class-test#test-skip-1): Skip this test. 4 | - [`test.fail()`](https://playwright.dev/docs/api/class-test#test-fail-1): This test should fail. I know it should fail. In fact, only hassle me if it _doesn't_ fail. 5 | - [`test.fixme()`](https://playwright.dev/docs/api/class-test#test-fixme-1): I know this one fails. Don't even run it. 6 | - [`test.slow()`](https://playwright.dev/docs/api/class-test#test-slow-1) I might be slow. Go ahead and triple the timeout. 7 | 8 | Playwright doesn't have a `skipIf` like Vitest, but it does have a slightly different flavor for skipping a test. 9 | 10 | ```ts 11 | test('skip this test', async ({ page, browserName }) => { 12 | test.skip(browserName === 'webkit', "I'll get to it one day."); 13 | }); 14 | ``` 15 | -------------------------------------------------------------------------------- /content/Prettier.md: -------------------------------------------------------------------------------- 1 | Prettier enforces formatting rules for and also formats the following types of files: 2 | 3 | - JavaScript (including experimental features) 4 | - [JSX](https://facebook.github.io/jsx/) 5 | - [Angular](https://angular.io/) 6 | - [Vue](https://vuejs.org/) 7 | - [Flow](https://flow.org/) 8 | - [TypeScript](https://www.typescriptlang.org/) 9 | - CSS, [Less](http://lesscss.org/), and [SCSS](https://sass-lang.com/) 10 | - [HTML](https://en.wikipedia.org/wiki/HTML) 11 | - [Ember/Handlebars](https://handlebarsjs.com/) 12 | - [JSON](https://json.org/) 13 | - [GraphQL](https://graphql.org/) 14 | - [Markdown](https://commonmark.org/), including [GFM](https://github.github.com/gfm/) and [MDX](https://mdxjs.com/) 15 | - [YAML](https://yaml.org/) 16 | 17 | # Further Reading 18 | 19 | - [What is Prettier? · Prettier](https://prettier.io/docs/en/index.html) 20 | -------------------------------------------------------------------------------- /content/Recording Network Requests with Playwright.md: -------------------------------------------------------------------------------- 1 | # Recording Network Requests with Playwright 2 | 3 | With Playwright, you can record network requests into a HAR file. 4 | 5 | You can either do that from the CLI: 6 | 7 | ``` 8 | npx playwright open --save-har=network-requests.har --save-har-glob="**/api**" http://localhost:3000 9 | ``` 10 | 11 | Or, from Playwright itself. 12 | 13 | ```ts 14 | const context = await browser.newContext({ 15 | recordHar: { 16 | mode: 'minimal', 17 | path: './network-requests.har', 18 | }, 19 | serviceWorkers: 'block', 20 | }); 21 | ``` 22 | 23 | ## Replaying 24 | 25 | ```ts 26 | await browserContext.routeFromHAR(har); 27 | await browserContext.routeFromHAR(har, { update: true }); 28 | await browserContext.routeFromHAR(har, { update: true, url: '**/api**' }); 29 | ``` 30 | -------------------------------------------------------------------------------- /content/Recording and Using Traces with Playwright.md: -------------------------------------------------------------------------------- 1 | - [ ] [Trace Viewer](https://playwright.dev/docs/trace-viewer) 2 | -------------------------------------------------------------------------------- /content/Recording with Playwright.md: -------------------------------------------------------------------------------- 1 | # Recording, Screenshots, and Snapshots 2 | with Playwright 3 | 4 | Typing stuff out is all well and good, but if we're looking to get some amount of coverage really quickly _and_ we're willing to remember that there is a lot of value in just making sure everything is where we expect, we can get use Playwright's recording feature to get a sanity check quickly. 5 | 6 | ![](../assets/Pasted%20image%2020230322042256.png) 7 | 8 | ## Screenshots 9 | 10 | You can take a visual snapshot of a element or the entire page. 11 | 12 | ```ts 13 | await expect(heading).toHaveScreenshot('heading.png'); 14 | await expect(heading).toHaveScreenshot('heading.png', { maxDiffPixels: 100 }); 15 | ``` 16 | 17 | You can also just assert that an element has what you expect as it's text content. 18 | 19 | ```ts 20 | const label = page.getByTestId('search-label'); 21 | await expect(await label.textContent()).toMatchSnapshot(); 22 | ``` -------------------------------------------------------------------------------- /content/Running Github Actions Locally.md: -------------------------------------------------------------------------------- 1 | # Running Github Actions Locally 2 | 3 | If you want to run your Github actions locally, you can totally do that, but you're going to need Docker and [act](https://github.com/nektos/act). 4 | 5 | The easiest way to get it is probably to use `brew`: 6 | 7 | ``` 8 | brew install act 9 | ``` 10 | 11 | And then you can trigger is using `act pull_request` or whatever other [event](Github%20Actions%20Events.md) you want to trigger. 12 | 13 | Pulling down Docker images is _slow_ and as much as I have a tolerance for live-coding, I don't have the same tolerance for live Dockering. So, consider this part an independent study. 14 | -------------------------------------------------------------------------------- /content/Running Your Tests with Vitest.md: -------------------------------------------------------------------------------- 1 | # Running Your Tests With Vitest 2 | 3 | Vitest comes with a bunch of commands for running your tests: 4 | 5 | - `vitest`, `vitest watch`, `vitest dev`: Run your test suite and then watches for changes to either your tests or your code. 6 | - `vitest run`: Runs your test suite once and only once. 7 | - `vitest related`: Accepts some paths and then walks your import statements to figure out all of the related files. Example: `vitest related /src/index.ts /src/hello-world.js`. 8 | - `vitest ${substring}` : Only runs the files with a filename that contain whatever substring you provide. Example: `vitest world` will run `/src/hello-world.test.ts` but not `/src/index.test.ts`. 9 | 10 | ## Options 11 | 12 | - `--update`, `-u`: Update snapshots. 13 | - `--ui`: Opens Vitest UI. 14 | - `--dom`: Mock browser APIs using [happy-dom](https://www.npmjs.com/package/happy-dom) or [jsdom](https://npm.im/jsdom). 15 | - `--browser`: Run your tests in the browser. 16 | - `--api`: Serve API. This one supports `--api.port` and `--api.host` as well. 17 | 18 | ## Some Example Tests 19 | 20 | Just in case you're coming from some other testing framework _or_ you testing muscle is just a little bit atrophied—we've all been there—let's start with some super simple tests and we'll escalate from there. 21 | 22 | Let's head into `getting-started` and run our tests for the first time. You can run this test by doing the following: 23 | 24 | - `npx vitest` from inside of `./src/examples/getting-started`. 25 | - Run `npm test` from inside of `./src/examples/getting-started`. 26 | 27 | ## `it` And `test` 28 | 29 | `it` and `test` are synonyms. You can (but _probably_ shouldn't) use them interchangeably. I like `it`. So, that's going to be what I use. But, you're welcome to use whatever you want. 30 | 31 | ```ts 32 | import { expect, it } from 'vitest'; 33 | 34 | const add = (a: number, b: number) => a + b; 35 | 36 | it('should work as expected', () => { 37 | expect(add(2, 4)).toBe(6); 38 | }); 39 | ``` 40 | 41 | You didn't ask, but **here is my general heuristic**: If I find myself starting every single test name with the word "it", then I'll use `it`. If that feels awkward, the I'll use `test`. But, at the very least, I'll try to be consistent in whatever file I'm working with. 42 | 43 | **Warning**: One thing to keep in mind is that if you test has no failing expectations, then it is a passing test. The twist, of course, is that if a test passes, it _could_ just be that none of the expectations were called. We'll revisit this when we cover [Testing Asynchronous Code](Testing%20Asynchronous%20Code.md). This isn't a **Bad Thing™** and will come in handy down the road. 44 | 45 | Let's talk a bit more about [Why Tests Pass and Fail](Why%20Tests%20Pass%20and%20Fail.md). 46 | -------------------------------------------------------------------------------- /content/Securiity and Dependencies.md: -------------------------------------------------------------------------------- 1 | - Code Security with Github 2 | - Supply Chain Security 3 | - Security Advisories 4 | - Dependabot 5 | - Code Scanning 6 | - Secret Scanning 7 | -------------------------------------------------------------------------------- /content/Setting Up Branch Protections.md: -------------------------------------------------------------------------------- 1 | # Setting Up Branch Protections 2 | 3 | You went through all of this work to make sure that you'd run your tests whenever you pushed a commit to a PR. You probably want to make sure that they actually stop someone from doing something silly, right? 4 | 5 | In your repository's **Settings > Branch > Branch protection rules**, you can add some restrictions: 6 | 7 | ![](../assets/branch-protection-rules.png) 8 | 9 | By default, this rule _does not_ apply to administrators of the repository—which is probably you right now, but there is an additional option at the bottom that enforces this for administrators as well. 10 | -------------------------------------------------------------------------------- /content/Setting Up Prettier Solution.md: -------------------------------------------------------------------------------- 1 | # Setting Up Prettier (Solution) 2 | 3 | Here is a quick action for setting up Prettier to check your code formatting before merging it into `main`. 4 | 5 | ```yml 6 | check-formatting: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout respository 10 | uses: actions/checkout@v3 11 | - name: Setup Node 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | cache: 'npm' 16 | - run: npm ci 17 | name: Install modules from npm 18 | - name: Check formatting with Prettier 19 | run: npm run format:check 20 | ``` 21 | 22 | You're not wrong to think that there is some duplication here between the steps. 23 | -------------------------------------------------------------------------------- /content/Setting Up Prettier with Github Actions.md: -------------------------------------------------------------------------------- 1 | # Setting Up Prettier 2 | 3 | Do we _need_ to set up Prettier _now_? **No**. Do I want an excuse to have you write your own Github Action? **Yes**. So, here we are. 4 | 5 | ``` 6 | npm install -D prettier 7 | ``` 8 | 9 | You can configure it however you want. You don't even have to configure it, frankly. As of right now, this is what my `.prettierrc` file looks like: 10 | 11 | ```json 12 | { 13 | "printWidth": 80, 14 | "tabWidth": 2, 15 | "useTabs": false, 16 | "semi": true, 17 | "singleQuote": true, 18 | "trailingComma": "all", 19 | "bracketSpacing": true, 20 | "bracketSameLine": false 21 | } 22 | ``` 23 | 24 | Let's also make our lives easier and add some commands to `package.json`: 25 | 26 | ```json 27 | { 28 | //… 29 | "scripts": { 30 | //… 31 | "format:check": "prettier . --check --ignore-path .gitignore", 32 | "format:fix": "prettier . --check --ignore-path .gitignore" 33 | } 34 | //… 35 | } 36 | ``` 37 | 38 | # Exercise 39 | 40 | Can you add a job that checks the formatting of PRs against `main`? 41 | 42 | You can see a solution [Setting Up Prettier Solution](Setting%20Up%20Prettier%20Solution.md). 43 | -------------------------------------------------------------------------------- /content/Setting the Environment in Vitest.md: -------------------------------------------------------------------------------- 1 | Maybe we _don't_ want to have to remember to manually set the enviornment for _every single test file_. 2 | 3 | ```ts 4 | // @vitest-environment jsdom 5 | 6 | import { test } from 'vitest'; 7 | 8 | test('test', () => { 9 | expect(typeof window).not.toBe('undefined'); 10 | }); 11 | ``` 12 | 13 | We _could_ set it globally in `vitest.config.ts`: 14 | 15 | ```ts 16 | import { defineConfig } from 'vitest/config'; 17 | import configuration from './vite.config'; 18 | 19 | export default defineConfig({ 20 | ...configuration, 21 | test: { 22 | globals: true, 23 | environment: 'jsdom', 24 | setupFiles: './test/setup.ts', 25 | }, 26 | }); 27 | ``` 28 | 29 | This will allow us to remove it from `counter.test.ts` without our test failing: 30 | 31 | ```ts 32 | import { screen, render } from '@testing-library/react'; 33 | import Counter from '.'; 34 | 35 | test('it should render the component', () => { 36 | render(); 37 | const currentCount = screen.getByTestId('current-count'); 38 | expect(currentCount).toHaveTextContent('0'); 39 | }); 40 | ``` 41 | 42 | # What If I Only Want to Emulate the DOM in Browser Tests? 43 | 44 | If using `jsdom` or `happy-dom` all the time was the path forward, then it would be the default right? Generally speaking, not using one of these environments should be faster. So, it would be nice if we we could just conditionally define different environments based on different file names. 45 | 46 | For example, I could choose to _only_ load `jsdom` if the extension is `.tsx`. 47 | 48 | ```ts 49 | import { defineConfig } from 'vitest/config'; 50 | import configuration from './vite.config'; 51 | 52 | export default defineConfig({ 53 | ...configuration, 54 | test: { 55 | globals: true, 56 | setupFiles: './test/setup.ts', 57 | environmentMatchGlobs: [ 58 | ['**/*.test.tsx', 'jsdom'], 59 | ['**/*.component.test.ts', 'jsdom'], 60 | ], 61 | }, 62 | }); 63 | ``` 64 | 65 | I also just demonstrated using `*.component.test.ts` in the event that I'm _not_ using React and I still want this same basic idea. 66 | 67 | Let's look at how to how [interact with our newly rendered component](Interacting%20with%20the%20DOM%20Using%20Testing%20Library.md). 68 | 69 | # Further Reading 70 | 71 | - [Test Environments in the Offical Vitest documentation](https://vitest.dev/guide/environment.html) 72 | -------------------------------------------------------------------------------- /content/Strategies for Testing with State from the Context API.md: -------------------------------------------------------------------------------- 1 | Okay, so we can wrap any component we want to set test in our state management system of choice, but setting that state could be difficult. We _could_ try to manually create the state every time. 2 | 3 | ```ts 4 | it('should display items', () => { 5 | store.dispatch(add({ name: 'Lucky beanie' })); 6 | 7 | render(, { 8 | wrapper: ItemsProvider, 9 | }); 10 | 11 | // (Your assertions here.) 12 | }); 13 | ``` 14 | 15 | But, there are a few problems: 16 | 17 | - This is tedious. 18 | - Similar to how hot module reloading works, React Testing Library will remount the components, but it will not clear out provider state. This means, that not only would we have to go through the tedium of setting all of that state ourselves, we're also going to have to make sure we clean up after ourselves. 19 | 20 | And, this my friends is typically where our hopes and dreams about creating a solid test suite fall apart. Or, is it? 21 | 22 | There are a few ways that we can navigate around this: 23 | 24 | - Modifying our code for the sake of our tests. 25 | - Look into mocking and stubbing the external world around the components that we want to test. 26 | - Rethink our architecture. 27 | 28 | I want to add a certain amount of judgement to each of those options, but depending on the reality of your particular code base, any one of those might be the right short-term choice. So, we'll talk about them all. 29 | -------------------------------------------------------------------------------- /content/Test Context.md: -------------------------------------------------------------------------------- 1 | # Test Context 2 | 3 | Vitest's [test contexts](https://vitest.dev/guide/test-context.html) are inspired by [Playwright's fixtures](https://playwright.dev/docs/test-fixtures), which we'll discuss later. 4 | 5 | `it` and `test` take a function as a second argument. This function receives the test context as a argument. The test context has two main properties: 6 | 7 | - `meta`: Some metadata about the test itself. 8 | - `expect`: A copy of the Expect API bound to the current test. 9 | 10 | Let's take a look at an otherwise silly example in `examples/05-test-context/context.test.ts`. 11 | 12 | ```ts 13 | import { test, expect } from 'vitest'; 14 | 15 | it('should work', (ctx) => { 16 | expect(ctx.meta.name).toBe('should work'); 17 | }); 18 | 19 | it('should really work', ({ meta }) => { 20 | expect(meta.name).toBe('should really work'); 21 | }); 22 | ``` 23 | 24 | There is also a version of `expect` bound to the current test. 25 | 26 | ```ts 27 | it('should have version of `expect` bound to the current test', (ctx) => { 28 | ctx.expect(ctx.expect).not.toBe(expect); 29 | }); 30 | ``` 31 | 32 | # Extending the Context 33 | 34 | ```ts 35 | interface LocalTestContext { 36 | foo: string; 37 | } 38 | 39 | beforeEach(async (context) => { 40 | // typeof context is 'TestContext & LocalTestContext' 41 | context.foo = 'bar'; 42 | 43 | it('should work', ({ foo }) => { 44 | // typeof foo is 'string' 45 | console.log(foo); // 'bar' 46 | }); 47 | }); 48 | ``` 49 | -------------------------------------------------------------------------------- /content/Test Suites and Annotations.md: -------------------------------------------------------------------------------- 1 | # `describe` 2 | 3 | You can group a set of tests into a suite using `describe`. If you don't use `describe`, all of the tests in a given file as grouped in a suite automatically. 4 | 5 | The is primarily used for organizing your tests. It's helpful because it allows you to skip or isolate a particular group of tests. 6 | 7 | > We'll cover this in a bit more depth in [More on Filtering Tests](More%20on%20Filtering%20Tests.md), but if you provide a `string` after `npm test` or `npx vitest`, then Vitest will only run the rests with that `string` in the filename. Let's say we have two test files: `math.test.ts` and `words.test.ts`. Running `npm test math` will _only_ run `math.test.ts` and _not_ `words.test.ts`. 8 | 9 | For example, if we ran our suite against `examples/02-test-suites/math.test.ts` (using `npm test math --reporter="verbose" --run`, just to make a point), we would see something like this: 10 | 11 | ``` 12 | ✓ math.test.ts (8) 13 | ✓ add (2) 14 | ✓ should add two numbers correctly 15 | ✓ should not add two numbers incorrectly 16 | ✓ subtract (2) 17 | ✓ should subtract the subtrahend from the minuend 18 | ✓ should not subtract two numbers incorrectly 19 | ✓ multiply (2) 20 | ✓ should multiply the multiplicand by the multiplier 21 | ✓ should not multiply two numbers incorrectly 22 | ✓ divide (2) 23 | ✓ should multiply the multiplicand by the multiplier 24 | ✓ should not multiply two numbers incorrectly 25 | ``` 26 | 27 | # Hooks 28 | 29 | Using `describe` allows you to pass a name to your suite, which is helpful when you're debugging. It also gives you access to some helpful hooks: 30 | 31 | - `beforeEach`: Runs before each and every test. 32 | - `afterEach`: Runs after each and every test. 33 | - `beforeAll`: Runs at the very beginning when the suite starts. 34 | - `afterAll`: Runs after all of the tests in the suite have completed. 35 | 36 | # Annotations 37 | 38 | These are fairly similar to what we saw with our individual tests. 39 | 40 | `describe` also has some annotations that add some logic to if any when the suite should run: 41 | 42 | - `describe.skip`: Skip this suite. 43 | - `describe.skipIf`: Skip this suite if the provided value is truthy. 44 | - `describe.only`: Only run this suite (and any others that use `.only` as well, of course). You probably _don't_ want to accidentally commit this. Trust me. It's embarassing. 45 | - `describe.todo`: Marks a suite as something you're going to implement later. This is helpful when you know the kinds of tests that you'll need and and want to keep track of how many you have less. 46 | - `describe.each`: Used for generating a multiple suites on based on a collection of data. We'll talk about this more in [Parameterizing Tests](Parameterizing%20Tests.md). 47 | - `describe.concurrent`: Run all of the tests in this suite concurrently. We'll talk about this more in [Parallelizing Tests](Parallelizing%20Tests.md). 48 | - `describe.shuffle`: Run these tests in a random order. 49 | -------------------------------------------------------------------------------- /content/Testing TypeScript Types.md: -------------------------------------------------------------------------------- 1 | # Further Reading 2 | 3 | - [Testing Types | Guide | Vitest](https://vitest.dev/guide/testing-types.html) 4 | - [expectTypeOf | Vitest](https://vitest.dev/api/expect-typeof.html) 5 | - [assertType | Vitest](https://vitest.dev/api/assert-type.html) 6 | -------------------------------------------------------------------------------- /content/Testing a Reducer Exercise.md: -------------------------------------------------------------------------------- 1 | Let's say we're building a packing list and it keeps track of items in the following manner: 2 | 3 | ```ts 4 | type Item = { 5 | id: string; 6 | name: string; 7 | packed: boolean; 8 | }; 9 | ``` 10 | 11 | And, let's assume we have a `reducer` that supports the following state: 12 | 13 | ```ts 14 | const items = [ 15 | { 16 | id: 2, 17 | name: 'iPhone', 18 | packed: true, 19 | }, 20 | { 21 | id: 2, 22 | name: 'iPhone charger', 23 | packed: false, 24 | }, 25 | ]; 26 | ``` 27 | 28 | This reducer supports the following actions: 29 | 30 | - `add({ name })`: adds an item with a given name to the application's state. 31 | - `remove({ id })`: removes the item with that `id` from the application's state. 32 | - `update({ id, name?, packed? })`: updates whatever properties are on the provided item. 33 | - `toggle({ id })`: Flips the `packed` boolean to its opposite. 34 | - `markAllAsUnpacked()`: Sets the `packed` boolean on all items to `false`. 35 | 36 | # Your Mission 37 | 38 | We have an example implementation in `items-slice.ts`. 39 | 40 | Can you make the tests in `items.slice.ts` pass? 41 | 42 | # Solution 43 | 44 | You can see a possible solution [here](Testing%20a%20Reducer%20Solution.md). 45 | -------------------------------------------------------------------------------- /content/Testing with Wrappers and Context.md: -------------------------------------------------------------------------------- 1 | Testing a basic little counter is all well and good, but what about testing something that is a bit more intertwined? For example: What if we need to test something that is using the Context API or Redux? 2 | 3 | It's a small enough application, so we _could_ just render it and test it at a high-level like we did [in the previous example](Component%20Testing%20Solution.md). 4 | 5 | That said, the reason that I whipped up this example is to use it as a case study for how to deal with smaller pieces of our application that are have dependencies on the larger application. In this case, we've got a React Redux provide that the `` component relies on—and cannot render without. So, how do we test that? 6 | 7 | Consider this disaster waiting to happen: 8 | 9 | ```ts 10 | it('should render', async () => { 11 | render(); 12 | }); 13 | ``` 14 | 15 | This will blow up. I'll save you the effort of scrolling up. Here is the error: 16 | 17 | > `Error: Uncaught [Error: could not find react-redux context value; please ensure the component is wrapped in a ]` 18 | 19 | And, I mean, this makes sense. We didn't wrap it in a ``. 20 | 21 | Luckily, React Testing Library Supports passing in a `wrapper` as an option. 22 | 23 | ```ts 24 | import { render } from 'test/utilities'; 25 | import ItemList from './item-list'; 26 | import { ItemsProvider } from './store'; 27 | 28 | it('should render', async () => { 29 | render(, { 30 | wrapper: ItemsProvider, 31 | }); 32 | }); 33 | ``` 34 | 35 | This test will now render appropriately. Remember how we made our own custom version of `render`? Well, you _could_ do that to always include a certain set of providers as well. (**Note**: I'm not going to right now because I'm working with multiple different example applications.) 36 | 37 | It would look something like this: 38 | 39 | ```ts 40 | export const render = (ui: ReactElement, options?: RenderOptions) => { 41 | return { 42 | ...renderComponent(ui, { wrapper: ItemsProvider, ...options }), 43 | user: userEvent.setup(), 44 | }; 45 | }; 46 | ``` 47 | 48 | And, now it would be included by default. That said, we're still not out of the woods yet. This all works as expected, but it's _difficult_ to create the situations that we want to provide. 49 | 50 | Let's talk about some [possible solutions](Strategies%20for%20Testing%20with%20State%20from%20the%20Context%20API.md). 51 | -------------------------------------------------------------------------------- /content/Unit Testing Exercise Ideas.md: -------------------------------------------------------------------------------- 1 | - A simplified version of the query builder from the Temporal UI 2 | - Connect Four 3 | - Game of War 4 | - A food ordering app (e.g. Toast) 5 | - [RealWorld](https://github.com/gothinkster/realworld) 6 | -------------------------------------------------------------------------------- /content/Unit, Integration, and End-to-End Testing.md: -------------------------------------------------------------------------------- 1 | These terms tend to be overloaded and have a lot of wiggle room, but let's at least draw some lines in the sand. 2 | 3 | - **Unit tests**: An isolated test that tests one thing. Typically, this is a set of tests that might pass particular arguments to a function and then make sure that the value that is return is what we were expecting. 4 | - **Integration tests**: This is where it goes a little squishy. These are tests that test one or more units working together. Sure, any test that exercises two or more units is technically an integration test. But, for our purposes, we're going to say that browser tests (e.g. [Cypress](https://www.cypress.io), [Playwright](https://playwright.dev), [Selenium](https://www.selenium.dev), [WebdriverIO](https://webdriver.io)) are integration tests. 5 | - **End-to-End tests**: These test the _whole system_. In a perfect world, these are testing everything from the authentication flow to the APIs to the UI. Obviously, these are super valuable, but getting this infrastructure in place can be difficult to the point of seemingly impossible without a large investment of effort. 6 | 7 | The moral of the story here is that all of your tests live on a spectrum: unit tests are easy to write and running hundreds or even thousands is pretty quick. A passing integration or end-to-end test provides a lot more confidence, but they're also a lot harder to write and take longer to run. 8 | 9 | The trick here is finding the right balance. It's all about confidence. We're not looking to test for testing's sake. What we _want_ is to be able to change or refactor our code with confidence that we're not accidentally breaking something important. Whatever kinds of tests get you there the fastest are the ones that you should write. 10 | 11 | Sure, integration tests are slower and somewhat harder to write, but sometimes a single integration test can provide a level of confidence that rivals 60 unit tests. 12 | -------------------------------------------------------------------------------- /content/Working with Dialogs.md: -------------------------------------------------------------------------------- 1 | https://playwright.dev/docs/dialogs 2 | -------------------------------------------------------------------------------- /content/git-blame-ignore-revs.md: -------------------------------------------------------------------------------- 1 | # Further Reading 2 | 3 | - [Ignore commits in the blame view (Beta) | GitHub Changelog](https://github.blog/changelog/2022-03-24-ignore-commits-in-the-blame-view-beta/) 4 | - [Ignoring mass reformatting commits with git blame – Rob Allen (akrabat.com)](https://akrabat.com/ignoring-revisions-with-git-blame/) 5 | - [A better `git blame` with `--ignore-rev` | michaelheap.com](https://michaelheap.com/git-ignore-rev/) 6 | -------------------------------------------------------------------------------- /content/orphaned files output.md: -------------------------------------------------------------------------------- 1 | - [[Enterprise UI](../random/Enterprise%20UI.md) 2 | - [[Labelling PRs](Labelling%20PRs.md) 3 | - [[Factories.md]] 4 | - [[Events Supported by fireEvent](Events%20Supported%20by%20fireEvent.md) 5 | - [[Creating Your Own Github Action](Creating%20Your%20Own%20Github%20Action.md) 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | Example Application 16 | 17 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | setupFilesAfterEnv: ['./test/setup.jest.ts'], 6 | moduleNameMapper: { 7 | '^\\$lib(.*)$': '/src/lib$1', 8 | '^\\$components(.*)$': '/src/components$1', 9 | '^test(.*)$': '/test$1', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "enterprise-ui", 3 | "version": "1.0.0", 4 | "description": "Example code and exercises for Steve's \"Enterprise UI Development\" course for Frontend Masters.", 5 | "main": "./src/main.ts", 6 | "type": "module", 7 | "scripts": { 8 | "start": "vite", 9 | "test:svelte": "vitest --config=vitest.config.svelte.ts", 10 | "test:all": "vitest", 11 | "test:accessibility": "vitest accessibility --run", 12 | "coverage": "vitest --coverage --run", 13 | "test": "echo \"Navigate into an example and run the tests from there!\" && exit 1", 14 | "build": "tsc && vite build", 15 | "preview": "vite preview" 16 | }, 17 | "keywords": [ 18 | "react", 19 | "svelte", 20 | "testing", 21 | "vite", 22 | "vitest" 23 | ], 24 | "author": "Steve Kinney ", 25 | "homepage": "https://frontendmasters.com/courses/enterprise-ui-dev/", 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/stevekinney/enterprise-ui-dev" 29 | }, 30 | "license": "MIT", 31 | "dependencies": { 32 | "@emotion/css": "^11.10.6", 33 | "@reduxjs/toolkit": "^1.9.3", 34 | "clsx": "^1.2.1", 35 | "react": "^18.2.0", 36 | "react-dom": "^18.2.0", 37 | "react-redux": "^8.0.5", 38 | "uuid": "^9.0.0" 39 | }, 40 | "devDependencies": { 41 | "@babel/preset-react": "^7.18.6", 42 | "@babel/preset-typescript": "^7.21.0", 43 | "@playwright/test": "^1.31.2", 44 | "@sveltejs/vite-plugin-svelte": "^2.0.3", 45 | "@testing-library/jest-dom": "^5.16.5", 46 | "@testing-library/react": "^14.0.0", 47 | "@testing-library/svelte": "^3.2.2", 48 | "@testing-library/user-event": "^14.4.3", 49 | "@tsconfig/svelte": "^3.0.0", 50 | "@types/jest-axe": "^3.5.5", 51 | "@types/react": "^18.0.28", 52 | "@types/react-dom": "^18.0.11", 53 | "@types/uuid": "^9.0.1", 54 | "@vitejs/plugin-react": "^3.1.0", 55 | "@vitest/coverage-c8": "^0.29.2", 56 | "@vitest/ui": "^0.29.2", 57 | "autoprefixer": "^10.4.14", 58 | "commitlint": "^17.4.4", 59 | "happy-dom": "^8.9.0", 60 | "husky": "^8.0.3", 61 | "jest": "^29.5.0", 62 | "jest-axe": "^7.0.0", 63 | "jest-environment-jsdom": "^29.5.0", 64 | "jsdom": "^21.1.1", 65 | "lint-staged": "^13.2.0", 66 | "postcss": "^8.4.21", 67 | "prettier": "^2.8.4", 68 | "prettier-plugin-tailwindcss": "^0.2.4", 69 | "svelte-preprocess": "^5.0.3", 70 | "tailwindcss": "^3.2.7", 71 | "ts-jest": "^29.0.5", 72 | "ts-node": "^10.9.1", 73 | "typescript": "^4.9.5", 74 | "vite": "^4.1.4", 75 | "vitest": "0.29.2" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /plugins/tailwind-buttons.cjs: -------------------------------------------------------------------------------- 1 | const plugin = require('tailwindcss/plugin'); 2 | 3 | module.exports = plugin(({ addComponents, theme }) => { 4 | const button = (color) => ({ 5 | display: 'inline-flex', 6 | 'align-items': 'center', 7 | 'justify-content': 'center', 8 | 'border-radius': theme('borderRadius.md'), 9 | 'border-width': theme('borderWidth.2'), 10 | 'padding-left': theme('spacing.4'), 11 | 'padding-right': theme('spacing.4'), 12 | 'padding-top': theme('spacing.2'), 13 | 'padding-bottom': theme('spacing.2'), 14 | 'transition-property': theme('transitionProperty.colors'), 15 | 'transition-timing-function': theme('transitionTimingFunction.DEFAULT'), 16 | 'transition-duration': theme('transitionDuration.DEFAULT'), 17 | 'transition-timing-function': theme('transitionTimingFunction.DEFAULT'), 18 | 'border-color': theme(`colors.${color}.700`, 'currentColor'), 19 | 'background-color': theme(`colors.${color}.200`, 'white'), 20 | '&:hover': { 21 | 'background-color': theme(`colors.${color}.300`, 'currentColor'), 22 | }, 23 | '&:active': { 24 | 'background-color': theme(`colors.${color}.400`, 'currentColor'), 25 | }, 26 | '&:disabled': { 27 | 'background-color': theme(`colors.slate.50`, 'currentColor'), 28 | color: theme(`colors.slate.500`, 'currentColor'), 29 | cursor: 'not-allowed', 30 | }, 31 | }); 32 | 33 | addComponents({ 34 | '.btn': button('secondary'), 35 | '.btn-primary': button('primary'), 36 | '.btn-secondary': button('secondary'), 37 | '.btn-danger': button('error'), 38 | '.btn-success': button('success'), 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'tailwindcss/nesting': {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/enterprise-ui-dev/ecdc83d0d019cd23d53d252a4657a5ba300a86d3/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "An Example Application", 3 | "short_name": "Example", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "infiniteLoopProtection": true, 3 | "hardReloadOnChange": false, 4 | "view": "browser" 5 | } 6 | -------------------------------------------------------------------------------- /src/application.tsx: -------------------------------------------------------------------------------- 1 | import FizzBuzz from './examples/fizz-buzz'; 2 | import Counter from './examples/counter'; 3 | import CharacterSearch from './examples/character-search'; 4 | import SignUp from './examples/sign-up'; 5 | import PackingList from './examples/packing-list'; 6 | import ObstacleCourse from './examples/obstacle-course'; 7 | import TimeZone from './examples/time-zone'; 8 | 9 | const Application = () => { 10 | return ( 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | ); 21 | }; 22 | 23 | export default Application; 24 | -------------------------------------------------------------------------------- /src/colors.json: -------------------------------------------------------------------------------- 1 | { 2 | "primary": { 3 | "DEFAULT": "#5E2885", 4 | "50": "#F8F3FB", 5 | "100": "#E8D7F3", 6 | "200": "#C7A1E3", 7 | "300": "#A66AD2", 8 | "400": "#8539BC", 9 | "500": "#5E2885", 10 | "600": "#502271", 11 | "700": "#421C5E", 12 | "800": "#34164A", 13 | "900": "#27103" 14 | }, 15 | "secondary": { 16 | "DEFAULT": "#EEE4F7", 17 | "50": "#FBF9FD", 18 | "100": "#F8F3FB", 19 | "200": "#F1E7F8", 20 | "300": "#EADCF4", 21 | "400": "#E3D0F1", 22 | "500": "#DCC4ED", 23 | "600": "#C299E0", 24 | "700": "#A86ED3", 25 | "800": "#8E43C6", 26 | "900": "#7030A1" 27 | }, 28 | "success": { 29 | "DEFAULT": "#168375", 30 | "50": "#6BE6D6", 31 | "100": "#5AE3D1", 32 | "200": "#37DDC7", 33 | "300": "#22C8B3", 34 | "400": "#1CA594", 35 | "500": "#168375", 36 | "600": "#0E534A", 37 | "700": "#06231F", 38 | "800": "#000000", 39 | "900": "#000000" 40 | }, 41 | "info": { 42 | "DEFAULT": "#3F88C5", 43 | "50": "#CCDFF0", 44 | "100": "#BCD6EB", 45 | "200": "#9DC2E1", 46 | "300": "#7EAFD8", 47 | "400": "#5E9BCE", 48 | "500": "#3F88C5", 49 | "600": "#2F6B9D", 50 | "700": "#224D72", 51 | "800": "#153046", 52 | "900": "#08131B" 53 | }, 54 | "warning": { 55 | "DEFAULT": "#FFBA08", 56 | "50": "#FFEDC0", 57 | "100": "#FFE8AB", 58 | "200": "#FFDC82", 59 | "300": "#FFD15A", 60 | "400": "#FFC531", 61 | "500": "#FFBA08", 62 | "600": "#CF9500", 63 | "700": "#976D00", 64 | "800": "#5F4400", 65 | "900": "#271C00" 66 | }, 67 | "error": { 68 | "DEFAULT": "#D00000", 69 | "50": "#FF8989", 70 | "100": "#FF7474", 71 | "200": "#FF4B4B", 72 | "300": "#FF2323", 73 | "400": "#F90000", 74 | "500": "#D00000", 75 | "600": "#980000", 76 | "700": "#600000", 77 | "800": "#280000", 78 | "900": "#000000" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import colors from '@/colors.json'; 2 | import { css } from '@emotion/css'; 3 | import { ComponentPropsWithoutRef } from 'react'; 4 | 5 | type ButtonProps = { 6 | variant?: keyof typeof colors; 7 | } & ComponentPropsWithoutRef<'button'>; 8 | 9 | const base = '200'; 10 | const hover = '300'; 11 | const active = '400'; 12 | const border = '700'; 13 | 14 | const Button = ({ children, variant = 'secondary' }: ButtonProps) => { 15 | return ( 16 | 33 | ); 34 | }; 35 | 36 | export default Button; 37 | -------------------------------------------------------------------------------- /src/components/frame.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import type { ComponentPropsWithoutRef } from 'react'; 3 | 4 | const Frame = ({ 5 | className, 6 | ...props 7 | }: ComponentPropsWithoutRef<'section'>) => { 8 | return ( 9 |
16 | ); 17 | }; 18 | 19 | export default Frame; 20 | -------------------------------------------------------------------------------- /src/components/input.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { ComponentPropsWithoutRef } from 'react'; 3 | 4 | type InputWithButtonProps = { 5 | id: string; 6 | label: string; 7 | error?: string; 8 | } & ComponentPropsWithoutRef<'input'>; 9 | 10 | const Input = ({ 11 | id, 12 | label, 13 | error, 14 | children, 15 | className, 16 | ...props 17 | }: InputWithButtonProps) => { 18 | return ( 19 |
20 | 29 |
30 | 36 | {children} 37 |
38 | {error &&

{error}

} 39 |
40 | ); 41 | }; 42 | 43 | export default Input; 44 | -------------------------------------------------------------------------------- /src/examples/asynchronicity/anti-pattern.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | 3 | test('Asynchronous code accidentally passes', () => { 4 | setTimeout(() => { 5 | expect(false).toBe(true); 6 | }, 1000); 7 | }); 8 | 9 | test('Asynchronous code has zero expectations', () => { 10 | expect.assertions(0); 11 | setTimeout(() => { 12 | expect(false).toBe(true); 13 | }, 1000); 14 | }); 15 | 16 | test.fails('Code inside of callback never runs', () => { 17 | expect.hasAssertions(); 18 | setTimeout(() => { 19 | expect(false).toBe(true); 20 | }, 1000); 21 | }); 22 | -------------------------------------------------------------------------------- /src/examples/asynchronicity/async-add.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest'; 2 | 3 | const addAsync = (a: number, b: number) => Promise.resolve(a + b); 4 | const onlyEvenNumbers = (a: number) => { 5 | if (a % 2 === 0) return Promise.resolve(a); 6 | return Promise.reject(a); 7 | }; 8 | 9 | it.fails("fails if you don't use an async function", () => { 10 | const result = addAsync(2, 3); 11 | expect(result).toBe(5); 12 | }); 13 | 14 | it('passes if use an `async/await`', async () => { 15 | const result = await addAsync(2, 3); 16 | expect(result).toBe(5); 17 | }); 18 | 19 | it('passes if we expect it to resolve', () => { 20 | const result = addAsync(2, 3); 21 | expect(result).resolves.toBe(5); 22 | }); 23 | 24 | it('passes if we expect to reject', () => { 25 | const result = onlyEvenNumbers(5); 26 | expect(result).rejects.toBe(5); 27 | }); 28 | 29 | it('allows us to catch the error in an async/await', async () => { 30 | expect.assertions(1); 31 | try { 32 | await onlyEvenNumbers(5); 33 | } catch (error) { 34 | expect(error).toBe(5); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /src/examples/asynchronicity/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "vitest" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/examples/character-search/character-card.tsx: -------------------------------------------------------------------------------- 1 | export type Character = { 2 | id: string | number; 3 | firstName: string; 4 | lastName: string; 5 | avatarUrl: string; 6 | type: string; 7 | department: string; 8 | occupation: string; 9 | strength: number; 10 | intelligence: number; 11 | wisdom: number; 12 | charisma: number; 13 | dexterity: number; 14 | constitution: number; 15 | }; 16 | 17 | const CharacterCard = ({ 18 | id, 19 | firstName, 20 | lastName, 21 | avatarUrl, 22 | type, 23 | department, 24 | occupation, 25 | strength, 26 | intelligence, 27 | wisdom, 28 | charisma, 29 | dexterity, 30 | constitution, 31 | }: Character) => { 32 | return ( 33 |
34 |
35 | {`${firstName} 36 |

37 | {firstName} {lastName} 38 |

39 |

#{id}

40 |
41 |
42 |
43 |
Type
44 |
{type}
45 |
46 |
47 |
Occupation
48 |
{occupation}
49 |
50 |
51 |
Department
52 |
{department}
53 |
54 |
55 |
56 |
57 |
Strength
58 |
{strength}
59 |
60 |
61 |
Intelligence
62 |
{intelligence}
63 |
64 |
65 |
Wisdom
66 |
{wisdom}
67 |
68 |
69 |
Charisma
70 |
{charisma}
71 |
72 |
73 |
Dexterity
74 |
{dexterity}
75 |
76 |
77 |
Constitution
78 |
{constitution}
79 |
80 |
81 |
82 | ); 83 | }; 84 | 85 | export default CharacterCard; 86 | -------------------------------------------------------------------------------- /src/examples/character-search/index.tsx: -------------------------------------------------------------------------------- 1 | import Frame from '$components/frame'; 2 | import Input from '$components/input'; 3 | import CharacterCard from './character-card'; 4 | import CharacterSearchInput from './search-input'; 5 | 6 | const character = { 7 | id: 1, 8 | firstName: 'Vivia', 9 | lastName: 'Try', 10 | avatarUrl: 'https://robohash.org/quassedquis.png?size=50x50&set=set1', 11 | type: 'Grass', 12 | department: 'Business Development', 13 | occupation: 'Mechanical Systems Engineer', 14 | strength: 17, 15 | intelligence: 15, 16 | wisdom: 15, 17 | charisma: 17, 18 | dexterity: 15, 19 | constitution: 16, 20 | }; 21 | 22 | const CharacterSearch = () => { 23 | return ( 24 | 25 | 26 |
27 | 28 | 29 |
30 | 31 | ); 32 | }; 33 | 34 | export default CharacterSearch; 35 | -------------------------------------------------------------------------------- /src/examples/character-search/search-input.tsx: -------------------------------------------------------------------------------- 1 | import Input from '$components/input'; 2 | 3 | const CharacterSearchInput = () => { 4 | return ( 5 |
event.preventDefault()}> 6 | 7 | 10 | 11 |
12 | ); 13 | }; 14 | 15 | export default CharacterSearchInput; 16 | -------------------------------------------------------------------------------- /src/examples/counter-context/context.tsx: -------------------------------------------------------------------------------- 1 | import { type PropsWithChildren, useState, createContext } from 'react'; 2 | 3 | type CounterProps = { 4 | initialCount?: number; 5 | }; 6 | 7 | export const CounterContext = createContext({ 8 | count: 0, 9 | increment: () => {}, 10 | reset: () => {}, 11 | }); 12 | 13 | export const CounterProvider = ({ 14 | children, 15 | initialCount = 0, 16 | }: PropsWithChildren) => { 17 | const [count, setCount] = useState(initialCount); 18 | const increment = () => setCount((n) => n + 1); 19 | const reset = () => setCount(0); 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/examples/counter-context/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen, render } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import Counter from '.'; 4 | 5 | test('it should render the component', () => { 6 | render(); 7 | const currentCount = screen.getByTestId('current-count'); 8 | expect(currentCount).toHaveTextContent('0'); 9 | }); 10 | 11 | test('it should increment when the "Increment" button is pressed', async () => { 12 | const user = userEvent.setup(); 13 | render(); 14 | 15 | const currentCount = screen.getByTestId('current-count'); 16 | const incrementButton = screen.getByRole('button', { name: 'Increment' }); 17 | 18 | await user.click(incrementButton); 19 | 20 | expect(currentCount).toHaveTextContent('1'); 21 | }); 22 | 23 | test('it should render the component (again)', () => { 24 | render(); 25 | const currentCount = screen.getByTestId('current-count'); 26 | expect(currentCount).toHaveTextContent('0'); 27 | }); 28 | 29 | test('it should increment when the "Increment" button is pressed (again)', async () => { 30 | const user = userEvent.setup(); 31 | render(); 32 | 33 | const currentCount = screen.getByTestId('current-count'); 34 | const incrementButton = screen.getByRole('button', { name: 'Increment' }); 35 | 36 | await user.click(incrementButton); 37 | 38 | expect(currentCount).toHaveTextContent('1'); 39 | }); 40 | -------------------------------------------------------------------------------- /src/examples/counter-context/index.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import Frame from '$components/frame'; 3 | import { CounterContext, CounterProvider } from './context'; 4 | 5 | const Counter = () => { 6 | const { count, increment, reset } = useContext(CounterContext); 7 | 8 | return ( 9 | 10 |
11 |

Days Since Our Last Gitastrophe™

12 |
13 |
14 | {count} 15 |
16 |
17 | 20 | 23 |
24 | 25 | ); 26 | }; 27 | 28 | export default () => ( 29 | 30 | 31 | 32 | ); 33 | -------------------------------------------------------------------------------- /src/examples/counter-context/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "test": "vitest", 5 | "test:solution": "vitest -c vitest.config.solution.ts", 6 | "test:svelte": "vitest -c vitest.config.svelte.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/examples/counter-context/test/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /src/examples/counter-context/test/utilities.solution.ts: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react'; 2 | import { render as renderComponent } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | type RenderOptions = Parameters[1]; 6 | 7 | export * from '@testing-library/react'; 8 | 9 | export const render = (ui: ReactElement, options?: RenderOptions) => { 10 | return { 11 | ...renderComponent(ui, options), 12 | user: userEvent.setup(), 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/examples/counter-context/test/utilities.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | /** 4 | * For a complete example, see: test/utilities.ts 5 | */ 6 | -------------------------------------------------------------------------------- /src/examples/counter-context/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defaultExclude, defineConfig } from 'vitest/config'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | $components: path.resolve(__dirname, '../../components'), 10 | test: path.resolve(__dirname, 'test'), 11 | }, 12 | }, 13 | test: { 14 | globals: true, 15 | exclude: [...defaultExclude, '**/*.svelte**'], 16 | setupFiles: path.resolve(__dirname, './test/setup.ts'), 17 | environmentMatchGlobs: [ 18 | ['**/*.test.tsx', 'jsdom'], 19 | ['**/*.component.test.ts', 'jsdom'], 20 | ], 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/examples/counter/counter.exercise.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen, render } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import Counter from '.'; 4 | 5 | test('it should render the component', () => { 6 | render(); 7 | const currentCount = screen.getByTestId('current-count'); 8 | expect(currentCount).toHaveTextContent('0'); 9 | }); 10 | 11 | test('it should increment when the "Increment" button is pressed', async () => { 12 | const user = userEvent.setup(); 13 | render(); 14 | 15 | const currentCount = screen.getByTestId('current-count'); 16 | const incrementButton = screen.getByRole('button', { name: 'Increment' }); 17 | 18 | await user.click(incrementButton); 19 | 20 | expect(currentCount).toHaveTextContent('1'); 21 | }); 22 | 23 | test.todo('it should render the component with an initial count', () => {}); 24 | 25 | test.todo( 26 | 'it should reset the count when the "Reset" button is pressed', 27 | async () => {}, 28 | ); 29 | -------------------------------------------------------------------------------- /src/examples/counter/counter.extension.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from './test/utilities.solution'; 2 | import Counter from '.'; 3 | 4 | test('it should render the component', () => { 5 | render(); 6 | const currentCount = screen.getByTestId('current-count'); 7 | expect(currentCount).toHaveTextContent('0'); 8 | }); 9 | 10 | test('it should increment when the "Increment" button is pressed', async () => { 11 | const { user } = render(); 12 | 13 | const currentCount = screen.getByTestId('current-count'); 14 | const incrementButton = screen.getByRole('button', { name: 'Increment' }); 15 | 16 | await user.click(incrementButton); 17 | 18 | expect(currentCount).toHaveTextContent('1'); 19 | }); 20 | 21 | test('it should render the component with an initial count', () => { 22 | render(); 23 | const currentCount = screen.getByTestId('current-count'); 24 | expect(currentCount).toHaveTextContent('400'); 25 | }); 26 | 27 | test('it should reset the count when the "Reset" button is pressed', async () => { 28 | const { user } = render(); 29 | 30 | const currentCount = screen.getByTestId('current-count'); 31 | const resetButton = screen.getByRole('button', { name: 'Reset' }); 32 | 33 | await user.click(resetButton); 34 | 35 | expect(currentCount).toHaveTextContent('0'); 36 | }); 37 | -------------------------------------------------------------------------------- /src/examples/counter/counter.solution.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen, render } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import Counter from '.'; 4 | 5 | test('it should render the component', () => { 6 | render(); 7 | const currentCount = screen.getByTestId('current-count'); 8 | expect(currentCount).toHaveTextContent('0'); 9 | }); 10 | 11 | test('it should increment when the "Increment" button is pressed', async () => { 12 | const user = userEvent.setup(); 13 | render(); 14 | 15 | const currentCount = screen.getByTestId('current-count'); 16 | const incrementButton = screen.getByRole('button', { name: 'Increment' }); 17 | 18 | await user.click(incrementButton); 19 | 20 | expect(currentCount).toHaveTextContent('1'); 21 | }); 22 | 23 | test('it should render the component with an initial count', () => { 24 | render(); 25 | const currentCount = screen.getByTestId('current-count'); 26 | expect(currentCount).toHaveTextContent('400'); 27 | }); 28 | 29 | test('it should reset the count when the "Reset" button is pressed', async () => { 30 | const user = userEvent.setup(); 31 | render(); 32 | 33 | const currentCount = screen.getByTestId('current-count'); 34 | const resetButton = screen.getByRole('button', { name: 'Reset' }); 35 | 36 | await user.click(resetButton); 37 | 38 | expect(currentCount).toHaveTextContent('0'); 39 | }); 40 | -------------------------------------------------------------------------------- /src/examples/counter/counter.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 |

Days Since Our Last Gitastrophe™

8 |
9 |
10 | {String(count)} 11 |
12 |
13 | 16 | 19 |
20 |
21 | -------------------------------------------------------------------------------- /src/examples/counter/counter.svelte.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | import { render, screen } from '@testing-library/svelte'; 4 | import userEvent from '@testing-library/user-event'; 5 | import Counter from './counter.svelte'; 6 | 7 | test('it should render the component', () => { 8 | render(Counter); 9 | const currentCount = screen.getByTestId('current-count'); 10 | expect(currentCount).toHaveTextContent('0'); 11 | }); 12 | 13 | test('it should increment when the "Increment" button is pressed', async () => { 14 | const user = userEvent.setup(); 15 | render(Counter); 16 | 17 | const currentCount = screen.getByTestId('current-count'); 18 | const incrementButton = screen.getByRole('button', { name: 'Increment' }); 19 | 20 | await user.click(incrementButton); 21 | 22 | expect(currentCount).toHaveTextContent('1'); 23 | }); 24 | 25 | test('it should render the component with an initial count', () => { 26 | render(Counter, { count: 400 }); 27 | const currentCount = screen.getByTestId('current-count'); 28 | expect(currentCount).toHaveTextContent('400'); 29 | }); 30 | 31 | test('it should reset the count when the "Reset" button is pressed', async () => { 32 | const user = userEvent.setup(); 33 | render(Counter, { count: 400 }); 34 | 35 | const currentCount = screen.getByTestId('current-count'); 36 | const resetButton = screen.getByRole('button', { name: 'Reset' }); 37 | 38 | await user.click(resetButton); 39 | 40 | expect(currentCount).toHaveTextContent('0'); 41 | }); 42 | -------------------------------------------------------------------------------- /src/examples/counter/counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen, render } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import Counter from '.'; 4 | 5 | test.todo('it should render the component', () => {}); 6 | 7 | test.todo( 8 | 'it should increment when the "Increment" button is pressed', 9 | async () => {}, 10 | ); 11 | -------------------------------------------------------------------------------- /src/examples/counter/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Frame from '$components/frame'; 3 | 4 | type CounterProps = { 5 | initialCount?: number; 6 | }; 7 | 8 | const Counter = ({ initialCount = 0 }: CounterProps) => { 9 | const [count, setCount] = useState(initialCount); 10 | 11 | return ( 12 | 13 |
14 |

Days Since Our Last Gitastrophe™

15 |
16 |
17 | {count} 18 |
19 |
20 | 23 | 30 |
31 | 32 | ); 33 | }; 34 | 35 | export default Counter; 36 | -------------------------------------------------------------------------------- /src/examples/counter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "test": "vitest", 5 | "test:solution": "vitest -c vitest.config.solution.ts", 6 | "test:svelte": "vitest -c vitest.config.svelte.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/examples/counter/test/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /src/examples/counter/test/utilities.solution.ts: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react'; 2 | import { render as renderComponent } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | type RenderOptions = Parameters[1]; 6 | 7 | export * from '@testing-library/react'; 8 | 9 | export const render = (ui: ReactElement, options?: RenderOptions) => { 10 | return { 11 | ...renderComponent(ui, options), 12 | user: userEvent.setup(), 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/examples/counter/test/utilities.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | /** 4 | * For a complete example, see: test/utilities.ts 5 | */ 6 | -------------------------------------------------------------------------------- /src/examples/counter/vitest.config.solution.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defaultExclude, defineConfig } from 'vitest/config'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | $components: path.resolve(__dirname, '../../components'), 10 | test: path.resolve(__dirname, 'test'), 11 | }, 12 | }, 13 | test: { 14 | globals: true, 15 | exclude: [...defaultExclude, '**/*.svelte**'], 16 | setupFiles: path.resolve(__dirname, './test/setup.ts'), 17 | environmentMatchGlobs: [ 18 | ['**/*.test.tsx', 'jsdom'], 19 | ['**/*.component.test.ts', 'jsdom'], 20 | ], 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/examples/counter/vitest.config.svelte.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig } from 'vite'; 3 | import { svelte } from '@sveltejs/vite-plugin-svelte'; 4 | 5 | export default defineConfig({ 6 | plugins: [svelte({ hot: !process.env.VITEST })], 7 | resolve: { 8 | alias: { 9 | $lib: path.resolve(__dirname, '../../lib'), 10 | $components: path.resolve(__dirname, '../../components'), 11 | test: path.resolve(__dirname, '../../../test'), 12 | }, 13 | }, 14 | test: { 15 | globals: true, 16 | include: ['**/*.svelte.test.*'], 17 | environment: 'jsdom', 18 | setupFiles: path.resolve(__dirname, '../../../test/setup.ts'), 19 | environmentMatchGlobs: [ 20 | ['**/*.test.tsx', 'jsdom'], 21 | ['**/*.component.test.ts', 'jsdom'], 22 | ], 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/examples/counter/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defaultExclude, defineConfig } from 'vitest/config'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | $components: path.resolve(__dirname, '../../components'), 10 | }, 11 | }, 12 | test: { 13 | globals: true, 14 | exclude: [...defaultExclude, '**/*.svelte**'], 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/examples/fizz-buzz/index.tsx: -------------------------------------------------------------------------------- 1 | import { type PropsWithChildren, useState } from 'react'; 2 | import Numbers from './numbers'; 3 | import NumberRange from './number-range'; 4 | import Frame from '$components/frame'; 5 | 6 | const FizzBuzz = ({ children }: PropsWithChildren) => { 7 | const [amount, setAmount] = useState(10); 8 | 9 | return ( 10 | 11 | {children} 12 | setAmount(+event.target.value)} 15 | className="w-full" 16 | /> 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default FizzBuzz; 23 | -------------------------------------------------------------------------------- /src/examples/fizz-buzz/number-range.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from 'react'; 2 | import { pluralize } from '$lib/pluralize'; 3 | 4 | type NumberRange = ComponentPropsWithoutRef<'input'> & { 5 | type?: 'range' | 'number'; 6 | value: number | string; 7 | }; 8 | 9 | const NumberRange = ({ 10 | id = 'select-number', 11 | value, 12 | min = 1, 13 | max = 100, 14 | type = 'range', 15 | ...props 16 | }: NumberRange) => { 17 | return ( 18 |
event.preventDefault} className="w-full"> 19 | 22 |
23 |

{min}

24 | 32 |

{max}

33 |
34 |
35 | ); 36 | }; 37 | 38 | export default NumberRange; 39 | -------------------------------------------------------------------------------- /src/examples/fizz-buzz/number.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { ComponentPropsWithoutRef } from 'react'; 3 | 4 | export const NumberItem = (props: ComponentPropsWithoutRef<'article'>) => { 5 | return ( 6 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/examples/fizz-buzz/numbers.tsx: -------------------------------------------------------------------------------- 1 | import { repeat } from '$lib/repeat'; 2 | import { toFizzBuzz } from '$lib/to-fizz-buzz'; 3 | import { NumberItem } from './number'; 4 | 5 | const Numbers = ({ times }: { times: number }) => { 6 | return ( 7 |
8 | {repeat(times, (i: number) => ( 9 | {toFizzBuzz(i)} 10 | ))} 11 |
12 | ); 13 | }; 14 | 15 | export default Numbers; 16 | -------------------------------------------------------------------------------- /src/examples/getting-started/README.md: -------------------------------------------------------------------------------- 1 | # 👩🏾‍🔬 Experiments 2 | 3 | This totally isn't an excuse to make sure your set up works before we go any farther. 4 | 5 | 1. Run `npx vitest --run` and look at which test files run. 6 | 2. Run `npx vitest words --run` and look at which test files run. 7 | 3. Run `npx vitest related ./math.ts --run` and look at which test files run. 8 | 4. Run `npx vitest related ./exponent.ts --run` and look at which test files run. 9 | 5. Assuming you don't have any unstaged or uncommited changes, run `npx vitest --changed HEAD --run` and look at which test files—umm—_didn't_ run. 10 | 6. Make a change to `words.ts` (or any other file, really) and then run `npx vitest --changed HEAD --run` and see what tests run. 11 | -------------------------------------------------------------------------------- /src/examples/getting-started/example.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect, test } from 'vitest'; 2 | 3 | it('should work', () => { 4 | expect(true).toBe(true); 5 | }); 6 | 7 | test('works witn "test" as well', () => { 8 | expect(false).not.toBe(true); 9 | }); 10 | 11 | it.fails('should be able to expect a test to fail', () => { 12 | expect(false).toBe(true); 13 | }); 14 | 15 | test('works when returning a promise', () => { 16 | return new Promise((done) => { 17 | setTimeout(() => { 18 | expect('This should fail.').not.toBe('Totally not the same.'); 19 | done(null); 20 | }, 0); 21 | }); 22 | }); 23 | 24 | // npx vitest --mode=development --run --reporter=verbose 25 | test.runIf(process.env.NODE_ENV === 'development')( 26 | 'it should run in development', 27 | () => { 28 | expect(process.env.NODE_ENV).toBe('development'); 29 | }, 30 | ); 31 | 32 | // npx vitest --run --reporter=verbose 33 | test.skipIf(process.env.NODE_ENV !== 'test')('it should run in test', () => { 34 | expect(process.env.NODE_ENV).toBe('test'); 35 | }); 36 | -------------------------------------------------------------------------------- /src/examples/getting-started/exponent.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { exponent, square, cube } from './exponent'; 3 | import { multiply } from './math'; 4 | 5 | describe('exponent', () => { 6 | it('should correctly calcuate the exponent of a number', () => { 7 | expect(exponent(4, 2)).toBe(Math.pow(4, 2)); 8 | }); 9 | }); 10 | 11 | describe('square', () => { 12 | it('should correctly calcuate the square of a number', () => { 13 | expect(square(4)).toBe(Math.pow(4, 2)); 14 | }); 15 | 16 | it('should be the same as multiplying a number by itself', () => { 17 | expect(square(8)).toBe(multiply(8, 8)); 18 | }); 19 | }); 20 | 21 | describe('cube', () => { 22 | it('should correctly calcuate the cube of a number', () => { 23 | expect(cube(2)).toBe(Math.pow(2, 3)); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/examples/getting-started/exponent.ts: -------------------------------------------------------------------------------- 1 | import { multiply } from './math'; 2 | 3 | export const exponent = (base: number, power: number) => { 4 | let result = base; 5 | 6 | for (let i = 1; i < power; i++) { 7 | result = multiply(result, base); 8 | } 9 | 10 | return result; 11 | }; 12 | 13 | export const square = (base: number) => exponent(base, 2); 14 | 15 | export const cube = (base: number) => exponent(base, 3); 16 | -------------------------------------------------------------------------------- /src/examples/getting-started/math.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { add, subtract, multiply, divide } from './math'; 3 | 4 | describe('add', () => { 5 | it('should add two numbers correctly', () => { 6 | expect(add(2, 2)).toBe(4); 7 | }); 8 | 9 | it('should not add two numbers incorrectly', () => { 10 | expect(add(2, 2)).not.toBe(5); 11 | }); 12 | }); 13 | 14 | describe('subtract', () => { 15 | it('should subtract the subtrahend from the minuend', () => { 16 | expect(subtract(4, 2)).toBe(2); 17 | }); 18 | 19 | it('should not subtract two numbers incorrectly', () => { 20 | expect(subtract(4, 2)).not.toBe(1); 21 | }); 22 | }); 23 | 24 | describe('multiply', () => { 25 | it('should multiply the multiplicand by the multiplier', () => { 26 | expect(multiply(3, 4)).toBe(12); 27 | }); 28 | 29 | it('should not multiply two numbers incorrectly', () => { 30 | expect(multiply(4, 2)).not.toBe(1000); 31 | }); 32 | }); 33 | 34 | describe('divide', () => { 35 | it('should multiply the multiplicand by the multiplier', () => { 36 | expect(divide(12, 4)).toBe(3); 37 | }); 38 | 39 | it('should not multiply two numbers incorrectly', () => { 40 | expect(multiply(4, 2)).not.toBe(1000); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/examples/getting-started/math.ts: -------------------------------------------------------------------------------- 1 | export const add = (a: number, b: number): number => a + b; 2 | 3 | export const subtract = (minuend: number, subtrahend: number): number => 4 | add(minuend, -subtrahend); 5 | 6 | export const multiply = (multiplicand: number, multiplier: number): number => { 7 | let result = 0; 8 | 9 | while (multiplier--) { 10 | result = add(result, multiplicand); 11 | } 12 | 13 | return result; 14 | }; 15 | 16 | export const divide = (dividend: number, denominator: number): number => { 17 | return dividend / denominator; 18 | }; 19 | 20 | export const sum = (...numbers: number[]): number => { 21 | return numbers.reduce((total, n) => total + n, 0); 22 | }; 23 | 24 | export const average = (...numbers: number[]): number => { 25 | return divide(sum(...numbers), numbers.length); 26 | }; 27 | -------------------------------------------------------------------------------- /src/examples/getting-started/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "vitest" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/examples/getting-started/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({}); 4 | -------------------------------------------------------------------------------- /src/examples/getting-started/words.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { embolden, makeLouder, repeat } from './words'; 3 | 4 | describe('repeat', () => { 5 | it('should repeat the word three times by default', () => { 6 | expect(repeat('lol')).toBe('lollollol'); 7 | }); 8 | 9 | it('should repeat the word a certain number of times', () => { 10 | expect(repeat('lol', 2)).toBe('lollol'); 11 | }); 12 | }); 13 | 14 | describe('makeLouder', () => { 15 | it('should repeat the word three times by default', () => { 16 | expect(makeLouder('lol')).toBe('LOL'); 17 | }); 18 | }); 19 | 20 | describe('embolden', () => { 21 | it('should wrap a fiven string in tags like it is 1999', () => { 22 | expect(embolden('lol')).toBe('lol'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/examples/getting-started/words.ts: -------------------------------------------------------------------------------- 1 | export const repeat = (x: string, times: number = 3) => x.repeat(times); 2 | 3 | export const makeLouder = (x: string) => x.toUpperCase(); 4 | 5 | export const embolden = (x: string) => x.bold(); 6 | -------------------------------------------------------------------------------- /src/examples/great-expectations/asymmetric-matchers.complete.test.ts: -------------------------------------------------------------------------------- 1 | import { v4 as id } from 'uuid'; 2 | import { expect, it } from 'vitest'; 3 | 4 | type ComputerScientist = { 5 | id: string; 6 | firstName: string; 7 | lastName: string; 8 | isCool?: boolean; 9 | }; 10 | 11 | const createComputerScientist = ( 12 | firstName: string, 13 | lastName: string, 14 | ): ComputerScientist => ({ id: 'cs-' + id(), firstName, lastName }); 15 | 16 | const addToCoolKidsClub = (p: ComputerScientist, club: unknown[]) => { 17 | club.push({ ...p, isCool: true }); 18 | }; 19 | 20 | it('include cool computer scientists by virtue of them being in the club', () => { 21 | const people: ComputerScientist[] = []; 22 | 23 | addToCoolKidsClub(createComputerScientist('Grace', 'Hopper'), people); 24 | addToCoolKidsClub(createComputerScientist('Ada', 'Lovelace'), people); 25 | addToCoolKidsClub(createComputerScientist('Annie', 'Easley'), people); 26 | addToCoolKidsClub(createComputerScientist('Dorothy', 'Vaughn'), people); 27 | 28 | for (const person of people) { 29 | expect(typeof person.firstName).toBe('string'); 30 | expect(typeof person.lastName).toBe('string'); 31 | expect(person.isCool).toBe(true); 32 | } 33 | 34 | for (const person of people) { 35 | expect(person).toEqual({ 36 | id: expect.stringMatching(/^cs-/), 37 | firstName: expect.any(String), 38 | lastName: expect.any(String), 39 | isCool: true, 40 | }); 41 | } 42 | 43 | for (const person of people) { 44 | expect(person).toEqual( 45 | expect.objectContaining({ 46 | isCool: expect.any(Boolean), 47 | }), 48 | ); 49 | } 50 | }); 51 | -------------------------------------------------------------------------------- /src/examples/great-expectations/asymmetric-matchers.test.ts: -------------------------------------------------------------------------------- 1 | import { v4 as id } from 'uuid'; 2 | import { expect, it } from 'vitest'; 3 | 4 | type ComputerScientist = { 5 | id: string; 6 | firstName: string; 7 | lastName: string; 8 | isCool?: boolean; 9 | }; 10 | 11 | const createComputerScientist = ( 12 | firstName: string, 13 | lastName: string, 14 | ): ComputerScientist => ({ id: 'cs-' + id(), firstName, lastName }); 15 | 16 | const addToCoolKidsClub = (p: ComputerScientist, club: unknown[]) => { 17 | club.push({ ...p, isCool: true }); 18 | }; 19 | 20 | it('include cool computer scientists by virtue of them being in the club', () => { 21 | const people: ComputerScientist[] = []; 22 | 23 | addToCoolKidsClub(createComputerScientist('Grace', 'Hopper'), people); 24 | addToCoolKidsClub(createComputerScientist('Ada', 'Lovelace'), people); 25 | addToCoolKidsClub(createComputerScientist('Annie', 'Easley'), people); 26 | addToCoolKidsClub(createComputerScientist('Dorothy', 'Vaughn'), people); 27 | }); 28 | -------------------------------------------------------------------------------- /src/examples/great-expectations/bonus-exercise.complete.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { KanbanBoard, defaultStatuses } from '$lib/kanban-board'; 3 | 4 | describe('Kanban Board', () => { 5 | it('should create a board with a title and an array of default statuses', () => { 6 | const title = 'Important Things'; 7 | const board = new KanbanBoard(title); 8 | 9 | expect(board).toEqual({ 10 | title: expect.any(String), 11 | statuses: expect.arrayContaining(defaultStatuses), 12 | url: expect.any(String), 13 | }); 14 | }); 15 | 16 | it('add a status to a board using #addStatus', () => { 17 | const title = 'Important Things'; 18 | const status = 'Verifying'; 19 | const board = new KanbanBoard(title); 20 | 21 | board.addStatus(status); 22 | 23 | expect(board).toEqual({ 24 | title: expect.any(String), 25 | statuses: expect.arrayContaining([status]), 26 | url: expect.any(String), 27 | }); 28 | }); 29 | 30 | it('have a URL property that has the title in kebab case', () => { 31 | const title = 'Important Things'; 32 | const board = new KanbanBoard(title); 33 | 34 | expect(board).toEqual( 35 | expect.objectContaining({ 36 | url: 'https://example.com/boards/important-things', 37 | }), 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/examples/great-expectations/bonus-exercise.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { KanbanBoard, defaultStatuses } from '$lib/kanban-board'; 3 | 4 | /** 5 | * expect.any(constructor): https://vitest.dev/api/expect.html#expect-any 6 | * expect.arrayContaining(): https://vitest.dev/api/expect.html#expect-arraycontaining 7 | * expect.objectContaining(): https://vitest.dev/api/expect.html#expect-objectcontaining 8 | */ 9 | 10 | describe('Kanban Board', () => { 11 | it.todo( 12 | 'should create a board with a title and an array of default statuses', 13 | () => { 14 | const title = 'Important Things'; 15 | const board = new KanbanBoard(title); 16 | 17 | expect.hasAssertions(); 18 | }, 19 | ); 20 | 21 | it.todo('add a status to a board using #addStatus', () => { 22 | const title = 'Important Things'; 23 | const status = 'Verifying'; 24 | const board = new KanbanBoard(title); 25 | 26 | board.addStatus(status); 27 | 28 | expect.hasAssertions(); 29 | 30 | // We don't really care what else is in board.statuses. 31 | // We just want to verify that it has the new status. 32 | }); 33 | 34 | it.todo('have a URL property that has the title in kebab case', () => { 35 | const title = 'Important Things'; 36 | const board = new KanbanBoard(title); 37 | 38 | expect.hasAssertions(); 39 | 40 | // Challenge: Could you say that I want this to be equal to *any* object 41 | // so long as it has a `url` property that matches. 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/examples/great-expectations/items-slice.solution.test.ts: -------------------------------------------------------------------------------- 1 | import reducer, { 2 | add, 3 | remove, 4 | toggle, 5 | markAllAsUnpacked, 6 | update, 7 | } from './items-slice'; 8 | 9 | it('returns an empty array as the initial state', () => { 10 | expect(reducer(undefined, { type: 'noop' })).toEqual([]); 11 | }); 12 | 13 | it('supports adding an item with the correct name', () => { 14 | expect(reducer([], add({ name: 'iPhone' }))).toEqual([ 15 | expect.objectContaining({ name: 'iPhone' }), 16 | ]); 17 | }); 18 | 19 | it('prefixes ids with "item-"', () => { 20 | expect(reducer([], add({ name: 'iPhone' }))).toEqual([ 21 | expect.objectContaining({ id: expect.stringMatching(/^item-/) }), 22 | ]); 23 | }); 24 | 25 | it('defaults new items to a packed status of false', () => { 26 | expect(reducer([], add({ name: 'iPhone' }))).toEqual([ 27 | expect.objectContaining({ packed: false }), 28 | ]); 29 | }); 30 | 31 | it('supports removing an item', () => { 32 | const state = [ 33 | { 34 | id: '1', 35 | name: 'iPhone', 36 | packed: false, 37 | }, 38 | ]; 39 | 40 | const result = reducer(state, remove({ id: '1' })); 41 | 42 | expect(result).toEqual([]); 43 | }); 44 | 45 | it('supports toggling an item', () => { 46 | const state = [ 47 | { 48 | id: '1', 49 | name: 'iPhone', 50 | packed: false, 51 | }, 52 | ]; 53 | 54 | const result = reducer(state, toggle({ id: '1' })); 55 | 56 | expect(result).toEqual([ 57 | { 58 | id: '1', 59 | name: 'iPhone', 60 | packed: true, 61 | }, 62 | ]); 63 | }); 64 | 65 | it('supports updating an item', () => { 66 | const state = [ 67 | { 68 | id: '1', 69 | name: 'iPhone', 70 | packed: false, 71 | }, 72 | ]; 73 | 74 | const result = reducer( 75 | state, 76 | update({ id: '1', name: 'Samsung Galaxy S23' }), 77 | ); 78 | 79 | expect(result).toEqual([ 80 | { 81 | id: '1', 82 | name: 'Samsung Galaxy S23', 83 | packed: false, 84 | }, 85 | ]); 86 | }); 87 | 88 | it('supports marking all items as unpacked', () => { 89 | const state = [ 90 | { 91 | id: '1', 92 | name: 'iPhone', 93 | packed: true, 94 | }, 95 | { 96 | id: '2', 97 | name: 'iPhone Charger', 98 | packed: true, 99 | }, 100 | ]; 101 | 102 | const result = reducer(state, markAllAsUnpacked()); 103 | 104 | expect(result).toEqual([ 105 | { 106 | id: '1', 107 | name: 'iPhone', 108 | packed: false, 109 | }, 110 | { 111 | id: '2', 112 | name: 'iPhone Charger', 113 | packed: false, 114 | }, 115 | ]); 116 | }); 117 | -------------------------------------------------------------------------------- /src/examples/great-expectations/items-slice.test.ts: -------------------------------------------------------------------------------- 1 | import reducer, { 2 | add, 3 | remove, 4 | toggle, 5 | markAllAsUnpacked, 6 | update, 7 | } from './items-slice'; 8 | 9 | it('returns an empty array as the initial state', () => { 10 | expect(reducer(undefined, { type: 'noop' })).toEqual([]); 11 | }); 12 | 13 | it.todo('supports adding an item with the correct name', () => { 14 | expect.hasAssertions(); 15 | const result = reducer([], add({ name: 'iPhone' })); 16 | }); 17 | 18 | it.todo('prefixes ids with "item-"', () => { 19 | expect.hasAssertions(); 20 | const result = reducer([], add({ name: 'iPhone' })); 21 | }); 22 | 23 | it.todo('defaults new items to a packed status of false', () => { 24 | expect.hasAssertions(); 25 | const result = reducer([], add({ name: 'iPhone' })); 26 | }); 27 | 28 | it.todo('supports removing an item', () => { 29 | expect.hasAssertions(); 30 | const state = [ 31 | { 32 | id: '1', 33 | name: 'iPhone', 34 | packed: false, 35 | }, 36 | ]; 37 | 38 | const result = reducer(state, remove({ id: '1' })); 39 | }); 40 | 41 | it.todo('supports toggling an item', () => { 42 | expect.hasAssertions(); 43 | const state = [ 44 | { 45 | id: '1', 46 | name: 'iPhone', 47 | packed: false, 48 | }, 49 | ]; 50 | 51 | const result = reducer(state, toggle({ id: '1' })); 52 | }); 53 | 54 | it.todo('supports updating an item', () => { 55 | expect.hasAssertions(); 56 | const state = [ 57 | { 58 | id: '1', 59 | name: 'iPhone', 60 | packed: false, 61 | }, 62 | ]; 63 | 64 | const result = reducer( 65 | state, 66 | update({ id: '1', name: 'Samsung Galaxy S23' }), 67 | ); 68 | }); 69 | 70 | it.todo('supports marking all items as unpacked', () => { 71 | expect.hasAssertions(); 72 | const state = [ 73 | { 74 | id: '1', 75 | name: 'iPhone', 76 | packed: true, 77 | }, 78 | { 79 | id: '2', 80 | name: 'iPhone Charger', 81 | packed: true, 82 | }, 83 | ]; 84 | 85 | const result = reducer(state, markAllAsUnpacked()); 86 | }); 87 | -------------------------------------------------------------------------------- /src/examples/great-expectations/items-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import { v4 as id } from 'uuid'; 3 | 4 | export type Item = { 5 | id: string; 6 | name: string; 7 | packed: boolean; 8 | }; 9 | 10 | const initialState: Item[] = []; 11 | 12 | const itemsSlice = createSlice({ 13 | name: 'items', 14 | initialState, 15 | reducers: { 16 | add(items, action: { payload: Pick }) { 17 | const name = action.payload.name; 18 | const item = { id: `item-${id()}`, name, packed: false }; 19 | items.push(item); 20 | }, 21 | toggle(items, action: { payload: Pick }) { 22 | const id = action.payload.id; 23 | const item = items.find((item) => item.id === id); 24 | if (item) item.packed = !item?.packed; 25 | }, 26 | update(items, action: { payload: Pick }) { 27 | const { id, name } = action.payload; 28 | const item = items.find((item) => item.id === id); 29 | if (item) item.name = name; 30 | }, 31 | remove(items, action: { payload: Pick }) { 32 | const id = action.payload.id; 33 | const item = items.find((item) => item.id === id); 34 | if (item) items.splice(items.indexOf(item), 1); 35 | }, 36 | markAllAsUnpacked(items) { 37 | return items.forEach((item) => (item.packed = false)); 38 | }, 39 | }, 40 | }); 41 | 42 | export const { add, toggle, remove, update, markAllAsUnpacked } = 43 | itemsSlice.actions; 44 | export default itemsSlice.reducer; 45 | -------------------------------------------------------------------------------- /src/examples/great-expectations/objects.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | describe('toBe', () => { 4 | test.fails('objects should not be strictly equal', () => { 5 | expect({ a: 1 }).toBe({ a: 1 }); 6 | }); 7 | 8 | test.fails('arrays should be strictly equal', () => { 9 | expect([1, 2, 3]).toBe([1, 2, 3]); 10 | }); 11 | 12 | test.fails('functions should to be strictly equal', () => { 13 | expect(() => {}).toBe(() => {}); 14 | }); 15 | }); 16 | 17 | describe('toEqual', () => { 18 | test('similar objects should pass with #toEqual', () => { 19 | expect({ a: 1 }).toEqual({ a: 1 }); 20 | }); 21 | 22 | test('similar nested objects should pass with #toEqual', () => { 23 | expect({ a: 1, b: { c: 2 } }).toEqual({ a: 1, b: { c: 2 } }); 24 | }); 25 | 26 | test('similar arrays should pass with #toEqual', () => { 27 | expect([1, 2, 3]).toEqual([1, 2, 3]); 28 | }); 29 | 30 | test('similar multi-dimensional arrays should pass with #toEqual', () => { 31 | expect([1, [2, 3]]).toEqual([1, [2, 3]]); 32 | }); 33 | 34 | test('functions should to be strictly equal if compared by reference', () => { 35 | const fn = () => {}; 36 | expect(fn).toBe(fn); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/examples/great-expectations/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "vitest" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/examples/great-expectations/primitives.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | 3 | test('strings should be strictly equal', () => { 4 | expect('string').toBe('string'); 5 | }); 6 | 7 | test('numbers should be strictly equal', () => { 8 | expect(2).toBe(2); 9 | }); 10 | 11 | test('booleans should be strictly equal', () => { 12 | expect(true).toBe(true); 13 | expect(false).toBe(false); 14 | }); 15 | 16 | test('undefined should be strictly equal to itself', () => { 17 | expect(undefined).toBe(undefined); 18 | }); 19 | 20 | test('null should be strictly equal to itself', () => { 21 | expect(null).toBe(null); 22 | }); 23 | 24 | test('BigInts should be strickly equal', () => { 25 | expect(BigInt(Number.MAX_SAFE_INTEGER)).toBe(BigInt(Number.MAX_SAFE_INTEGER)); 26 | }); 27 | -------------------------------------------------------------------------------- /src/examples/logjam/arithmetic.ts: -------------------------------------------------------------------------------- 1 | const Arithmetic = { 2 | add(a: number, b: number) { 3 | return a + b; 4 | }, 5 | 6 | multiply(a: number, b: number) { 7 | let result = 0; 8 | 9 | while (b--) { 10 | result = this.add(result, a); 11 | } 12 | 13 | return result; 14 | }, 15 | 16 | double(a: number) { 17 | return this.multiply(a, 2); 18 | }, 19 | }; 20 | 21 | export default Arithmetic; 22 | -------------------------------------------------------------------------------- /src/examples/logjam/log.ts: -------------------------------------------------------------------------------- 1 | export const log = ( 2 | channel: 'log' | 'error' | 'warn' | 'info', 3 | ...args: unknown[] 4 | ) => { 5 | console[channel](...args); 6 | }; 7 | -------------------------------------------------------------------------------- /src/examples/logjam/test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, vi } from 'vitest'; 2 | 3 | test.todo('it spies on the multiply method'); 4 | -------------------------------------------------------------------------------- /src/examples/obstacle-course/accessibility.svelte.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/svelte'; 2 | import { axe, toHaveNoViolations } from 'jest-axe'; 3 | import ObstacleCourse from './obstacle-course.svelte'; 4 | 5 | expect.extend(toHaveNoViolations); 6 | 7 | it('should demonstrate this matcher`s usage', async () => { 8 | const { container } = render(ObstacleCourse); 9 | const results = await axe(container); 10 | 11 | expect(results).toHaveNoViolations(); 12 | }); 13 | -------------------------------------------------------------------------------- /src/examples/obstacle-course/accessibility.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'test/utilities'; 2 | import { axe, toHaveNoViolations } from 'jest-axe'; 3 | import ObstacleCourse from '.'; 4 | 5 | expect.extend(toHaveNoViolations); 6 | 7 | it('should demonstrate this matcher`s usage', async () => { 8 | const { container } = render(); 9 | const results = await axe(container); 10 | 11 | expect(results).toHaveNoViolations(); 12 | }); 13 | -------------------------------------------------------------------------------- /src/examples/obstacle-course/exercise.test.tsx: -------------------------------------------------------------------------------- 1 | import ObstacleCourse from '.'; 2 | 3 | it.todo('should input text into the input field', () => { 4 | const thought = 'Ravioli are a form of pop tart.'; 5 | }); 6 | 7 | it.todo('should control a select input', () => {}); 8 | 9 | it.todo('should find and control a checkbox input', () => {}); 10 | 11 | it.todo('should find and control a radio input', () => {}); 12 | 13 | it.todo('should find and control a color input', () => {}); 14 | 15 | it.todo('should find and control a date input', () => {}); 16 | 17 | it.todo('should find and control a range input', () => {}); 18 | 19 | it.todo('should find and control a file input', () => {}); 20 | -------------------------------------------------------------------------------- /src/examples/obstacle-course/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "test": "vitest", 5 | "test:svelte": "vitest -c vitest.config.svelte.js" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/examples/obstacle-course/vitest.config.svelte.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig } from 'vite'; 3 | import { svelte } from '@sveltejs/vite-plugin-svelte'; 4 | 5 | export default defineConfig({ 6 | plugins: [svelte({ hot: !process.env.VITEST })], 7 | resolve: { 8 | alias: { 9 | $lib: path.resolve(__dirname, '../../lib'), 10 | $components: path.resolve(__dirname, '../../components'), 11 | test: path.resolve(__dirname, '../../../test'), 12 | }, 13 | }, 14 | test: { 15 | globals: true, 16 | include: ['**/*.svelte.test.*'], 17 | environment: 'jsdom', 18 | setupFiles: path.resolve(__dirname, '../../../test/setup.ts'), 19 | environmentMatchGlobs: [ 20 | ['**/*.test.tsx', 'jsdom'], 21 | ['**/*.component.test.ts', 'jsdom'], 22 | ], 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/examples/obstacle-course/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defaultExclude, defineConfig } from 'vitest/config'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | $lib: path.resolve(__dirname, '../../lib'), 10 | $components: path.resolve(__dirname, '../../components'), 11 | test: path.resolve(__dirname, '../../../test'), 12 | }, 13 | }, 14 | test: { 15 | globals: true, 16 | exclude: [...defaultExclude, '**/*.svelte**'], 17 | setupFiles: path.resolve(__dirname, '../../../test/setup.ts'), 18 | environmentMatchGlobs: [ 19 | ['**/*.test.tsx', 'jsdom'], 20 | ['**/*.component.test.ts', 'jsdom'], 21 | ], 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/index.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'react-redux'; 2 | import Frame from '$components/frame'; 3 | import ItemList from './item-list'; 4 | import MarkAllAsUnpacked from './mark-all-as-unpacked'; 5 | import NewItem from './new-item'; 6 | import { store } from './store'; 7 | 8 | export const PackingList = () => ( 9 | 10 |
11 |

Packing List

12 |
13 | 14 |
15 | 16 | 17 |
18 | 19 | 20 | ); 21 | 22 | const Application = () => { 23 | return ( 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default Application; 31 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/item-list.test.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { render, screen } from 'test/utilities'; 4 | import ItemList from './item-list'; 5 | import { store } from './store'; 6 | import { add } from './store/items-slice'; 7 | 8 | it('should render', async () => { 9 | render(, { 10 | wrapper: ({ children }: PropsWithChildren) => ( 11 | {children} 12 | ), 13 | }); 14 | }); 15 | 16 | it('should display items', () => { 17 | store.dispatch(add({ name: 'Lucky beanie' })); 18 | 19 | render(, { 20 | wrapper: ({ children }: PropsWithChildren) => ( 21 | {children} 22 | ), 23 | }); 24 | 25 | expect(screen.getByTestId('unpacked-items-list')).toMatchInlineSnapshot(` 26 |
    30 |
  • 33 | 38 | 44 | 49 |
    52 | 58 | 64 |
    65 |
  • 66 |
67 | `); 68 | }); 69 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/item-list.tsx: -------------------------------------------------------------------------------- 1 | import { toKebabCase } from '$lib/to-kebab-case'; 2 | import { useItemIds } from './store/hooks'; 3 | import Item from './item'; 4 | 5 | type ItemsProps = { 6 | title: string; 7 | packed: boolean; 8 | }; 9 | 10 | const ItemList = ({ title, packed }: ItemsProps) => { 11 | const id = toKebabCase(title); 12 | const itemIds: string[] = useItemIds(packed); 13 | 14 | return ( 15 |
20 |
21 |

{title}

22 |
23 |
    24 | {itemIds.map((itemId) => ( 25 | 26 | ))} 27 |
28 | {!itemIds.length && ( 29 |

(Nothing to show.)

30 | )} 31 |
32 | ); 33 | }; 34 | 35 | export default ItemList; 36 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/item.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { useState } from 'react'; 3 | import { useDispatch } from 'react-redux'; 4 | import { useItem } from './store/hooks'; 5 | import { remove, toggle, update } from './store/items-slice'; 6 | 7 | type ItemProps = { 8 | itemId: string; 9 | }; 10 | 11 | const Item = ({ itemId }: ItemProps) => { 12 | const [editing, setEditing] = useState(false); 13 | const item = useItem(itemId); 14 | const dispatch = useDispatch(); 15 | 16 | return ( 17 |
  • 18 | dispatch(toggle({ id: itemId }))} 24 | /> 25 | 31 | 36 | dispatch(update({ id: itemId, name: event.target.value })) 37 | } 38 | /> 39 |
    40 | 47 | 54 |
    55 |
  • 56 | ); 57 | }; 58 | 59 | export default Item; 60 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/mark-all-as-unpacked.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux'; 2 | import { markAllAsUnpacked } from './store/items-slice'; 3 | 4 | const MarkAllAsUnpacked = () => { 5 | const dispatch = useDispatch(); 6 | return ( 7 |
    8 | 11 | {/* Weird… who put this here? */} 12 |
    13 | ); 14 | }; 15 | 16 | export default MarkAllAsUnpacked; 17 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/new-item.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { add } from './store/items-slice'; 3 | import { useDispatch } from './store/hooks'; 4 | 5 | const NewItem = () => { 6 | const [newItemName, setNewItemName] = useState(''); 7 | const dispatch = useDispatch(); 8 | 9 | return ( 10 |
    { 13 | e.preventDefault(); 14 | dispatch(add({ name: newItemName })); 15 | setNewItemName(''); 16 | }} 17 | > 18 | 21 |
    22 | setNewItemName(event.target.value)} 29 | /> 30 | 39 |
    40 |
    41 | ); 42 | }; 43 | 44 | export default NewItem; 45 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "test": "vitest" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useSelector, useDispatch as useReduxDispatch } from 'react-redux'; 2 | import type { Item } from './items-slice'; 3 | import type { ApplicationState, ApplicationDispatch } from '.'; 4 | 5 | export const useAllItems = (): Item[] => { 6 | return useSelector((state) => 7 | Object.values(state.items), 8 | ); 9 | }; 10 | 11 | export const useItems = (packed: boolean): Item[] => { 12 | return useSelector((state) => 13 | Object.values(state.items).filter((item) => item.packed === packed), 14 | ); 15 | }; 16 | 17 | export const useItemIds = (packed: boolean): string[] => { 18 | return useItems(packed).map(({ id }) => id); 19 | }; 20 | 21 | export const useItem = (id: string): Item => { 22 | return useSelector((state) => state.items[id]); 23 | }; 24 | 25 | export const useDispatch: () => ApplicationDispatch = useReduxDispatch; 26 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/store/index.tsx: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import reducer from './items-slice'; 3 | 4 | export const createStore = () => { 5 | return configureStore({ 6 | reducer: { items: reducer }, 7 | }); 8 | }; 9 | 10 | export const store = createStore(); 11 | 12 | export type ApplicationState = ReturnType; 13 | export type ApplicationDispatch = typeof store.dispatch; 14 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/store/items-slice.test.ts: -------------------------------------------------------------------------------- 1 | import reducer, { 2 | add, 3 | remove, 4 | toggle, 5 | markAllAsUnpacked, 6 | update, 7 | } from './items-slice'; 8 | 9 | /* 10 | * Note: These tests are a *bit* flawed in ways that we'll explore later, but 11 | * bonus points if you can figure it out beforehand. 12 | */ 13 | 14 | it('returns an empty object as the initial state', () => { 15 | expect(reducer(undefined, { type: 'noop' })).toEqual({}); 16 | }); 17 | 18 | it('supports adding an item', () => { 19 | expect(reducer({}, add({ name: 'iPhone' }))).toEqual({ 20 | 1: { 21 | id: '1', 22 | name: 'iPhone', 23 | packed: false, 24 | }, 25 | }); 26 | }); 27 | 28 | it('supports removing an item', () => { 29 | const initialState = { 30 | 1: { 31 | id: '1', 32 | name: 'iPhone', 33 | packed: false, 34 | }, 35 | }; 36 | 37 | expect(reducer(initialState, remove({ id: '1' }))).toEqual({}); 38 | }); 39 | 40 | it('supports toggling an item', () => { 41 | const initialState = { 42 | 1: { 43 | id: '1', 44 | name: 'iPhone', 45 | packed: false, 46 | }, 47 | }; 48 | 49 | expect(reducer(initialState, toggle({ id: '1' }))).toEqual({ 50 | 1: { 51 | id: '1', 52 | name: 'iPhone', 53 | packed: true, 54 | }, 55 | }); 56 | }); 57 | 58 | it('supports updating an item', () => { 59 | const initialState = { 60 | 1: { 61 | id: '1', 62 | name: 'iPhone', 63 | packed: false, 64 | }, 65 | }; 66 | 67 | expect( 68 | reducer(initialState, update({ id: '1', name: 'Samsung Galaxy S23' })), 69 | ).toEqual({ 70 | 1: { 71 | id: '1', 72 | name: 'Samsung Galaxy S23', 73 | packed: false, 74 | }, 75 | }); 76 | }); 77 | 78 | it('supports marking all items as unpacked', () => { 79 | const initialState = { 80 | 1: { 81 | id: '1', 82 | name: 'iPhone', 83 | packed: true, 84 | }, 85 | 2: { 86 | id: '2', 87 | name: 'iPhone Charger', 88 | packed: true, 89 | }, 90 | }; 91 | 92 | expect(reducer(initialState, markAllAsUnpacked())).toEqual({ 93 | 1: { 94 | id: '1', 95 | name: 'iPhone', 96 | packed: false, 97 | }, 98 | 2: { 99 | id: '2', 100 | name: 'iPhone Charger', 101 | packed: false, 102 | }, 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/store/items-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | export type Item = { 4 | id: string; 5 | name: string; 6 | packed: boolean; 7 | }; 8 | 9 | let currentId = 1; 10 | 11 | const initialState: Record = {}; 12 | 13 | const itemsSlice = createSlice({ 14 | name: 'items', 15 | initialState, 16 | reducers: { 17 | add(items, action: { payload: Pick }) { 18 | const id = String(currentId++); 19 | items[id] = { id, name: action.payload.name, packed: false }; 20 | }, 21 | toggle(items, action: { payload: Pick }) { 22 | const id = action.payload.id; 23 | items[id].packed = !items[id].packed; 24 | }, 25 | update(items, action: { payload: Partial & Pick }) { 26 | const { id, ...props } = action.payload; 27 | items[id] = { ...items[id], ...props }; 28 | }, 29 | remove(items, action: { payload: Pick }) { 30 | delete items[action.payload.id]; 31 | }, 32 | markAllAsUnpacked(items) { 33 | return Object.values(items).forEach((item) => (item.packed = false)); 34 | }, 35 | }, 36 | }); 37 | 38 | export const { add, toggle, remove, update, markAllAsUnpacked } = 39 | itemsSlice.actions; 40 | export default itemsSlice.reducer; 41 | -------------------------------------------------------------------------------- /src/examples/packing-list-revisited/test/utilities.ts: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react'; 2 | import { render as renderComponent } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | type RenderOptions = Parameters[1]; 6 | 7 | export * from '@testing-library/react'; 8 | 9 | export const render = (ui: ReactElement, options?: RenderOptions) => { 10 | return { 11 | ...renderComponent(ui, options), 12 | user: userEvent.setup(), 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/examples/packing-list/README.md: -------------------------------------------------------------------------------- 1 | `accessibility.test.solution.tsx` is purposely named that way so that it does _not_ run by default. Change its name to `accessibility.solution.test.tsx` or just remove the `solution` segment so thst Vite and/or Jest pick it up. 2 | -------------------------------------------------------------------------------- /src/examples/packing-list/accessibility.test.solution.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'test/utilities'; 2 | import { axe, toHaveNoViolations } from 'jest-axe'; 3 | import PackingList from '.'; 4 | 5 | expect.extend(toHaveNoViolations); 6 | 7 | it('should have no accessibility violations', async () => { 8 | const { container } = render(); 9 | const results = await axe(container); 10 | 11 | expect(results).toHaveNoViolations(); 12 | }); 13 | -------------------------------------------------------------------------------- /src/examples/packing-list/index.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'react-redux'; 2 | import Frame from '$components/frame'; 3 | import ItemList from './item-list'; 4 | import MarkAllAsUnpacked from './mark-all-as-unpacked'; 5 | import NewItem from './new-item'; 6 | import { store } from './store'; 7 | 8 | const PackingList = () => { 9 | return ( 10 | 11 | 12 |
    13 |

    Packing List

    14 |
    15 | 16 |
    17 | 18 | 19 |
    20 | 21 | 22 |
    23 | ); 24 | }; 25 | 26 | export default PackingList; 27 | -------------------------------------------------------------------------------- /src/examples/packing-list/item-list.test.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { render, screen } from 'test/utilities'; 4 | import ItemList from './item-list'; 5 | import { store } from './store'; 6 | import { add } from './store/items-slice'; 7 | 8 | it('should render', async () => { 9 | render(, { 10 | wrapper: ({ children }: PropsWithChildren) => ( 11 | {children} 12 | ), 13 | }); 14 | }); 15 | 16 | it('should display items', () => { 17 | store.dispatch(add({ name: 'Lucky beanie' })); 18 | 19 | render(, { 20 | wrapper: ({ children }: PropsWithChildren) => ( 21 | {children} 22 | ), 23 | }); 24 | 25 | expect(screen.getByTestId('unpacked-items-list')).toMatchInlineSnapshot(` 26 |
      30 |
    • 33 | 38 | 44 | 49 |
      52 | 58 | 64 |
      65 |
    • 66 |
    67 | `); 68 | }); 69 | -------------------------------------------------------------------------------- /src/examples/packing-list/item-list.tsx: -------------------------------------------------------------------------------- 1 | import { toKebabCase } from '$lib/to-kebab-case'; 2 | import { useItemIds } from './store/hooks'; 3 | import Item from './item'; 4 | 5 | type ItemsProps = { 6 | title: string; 7 | packed: boolean; 8 | }; 9 | 10 | const ItemList = ({ title, packed }: ItemsProps) => { 11 | const id = toKebabCase(title); 12 | const itemIds: string[] = useItemIds(packed); 13 | 14 | return ( 15 |
    20 |
    21 |

    {title}

    22 |
    23 |
      24 | {itemIds.map((itemId) => ( 25 | 26 | ))} 27 |
    28 | {!itemIds.length && ( 29 |

    (Nothing to show.)

    30 | )} 31 |
    32 | ); 33 | }; 34 | 35 | export default ItemList; 36 | -------------------------------------------------------------------------------- /src/examples/packing-list/item.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { useState } from 'react'; 3 | import { useDispatch } from 'react-redux'; 4 | import { useItem } from './store/hooks'; 5 | import { remove, toggle, update } from './store/items-slice'; 6 | 7 | type ItemProps = { 8 | itemId: string; 9 | }; 10 | 11 | const Item = ({ itemId }: ItemProps) => { 12 | const [editing, setEditing] = useState(false); 13 | const item = useItem(itemId); 14 | const dispatch = useDispatch(); 15 | 16 | return ( 17 |
  • 18 | dispatch(toggle({ id: itemId }))} 24 | /> 25 | 31 | 36 | dispatch(update({ id: itemId, name: event.target.value })) 37 | } 38 | /> 39 |
    40 | 47 | 54 |
    55 |
  • 56 | ); 57 | }; 58 | 59 | export default Item; 60 | -------------------------------------------------------------------------------- /src/examples/packing-list/mark-all-as-unpacked.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux'; 2 | import { markAllAsUnpacked } from './store/items-slice'; 3 | 4 | const MarkAllAsUnpacked = () => { 5 | const dispatch = useDispatch(); 6 | return ( 7 |
    8 | 11 | {/* Weird… who put this here? */} 12 |
    13 | ); 14 | }; 15 | 16 | export default MarkAllAsUnpacked; 17 | -------------------------------------------------------------------------------- /src/examples/packing-list/new-item.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { add } from './store/items-slice'; 3 | import { useDispatch } from './store/hooks'; 4 | 5 | const NewItem = () => { 6 | const [newItemName, setNewItemName] = useState(''); 7 | const dispatch = useDispatch(); 8 | 9 | return ( 10 |
    { 13 | e.preventDefault(); 14 | dispatch(add({ name: newItemName })); 15 | setNewItemName(''); 16 | }} 17 | > 18 | 21 |
    22 | setNewItemName(event.target.value)} 29 | /> 30 | 39 |
    40 |
    41 | ); 42 | }; 43 | 44 | export default NewItem; 45 | -------------------------------------------------------------------------------- /src/examples/packing-list/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "test": "vitest" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/examples/packing-list/packing-list.solution.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'test/utilities'; 2 | import PackingList from '.'; 3 | 4 | it('renders the Packing List application', () => { 5 | render(); 6 | }); 7 | 8 | it('has the correct title', async () => { 9 | render(); 10 | screen.getByText('Packing List'); 11 | }); 12 | 13 | it('has an input field for a new item', () => { 14 | render(); 15 | screen.getByLabelText('New Item Name'); 16 | }); 17 | 18 | it('has a "Add New Item" button that is disabled when the input is empty', () => { 19 | render(); 20 | const newItemInput = screen.getByLabelText('New Item Name'); 21 | const addNewItemButton = screen.getByRole('button', { name: 'Add New Item' }); 22 | 23 | expect(newItemInput).toHaveValue(''); 24 | expect(addNewItemButton).toBeDisabled(); 25 | }); 26 | 27 | it('enables the "Add New Item" button when there is text in the input field', async () => { 28 | const { user } = render(); 29 | const newItemInput = screen.getByLabelText('New Item Name'); 30 | const addNewItemButton = screen.getByRole('button', { name: 'Add New Item' }); 31 | 32 | await user.type(newItemInput, 'MacBook Pro'); 33 | 34 | expect(addNewItemButton).toBeEnabled(); 35 | }); 36 | 37 | it('adds a new item to the unpacked item list when the clicking "Add New Item"', async () => { 38 | const { user } = render(); 39 | const newItemInput = screen.getByLabelText('New Item Name'); 40 | const addNewItemButton = screen.getByRole('button', { 41 | name: 'Add New Item', 42 | }); 43 | 44 | await user.type(newItemInput, 'MacBook Pro'); 45 | await user.click(addNewItemButton); 46 | 47 | expect(screen.getByLabelText('MacBook Pro')).not.toBeChecked(); 48 | }); 49 | 50 | // This test is sublty flawed. 51 | it('removes an item when the remove button is clicked', async () => { 52 | const { user } = render(); 53 | 54 | const newItemInput = screen.getByLabelText('New Item Name'); 55 | const addNewItemButton = screen.getByRole('button', { 56 | name: 'Add New Item', 57 | }); 58 | 59 | await user.type(newItemInput, 'iPad Pro'); 60 | await user.click(addNewItemButton); 61 | 62 | const item = screen.getByLabelText('iPad Pro'); 63 | const removeButton = screen.getByRole('button', { 64 | name: 'Remove iPad Pro', 65 | }); 66 | 67 | await user.click(removeButton); 68 | 69 | expect(item).not.toBeInTheDocument(); 70 | }); 71 | -------------------------------------------------------------------------------- /src/examples/packing-list/packing-list.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'test/utilities'; 2 | import PackingList from '.'; 3 | 4 | it('renders the Packing List application', () => { 5 | render(); 6 | }); 7 | 8 | it('has the correct title', async () => { 9 | render(); 10 | screen.getByText('Packing List'); 11 | }); 12 | 13 | it.todo('has an input field for a new item', () => {}); 14 | 15 | it.todo( 16 | 'has a "Add New Item" button that is disabled when the input is empty', 17 | () => {}, 18 | ); 19 | 20 | it.todo( 21 | 'enables the "Add New Item" button when there is text in the input field', 22 | async () => {}, 23 | ); 24 | 25 | it.todo( 26 | 'adds a new item to the unpacked item list when the clicking "Add New Item"', 27 | async () => {}, 28 | ); 29 | -------------------------------------------------------------------------------- /src/examples/packing-list/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useSelector, useDispatch as useReduxDispatch } from 'react-redux'; 2 | import type { Item } from './items-slice'; 3 | import type { ApplicationState, ApplicationDispatch } from '.'; 4 | 5 | export const useAllItems = (): Item[] => { 6 | return useSelector((state) => 7 | Object.values(state.items), 8 | ); 9 | }; 10 | 11 | export const useItems = (packed: boolean): Item[] => { 12 | return useSelector((state) => 13 | Object.values(state.items).filter((item) => item.packed === packed), 14 | ); 15 | }; 16 | 17 | export const useItemIds = (packed: boolean): string[] => { 18 | return useItems(packed).map(({ id }) => id); 19 | }; 20 | 21 | export const useItem = (id: string): Item => { 22 | return useSelector((state) => state.items[id]); 23 | }; 24 | 25 | export const useDispatch: () => ApplicationDispatch = useReduxDispatch; 26 | -------------------------------------------------------------------------------- /src/examples/packing-list/store/index.tsx: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import reducer from './items-slice'; 3 | 4 | export const store = configureStore({ 5 | reducer: { items: reducer }, 6 | }); 7 | 8 | export type ApplicationState = ReturnType; 9 | export type ApplicationDispatch = typeof store.dispatch; 10 | -------------------------------------------------------------------------------- /src/examples/packing-list/store/items-slice.test.ts: -------------------------------------------------------------------------------- 1 | import reducer, { 2 | add, 3 | remove, 4 | toggle, 5 | markAllAsUnpacked, 6 | update, 7 | } from './items-slice'; 8 | 9 | /* 10 | * Note: These tests are a *bit* flawed in ways that we'll explore later, but 11 | * bonus points if you can figure it out beforehand. 12 | */ 13 | 14 | it('returns an empty object as the initial state', () => { 15 | expect(reducer(undefined, { type: 'noop' })).toEqual({}); 16 | }); 17 | 18 | it('supports adding an item', () => { 19 | expect(reducer({}, add({ name: 'iPhone' }))).toEqual({ 20 | 1: { 21 | id: '1', 22 | name: 'iPhone', 23 | packed: false, 24 | }, 25 | }); 26 | }); 27 | 28 | it('supports removing an item', () => { 29 | const initialState = { 30 | 1: { 31 | id: '1', 32 | name: 'iPhone', 33 | packed: false, 34 | }, 35 | }; 36 | 37 | expect(reducer(initialState, remove({ id: '1' }))).toEqual({}); 38 | }); 39 | 40 | it('supports toggling an item', () => { 41 | const initialState = { 42 | 1: { 43 | id: '1', 44 | name: 'iPhone', 45 | packed: false, 46 | }, 47 | }; 48 | 49 | expect(reducer(initialState, toggle({ id: '1' }))).toEqual({ 50 | 1: { 51 | id: '1', 52 | name: 'iPhone', 53 | packed: true, 54 | }, 55 | }); 56 | }); 57 | 58 | it('supports updating an item', () => { 59 | const initialState = { 60 | 1: { 61 | id: '1', 62 | name: 'iPhone', 63 | packed: false, 64 | }, 65 | }; 66 | 67 | expect( 68 | reducer(initialState, update({ id: '1', name: 'Samsung Galaxy S23' })), 69 | ).toEqual({ 70 | 1: { 71 | id: '1', 72 | name: 'Samsung Galaxy S23', 73 | packed: false, 74 | }, 75 | }); 76 | }); 77 | 78 | it('supports marking all items as unpacked', () => { 79 | const initialState = { 80 | 1: { 81 | id: '1', 82 | name: 'iPhone', 83 | packed: true, 84 | }, 85 | 2: { 86 | id: '2', 87 | name: 'iPhone Charger', 88 | packed: true, 89 | }, 90 | }; 91 | 92 | expect(reducer(initialState, markAllAsUnpacked())).toEqual({ 93 | 1: { 94 | id: '1', 95 | name: 'iPhone', 96 | packed: false, 97 | }, 98 | 2: { 99 | id: '2', 100 | name: 'iPhone Charger', 101 | packed: false, 102 | }, 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/examples/packing-list/store/items-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | export type Item = { 4 | id: string; 5 | name: string; 6 | packed: boolean; 7 | }; 8 | 9 | let currentId = 1; 10 | 11 | const initialState: Record = {}; 12 | 13 | const itemsSlice = createSlice({ 14 | name: 'items', 15 | initialState, 16 | reducers: { 17 | add(items, action: { payload: Pick }) { 18 | const id = String(currentId++); 19 | items[id] = { id, name: action.payload.name, packed: false }; 20 | }, 21 | toggle(items, action: { payload: Pick }) { 22 | const id = action.payload.id; 23 | items[id].packed = !items[id].packed; 24 | }, 25 | update(items, action: { payload: Partial & Pick }) { 26 | const { id, ...props } = action.payload; 27 | items[id] = { ...items[id], ...props }; 28 | }, 29 | remove(items, action: { payload: Pick }) { 30 | delete items[action.payload.id]; 31 | }, 32 | markAllAsUnpacked(items) { 33 | return Object.values(items).forEach((item) => (item.packed = false)); 34 | }, 35 | }, 36 | }); 37 | 38 | export const { add, toggle, remove, update, markAllAsUnpacked } = 39 | itemsSlice.actions; 40 | export default itemsSlice.reducer; 41 | -------------------------------------------------------------------------------- /src/examples/parallelizing-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "vitest" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/examples/parallelizing-tests/sleep.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest'; 2 | 3 | const sleep = (time = 1000) => { 4 | return new Promise((resolve) => { 5 | setTimeout(() => { 6 | resolve(); 7 | }, time); 8 | }); 9 | }; 10 | 11 | describe.concurrent('sleep', () => { 12 | it('should sleep for 500ms', async ({ expect }) => { 13 | await sleep(500); 14 | expect(true).toBe(true); 15 | }); 16 | 17 | it('should sleep for 750ms', async ({ expect }) => { 18 | await sleep(750); 19 | expect(true).toBe(true); 20 | }); 21 | 22 | it('should sleep for 1000ms', async ({ expect }) => { 23 | await sleep(1000); 24 | expect(true).toBe(true); 25 | }); 26 | 27 | it('should sleep for 1500ms', async ({ expect }) => { 28 | await sleep(1500); 29 | expect(true).toBe(true); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/examples/parameterizing-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "vitest" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/examples/parameterizing-tests/polygon.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { createPolygon, Polygon } from '$lib/polygon'; 3 | 4 | const polygonNames = [ 5 | [3, 'triangle'], 6 | [4, 'quadrilateral'], 7 | [5, 'pentagon'], 8 | [6, 'hexagon'], 9 | [7, 'heptagon'], 10 | [8, 'octagon'], 11 | [9, 'nonagon'], 12 | [10, 'decagon'], 13 | ]; 14 | 15 | const polygons = ` 16 | polygonType | sides | lengthOfSide | sumOfAngles | perimeter | area 17 | ${'triangle'} | ${3} | ${10} | ${180} | ${30} | ${43.3012701892219} 18 | ${'quadrilateral'} | ${4} | ${10} | ${360} | ${40} | ${100} 19 | ${'pentagon'} | ${5} | ${10} | ${540} | ${50} | ${172.047740058897} 20 | ${'hexagon'} | ${6} | ${10} | ${720} | ${60} | ${259.807621135332} 21 | ${'heptagon'} | ${7} | ${10} | ${900} | ${70} | ${363.391244400159} 22 | ${'octagon'} | ${8} | ${10} | ${1080} | ${80} | ${482.842712474619} 23 | ${'nonagon'} | ${9} | ${10} | ${1260} | ${90} | ${618.18241937729} 24 | ${'decagon'} | ${10} | ${10} | ${1440} | ${100} | ${769.420884293813} 25 | `; 26 | 27 | describe('createPolygon', () => { 28 | it('should create an object that is an instance of the Polygon class', () => { 29 | const polygon = createPolygon('triangle', 20); 30 | expect(polygon).toBeInstanceOf(Polygon); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/examples/sign-up/index.tsx: -------------------------------------------------------------------------------- 1 | import Frame from '$components/frame'; 2 | import SignUpForm from './sign-up-form'; 3 | 4 | const SignUp = () => { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | }; 11 | 12 | export default SignUp; 13 | -------------------------------------------------------------------------------- /src/examples/sign-up/sign-up-form.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef, FormEventHandler, useRef } from 'react'; 2 | import clsx from 'clsx'; 3 | import Input from '$components/input'; 4 | 5 | const SignUpForm = ({ 6 | onSubmit = () => {}, 7 | onInvalid, 8 | className, 9 | ...props 10 | }: ComponentPropsWithoutRef<'form'>) => { 11 | const ref = useRef(null); 12 | 13 | const handleSubmit: FormEventHandler = (event) => { 14 | event.preventDefault(); 15 | onSubmit(event); 16 | }; 17 | 18 | const showErrors: FormEventHandler = (event) => { 19 | if (onInvalid) { 20 | onInvalid(event); 21 | } else { 22 | if (ref?.current?.dataset) { 23 | ref.current.dataset.validate = 'true'; 24 | } 25 | } 26 | }; 27 | 28 | return ( 29 |
    36 | 43 | 50 | 57 | 58 |
    59 | ); 60 | }; 61 | 62 | export default SignUpForm; 63 | -------------------------------------------------------------------------------- /src/examples/snapshot-tests/__snapshots__/polygon.complete.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Polygon > should match the snapshot 1`] = ` 4 | { 5 | "area": 482.842712474619, 6 | "lengthOfSides": 10, 7 | "perimeter": 80, 8 | "sides": 8, 9 | "sumOfAngles": 1080, 10 | } 11 | `; 12 | -------------------------------------------------------------------------------- /src/examples/snapshot-tests/polygon.complete.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { Polygon } from '$lib/polygon'; 3 | 4 | describe('Polygon', () => { 5 | it('should match the snapshot', () => { 6 | const polygon = new Polygon(8, 10); 7 | expect(polygon.toJSON()).toMatchSnapshot(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/examples/test-context/context.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest'; 2 | 3 | it('should work', (ctx) => { 4 | expect(ctx.meta.name).toBe('should work'); 5 | }); 6 | 7 | it('should really work', ({ meta }) => { 8 | expect(meta.name).toBe('should really work'); 9 | }); 10 | 11 | it('should have version of `expect` bound to the current test', (ctx) => { 12 | ctx.expect(ctx.expect).not.toBe(expect); 13 | }); 14 | -------------------------------------------------------------------------------- /src/examples/test-context/extending-context.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest'; 2 | 3 | it('should work', (ctx) => { 4 | expect(ctx.meta.name).toBe('should work'); 5 | }); 6 | 7 | it('should really work', ({ meta }) => { 8 | expect(meta.name).toBe('should really work'); 9 | }); 10 | 11 | it('should have version of `expect` bound to the current test', (ctx) => { 12 | ctx.expect(ctx.expect).not.toBe(expect); 13 | }); 14 | -------------------------------------------------------------------------------- /src/examples/test-context/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "vitest" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/examples/time-zone/__snapshots__/time-zone.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`should match the snapshot 1`] = ` 4 |
    5 |
    8 |

    9 | 1679492355195 10 |

    11 |

    12 | You've been staring at this page for 13 | 0 14 | second(s). 15 |

    16 |
    17 |
    18 | `; 19 | -------------------------------------------------------------------------------- /src/examples/time-zone/get-tasks-from-api.ts: -------------------------------------------------------------------------------- 1 | import { random } from './random'; 2 | import { Task } from './tasks'; 3 | 4 | export const getTasksFromApi = (limit = 5): Promise => { 5 | return fetch('https://jsonplaceholder.typicode.com/users/1/todos') 6 | .then((response) => response.json()) 7 | .then((tasks) => tasks.sort(random)) 8 | .then((tasks) => tasks.slice(0, limit)); 9 | }; 10 | -------------------------------------------------------------------------------- /src/examples/time-zone/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import Frame from '$components/frame'; 3 | import { getTasksFromApi } from './get-tasks-from-api'; 4 | import TaskList, { Task } from './tasks'; 5 | 6 | const TimeZone = ({ getTodos }: { getTodos?: boolean }) => { 7 | const startTime = useRef(Date.now()); 8 | const [currentTime, setCurrentTime] = useState(Date.now()); 9 | const [tasks, setTasks] = useState([]); 10 | 11 | const elapsed = (currentTime - startTime.current) / 1000; 12 | 13 | useEffect(() => { 14 | const interval = setInterval(() => { 15 | setCurrentTime(Date.now()); 16 | }, 1000); 17 | if (getTodos) getTasksFromApi().then(setTasks); 18 | return () => clearInterval(interval); 19 | }, []); 20 | 21 | return ( 22 | 23 |

    {currentTime}

    24 |

    25 | You've been staring at this page for {elapsed} second(s). 26 |

    27 | {getTodos && } 28 | 29 | ); 30 | }; 31 | 32 | export default TimeZone; 33 | -------------------------------------------------------------------------------- /src/examples/time-zone/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "vitest" 4 | }, 5 | "devDependencies": { 6 | "msw": "^1.2.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/examples/time-zone/random.ts: -------------------------------------------------------------------------------- 1 | export const random = () => Math.floor(Math.random() * 2 - Math.random() * 2); 2 | -------------------------------------------------------------------------------- /src/examples/time-zone/tasks.tsx: -------------------------------------------------------------------------------- 1 | export type Task = { 2 | title: string; 3 | completed: boolean; 4 | id: number; 5 | }; 6 | 7 | export const TaskListItem = ({ title, completed, id }: Task) => { 8 | return ( 9 |
  • 10 | 11 | 12 |
  • 13 | ); 14 | }; 15 | 16 | const TaskList = ({ tasks }: { tasks: Task[] }) => { 17 | return ( 18 |
      19 | {tasks.map((task) => ( 20 | 21 | ))} 22 |
    23 | ); 24 | }; 25 | 26 | export default TaskList; 27 | -------------------------------------------------------------------------------- /src/examples/time-zone/test/setup.jest.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /src/examples/time-zone/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'vitest'; 2 | import matchers from '@testing-library/jest-dom/matchers'; 3 | 4 | expect.extend(matchers); 5 | -------------------------------------------------------------------------------- /src/examples/time-zone/test/utilities.ts: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react'; 2 | import { render as renderComponent } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | type RenderOptions = Parameters[1]; 6 | 7 | export * from '@testing-library/react'; 8 | 9 | export const render = (ui: ReactElement, options?: RenderOptions) => { 10 | return { 11 | ...renderComponent(ui, options), 12 | user: userEvent.setup(), 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/examples/time-zone/time-zone.test.tsx: -------------------------------------------------------------------------------- 1 | import { test, expect, vi } from 'vitest'; 2 | import { render } from 'test/utilities'; 3 | import TimeZone from '.'; 4 | 5 | test('it should render successfully', () => { 6 | render(); 7 | }); 8 | 9 | test.fails('should match the snapshot', async () => { 10 | const { container } = render(); 11 | expect(container).toMatchSnapshot(); 12 | }); 13 | -------------------------------------------------------------------------------- /src/examples/time-zone/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defaultExclude, defineConfig } from 'vitest/config'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | $lib: path.resolve(__dirname, '../../lib'), 10 | $components: path.resolve(__dirname, '../../components'), 11 | test: path.resolve(__dirname, '../../../test'), 12 | }, 13 | }, 14 | test: { 15 | exclude: [...defaultExclude, '**/*.svelte**'], 16 | setupFiles: path.resolve(__dirname, '../../../test/setup.ts'), 17 | environmentMatchGlobs: [ 18 | ['**/*.test.tsx', 'jsdom'], 19 | ['**/*.component.test.ts', 'jsdom'], 20 | ], 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | #root { 6 | @apply mx-auto h-screen w-screen max-w-7xl py-8 md:px-16; 7 | } 8 | 9 | *, 10 | html, 11 | body { 12 | @apply box-border; 13 | } 14 | 15 | html, 16 | body { 17 | @apply mx-0 mb-8 w-screen; 18 | } 19 | 20 | h1, 21 | h2, 22 | h3, 23 | h4, 24 | h5, 25 | h6 { 26 | @apply font-bold leading-tight first:mt-0; 27 | } 28 | 29 | h1 { 30 | @apply text-4xl leading-tight; 31 | } 32 | 33 | header h1 { 34 | @apply underline decoration-primary-400 decoration-4 underline-offset-4; 35 | } 36 | 37 | h2 { 38 | @apply text-3xl; 39 | } 40 | 41 | h3 { 42 | @apply text-2xl; 43 | } 44 | 45 | h4 { 46 | @apply text-xl; 47 | } 48 | 49 | h5 { 50 | @apply text-lg; 51 | } 52 | 53 | h6 { 54 | @apply text-base; 55 | } 56 | 57 | p, 58 | ul, 59 | ol, 60 | dl { 61 | @apply leading-relaxed; 62 | } 63 | 64 | input { 65 | @apply border-2 border-primary-700 bg-primary-50 p-2 placeholder-primary-400 accent-primary-600 shadow-sm outline-none transition-all ease-in out-of-range:border-error-800 out-of-range:bg-error-200 hover:bg-primary-100 hover:accent-primary-700 focus:bg-primary-100 disabled:border-slate-500 disabled:bg-slate-100 disabled:placeholder-slate-400; 66 | } 67 | 68 | form[data-validate] input { 69 | @apply invalid:border-error-500; 70 | } 71 | 72 | button, 73 | input[type='button'], 74 | input[type='reset'] { 75 | @apply btn-secondary; 76 | } 77 | 78 | button[type='submit'], 79 | input[type='submit'] { 80 | @apply btn-primary; 81 | } 82 | 83 | input[type='checkbox'], 84 | input[type='radio'] { 85 | @apply hover:accent-primary-700; 86 | } 87 | 88 | input[type='range'] { 89 | @apply h-2 w-full cursor-pointer appearance-none rounded-lg bg-primary-100; 90 | } 91 | 92 | input[type='color'] { 93 | @apply h-12 p-1; 94 | } 95 | 96 | input[type='file']::-webkit-file-upload-button { 97 | @apply placeholder-error-400 accent-error-800; 98 | } 99 | 100 | input[type='date'], 101 | input[type='datetime'], 102 | input[type='datetime-local'], 103 | input[type='month'], 104 | input[type='week'], 105 | input[type='time'] { 106 | @apply text-primary-900; 107 | } 108 | 109 | label { 110 | @apply font-semibold; 111 | } 112 | 113 | input + label { 114 | @apply font-semibold text-primary-900 transition-colors hover:text-primary-800; 115 | } 116 | 117 | select { 118 | @apply block w-full rounded border-2 border-purple-700 px-4 py-2 pr-8 shadow hover:bg-primary-200 hover:accent-primary-700 focus:bg-primary-50 focus:outline-none disabled:border-slate-500 disabled:bg-slate-100 disabled:placeholder-slate-400; 119 | } 120 | 121 | .flex > input + button[type='submit'] { 122 | @apply whitespace-nowrap rounded-l-none border-l-0; 123 | } 124 | 125 | dt { 126 | @apply font-semibold text-primary-800; 127 | } 128 | 129 | dd { 130 | @apply text-primary-500; 131 | } 132 | -------------------------------------------------------------------------------- /src/lib/__snapshots__/polygon.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`'decagon' > should generate JSON that matches the snapshot 1`] = ` 4 | { 5 | "area": 769.4208842938134, 6 | "lengthOfSides": 10, 7 | "perimeter": 100, 8 | "sides": 10, 9 | "sumOfAngles": 1440, 10 | } 11 | `; 12 | 13 | exports[`'heptagon' > should generate JSON that matches the snapshot 1`] = ` 14 | { 15 | "area": 363.39124440015894, 16 | "lengthOfSides": 10, 17 | "perimeter": 70, 18 | "sides": 7, 19 | "sumOfAngles": 900, 20 | } 21 | `; 22 | 23 | exports[`'hexagon' > should generate JSON that matches the snapshot 1`] = ` 24 | { 25 | "area": 259.8076211353316, 26 | "lengthOfSides": 10, 27 | "perimeter": 60, 28 | "sides": 6, 29 | "sumOfAngles": 720, 30 | } 31 | `; 32 | 33 | exports[`'nonagon' > should generate JSON that matches the snapshot 1`] = ` 34 | { 35 | "area": 618.1824193772901, 36 | "lengthOfSides": 10, 37 | "perimeter": 90, 38 | "sides": 9, 39 | "sumOfAngles": 1260, 40 | } 41 | `; 42 | 43 | exports[`'octagon' > should generate JSON that matches the snapshot 1`] = ` 44 | { 45 | "area": 482.842712474619, 46 | "lengthOfSides": 10, 47 | "perimeter": 80, 48 | "sides": 8, 49 | "sumOfAngles": 1080, 50 | } 51 | `; 52 | 53 | exports[`'pentagon' > should generate JSON that matches the snapshot 1`] = ` 54 | { 55 | "area": 172.04774005889672, 56 | "lengthOfSides": 10, 57 | "perimeter": 50, 58 | "sides": 5, 59 | "sumOfAngles": 540, 60 | } 61 | `; 62 | 63 | exports[`'quadrilateral' > should generate JSON that matches the snapshot 1`] = ` 64 | { 65 | "area": 100.00000000000001, 66 | "lengthOfSides": 10, 67 | "perimeter": 40, 68 | "sides": 4, 69 | "sumOfAngles": 360, 70 | } 71 | `; 72 | 73 | exports[`'triangle' > should generate JSON that matches the snapshot 1`] = ` 74 | { 75 | "area": 43.301270189221945, 76 | "lengthOfSides": 10, 77 | "perimeter": 30, 78 | "sides": 3, 79 | "sumOfAngles": 180, 80 | } 81 | `; 82 | -------------------------------------------------------------------------------- /src/lib/get-area.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { getArea } from './get-area'; 3 | 4 | const examples = [ 5 | { sides: 3, lengthOfSides: 10, area: 43.3012701892219 }, 6 | { sides: 4, lengthOfSides: 10, area: 100 }, 7 | { sides: 5, lengthOfSides: 10, area: 172.047740058897 }, 8 | { sides: 6, lengthOfSides: 10, area: 259.807621135332 }, 9 | { sides: 7, lengthOfSides: 10, area: 363.391244400159 }, 10 | { sides: 8, lengthOfSides: 10, area: 482.842712474619 }, 11 | { sides: 9, lengthOfSides: 10, area: 618.18241937729 }, 12 | { sides: 10, lengthOfSides: 10, area: 769.420884293813 }, 13 | { sides: 3, lengthOfSides: 20, area: 173.205080756888 }, 14 | { sides: 4, lengthOfSides: 20, area: 400 }, 15 | { sides: 5, lengthOfSides: 20, area: 688.190960235587 }, 16 | { sides: 6, lengthOfSides: 20, area: 1039.23048454133 }, 17 | { sides: 7, lengthOfSides: 20, area: 1453.56497760064 }, 18 | { sides: 8, lengthOfSides: 20, area: 1931.37084989848 }, 19 | { sides: 9, lengthOfSides: 20, area: 2472.72967750916 }, 20 | { sides: 10, lengthOfSides: 20, area: 3077.68353717525 }, 21 | ]; 22 | 23 | test.each(examples)( 24 | 'it should correctly calculate the area for a polygon with $sides sides with a length of $lengthOfSides', 25 | ({ sides, lengthOfSides, area }) => { 26 | expect(getArea(sides, lengthOfSides)).toBeCloseTo(area, 2); 27 | }, 28 | ); 29 | -------------------------------------------------------------------------------- /src/lib/get-area.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates the area of a regular polygon. 3 | * @param sides The number of sides of the polygon. 4 | * @param lengthOfSides The length of each side. 5 | * @returns The area of the polygon. 6 | */ 7 | export const getArea = (sides: number, lengthOfSides: number) => { 8 | return (Math.pow(lengthOfSides, 2) * sides) / (4 * Math.tan(Math.PI / sides)); 9 | }; 10 | -------------------------------------------------------------------------------- /src/lib/id.ts: -------------------------------------------------------------------------------- 1 | let id = 1; 2 | 3 | export const generateId = (): string => `${id++}`; 4 | -------------------------------------------------------------------------------- /src/lib/kanban-board.ts: -------------------------------------------------------------------------------- 1 | import { toKebabCase } from './to-kebab-case'; 2 | 3 | export const defaultStatuses = ['Backlog', 'Ready', 'In Progress', 'Completed']; 4 | 5 | export class KanbanBoard { 6 | title: string; 7 | statuses: string[]; 8 | url: string; 9 | 10 | constructor(title: string) { 11 | this.title = title; 12 | this.statuses = [...defaultStatuses]; 13 | this.url = `https://example.com/boards/${toKebabCase(this.title)}`; 14 | } 15 | 16 | addStatus(status: string) { 17 | this.statuses.push(status); 18 | } 19 | 20 | removeStatus(status: string) { 21 | this.statuses.splice(this.statuses.indexOf(status)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/person.ts: -------------------------------------------------------------------------------- 1 | type PersonOptions = { 2 | firstName: string; 3 | middleName?: string; 4 | lastName?: string; 5 | age?: string | number; 6 | }; 7 | 8 | const parseFullName = (fullName: string): PersonOptions => { 9 | if (!fullName.length) throw new Error('fullName cannot be an empty string.'); 10 | 11 | const [firstName, ...rest] = fullName.split(' '); 12 | const lastName = rest.pop(); 13 | let middleName: string | undefined; 14 | 15 | if (rest.length) { 16 | middleName = rest.join(' '); 17 | } 18 | 19 | return { firstName, middleName, lastName }; 20 | }; 21 | 22 | export const createPerson = (options: PersonOptions | string) => { 23 | return new Person(options); 24 | }; 25 | 26 | export const areFriends = (a: Person, b: Person): boolean => { 27 | return a.friends.has(b); 28 | }; 29 | 30 | export const isFriendOfFriend = (a: Person, b: Person): boolean => { 31 | for (const friend of a.friends) { 32 | if (areFriends(friend, b)) return true; 33 | if (isFriendOfFriend(b, friend)) return true; 34 | } 35 | 36 | return false; 37 | }; 38 | 39 | export class Person { 40 | firstName: string; 41 | middleName?: string; 42 | lastName?: string; 43 | friends: Set = new Set(); 44 | 45 | constructor(args: PersonOptions | string) { 46 | if (typeof args === 'string') { 47 | args = parseFullName(args); 48 | } 49 | 50 | const { firstName, middleName, lastName, age } = args; 51 | 52 | this.firstName = firstName; 53 | this.middleName = middleName; 54 | this.lastName = lastName; 55 | } 56 | 57 | get fullName() { 58 | let fullName = this.firstName; 59 | if (this.middleName) fullName = `${fullName} ${this.middleName}`; 60 | if (this.lastName) fullName = `${fullName} ${this.lastName}`; 61 | return fullName; 62 | } 63 | 64 | set fullName(fullName: string) { 65 | const { firstName, middleName, lastName } = parseFullName(fullName); 66 | 67 | this.firstName = firstName; 68 | this.middleName = middleName; 69 | this.lastName = lastName; 70 | } 71 | 72 | addFriend(friend: Person) { 73 | this.friends.add(friend); 74 | friend.friends.add(this); 75 | } 76 | 77 | removeFriend(formerFriend: Person) { 78 | this.friends.delete(formerFriend); 79 | formerFriend.friends.delete(this); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/pluralize.ts: -------------------------------------------------------------------------------- 1 | export const pluralize = ( 2 | text: string, 3 | items: unknown[] | number | string, 4 | ): string => { 5 | const n = Array.isArray(items) ? items.length : Number(items); 6 | if (n === 1) return text; 7 | return `${text}s`; 8 | }; 9 | -------------------------------------------------------------------------------- /src/lib/polygon.ts: -------------------------------------------------------------------------------- 1 | import { getArea } from './get-area'; 2 | 3 | type PolygonType = keyof typeof namedPolygons; 4 | type NamedPolygon = { 5 | type: T; 6 | sides: (typeof namedPolygons)[T]; 7 | } & Polygon; 8 | 9 | export type Triangle = NamedPolygon<'triangle'>; 10 | export type Quadrilateral = NamedPolygon<'quadrilateral'>; 11 | export type Pentagon = NamedPolygon<'pentagon'>; 12 | export type Hexagon = NamedPolygon<'hexagon'>; 13 | export type Heptagon = NamedPolygon<'heptagon'>; 14 | export type Octagon = NamedPolygon<'octagon'>; 15 | export type Nonagon = NamedPolygon<'nonagon'>; 16 | export type Decagon = NamedPolygon<'decagon'>; 17 | 18 | const namedPolygons = { 19 | triangle: 3, 20 | quadrilateral: 4, 21 | pentagon: 5, 22 | hexagon: 6, 23 | heptagon: 7, 24 | octagon: 8, 25 | nonagon: 9, 26 | decagon: 10, 27 | } as const; 28 | 29 | export class Polygon { 30 | sides: number; 31 | lengthOfSides: number; 32 | 33 | constructor(sides: number, lengthOfSides: number) { 34 | if (sides < 3) throw new Error('Polygons must have three or more sides.'); 35 | this.sides = sides; 36 | this.lengthOfSides = lengthOfSides; 37 | } 38 | 39 | get type(): PolygonType | undefined { 40 | if (this.sides === 3) return 'triangle'; 41 | if (this.sides === 4) return 'quadrilateral'; 42 | if (this.sides === 5) return 'pentagon'; 43 | if (this.sides === 6) return 'hexagon'; 44 | if (this.sides === 7) return 'heptagon'; 45 | if (this.sides === 8) return 'octagon'; 46 | if (this.sides === 9) return 'nonagon'; 47 | if (this.sides === 10) return 'decagon'; 48 | } 49 | 50 | get sumOfAngles() { 51 | return 180 + (this.sides - 3) * 180; 52 | } 53 | 54 | get perimeter() { 55 | return this.lengthOfSides * this.sides; 56 | } 57 | 58 | get area() { 59 | return getArea(this.sides, this.lengthOfSides); 60 | } 61 | 62 | toJSON() { 63 | return { 64 | sides: this.sides, 65 | lengthOfSides: this.lengthOfSides, 66 | sumOfAngles: this.sumOfAngles, 67 | perimeter: this.perimeter, 68 | area: this.area, 69 | }; 70 | } 71 | } 72 | 73 | export const createPolygon = (type: PolygonType, lengthOfSides: number) => { 74 | return new Polygon(namedPolygons[type], lengthOfSides); 75 | }; 76 | -------------------------------------------------------------------------------- /src/lib/repeat.ts: -------------------------------------------------------------------------------- 1 | export const repeat = (times: number, callback: (i: number) => T): T[] => { 2 | return [...Array(times).keys()].map((i) => callback(i + 1)); 3 | }; 4 | -------------------------------------------------------------------------------- /src/lib/to-fizz-buzz.ts: -------------------------------------------------------------------------------- 1 | const isMultipleOf = (multiple: number, factor: number): boolean => { 2 | return factor % multiple === 0; 3 | }; 4 | 5 | export const toFizzBuzz = (i: number): number | string => { 6 | const isMultipleOfThree = isMultipleOf(3, i); 7 | const isMultipleOfFive = isMultipleOf(5, i); 8 | 9 | if (isMultipleOfThree && isMultipleOfFive) return 'FizzBuzz'; 10 | if (isMultipleOfThree) return 'Fizz'; 11 | if (isMultipleOfFive) return 'Buzz'; 12 | 13 | return i; 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/to-kebab-case.ts: -------------------------------------------------------------------------------- 1 | export const toKebabCase = (s: string): string => { 2 | return s.toLowerCase().replace(/\s/g, '-'); 3 | }; 4 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import Application from './application'; 4 | 5 | import './index.css'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement, 9 | ); 10 | 11 | root.render( 12 | 13 | 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | const colors = require('./src/colors.json'); 4 | const buttons = require('./plugins/tailwind-buttons.cjs'); 5 | 6 | module.exports = { 7 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 8 | theme: { 9 | extend: { 10 | colors, 11 | }, 12 | }, 13 | plugins: [buttons], 14 | }; 15 | -------------------------------------------------------------------------------- /test/setup.jest.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'vitest'; 2 | import matchers from '@testing-library/jest-dom/matchers'; 3 | 4 | expect.extend(matchers); 5 | -------------------------------------------------------------------------------- /test/utilities.ts: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react'; 2 | import { render as renderComponent } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | type RenderOptions = Parameters[1]; 6 | 7 | export * from '@testing-library/react'; 8 | 9 | export const render = (ui: ReactElement, options?: RenderOptions) => { 10 | return { 11 | ...renderComponent(ui, options), 12 | user: userEvent.setup(), 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "paths": { 19 | "@/*": ["./src/*"], 20 | "$lib/*": ["./src/lib/*"], 21 | "$components/*": ["./src/components/*"], 22 | "test/*": ["./test/*"] 23 | } 24 | }, 25 | "include": [ 26 | "test/**/*.ts", 27 | "src/**/*.d.ts", 28 | "src/**/*.ts", 29 | "src/**/*.tsx", 30 | "src/**/*.js", 31 | "src/**/*.jsx", 32 | "src/**/*.svelte" 33 | ], 34 | "references": [{ "path": "./tsconfig.node.json" }] 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig, UserConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | // https://vitejs.dev/config/ 6 | const configuration: UserConfig = { 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | '@': path.resolve(__dirname, './src'), 11 | $lib: path.resolve(__dirname, './src/lib'), 12 | $components: path.resolve(__dirname, './src/components'), 13 | }, 14 | }, 15 | }; 16 | 17 | export default configuration; 18 | -------------------------------------------------------------------------------- /vitest.config.svelte.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig } from 'vite'; 3 | import { svelte } from '@sveltejs/vite-plugin-svelte'; 4 | 5 | export default defineConfig({ 6 | plugins: [svelte({ hot: !process.env.VITEST })], 7 | resolve: { 8 | alias: { 9 | '@': path.resolve(__dirname, './'), 10 | $lib: path.resolve(__dirname, './src/lib'), 11 | }, 12 | }, 13 | test: { 14 | globals: true, 15 | include: ['**/*.svelte.test.*'], 16 | environment: 'jsdom', 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig, defaultExclude } from 'vitest/config'; 3 | import configuration from './vite.config'; 4 | 5 | export default defineConfig({ 6 | ...configuration, 7 | resolve: { 8 | alias: { 9 | ...configuration?.resolve?.alias, 10 | test: path.resolve(__dirname, './test'), 11 | }, 12 | }, 13 | test: { 14 | globals: true, 15 | setupFiles: path.resolve(__dirname, 'test/setup.ts'), 16 | exclude: [...defaultExclude, '**/*.svelte**'], 17 | environmentMatchGlobs: [ 18 | ['**/*.test.tsx', 'jsdom'], 19 | ['**/*.component.test.ts', 'jsdom'], 20 | ], 21 | coverage: { 22 | statements: 54.92, 23 | thresholdAutoUpdate: true, 24 | include: ['src/**/*'], 25 | exclude: [ 26 | 'test/**', 27 | 'vite.*.ts', 28 | '**/*.d.ts', 29 | '**/*.test.*', 30 | '**/*.config.*', 31 | '**/snapshot-tests/**', 32 | '**/*.solution.tsx', 33 | '**/coverage/**', 34 | ], 35 | all: true, 36 | }, 37 | }, 38 | }); 39 | --------------------------------------------------------------------------------