diff --git a/.env-example b/.env-example index b5cc7846b..023cb7f6f 100644 --- a/.env-example +++ b/.env-example @@ -1,5 +1,4 @@ # Development -ASSIGNMENT_FOLDER=assignment BRANCH_CHECKS=0 ENABLE_CLEAN=1 # HUSKY=0 \ No newline at end of file diff --git a/.github-later/workflows/test-report.yml b/.github-later/workflows/test-report.yml deleted file mode 100644 index 45451b7da..000000000 --- a/.github-later/workflows/test-report.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: 'Test Report' -on: - workflow_run: - workflows: ['CI'] # runs after CI workflow - types: - - completed -permissions: - contents: read - actions: read - checks: write -jobs: - report: - runs-on: ubuntu-latest - steps: - - uses: dorny/test-reporter@v1 - with: - artifact: test-results # artifact name - name: JEST Tests # Name of the check run which will be created - path: '*.xml' # Path to test results (inside artifact .zip) - reporter: jest-junit # Format of test results diff --git a/.github-later/workflows/ci.yml b/.github/workflows/test-reports-check.yml similarity index 57% rename from .github-later/workflows/ci.yml rename to .github/workflows/test-reports-check.yml index e01c6c5c4..9ee2e8360 100644 --- a/.github-later/workflows/ci.yml +++ b/.github/workflows/test-reports-check.yml @@ -1,6 +1,8 @@ name: 'CI' on: pull_request: + branches: + - main jobs: build-test: runs-on: ubuntu-latest @@ -16,9 +18,4 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm ci # install packages - - run: ./module-week.sh # run tests (configured to use jest-junit reporter) - - uses: actions/upload-artifact@v4 # upload test results - if: success() || failure() # run this step even if previous step failed - with: - name: test-results - path: junit.xml + - run: npm run test-reports-check diff --git a/1-JavaScript/Week2/README.md b/1-JavaScript/Week2/README.md index ec79e0e4d..caeff9e9b 100644 --- a/1-JavaScript/Week2/README.md +++ b/1-JavaScript/Week2/README.md @@ -74,15 +74,13 @@ Let's do some grocery shopping! We're going to get some things to cook dinner wi #### Exercise instructions -1. Create an array called `shoppingCart` that holds the following strings: `"bananas"` and `"milk"`. - -2. Complete the function named `addToShoppingCart` as follows: +1. Complete the function named `addToShoppingCart` as follows: - It should take one parameter: a grocery item (string) - - It should add the grocery item to `shoppingCart`. If the number of items is more than three remove the first one in the array. + - It should add the grocery item to the `shoppingCart` array. If the number of items is more than three remove the first one in the array. - It should return a string "You bought _\_!", where _\_ is a comma-separated list of items from the shopping cart array. -3. Confirm that your code passes the unit tests (see below). +2. Confirm that your code passes the unit tests (see below). #### Unit tests diff --git a/1-JavaScript/Week2/assignment/ex4-shoppingCart.js b/1-JavaScript/Week2/assignment/ex4-shoppingCart.js index eb6ac9431..a3f15a2a7 100644 --- a/1-JavaScript/Week2/assignment/ex4-shoppingCart.js +++ b/1-JavaScript/Week2/assignment/ex4-shoppingCart.js @@ -5,19 +5,16 @@ Let's do some grocery shopping! We're going to get some things to cook dinner with. However, you like to spend money and always buy too many things. So when you have more than 3 items in your shopping cart the first item gets taken out. -1. Create an array called `shoppingCart` that holds the following strings: - "bananas" and "milk". - -2. Complete the function named `addToShoppingCart` as follows: +1. Complete the function named `addToShoppingCart` as follows: - It should take one argument: a grocery item (string) - - It should add the grocery item to `shoppingCart`. If the number of items is + - It should add the grocery item to the `shoppingCart` array. If the number of items is more than three remove the first one in the array. - It should return a string "You bought !", where is a comma-separated list of items from the shopping cart array. -3. Confirm that your code passes the unit tests. +2. Confirm that your code passes the unit tests. -----------------------------------------------------------------------------*/ const shoppingCart = ['bananas', 'milk']; diff --git a/1-JavaScript/Week2/assignment/ex7-mindPrivacy.js b/1-JavaScript/Week2/assignment/ex7-mindPrivacy.js index 65a9faf2f..ae686deab 100644 --- a/1-JavaScript/Week2/assignment/ex7-mindPrivacy.js +++ b/1-JavaScript/Week2/assignment/ex7-mindPrivacy.js @@ -35,7 +35,7 @@ function filterPrivateData(/* TODO parameter(s) go here */) { // ! Test functions (plain vanilla JavaScript) function test1() { - console.log('Test 1: filterPrivateData should take one parameters'); + console.log('Test 1: filterPrivateData should take one parameter'); console.assert(filterPrivateData.length === 1); } diff --git a/1-JavaScript/Week3/README.md b/1-JavaScript/Week3/README.md index 9f1a8d5cf..725dd5970 100644 --- a/1-JavaScript/Week3/README.md +++ b/1-JavaScript/Week3/README.md @@ -6,6 +6,8 @@ The assignment for this week can be found in the `assignment` folder. > In this week we will be using a test library called [Jest](https://jestjs.io/) rather than using plain vanilla JavaScript as we did last week. > +> Note: Because Jest currently does not support the newer `import` and `export` keywords of modern JavaScript (it instead expects the older `module.exports` syntax), we use Jest in HYF in combination with a tool called [Babel](https://babeljs.io/). Babel transforms the newer syntax on-the-fly to the older syntax before Jest "sees" it. +> > For an introduction of Unit Testing with Jest we recommend the [Jest Crash Course - Unit Testing in JavaScript](https://youtu.be/7r4xVDI2vho) YouTube video from Traversy Media. For this week, please watch it up to the 0:21:24 time marker. ### Exercise 1: The odd ones out @@ -480,22 +482,7 @@ walletJane.reportBalance(); Since this is a browser-based exercise, the file `index.js` will be loaded via a ` + diff --git a/2-Browsers/Week1/assignment/ex2-aboutMe/index.html b/2-Browsers/Week1/assignment/ex2-aboutMe/index.html index 7b3c532ea..86254f036 100644 --- a/2-Browsers/Week1/assignment/ex2-aboutMe/index.html +++ b/2-Browsers/Week1/assignment/ex2-aboutMe/index.html @@ -15,6 +15,6 @@

About Me

  • Hometown:
  • - + diff --git a/2-Browsers/Week1/assignment/ex4-whatsTheTime/index.html b/2-Browsers/Week1/assignment/ex4-whatsTheTime/index.html index 5dac7dab6..fd5536b97 100644 --- a/2-Browsers/Week1/assignment/ex4-whatsTheTime/index.html +++ b/2-Browsers/Week1/assignment/ex4-whatsTheTime/index.html @@ -6,6 +6,6 @@ What's The Time? - + diff --git a/2-Browsers/Week1/assignment/ex5-catWalk/index.html b/2-Browsers/Week1/assignment/ex5-catWalk/index.html index 7403bec69..b6660ca8f 100644 --- a/2-Browsers/Week1/assignment/ex5-catWalk/index.html +++ b/2-Browsers/Week1/assignment/ex5-catWalk/index.html @@ -15,6 +15,6 @@ src="http://www.anniemation.com/clip_art/images/cat-walk.gif" alt="Cat walking" /> - + diff --git a/2-Browsers/Week1/unit-tests/ex3-hijackLogo.test.ts b/2-Browsers/Week1/unit-tests/ex3-hijackLogo.test.ts index 526b007f7..d3725897a 100644 --- a/2-Browsers/Week1/unit-tests/ex3-hijackLogo.test.ts +++ b/2-Browsers/Week1/unit-tests/ex3-hijackLogo.test.ts @@ -35,11 +35,11 @@ describe('br-wk1-ex3-hijackLogo', () => { testTodosRemoved(() => exInfo.source); - test('should set the `src` property', () => { + test('should set the `.src` property', () => { expect(state.src).toBeDefined(); }); - test('should set the `srcset` property', () => { + test('should set the `.srcset` property', () => { expect(state.srcset).toBeDefined(); }); }); diff --git a/3-UsingAPIs/Week1/assignment/ex3-rollDie.js b/3-UsingAPIs/Week1/assignment/ex3-rollDie.js index 95f22b9c2..7e4b77888 100644 --- a/3-UsingAPIs/Week1/assignment/ex3-rollDie.js +++ b/3-UsingAPIs/Week1/assignment/ex3-rollDie.js @@ -58,3 +58,5 @@ function main() { if (process.env.NODE_ENV !== 'test') { main(); } + +// TODO Replace this comment by your explanation that was asked for in the assignment description. diff --git a/3-UsingAPIs/Week1/assignment/ex4-pokerDiceAll.js b/3-UsingAPIs/Week1/assignment/ex4-pokerDiceAll.js index bc9404049..d88cd71d2 100644 --- a/3-UsingAPIs/Week1/assignment/ex4-pokerDiceAll.js +++ b/3-UsingAPIs/Week1/assignment/ex4-pokerDiceAll.js @@ -24,7 +24,7 @@ exercise file. // The line below makes the rollDie() function available to this file. // Do not change or remove it. -const rollDie = require('../../helpers/pokerDiceRoller'); +import { rollDie } from '../../helpers/pokerDiceRoller.js'; export function rollDice() { // TODO Refactor this function @@ -42,3 +42,5 @@ function main() { if (process.env.NODE_ENV !== 'test') { main(); } + +// TODO Replace this comment by your explanation that was asked for in the assignment description. diff --git a/3-UsingAPIs/Week1/assignment/ex5-pokerDiceChain.js b/3-UsingAPIs/Week1/assignment/ex5-pokerDiceChain.js index be936b3f5..5b1394b84 100644 --- a/3-UsingAPIs/Week1/assignment/ex5-pokerDiceChain.js +++ b/3-UsingAPIs/Week1/assignment/ex5-pokerDiceChain.js @@ -12,7 +12,7 @@ to expand the given promise chain to include five dice. // The line below makes the rollDie() function available to this file. // Do not change or remove it. -const rollDie = require('../../helpers/pokerDiceRoller'); +import { rollDie } from '../../helpers/pokerDiceRoller.js'; export function rollDice() { const results = []; diff --git a/3-UsingAPIs/Week2/assignment/ex4-diceRace.js b/3-UsingAPIs/Week2/assignment/ex4-diceRace.js index 742588a6d..ddff3242c 100644 --- a/3-UsingAPIs/Week2/assignment/ex4-diceRace.js +++ b/3-UsingAPIs/Week2/assignment/ex4-diceRace.js @@ -30,3 +30,5 @@ function main() { if (process.env.NODE_ENV !== 'test') { main(); } + +// TODO Replace this comment by your explanation that was asked for in the assignment description. diff --git a/README.md b/README.md index a9ff1bc37..d457d2828 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,38 @@ This repository contains all of the assignments exercises that need to be handed in for the JavaScript modules (i.e., JavaScript, Browsers and UsingAPIs) of the HackYourFuture curriculum. -Please read this README carefully before starting work on the assignment exercises. +## TL;DR + +We highly recommend that you go through this README in detail before starting to work on the assignments. Having said that, the essentials can be summarized as follows: + +> Important: If you are using a Windows-based computer, please type this command from a Windows Terminal window before you proceed: +> +> `git config --global core.autocrlf false` +> +> This will ensure that the line endings for text files are compatible with those used on MacOS and Linux-based computers. + +1. Fork the `HackYourAssignment/Assignments-CohortXX` repository to your own GitHub account. +2. Clone the fork to your local computer. +3. Open the root folder of the repository in VSCode. +4. When invited to do so, please install the recommended VSCode extensions. +5. Run the command `npm install` from the root folder of the repository. +6. Make sure that you are on the `main` branch (if not, check it out first). +7. Create a new branch and name it (e.g., for week 2 of JavaScript) `YOUR_NAME-w2-JavaScript`. Replace `YOUR_NAME` with your name. Note that you should not work on / modify the `main` branch. +8. Start your work on the assignments for week 2. +9. After finishing an assignment, use the command `npm test` to test your solution. +10. Fix any reported issues and rerun the test. Repeat until all issues are fixed. +11. When all assignments are done, commit all changed files. This includes the modified exercises, the generated test summary (`TEST_SUMMARY.md`) and test reports (`EXERCISE_NAME.report.txt`). +12. Push the changes to your fork. +13. Create a pull request against the `main` branch of the `HackYourAssignment/Assignments-CohortXX` repository. For the title of your pull request use the same format as the branch name, e.g.: `YOUR_NAME-w2-JavaScript`. + +Repeat steps 6-13 for each week. For subsequent weeks the mandated branch names are: + +- `YOUR_NAME-w3-JavaScript` +- `YOUR_NAME-w1-Browsers` +- `YOUR_NAME-w1-UsingAPIs` +- `YOUR_NAME-w2-UsingAPIs` + +For more information how to hand in your weekly assignments please refer to the [Hand-in Assignments Guide](https://github.com/HackYourFuture/JavaScript/blob/main/hand-in-assignments-guide.md#12-every-week). ## Introduction @@ -25,17 +56,25 @@ This command: ## VSCode -You will be spending a lot of time in [VSCode](https://code.visualstudio.com/) while working with this repository. To open it with VSCode you can use the command line: +You will be spending a lot of time in [VSCode](https://code.visualstudio.com/) while working with this repository. If you are new to VSCode please check out the [VSCode Tips](https://github.com/HackYourFuture/fundamentals/blob/master/VSCodeTips/README.md) before continuing. + +From the command line, while inside the `Assignments-cohortXX` folder, you can use this command to open VSCode (the `.` stands for the current directory): ```text -code Assignments-cohortXX +code . ``` -> When working on your assignment it is strongly recommended to open `Assignments-cohortXX` folder itself in VSCode rather than one of its sub-folders. This gives VSCode and all its extensions the full view on the repo for the best overall developer experience. +> When working on your assignments it is strongly recommended to always open the `Assignments-cohortXX` folder in VSCode rather than one of its sub-folders. This gives VSCode and all its extensions the full view on the repository for the best overall developer experience. +> +> Note that the name of the folder opened in VSCode can always be found in the `EXPLORER` panel ( `ASSIGNMENTS_COHORT49` in the picture below): +> +> ![folder-name](./assets/folder-name.png) ### Install Recommended VSCode Extensions -**Important**: When you open the repository for the first time you may be invited to install a set of **recommended VSCode extensions**. These extensions will provide useful help and guidance when you are creating and editing files in VSCode. **Please install these extensions when invited to do so.** +**Important**: When you open the repository for the first time you may see the dialog box shown below that invites you to install a set of **recommended VSCode extensions**. These extensions will provide useful help and guidance when you are creating and editing files in VSCode. **Please install these extensions when invited to do so.** + +![recommended-extensions](./assets/recommended-extensions.png) If, for some reason, the prompt to install the extensions does not appear then please install the extensions manually (click on the triangle below for details). @@ -108,13 +147,13 @@ When you are in the process of making changes to a file you will notice a dot or This indicates that you have unsaved changes. Once you are done, you can use the **File**, **Save** menu commands (or a keyboard shortcut) to save the changes. However, in this repository we have included a setting that automatically saves changes for you whenever you click away from the editor window. -> If you are curious about the VSCode settings that we included in this repo, check the file `settings.json` in the `.vscode` folder. The setting we mentioned in the previous paragraph is: **"files.autoSave": "onFocusChange"**. +> If you are curious about the VSCode settings that we included in this repository, check the file `settings.json` in the `.vscode` folder. The setting we mentioned in the previous paragraph is: **"files.autoSave": "onFocusChange"**. > > You can learn more about VSCode settings here: [User and Workspace Settings](https://code.visualstudio.com/docs/getstarted/settings). ### Prettier VSCode Extension -This is a recommended VSCode extension that we have included in this repo. **Prettier** is an automatic code formatter to make your code look "pretty". However, it is not just that your code is made pretty, it formats your code into a popular standard that has become well established in the JavaScript community. Other developers, whether trainees, mentors or, later, your colleagues will thank you for using it. +This is a recommended VSCode extension that we have included in this repository. **Prettier** is an automatic code formatter to make your code look "pretty". However, it is not just that your code is made pretty, it formats your code into a popular standard that has become well established in the JavaScript community. Other developers, whether trainees, mentors or, later, your colleagues will thank you for using it. > Ensure that you do not install any other code formatter extensions, for example, **Beautify**, in addition to Prettier. This may cause formatting conflicts. @@ -144,7 +183,7 @@ To run the exercise while in VSCode, first open a VSCode **Integrated Terminal** > Tip: for an overview of the keyboard shortcuts available in VSCode, select the **Help**, **Keyboard Shortcut Reference** menu commands. This will open a PDF file in the standard browser, listing all available shortcuts. -The most convenient way to run an exercise from the command line is to use the **exercise runner** included in this repo. Type the following command to run an exercise this way: +The most convenient way to run an exercise from the command line is to use the **exercise runner** included in this repository. Type the following command to run an exercise this way: ```text npm start @@ -222,7 +261,7 @@ To run a test, type the following command from the command line: npm test ``` -The test runner examines the exercise files in your repo and if it finds exactly one modified exercise it will prompt you whether you want to test that one. For example: +The test runner examines the exercise files in your repository and if it finds one or more modified exercises it will prompt you whether you want to test the first one. For example: ```text ? Test modified exercise (1-JavaScript, Week2, ex1-giveCompliment)? (Y/n) @@ -243,16 +282,16 @@ Running test, please wait... *** Unit Test Error Report *** -Command failed: npx jest H:/dev/hackyourfuture/temp/Assignments/.dist/1-JavaScript/Week2/unit-tests/ex1-giveCompliment.test.js --colors --noStackTrace +Command failed: npx jest H:/dev/hackyourfuture/Assignments/.dist/1-JavaScript/Week2/unit-tests/ex1-giveCompliment.test.js --colors --noStackTrace --json FAIL .dist/1-JavaScript/Week2/unit-tests/ex1-giveCompliment.test.js js-wk2-ex1-giveCompliment - √ should exist and be executable (2 ms) - × should have all TODO comments removed (1 ms) - √ `giveCompliment` should not contain unneeded console.log calls (1 ms) - × should take a single parameter (1 ms) - √ should include a `compliments` array inside its function body (1 ms) - × the `compliments` array should be initialized with 10 strings - × should give a random compliment: You are `compliment`, `name`! (1 ms) + ✅ should exist and be executable (2 ms) + ❌ should have all TODO comments removed (1 ms) + ✅ `giveCompliment` should not contain unneeded console.log calls + ❌ should take a single parameter (1 ms) + ✅ should include a `compliments` array inside its function body + ❌ the `compliments` array should be initialized with 10 strings (1 ms) + ❌ should give a random compliment: You are `compliment`, `name`! (2 ms) ● js-wk2-ex1-giveCompliment › should have all TODO comments removed @@ -286,11 +325,10 @@ Command failed: npx jest H:/dev/hackyourfuture/temp/Assignments/.dist/1-JavaScri Test Suites: 1 failed, 1 total Tests: 4 failed, 3 passed, 7 total Snapshots: 0 total -Time: 1.71 s -Ran all test suites matching /H:\\dev\\hackyourfuture\\temp\\Assignments\\.dist\\1-JavaScript\\Week2\\unit-tests\\ex1-giveCompliment.test.js/i. +Time: 1.582 s +Ran all test suites matching /H:\\dev\\hackyourfuture\\Assignments\\.dist\\1-JavaScript\\Week2\\unit-tests\\ex1-giveCompliment.test.js/i. No linting errors detected. No spelling errors detected. - ``` Figure 8. Running a test. diff --git a/assets/dev-tools-debugger.png b/assets/dev-tools-debugger.png index bd10b6f12..b75555bff 100644 Binary files a/assets/dev-tools-debugger.png and b/assets/dev-tools-debugger.png differ diff --git a/assets/folder-name.png b/assets/folder-name.png new file mode 100644 index 000000000..0e57dbe83 Binary files /dev/null and b/assets/folder-name.png differ diff --git a/assets/recommended-extensions.png b/assets/recommended-extensions.png new file mode 100644 index 000000000..00b57c216 Binary files /dev/null and b/assets/recommended-extensions.png differ diff --git a/assets/wallet-breakpoint-26.png b/assets/wallet-breakpoint-26.png deleted file mode 100644 index ca5eaeaab..000000000 Binary files a/assets/wallet-breakpoint-26.png and /dev/null differ diff --git a/assets/wallet-breakpoint-hit.png b/assets/wallet-breakpoint-hit.png new file mode 100644 index 000000000..81f2416a3 Binary files /dev/null and b/assets/wallet-breakpoint-hit.png differ diff --git a/assets/wallet-hit-26.png b/assets/wallet-hit-26.png deleted file mode 100644 index 9d0b1c268..000000000 Binary files a/assets/wallet-hit-26.png and /dev/null differ diff --git a/assets/wallet-set-breakpoint.png b/assets/wallet-set-breakpoint.png new file mode 100644 index 000000000..04b30460c Binary files /dev/null and b/assets/wallet-set-breakpoint.png differ diff --git a/.hashes.json b/exercises.json similarity index 80% rename from .hashes.json rename to exercises.json index c6dc8be95..eab0103c4 100644 --- a/.hashes.json +++ b/exercises.json @@ -4,10 +4,10 @@ "ex1-giveCompliment": "c2c8b6253ad706989b9120f00d80e8453b5be70f34c35fcd7567dcf5550ba897", "ex2-dogYears": "83cde87e41e27739a745da5038550cdb2470ce917b8b6f3b5b5d92d9576c6b8e", "ex3-tellFortune": "8b2f61a4b1fc2603bac8d09a497fce6febcc94103f4a8eef05562f31410c04e0", - "ex4-shoppingCart": "faa80df26c206721a4126e3e11f2d8601e7a74b5f480b5c2b895cd21573389db", + "ex4-shoppingCart": "c322c8cc08b67cd746d16d7b9d5614c9b457698639844b62bc4cc92a6af22799", "ex5-shoppingCartPure": "3476061983b7ffbd6550e5e57fd7499bd2808d31ee2d4bc61cb97404d9f38d44", "ex6-totalCost": "5b1d0494344f16ac5b707ba868879c799b503b60595afa6684698ea4bf7aa351", - "ex7-mindPrivacy": "f809108686ee3c05c2b7dc39de4ec6259ae48b7940f4cf56f34293302e256380" + "ex7-mindPrivacy": "eb34a2493581110393fe8a3a1cd1a453b0eebe792ee29df7182c9a76661b93ca" }, "Week3": { "ex1-doubleEvenNumbers.test": "99e7d34f1878d376c2c14367a5de445a3bf00abee43ff86996f1d2a1bb1bc683", @@ -31,15 +31,15 @@ "Week1": { "ex1-johnWho": "3a45143e7c62f304fd5bd5eb706cea077912373fa521c80934adac49ec9436ef", "ex2-checkDoubleDigits": "05d6893c90f828e26c4471b69054252a7c094a8a1ea02e28fb72716230cfbbc8", - "ex3-rollDie": "a3001b96605ee20f187421d3323635c1556cc77c06346ff25d79302a2b798c55", - "ex4-pokerDiceAll": "c03cf0564e4702c69bc4224ceba797efbf69420ed87979cb823c920567866923", - "ex5-pokerDiceChain": "e0b3668e27909a717506dd028b67b7c3b53f8ba1e5b643c39dfb2a40c44d89f4" + "ex3-rollDie": "90c12c5033853dbc7cbf199ea4b9e45ab4f21ad5253ccd3026e7f9318e4706ac", + "ex4-pokerDiceAll": "07011841855d76b4084f6872c66ff11631ab6c641d842271fe0fba6e88d45f4b", + "ex5-pokerDiceChain": "56e08a6fe9aa5455bb87e9510c42981fc5221e3e0ca392335d65a8de187b0e32" }, "Week2": { "ex1-programmerFun": "e6cbf8715cf9b840b26fc8d8553dd235ed52b456fa6b37d639f6d54625e58797", "ex2-pokemonApp": "b9ec9888524d269f507943086df9ccba3ae32c76b1d39ac6565f6f8371c04631", "ex3-rollAnAce": "8c840efffc0734a9b226588289a44df893955da251120e21ef1a1afa10a5e181", - "ex4-diceRace": "d21ce44070d9ff596b941d138ca1a701fbfc1292afdd412cc8b8951ef512d413", + "ex4-diceRace": "6191e46ed7f0f5724caed20584730a8d079c5b057f6897dc1bea8079cfdb47ce", "ex5-vscDebug": "6242bc8861bdb0abea48d00fa6f2cd9bf3a6f93fdd9b708ad130c8b64c6c75eb", "ex6-browserDebug": "1787583932555b23bcb625030cfcad2094bc222824dcba2d79db4bc01e6b9142" } diff --git a/package-lock.json b/package-lock.json index cef35e241..c2a454f51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4642,10 +4642,11 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -5512,21 +5513,6 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, - "node_modules/fast-url-parser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz", - "integrity": "sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0=", - "dev": true, - "dependencies": { - "punycode": "^1.3.2" - } - }, - "node_modules/fast-url-parser/node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", - "dev": true - }, "node_modules/fastq": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.1.tgz", @@ -7930,10 +7916,11 @@ } }, "node_modules/path-to-regexp": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz", - "integrity": "sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==", - "dev": true + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -8426,18 +8413,18 @@ } }, "node_modules/serve-handler": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.5.tgz", - "integrity": "sha512-ijPFle6Hwe8zfmBxJdE+5fta53fdIY0lHISJvuikXB3VYFafRjMRpOffSPvCYsbKyBA7pvy9oYr/BT1O3EArlg==", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", + "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", "dev": true, + "license": "MIT", "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", - "fast-url-parser": "1.1.3", "mime-types": "2.1.18", "minimatch": "3.1.2", "path-is-inside": "1.0.2", - "path-to-regexp": "2.2.1", + "path-to-regexp": "3.3.0", "range-parser": "1.2.0" } }, @@ -12772,9 +12759,9 @@ } }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "requires": { "path-key": "^3.1.0", @@ -13394,23 +13381,6 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, - "fast-url-parser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz", - "integrity": "sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0=", - "dev": true, - "requires": { - "punycode": "^1.3.2" - }, - "dependencies": { - "punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", - "dev": true - } - } - }, "fastq": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.1.tgz", @@ -15150,9 +15120,9 @@ } }, "path-to-regexp": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz", - "integrity": "sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", "dev": true }, "path-type": { @@ -15492,18 +15462,17 @@ "dev": true }, "serve-handler": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.5.tgz", - "integrity": "sha512-ijPFle6Hwe8zfmBxJdE+5fta53fdIY0lHISJvuikXB3VYFafRjMRpOffSPvCYsbKyBA7pvy9oYr/BT1O3EArlg==", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", + "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", "dev": true, "requires": { "bytes": "3.0.0", "content-disposition": "0.5.2", - "fast-url-parser": "1.1.3", "mime-types": "2.1.18", "minimatch": "3.1.2", "path-is-inside": "1.0.2", - "path-to-regexp": "2.2.1", + "path-to-regexp": "3.3.0", "range-parser": "1.2.0" }, "dependencies": { diff --git a/package.json b/package.json index bf412f362..728703ac3 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,13 @@ "scripts": { "start": "node ./.dist/test-runner/exercise-runner", "test": "node ./.dist/test-runner/test-cli", - "test:runner": "glob -c \"node --test\" \"./.dist/__tests__/**/*.test.js\"", - "jest:js2": "jest ./.dist/1-JavaScript/Week2", - "jest:js3": "jest ./1-JavaScript/Week3", - "jest:br1": "jest ./.dist/2-Browser/Week1", - "jest:api1": "jest ./.dist/3-UsingAPIs/Week1", - "jest:api2": "jest ./.dist/3-UsingAPIs/Week2", + "seal": "node ./.dist/test-runner/seal", "clean": "node ./.dist/test-runner/clean", - "create-hashes": "node ./.dist/test-runner/create-hashes.js", - "test-changed": "node ./.dist/test-runner/test-changed", "postinstall": "tsc --build", "prepare": "husky", "pre-commit": "node .dist/test-runner/pre-commit", - "pre-push": "node .dist/test-runner/pre-push" + "pre-push": "node .dist/test-runner/pre-push", + "test-reports-check": "node .dist/test-runner/test-reports-check" }, "repository": { "type": "git", diff --git a/test-runner/ExerciseMenu.ts b/test-runner/ExerciseMenu.ts index fd01b8ff6..3d56b5994 100644 --- a/test-runner/ExerciseMenu.ts +++ b/test-runner/ExerciseMenu.ts @@ -1,33 +1,31 @@ import { confirm, select } from '@inquirer/prompts'; -import fg from 'fast-glob'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; + import { getUntestedExercises } from './compliance.js'; +import { ExerciseHashes, getExerciseMap } from './exercises.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export type MenuData = { [module: string]: { [week: string]: string[] } }; type ExercisePathOptions = { isTest: boolean }; export function buildExercisePath( module: string, week: string, - exercise: string, - assignmentFolder = 'assignment' + exercise: string ) { return path - .join(__dirname, `../../${module}/${week}/${assignmentFolder}/${exercise}`) + .join(__dirname, `../../${module}/${week}/assignment/${exercise}`) .replace(/\\/g, '/'); } export default class ExerciseMenu { - #assignmentFolder: string; #module = ''; #week = ''; #exercise = ''; - #menuData: MenuData = {}; + #exerciseHashes: ExerciseHashes = {}; get module() { return this.#module; @@ -41,52 +39,21 @@ export default class ExerciseMenu { return this.#exercise; } - get menuData() { - return this.#menuData; + get exerciseHashes() { + return this.#exerciseHashes; } - constructor(assignmentFolder = 'assignment') { - this.#assignmentFolder = assignmentFolder; - this.compileMenuData(); + constructor() { + this.#exerciseHashes = getExerciseMap(); this.getMostRecentSelection(); } - private compileMenuData() { - // Look for file and folder names that match the expected structure. - // Windows paths are converted to POSIX paths to ensure compatibility. - const posixFileSpec = path - .join(__dirname, `../../**/${this.#assignmentFolder}/ex([0-9])-*`) - .replace(/\\/g, '/'); - - const filePaths = fg.sync([posixFileSpec, '!**/node_modules'], { - onlyFiles: false, - }); - - filePaths.forEach((filePath) => { - const regexp = RegExp( - String.raw`^.*/(.+)/(Week\d)/${this.#assignmentFolder}/(.+?)(?:\.js)?$`, - 'i' - ); - const matches = filePath.match(regexp); - if (matches) { - const [, module, week, exercise] = matches; - if (!this.menuData[module]) { - this.menuData[module] = {}; - } - if (!this.menuData[module][week]) { - this.menuData[module][week] = []; - } - this.menuData[module][week].push(exercise); - } - }); - } - async getExercisePath( options: ExercisePathOptions = { isTest: true } ): Promise { let haveSelection = false; - const untestedExercises = getUntestedExercises(this.menuData); + const untestedExercises = getUntestedExercises(this.exerciseHashes); // If there is at least one untested exercise, ask the user whether to use it. if (untestedExercises.length > 0) { @@ -121,18 +88,15 @@ export default class ExerciseMenu { this.putMostRecentSelection(); } - return buildExercisePath( - this.module, - this.week, - this.exercise, - this.#assignmentFolder - ); + return buildExercisePath(this.module, this.week, this.exercise); } private async selectModule(): Promise { const module = await select({ message: 'Which module?', - choices: Object.keys(this.menuData).map((choice) => ({ value: choice })), + choices: Object.keys(this.exerciseHashes).map((choice) => ({ + value: choice, + })), default: this.module, }); @@ -149,7 +113,7 @@ export default class ExerciseMenu { const week = await select({ message: 'Which week?', - choices: Object.keys(this.menuData[this.module]).map((choice) => ({ + choices: Object.keys(this.exerciseHashes[this.module]).map((choice) => ({ value: choice, })), default: this.week, @@ -168,9 +132,11 @@ export default class ExerciseMenu { this.#exercise = await select({ message: 'Which exercise?', - choices: this.menuData[this.module][this.week].map((choice) => ({ - value: choice, - })), + choices: Object.keys(this.exerciseHashes[this.module][this.week]).map( + (choice) => ({ + value: choice, + }) + ), default: this.exercise, }); diff --git a/test-runner/README.md b/test-runner/README.md index 902f79493..0e5fdc31a 100644 --- a/test-runner/README.md +++ b/test-runner/README.md @@ -19,31 +19,32 @@ A test is selected by going through a series of prompts, for instance: ? Which week? Week2 ? Which exercise? ex1-giveCompliment Running test, please wait... +*** Unit Test Error Report *** + PASS .dist/1-JavaScript/Week2/unit-tests/ex1-giveCompliment.test.js js-wk2-ex1-giveCompliment - √ should exist and be executable (2 ms) - √ should have all TODO comments removed - √ `giveCompliment` should not contain unneeded console.log calls - √ should take a single parameter (1 ms) - √ should include a `compliments` array inside its function body - √ the `compliments` array should be initialized with 10 strings (1 ms) - √ should give a random compliment: You are `compliment`, `name`! (4 ms) + ✅ should exist and be executable (1 ms) + ✅ should have all TODO comments removed + ✅ `giveCompliment` should not contain unneeded console.log calls + ✅ should take a single parameter + ✅ should include a `compliments` array inside its function body + ✅ the `compliments` array should be initialized with 10 strings (1 ms) + ✅ should give a random compliment: You are `compliment`, `name`! Test Suites: 1 passed, 1 total Tests: 7 passed, 7 total Snapshots: 0 total -Time: 0.882 s, estimated 1 s -Ran all test suites matching /H:\\dev\\hackyourfuture\\Assignments\\.dist\\1-JavaScript\\Week2\\unit-tests\\ex1-giveCompliment.test.js/i. - +Time: 0.29 s, estimated 1 s +Ran all test suites matching /\/home\/jim\/dev\/hackyourfuture\/Assignments\/.dist\/1-JavaScript\/Week2\/unit-tests\/ex1-giveCompliment.test.js/i. No linting errors detected. No spelling errors detected. ``` ### Report file -When you run a test the results are reported to the console, but also written to a report file named `TEST_REPORT.log`, in the root folder. +When you run a test the results are reported to the console, but also written to a report file named `.report.txt`, in a `test-reports` folder for each week. -A report file named `TEST_REPORT.log` is generated to which test result are written for each test run. Trainees are expected to include this file in their pull request for the benefit of the assignment reviewer. +Trainees are expected to include the test reports in their pull request for the benefit of the assignment reviewer. Trainees are expected to run the relevant tests. Running a test gives them early feedback on the correctness of the expected results and on conformance to the mandated coding style (as per ESLint). This provides them an early opportunity for corrective action. Once submitted as part of a PR, the report files give pull request reviewers some key indicators into the correctness of the homework while doing a more elaborate visual inspection of the actual code. @@ -56,7 +57,6 @@ The test runner relies on strict adherence to a predefined naming convention and | ------ | ----------- | | `/Week𝑛/assignment` | Example: `1-JavaScript/Week3/assignment`

    The JavaScript file representing the exercise must named `.js` and placed in this folder. However, if the exercise consists of multiple files (e.g. a browser-based exercise) then these files must be placed in a _folder_ named ``. In this case, the main JavaScript file must be called `index.js`.

    There can be multiple exercises per _Week𝑛_ folder. | | `/Week𝑛/unit-tests` | This folder contains the unit test (JavaScript or TypeScript) files. The JavaScript/TypeScript file containing the unit test(s) for an exercise must named `.test.[jt]s`. Unit test files are optional. If not provided, the unit test step of the test runner is skipped.

    Note that TypeScript unit tests are transpiled to JavaScript to a `.dist` folder in the project root, using an `npm postinstall` script. | -| `/Week𝑛/@assignment` | This folder (notice the leading `@`-sign in the name) is only used during development and maintenance of this repo. Working solutions to exercises can be placed in this folder to test the "happy" path of the unit tests. An `@assignment` folder is used in place of a regular `assignment` folder when a unit test is run with the command: `npm run testalt`.

    Notes:

    1. `@assignment` folders should not be committed and are therefore included in `.gitignore`.
    2. To test the exercises from the `@assignment` folder, set the `ASSIGNMENT_FOLDER` environment variable to `@assignment`. (See `.env-example`.) | ## Linting @@ -66,11 +66,15 @@ ESLint rules are configured as usual in the file `.eslintrc.js`. Should this be An npm `postinstall` script is automatically executed as part of the `npm install` process. This script simply transpiles all TypeScript files to the `.dist` folder. -## npm `clean` script +## `npm run seal` + +This script scans the directory structure of the repository for `assignment` folders and globs into them to collect file and folder names of exercises they contain. For each exercise it computes a hash over its file(s). The results are recorded in the file `exercises.json`. The test runner uses this file to drive its prompt menu and to establish whether exercises have been modified from their "sealed" (pristine) state, i.e. if a newly computed hash over the exercises file(s) differ from the stored hash. + +## `npm run clean` -This script cleans out the `test-report` folders and `unit-test.log` file. +This script cleans out the `test-report` folders and removes the `TEST_SUMMARY.md` file. -Furthermore, a file `.hashes.json` is created in the root folder that contains a JSON object with hashes computed over of the `.js` file(s) of the exercises, one hash per exercise. This information is used to detect whether the starter code of and exercise has been modified since initial installation. +Furthermore, a file `.exercises.json` is created in the root folder that contains a JSON object with hashes computed over of the `.js` file(s) of the exercises, one hash per exercise. This information is used to detect whether the starter code of and exercise has been modified since initial installation. To prevent trainees from accidentally running this script the `ENABLE_CLEANUP` environment variable must be set to "true" (see `.env-example`). diff --git a/test-runner/__tests__/ExerciseMenu.test.ts b/test-runner/__tests__/ExerciseMenu.test.ts deleted file mode 100644 index e7cee5210..000000000 --- a/test-runner/__tests__/ExerciseMenu.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { describe, it } from 'node:test'; -import ExerciseMenu from '../ExerciseMenu.js'; - -// Note: these test use the Node test runner, which is not the same as Jest. - -describe('ExerciseMenu', () => { - it('should compile data that matches the existing folder structure', async () => { - const menu = new ExerciseMenu('assignment'); - - console.log('menu', menu); - // Thanks GitHub Copilot for the following code snippet - const exercisePaths = Object.entries(menu.menuData).flatMap( - ([module, weeks]) => { - return Object.entries(weeks).flatMap(([week, exercises]) => { - return exercises.map( - (exercise) => `${module}/${week}/assignment/${exercise}` - ); - }); - } - ); - - for (const exercisePath of exercisePaths) { - let exerciseExists = - fs.existsSync(exercisePath) || // Folder containing the exercise - fs.existsSync(exercisePath + '.js') || // Exercise file - fs.existsSync(exercisePath + '.test.js'); // Exercise file with embedded tests - assert.ok(exerciseExists, `Exercise not found: ${exercisePath}`); - } - }); -}); diff --git a/test-runner/clean.ts b/test-runner/clean.ts index 5a4d685e1..cf28d53fc 100644 --- a/test-runner/clean.ts +++ b/test-runner/clean.ts @@ -1,11 +1,10 @@ import 'dotenv/config.js'; -import chalk from 'chalk'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import chalk from 'chalk'; import { rimrafSync } from 'rimraf'; -import { fileURLToPath } from 'url'; -import { createExerciseHashes } from './compliance.js'; -import ExerciseMenu from './ExerciseMenu.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -19,19 +18,17 @@ try { process.exit(1); } - const { menuData } = new ExerciseMenu(); - - console.log('Computing and saving exercise hashes...'); - createExerciseHashes(menuData); - console.log('Cleaning up junit.xml...'); rimrafSync(path.join(__dirname, '../../junit.xml')); console.log('Cleaning up test results...'); - rimrafSync(path.join(__dirname, '../../.test-summary')); - rimrafSync(path.join(__dirname, '../../**/test-reports'), { - glob: true, - }); + rimrafSync(path.join(__dirname, '../../.test-summary').replace(/\\/g, '/')); + rimrafSync( + path.join(__dirname, '../../**/test-reports').replace(/\\/g, '/'), + { + glob: true, + } + ); } catch (err: any) { console.error(chalk.red(`Something went wrong: ${err.message}`)); throw err; diff --git a/test-runner/compliance.ts b/test-runner/compliance.ts index 21fdd452d..e67424ab3 100644 --- a/test-runner/compliance.ts +++ b/test-runner/compliance.ts @@ -1,7 +1,5 @@ import 'dotenv/config.js'; -import chalk from 'chalk'; -import fg from 'fast-glob'; import { exec } from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; @@ -9,15 +7,17 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -import ExerciseMenu, { MenuData } from './ExerciseMenu.js'; +import chalk from 'chalk'; +import fg from 'fast-glob'; + +import ExerciseMenu from './ExerciseMenu.js'; +import { ExerciseHashes } from './exercises.js'; const execAsync = promisify(exec); const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const COMPUTED_HASHES_JSON_PATH = path.join(__dirname, '../../.hashes.json'); - -function computeHash(exercisePath: string): string { +export function computeHash(exercisePath: string): string { const sha256sum = crypto.createHash('sha256'); const fileSpec = fs.existsSync(exercisePath) ? '/**/*.js' : '.js'; const globSpec = path @@ -25,46 +25,31 @@ function computeHash(exercisePath: string): string { .replace(/\\/g, '/'); const filePaths = fg.sync(globSpec); for (const filePath of filePaths) { - const content = fs.readFileSync(filePath, 'utf8'); + // Note: convert potential Windows \r\n line endings to \n + // to avoid hash mismatches + const content = fs.readFileSync(filePath, 'utf8').replaceAll('\r\n', '\n'); sha256sum.update(content); } return sha256sum.digest('hex'); } -type Hashes = { - [module: string]: { [week: string]: { [exercise: string]: string } }; -}; - -export function createExerciseHashes(menuData: MenuData): void { - const hashes: Hashes = {}; - for (const module in menuData) { - for (const week in menuData[module]) { - for (const exercise of menuData[module][week]) { - const exercisePath = `${module}/${week}/assignment/${exercise}`; - if (!hashes[module]) { - hashes[module] = {}; - } - if (!hashes[module][week]) { - hashes[module][week] = {}; - } - hashes[module][week][exercise] = computeHash(exercisePath); - } - } - } - - const hashesJson = JSON.stringify(hashes, null, 2); - fs.writeFileSync(COMPUTED_HASHES_JSON_PATH, hashesJson); +export function isModifiedExercise(menu: ExerciseMenu): boolean { + const { module, week, exercise, exerciseHashes } = menu; + const exercisePath = `${module}/${week}/assignment/${exercise}`; + const computedHash = computeHash(exercisePath); + const cleanHash = exerciseHashes[module][week][exercise]; + return computedHash !== cleanHash; } -export function diffExerciseHashes(menuData: MenuData): Hashes { - const diff: Hashes = {}; - const computedHashes = JSON.parse( - fs.readFileSync(COMPUTED_HASHES_JSON_PATH, 'utf8') - ); - for (const module in menuData) { - for (const week in menuData[module]) { - for (const exercise of menuData[module][week]) { - const computedHash = computedHashes[module][week][exercise]; +export function diffExerciseHashes( + exerciseHashes: ExerciseHashes +): ExerciseHashes { + const diff: ExerciseHashes = {}; + + for (const module in exerciseHashes) { + for (const week in exerciseHashes[module]) { + for (const exercise in exerciseHashes[module][week]) { + const computedHash = exerciseHashes[module][week][exercise]; const exercisePath = `${module}/${week}/assignment/${exercise}`; const actualHash = computeHash(exercisePath); if (computedHash !== actualHash) { @@ -84,17 +69,36 @@ export function diffExerciseHashes(menuData: MenuData): Hashes { } const MAIN_BRANCH_MESSAGE = ` -You are currently on the *main* branch. In the Assignments repository you should not be working directly on the main branch. +You are currently on the *main* branch. In the Assignments repository you should +not be working directly on the main branch. + +Please create a new branch for each week. Valid branch names should +start with your name, followed by the week number and the module name in this +format: -Please create a new branch for each week (e.g. YOUR_NAME-w2-JavaScript) as instructed in the link below: +YOUR_NAME-w2-JavaScript +YOUR_NAME-w3-JavaScript +YOUR_NAME-w1-Browsers +YOUR_NAME-w1-UsingAPIs +YOUR_NAME-w2-UsingAPIs + +For more information please refer to the link below: https://github.com/HackYourFuture/JavaScript/blob/main/hand-in-assignments-guide.md#12-every-week `; const BRANCH_NAME_MESSAGE = ` -Your branch name does conform to the mandated pattern, e.g. YOUR_NAME-w2-JavaScript. +Your branch name does conform to the mandated pattern. Valid branch names should +start with your name, followed by the week number and the module name in this +format: + +YOUR_NAME-w2-JavaScript +YOUR_NAME-w3-JavaScript +YOUR_NAME-w1-Browsers +YOUR_NAME-w1-UsingAPIs +YOUR_NAME-w2-UsingAPIs -Please rename your branch to match the pattern as described in the link below: +For more information please refer to the link below: https://github.com/HackYourFuture/JavaScript/blob/main/hand-in-assignments-guide.md#12-every-week `; @@ -104,14 +108,6 @@ export async function isValidBranchName(menu: ExerciseMenu): Promise { return true; } - const modulesNames = Object.keys(menu.menuData).map((name) => - name.replace(/\d-/, '') - ); - const branchNamePattern = new RegExp( - String.raw`-(?:w|wk|week)\d-(?:${modulesNames.join('|')})$`, - 'i' - ); - const { stdout } = await execAsync('git branch --show-current'); const branchName = stdout.trim(); @@ -120,7 +116,26 @@ export async function isValidBranchName(menu: ExerciseMenu): Promise { return false; } - if (!branchNamePattern.test(branchName)) { + const validBranchPatterns: RegExp[] = []; + for (const module in menu.exerciseHashes) { + const match = module.match(/^\d-(.*)$/); + if (!match) { + throw new Error(`Invalid module name: ${module}`); + } + const moduleName = match[1]; + for (const week in menu.exerciseHashes[module]) { + const match = week.match(/\d+$/); + if (!match) { + throw new Error(`Invalid week number: ${week}`); + } + const weekNumber = match[0]; + validBranchPatterns.push( + new RegExp(`-w${weekNumber}-${moduleName}$`, 'i') + ); + } + } + + if (!validBranchPatterns.some((pattern) => pattern.test(branchName))) { console.error(chalk.red(BRANCH_NAME_MESSAGE)); return false; } @@ -131,10 +146,10 @@ export async function isValidBranchName(menu: ExerciseMenu): Promise { type CheckOptions = { silent: boolean }; export function checkExerciseHashes( - menuData: MenuData, + exerciseHashes: ExerciseHashes, options: CheckOptions = { silent: false } ): string { - const diff = diffExerciseHashes(menuData); + const diff = diffExerciseHashes(exerciseHashes); const changes: Record = {}; for (const module in diff) { @@ -239,9 +254,9 @@ export function updateTestHash( return moduleStats; } -export function getUntestedExercises(menuData: MenuData): string[] { +export function getUntestedExercises(exerciseHashes: ExerciseHashes): string[] { // Get info about the exercises that have been modified in the current branch - const diff = diffExerciseHashes(menuData); + const diff = diffExerciseHashes(exerciseHashes); // Get info about the exercises that have been tested const testHashPath = path.join(__dirname, `../.test-stats.json`); @@ -274,7 +289,7 @@ export function getUntestedExercises(menuData: MenuData): string[] { } export function checkForUntestedExercises(menu: ExerciseMenu): void { - const untestedExercises = getUntestedExercises(menu.menuData); + const untestedExercises = getUntestedExercises(menu.exerciseHashes); if (untestedExercises.length > 0) { if (untestedExercises.length === 1) { console.error( @@ -295,8 +310,8 @@ export function checkForUntestedExercises(menu: ExerciseMenu): void { } } -export function getChangedWeeks(menuData: MenuData): string[] { - const diff = diffExerciseHashes(menuData); +export function getChangedWeeks(exerciseHashes: ExerciseHashes): string[] { + const diff = diffExerciseHashes(exerciseHashes); const changedWeeks: string[] = []; for (const module in diff) { for (const week in diff[module]) { diff --git a/test-runner/exercise-runner.ts b/test-runner/exercise-runner.ts index a4327b699..fb7bcbe0c 100644 --- a/test-runner/exercise-runner.ts +++ b/test-runner/exercise-runner.ts @@ -79,16 +79,14 @@ async function runExercise(exercisePath: string) { async function main() { try { - const homeworkFolder = process.argv[2] ?? 'assignment'; - - const menu = new ExerciseMenu(homeworkFolder); + const menu = new ExerciseMenu(); if (!(await isValidBranchName(menu))) { process.exit(1); } - const moduleWeek = checkExerciseHashes(menu.menuData); - if (moduleWeek === 'none' || moduleWeek === 'multiple') { + const moduleWeek = checkExerciseHashes(menu.exerciseHashes); + if (moduleWeek === 'multiple') { return; } diff --git a/test-runner/exercises.ts b/test-runner/exercises.ts new file mode 100644 index 000000000..1414f6a8b --- /dev/null +++ b/test-runner/exercises.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import fg from 'fast-glob'; + +import { computeHash } from './compliance.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const EXERCISE_HASHES_PATH = path + .join(__dirname, '../../exercises.json') + .replaceAll(/\\/g, '/'); + +export type ExerciseHashes = { + [module: string]: { [week: string]: { [exercise: string]: string } }; +}; + +export function sealExercises() { + const exerciseHashes: ExerciseHashes = {}; + + // Look for file and folder names that match the expected structure. + // Windows paths are converted to POSIX paths to ensure compatibility. + const posixFileSpec = path + .join(__dirname, `../../**/assignment/ex([0-9])-*`) + .replace(/\\/g, '/'); + + const filePaths = fg.sync([posixFileSpec, '!**/node_modules'], { + onlyFiles: false, + }); + + filePaths.forEach((filePath) => { + const regexp = RegExp( + String.raw`^.*/(.+)/(Week\d)/assignment/(.+?)(?:\.js)?$`, + 'i' + ); + const matches = filePath.match(regexp); + if (matches) { + const [, module, week, exercise] = matches; + if (!exerciseHashes[module]) { + exerciseHashes[module] = {}; + } + if (!exerciseHashes[module][week]) { + exerciseHashes[module][week] = {}; + } + const exercisePath = `${module}/${week}/assignment/${exercise}`; + exerciseHashes[module][week][exercise] = computeHash(exercisePath); + } + }); + + const hashesJson = JSON.stringify(exerciseHashes, null, 2); + fs.writeFileSync(EXERCISE_HASHES_PATH, hashesJson); +} + +export function getExerciseMap(): ExerciseHashes { + const data = fs.readFileSync(EXERCISE_HASHES_PATH, 'utf8'); + return JSON.parse(data); +} diff --git a/test-runner/jsdom-helpers.ts b/test-runner/jsdom-helpers.ts index 52d29a4e0..38fea8178 100644 --- a/test-runner/jsdom-helpers.ts +++ b/test-runner/jsdom-helpers.ts @@ -13,8 +13,6 @@ function sleep(ms: number) { } export async function prepare() { - const assignmentFolder = process.env.ASSIGNMENT_FOLDER || 'assignment'; - const { testPath } = expect.getState(); if (!testPath) { throw new Error('testPath not found in expect.getState()'); @@ -23,7 +21,7 @@ export async function prepare() { const exercisePath = testPath .replace(/\\/g, '/') .replace('/.dist/', '/') - .replace('unit-tests', assignmentFolder) + .replace('unit-tests', 'assignment') .replace(/\.test\.[jt]s$/, ''); const virtualConsole = new jsdom.VirtualConsole(); diff --git a/test-runner/module-week.ts b/test-runner/module-week.ts index b5decdf11..d15a1eb57 100644 --- a/test-runner/module-week.ts +++ b/test-runner/module-week.ts @@ -1,7 +1,6 @@ import { checkExerciseHashes } from './compliance.js'; import ExerciseMenu from './ExerciseMenu.js'; -const assignmentFolder = process.env.ASSIGNMENT_FOLDER || 'assignment'; -const menu = new ExerciseMenu(assignmentFolder); -const result = checkExerciseHashes(menu.menuData, { silent: true }); +const menu = new ExerciseMenu(); +const result = checkExerciseHashes(menu.exerciseHashes, { silent: true }); console.log(result); diff --git a/test-runner/pre-commit.ts b/test-runner/pre-commit.ts index 195ccb35d..122402223 100644 --- a/test-runner/pre-commit.ts +++ b/test-runner/pre-commit.ts @@ -17,7 +17,7 @@ if (!(await isValidBranchName(menu))) { process.exit(1); } -const moduleWeek = checkExerciseHashes(menu.menuData); +const moduleWeek = checkExerciseHashes(menu.exerciseHashes); if (moduleWeek === 'multiple') { process.exit(1); } diff --git a/test-runner/pre-push.ts b/test-runner/pre-push.ts index 1a92fb016..47f94ea52 100644 --- a/test-runner/pre-push.ts +++ b/test-runner/pre-push.ts @@ -13,7 +13,7 @@ if (!(await isValidBranchName(menu))) { process.exit(1); } -const untested = getUntestedExercises(menu.menuData); +const untested = getUntestedExercises(menu.exerciseHashes); if (untested.length > 0) { console.error( `There are ${untested.length} exercise(s) that need (re)testing before you can push.` diff --git a/test-runner/seal.ts b/test-runner/seal.ts new file mode 100644 index 000000000..1856ebcdf --- /dev/null +++ b/test-runner/seal.ts @@ -0,0 +1,3 @@ +import { sealExercises } from './exercises.js'; + +sealExercises(); diff --git a/test-runner/test-changed.ts b/test-runner/test-changed.ts deleted file mode 100644 index ca7986d45..000000000 --- a/test-runner/test-changed.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { diffExerciseHashes } from './compliance.js'; -import ExerciseMenu from './ExerciseMenu.js'; -import { runTest } from './test-runner.js'; - -async function main() { - const { menuData } = new ExerciseMenu(); - const changes = diffExerciseHashes(menuData); - if (Object.keys(changes).length === 0) { - console.log('No exercises have been changed'); - return; - } - - for (const module in changes) { - for (const week in changes[module]) { - for (const exercise in changes[module][week]) { - await runTest(module, week, exercise); - } - } - } -} - -main(); diff --git a/test-runner/test-cli.ts b/test-runner/test-cli.ts index 49a46b6df..aed4dd52a 100644 --- a/test-runner/test-cli.ts +++ b/test-runner/test-cli.ts @@ -51,15 +51,13 @@ async function main(): Promise { } try { - const assignmentFolder = process.env.ASSIGNMENT_FOLDER || 'assignment'; - - const menu = new ExerciseMenu(assignmentFolder); + const menu = new ExerciseMenu(); if (!(await isValidBranchName(menu))) { process.exit(1); } - const moduleWeek = checkExerciseHashes(menu.menuData); + const moduleWeek = checkExerciseHashes(menu.exerciseHashes); if (moduleWeek === 'multiple') { return; } @@ -68,7 +66,7 @@ async function main(): Promise { console.log('Running test, please wait...'); - await runTest(menu.module, menu.week, menu.exercise, assignmentFolder); + await runTest(menu); await showDisclaimer(); diff --git a/test-runner/test-reports-check.ts b/test-runner/test-reports-check.ts new file mode 100644 index 000000000..1c7c9ff84 --- /dev/null +++ b/test-runner/test-reports-check.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { diffExerciseHashes } from './compliance.js'; +import { getExerciseMap } from './exercises.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export async function testReportsCheck() { + try { + const exerciseHashes = getExerciseMap(); + const diff = diffExerciseHashes(exerciseHashes); + + if (Object.keys(diff).length === 0) { + process.exit(0); + } + + const missingFiles: string[] = []; + + const testSummaryFile = path + .join(__dirname, '../../.test-summary/TEST_SUMMARY.md') + .replaceAll(/\\/g, '/'); + if (!fs.existsSync(testSummaryFile)) { + missingFiles.push('TEST_SUMMARY.md'); + } + + for (const module in diff) { + for (const week in diff[module]) { + const testReportFolder = `../../${module}/${week}/test-reports`; + for (const exercise in diff[module][week]) { + const testReportFileName = `${exercise}.report.txt`; + const testReportFile = path + .join(__dirname, testReportFolder, testReportFileName) + .replaceAll(/\\/g, '/'); + const testReportExists = fs.existsSync(testReportFile); + if (!testReportExists) { + missingFiles.push(testReportFileName); + } + } + } + } + + if (missingFiles.length > 0) { + throw new Error(`Missing test report files:\n${missingFiles.join('\n')}`); + } + process.exit(0); + } catch (err: any) { + console.error(err.message); + process.exit(1); + } +} + +testReportsCheck(); diff --git a/test-runner/test-runner.ts b/test-runner/test-runner.ts index d87c81193..e506ca2dc 100644 --- a/test-runner/test-runner.ts +++ b/test-runner/test-runner.ts @@ -9,8 +9,12 @@ import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import stripAnsi from 'strip-ansi'; -import { buildExercisePath } from './ExerciseMenu.js'; -import { ModuleTestStats, updateTestHash } from './compliance.js'; +import ExerciseMenu, { buildExercisePath } from './ExerciseMenu.js'; +import { + isModifiedExercise, + ModuleTestStats, + updateTestHash, +} from './compliance.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const execAsync = promisify(exec); @@ -101,10 +105,7 @@ function getFirstPathMatch(partialPath: string): string | null { return path.normalize(entries[0]).replace(/\\/g, '/'); } -function getUnitTestPath( - exercisePath: string, - homeworkFolder: string -): string | null { +function getUnitTestPath(exercisePath: string): string | null { // If the exercise path ends with `.test` it is expected to represent a // single JavaScript (not TypeScript) file that contains both a function-under-test and // a unit test or suite of tests. @@ -139,9 +140,7 @@ function getUnitTestPath( // If the exercise directory does not contain a unit test file then it may // exist as a transpiled TypeScript file in the `.dist` folder. - const regexp = new RegExp( - String.raw`/(\d-\w+?)/(Week\d+)/${homeworkFolder}/` - ); + const regexp = new RegExp(String.raw`/(\d-\w+?)/(Week\d+)/assignment/`); const unitTestPath = exercisePath.replace(regexp, `/.dist/$1/$2/unit-tests/`) + '.test.js'; @@ -150,25 +149,22 @@ function getUnitTestPath( } type JestResult = { - numFailedTests: number; - numPassedTests: number; + numFailedTests?: number; + numPassedTests?: number; message: string; }; -async function execJest( - exercisePath: string, - assignmentFolder: string -): Promise { +async function execJest(exercisePath: string): Promise { let message: string; let output: string; let numPassedTests = 0; let numFailedTests = 0; - const unitTestPath = getUnitTestPath(exercisePath, assignmentFolder); + const unitTestPath = getUnitTestPath(exercisePath); if (!unitTestPath) { message = 'A unit test file was not provided for this exercise.'; console.log(chalk.yellow(message)); - return null; + return { message }; } let cmdLine = `npx jest ${unitTestPath} --colors --noStackTrace --json`; @@ -176,10 +172,7 @@ async function execJest( try { const { stdout, stderr } = await execAsync(cmdLine, { encoding: 'utf8', - env: { - ...process.env, - ASSIGNMENT_FOLDER: assignmentFolder, - }, + env: { ...process.env }, }); ({ numFailedTests, numPassedTests } = JSON.parse(stdout)); output = stderr; @@ -188,9 +181,6 @@ async function execJest( output = err.message; } - // console.log('err.stdout', err.stdout); - // console.log('err.message', err.message); - output = `${output}` .trim() .replaceAll(/[√✓]/g, '✅') @@ -232,8 +222,9 @@ async function execESLint(exercisePath: string): Promise { return message; } - console.log(chalk.green('No linting errors detected.')); - return ''; + const message = 'No linting errors detected.'; + console.log(chalk.green(message)); + return message; } async function execSpellChecker(exercisePath: string): Promise { @@ -242,8 +233,9 @@ async function execSpellChecker(exercisePath: string): Promise { ? path.normalize(`${exercisePath}/*.js`) : `${exercisePath}.js`; await execAsync(`npx cspell ${cspellSpec}`, { encoding: 'utf8' }); - console.log(chalk.green('No spelling errors detected.')); - return ''; + const message = 'No spelling errors detected.'; + console.log(chalk.green(message)); + return message; } catch (err: any) { let output = err.stdout.trim(); if (!output) { @@ -262,37 +254,30 @@ async function execSpellChecker(exercisePath: string): Promise { } } -export async function runTest( - module: string, - week: string, - exercise: string, - assignmentFolder = 'assignment' -): Promise { +export async function runTest(menu: ExerciseMenu): Promise { + const { module, week, exercise } = menu; + let report = ''; - const exercisePath = buildExercisePath( - module, - week, - exercise, - assignmentFolder - ); + const exercisePath = buildExercisePath(module, week, exercise); - const jestResult = await execJest(exercisePath, assignmentFolder); - if (jestResult) { - report += jestResult.message; - } + const result = await execJest(exercisePath); + report += `${result.message}\n`; const eslintReport = await execESLint(exercisePath); + report += `${eslintReport}\n`; - report += eslintReport; - report += await execSpellChecker(exercisePath); + const spellCheckerReport = await execSpellChecker(exercisePath); + report += `${spellCheckerReport}\n`; - const moduleStats = updateTestHash(module, week, exercise, { - numPassedTests: jestResult?.numPassedTests || 0, - numFailedTests: jestResult?.numFailedTests || 0, - hasESLintErrors: !!eslintReport, - }); + if (isModifiedExercise(menu)) { + const moduleStats = updateTestHash(module, week, exercise, { + numPassedTests: result?.numPassedTests || 0, + numFailedTests: result?.numFailedTests || 0, + hasESLintErrors: !!eslintReport, + }); - await writeTestReport(module, week, exercise, report); + await writeTestReport(module, week, exercise, report); - writeTestSummary(module, week, moduleStats); + writeTestSummary(module, week, moduleStats); + } } diff --git a/test-runner/unit-test-helpers.ts b/test-runner/unit-test-helpers.ts index cd4c4641d..b07912ceb 100644 --- a/test-runner/unit-test-helpers.ts +++ b/test-runner/unit-test-helpers.ts @@ -32,12 +32,10 @@ export async function beforeAllHelper( throw new Error(`Unexpected test path: ${testFilePath}`); } - const homeworkFolder = process.env.ASSIGNMENT_FOLDER || 'assignment'; - const [, module, week, exercise] = matches; let exercisePath = path.join( __dirname, - `../../${module}/${week}/${homeworkFolder}/${exercise}` + `../../${module}/${week}/assignment/${exercise}` ); exercisePath = fs.existsSync(exercisePath)