feat: support beforeEach and afterEach (#60921)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Oliver Eyton-Williams
2025-07-07 12:46:09 +02:00
committed by GitHub
parent 38cd1727e4
commit 2a7b220a4f
21 changed files with 549 additions and 165 deletions

View File

@@ -300,6 +300,7 @@ exports.createSchemaCustomization = ({ actions }) => {
challengeFiles: [FileContents]
chapter: String
explanation: String
hooks: Hooks
notes: String
url: String
assignments: [String]
@@ -379,6 +380,11 @@ exports.createSchemaCustomization = ({ actions }) => {
distractors: [String]
answer: String
}
type Hooks {
beforeEach: String
afterEach: String
beforeAll: String
}
`;
createTypes(typeDefs);
};

View File

@@ -192,7 +192,7 @@ export type ChallengeNode = {
head: string[];
hasEditableBoundaries: boolean;
helpCategory: string;
hooks?: { beforeAll: string };
hooks?: Hooks;
id: string;
instructions: string;
isComingSoon: boolean;
@@ -240,6 +240,12 @@ export type ChallengeNode = {
};
};
export interface Hooks {
beforeAll?: string;
beforeEach?: string;
afterEach?: string;
}
type Quiz = {
questions: QuizQuestion[];
};

View File

@@ -20,6 +20,7 @@ import type {
ChallengeFiles,
ChallengeMeta,
ChallengeNode,
Hooks,
ResizeProps,
SavedChallenge,
SavedChallengeFiles,
@@ -110,7 +111,7 @@ interface ShowClassicProps extends Pick<PreviewProps, 'previewMounted'> {
challengeFiles: ChallengeFiles;
initConsole: (arg0: string) => void;
initTests: (tests: Test[]) => void;
initHooks: (hooks?: { beforeAll: string }) => void;
initHooks: (hooks?: Hooks) => void;
initVisibleEditors: () => void;
isChallengeCompleted: boolean;
output: string;
@@ -579,6 +580,8 @@ export const query = graphql`
forumTopicId
hooks {
beforeAll
beforeEach
afterEach
}
fields {
blockName

View File

@@ -11,6 +11,7 @@ import type {
FrameDocument,
PythonDocument
} from '../../../../../tools/client-plugins/browser-scripts';
import { Hooks } from '../../../redux/prop-types';
export const helperVersion = _helperVersion;
@@ -28,10 +29,6 @@ export interface Source {
editableContents: string;
}
interface Hooks {
beforeAll?: string;
}
export interface Context {
window?: Window &
typeof globalThis & {

View File

@@ -18,6 +18,12 @@ Using `let` or `const`, declare a global variable named `myGlobal` outside of an
Inside function `fun1`, assign `5` to `oopsGlobal` ***without*** using the `var`, `let` or `const` keywords.
# --before-each--
```js
var oopsGlobal;
```
# --hints--
`myGlobal` should be defined
@@ -41,45 +47,12 @@ assert(/(let|const)\s+myGlobal/.test(__helpers.removeJSComments(code)));
`oopsGlobal` should be a global variable and have a value of `5`
```js
assert(typeof oopsGlobal != 'undefined' && oopsGlobal === 5);
fun1();
assert(typeof oopsGlobal != 'undefined');
```
# --seed--
## --before-user-code--
```js
var logOutput = "";
var originalConsole = console
function capture() {
var nativeLog = console.log;
console.log = function (message) {
logOutput = message;
if(nativeLog.apply) {
nativeLog.apply(originalConsole, arguments);
} else {
var nativeMsg = Array.prototype.slice.apply(arguments).join(' ');
nativeLog(nativeMsg);
}
};
}
function uncapture() {
console.log = originalConsole.log;
}
var oopsGlobal;
capture();
```
## --after-user-code--
```js
fun1();
fun2();
uncapture();
(function() { return logOutput || "console.log never called"; })();
```
## --seed-contents--
```js

View File

@@ -23,12 +23,19 @@ Fulfill the user stories below and get all the tests to pass to complete the lab
1. You should log the `codingFact` to the console a third time.
1. You should log `"It was fun sharing these facts with you. Goodbye! - (botName) from (botLocation)."` to the console as a farewell statement from the bot.
# --before-each--
```js
const spy = __helpers.spyOn(console, 'log');
const getLogs = () => spy.calls.map(call => call?.[0]);
```
# --hints--
You should log `"Hello! I'm your coding fun fact guide!"` to the console.
```js
assert.equal(output[0], "Hello! I'm your coding fun fact guide!")
assert.equal(getLogs()[0], "Hello! I'm your coding fun fact guide!")
```
You should declare a `botName` variable. Double check for any spelling or casing errors.
@@ -71,7 +78,7 @@ You should log to the console `"My name is (botName) and I live on (botLocation)
```js
const codeWithoutComments = __helpers.removeJSComments(code);
assert.equal(output[1], `My name is ${botName} and I live on ${botLocation}.`)
assert.equal(getLogs()[1], `My name is ${botName} and I live on ${botLocation}.`)
assert.match(codeWithoutComments, /is ("|')\s*\+\s*botName\s*\+\s*("|') and I live on \2\s*\+\s*botLocation\s*\+\s*('|")\./)
```
@@ -79,7 +86,7 @@ You should log to the console `"My favorite programming language is (favoriteLan
```js
const codeWithoutComments = __helpers.removeJSComments(code);
assert.equal(output[2], `My favorite programming language is ${favoriteLanguage}.`)
assert.equal(getLogs()[2], `My favorite programming language is ${favoriteLanguage}.`)
assert.match(codeWithoutComments, /language is ('|")\s*\+\s*favoriteLanguage\s*\+\s*('|")\./);
```
@@ -106,7 +113,7 @@ You should log `codingFact` to the console.
```js
const codeWithoutComments = __helpers.removeJSComments(code);
const loggingCodingFacts = codeWithoutComments.match(/console\.log\(\s*codingFact\s*\)/g)
assert.include(output[3], favoriteLanguage);
assert.include(getLogs()[3], favoriteLanguage);
assert.isAtLeast(loggingCodingFacts.length, 1);
```
@@ -116,8 +123,8 @@ You should assign a new value to `codingFact` that also contains `favoriteLangua
const codeWithoutComments = __helpers.removeJSComments(code);
const loggingCodingFacts = codeWithoutComments.match(/console\.log\(\s*codingFact\s*\)/g)
const [first, second, third] = codeWithoutComments.match(/(let )?\s*codingFact\s*=\s*(("|')?.+?\2?\s*\+\s*|favoriteLanguage\s*\+\s*(("|')?.+?\2?))/g);
assert.include(output[4], favoriteLanguage);
assert.notEqual(output[4], output[3]);
assert.include(getLogs()[4], favoriteLanguage);
assert.notEqual(getLogs()[4], getLogs()[3]);
assert.isAtLeast(loggingCodingFacts.length, 2);
assert.exists(second);
assert.isNotEmpty(codingFact);
@@ -129,9 +136,9 @@ You should assign a value to `codingFact` for the third time that also contains
const codeWithoutComments = __helpers.removeJSComments(code);
const loggingCodingFacts = codeWithoutComments.match(/console\.log\(\s*codingFact\s*\)/g)
const [first, second, third] = codeWithoutComments.match(/(let )?\s*codingFact\s*=\s*(("|')?.+?\2?\s*\+\s*|favoriteLanguage\s*\+\s*(("|')?.+?\2?))/g);
assert.include(output[5], favoriteLanguage);
assert.notEqual(output[5], output[4]);
assert.equal(output[5], codingFact);
assert.include(getLogs()[5], favoriteLanguage);
assert.notEqual(getLogs()[5], getLogs()[4]);
assert.equal(getLogs()[5], codingFact);
assert.lengthOf(loggingCodingFacts, 3);
assert.exists(third);
assert.isNotEmpty(codingFact);
@@ -141,23 +148,12 @@ You should log to the console `"It was fun sharing these facts with you. Goodbye
```js
const codeWithoutComments = __helpers.removeJSComments(code);
assert.equal(output[6], `It was fun sharing these facts with you. Goodbye! - ${botName} from ${botLocation}.`);
assert.equal(getLogs()[6], `It was fun sharing these facts with you. Goodbye! - ${botName} from ${botLocation}.`);
assert.match(codeWithoutComments, /\. Goodbye! - ("|')\s*\+\s*botName\s*\+\s*('|") from \2\s*\+\s*botLocation\s*\+\s*("|')\./)
```
# --seed--
## --before-user-code--
```js
const temp = console.log
const output = []
console.log = function (...args) {
temp(...args)
output.push(...args)
}
```
## --seed-contents--
```js

View File

@@ -302,7 +302,9 @@ const schema = Joi.object()
superOrder: Joi.number(),
suborder: Joi.number(),
hooks: Joi.object().keys({
beforeAll: Joi.string().allow('')
beforeAll: Joi.string().allow(''),
beforeEach: Joi.string().allow(''),
afterEach: Joi.string().allow('')
}),
tests: Joi.array()
.items(

View File

@@ -0,0 +1,40 @@
# --description--
Paragraph 1
```html
code example
```
# --after-each--
```js
// after each code
function cleanup() {
return 'cleaned up';
}
cleanup();
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```

View File

@@ -0,0 +1,34 @@
# --description--
Paragraph 1
```html
code example
```
# --after-each--
gubbins
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```

View File

@@ -0,0 +1,34 @@
# --description--
Paragraph 1
```html
code example
```
# --before-each--
gubbins
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```

View File

@@ -0,0 +1,40 @@
# --description--
Paragraph 1
```html
code example
```
# --before-each--
```js
// before each code
function setup() {
return 'initialized';
}
setup();
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```

View File

@@ -0,0 +1,42 @@
# --description--
Paragraph 1
```html
code example
```
# --after-each--
```js
// after each code
function cleanup() {
return 'cleaned up';
}
cleanup();
```
gubbins
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```

View File

@@ -0,0 +1,42 @@
# --description--
Paragraph 1
```html
code example
```
# --before-each--
```js
// before each code
function setup() {
return 'initialized';
}
setup();
```
gubbins
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```

View File

@@ -0,0 +1,40 @@
# --description--
Paragraph 1
```html
code example
```
# --after-each--
```ts
// after each code
function cleanup() {
return 'cleaned up';
}
cleanup();
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```

View File

@@ -0,0 +1,40 @@
# --description--
Paragraph 1
```html
code example
```
# --before-each--
```ts
// before each code
function setup() {
return 'initialized';
}
setup();
```
# --hints--
First hint
```js
// test code
```
Second hint with <code>code</code>
```js
// more test code
```
Third *hint* with <code>code</code> and `inline code`
```js
// more test code
if(let x of xs) {
console.log(x);
}
```

View File

@@ -7,7 +7,7 @@ const addFillInTheBlank = require('./plugins/add-fill-in-the-blank');
const addFrontmatter = require('./plugins/add-frontmatter');
const addSeed = require('./plugins/add-seed');
const addSolution = require('./plugins/add-solution');
const addBeforeHook = require('./plugins/add-before-hook');
const addHooks = require('./plugins/add-hooks');
const addTests = require('./plugins/add-tests');
const addText = require('./plugins/add-text');
const addVideoQuestion = require('./plugins/add-video-question');
@@ -53,7 +53,7 @@ const processor = unified()
.use(addAssignment)
.use(addScene)
.use(addQuizzes)
.use(addBeforeHook)
.use(addHooks)
.use(addTests)
.use(addText, [
'description',

View File

@@ -1,33 +0,0 @@
const { getSection } = require('./utils/get-section');
function plugin() {
return transformer;
function transformer(tree, file) {
const section = getSection(tree, '--before-all--');
if (section.length === 0) return;
if (section.length > 1)
throw Error(
'#--before-all-- section must only contain a single code block'
);
const codeNode = section[0];
if (codeNode.type !== 'code')
throw Error('#--before-all-- section must contain a code block');
if (codeNode.lang !== 'javascript' && codeNode.lang !== 'js')
throw Error('#--before-all-- hook must be written in JavaScript');
const beforeAll = getBeforeAll(codeNode);
file.data.hooks = { beforeAll };
}
}
function getBeforeAll(codeNode) {
const beforeAll = codeNode.value;
return beforeAll;
}
module.exports = plugin;

View File

@@ -1,67 +0,0 @@
const parseFixture = require('../__fixtures__/parse-fixture');
const addBeforeHook = require('./add-before-hook');
describe('add-before-hook plugin', () => {
let withBeforeHookAST,
withInvalidHookAST,
withAnotherInvalidHookAST,
withNonJSHookAST;
const plugin = addBeforeHook();
let file = { data: {} };
beforeAll(async () => {
withBeforeHookAST = await parseFixture('with-before-hook.md');
withInvalidHookAST = await parseFixture('with-invalid-before-hook.md');
withAnotherInvalidHookAST = await parseFixture(
'with-another-invalid-before-hook.md'
);
withNonJSHookAST = await parseFixture('with-non-js-before-hook.md');
});
beforeEach(() => {
file = { data: {} };
});
it('returns a function', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `hooks` property to `file.data`', () => {
plugin(withBeforeHookAST, file);
expect('hooks' in file.data).toBe(true);
});
it('populates `hooks.beforeAll` with the contents of the code block', () => {
plugin(withBeforeHookAST, file);
expect(file.data.hooks.beforeAll).toBe(`// before all code
function foo() {
return 'bar';
}
foo();`);
});
it('should throw an error if the beforeAll section has more than one child', () => {
expect(() => plugin(withInvalidHookAST, file)).toThrow(
`#--before-all-- section must only contain a single code block`
);
});
it('should throw an error if the beforeAll section does not contain a code block', () => {
expect(() => plugin(withAnotherInvalidHookAST, file)).toThrow(
`#--before-all-- section must contain a code block`
);
});
it('should throw an error if the code language is not javascript', () => {
expect(() => plugin(withNonJSHookAST, file)).toThrow(
`#--before-all-- hook must be written in JavaScript`
);
});
it('should have an output to match the snapshot', () => {
plugin(withBeforeHookAST, file);
expect(file.data).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,40 @@
const { getSection } = require('./utils/get-section');
function plugin() {
return transformer;
function transformer(tree, file) {
const beforeAll = getHook(tree, '--before-all--');
const beforeEach = getHook(tree, '--before-each--');
const afterEach = getHook(tree, '--after-each--');
if (!beforeAll && !beforeEach && !afterEach) return;
file.data.hooks = file.data.hooks = {
...(beforeAll && { beforeAll }),
...(beforeEach && { beforeEach }),
...(afterEach && { afterEach })
};
}
}
function getHook(tree, sectionName) {
const section = getSection(tree, sectionName);
if (section.length === 0) return;
if (section.length > 1)
throw Error(
`# ${sectionName} section must only contain a single code block`
);
const codeNode = section[0];
if (codeNode.type !== 'code')
throw Error(`# ${sectionName} section must contain a code block`);
if (codeNode.lang !== 'javascript' && codeNode.lang !== 'js')
throw Error(`# ${sectionName} hook must be written in JavaScript`);
return codeNode.value;
}
module.exports = plugin;

View File

@@ -0,0 +1,149 @@
const parseFixture = require('../__fixtures__/parse-fixture');
const addBeforeHook = require('./add-hooks');
describe('add-before-hook plugin', () => {
let withBeforeHookAST,
withInvalidHookAST,
withAnotherInvalidHookAST,
withNonJSHookAST,
withBeforeEachHookAST,
withInvalidBeforeEachHookAST,
withAnotherInvalidBeforeEachHookAST,
withNonJSBeforeEachHookAST,
withAfterEachHookAST,
withInvalidAfterEachHookAST,
withAnotherInvalidAfterEachHookAST,
withNonJSAfterEachHookAST;
const plugin = addBeforeHook();
let file = { data: {} };
beforeAll(async () => {
withBeforeHookAST = await parseFixture('with-before-hook.md');
withInvalidHookAST = await parseFixture('with-invalid-before-hook.md');
withAnotherInvalidHookAST = await parseFixture(
'with-another-invalid-before-hook.md'
);
withNonJSHookAST = await parseFixture('with-non-js-before-hook.md');
withBeforeEachHookAST = await parseFixture('with-before-each-hook.md');
withInvalidBeforeEachHookAST = await parseFixture(
'with-invalid-before-each-hook.md'
);
withAnotherInvalidBeforeEachHookAST = await parseFixture(
'with-another-invalid-before-each-hook.md'
);
withNonJSBeforeEachHookAST = await parseFixture(
'with-non-js-before-each-hook.md'
);
withAfterEachHookAST = await parseFixture('with-after-each-hook.md');
withInvalidAfterEachHookAST = await parseFixture(
'with-invalid-after-each-hook.md'
);
withAnotherInvalidAfterEachHookAST = await parseFixture(
'with-another-invalid-after-each-hook.md'
);
withNonJSAfterEachHookAST = await parseFixture(
'with-non-js-after-each-hook.md'
);
});
beforeEach(() => {
file = { data: {} };
});
it('returns a function', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `hooks` property to `file.data`', () => {
plugin(withBeforeHookAST, file);
expect('hooks' in file.data).toBe(true);
});
it('populates `hooks.beforeAll` with the contents of the code block', () => {
plugin(withBeforeHookAST, file);
expect(file.data.hooks.beforeAll).toBe(`// before all code
function foo() {
return 'bar';
}
foo();`);
});
it('should throw an error if the beforeAll section has more than one child', () => {
expect(() => plugin(withInvalidHookAST, file)).toThrow(
`# --before-all-- section must only contain a single code block`
);
});
it('should throw an error if the beforeAll section does not contain a code block', () => {
expect(() => plugin(withAnotherInvalidHookAST, file)).toThrow(
`# --before-all-- section must contain a code block`
);
});
it('should throw an error if the code language is not javascript', () => {
expect(() => plugin(withNonJSHookAST, file)).toThrow(
`# --before-all-- hook must be written in JavaScript`
);
});
it('should have an output to match the snapshot', () => {
plugin(withBeforeHookAST, file);
expect(file.data).toMatchSnapshot();
});
it('populates `hooks.beforeEach` with the contents of the code block', () => {
plugin(withBeforeEachHookAST, file);
expect(file.data.hooks.beforeEach).toBe(`// before each code
function setup() {
return 'initialized';
}
setup();`);
});
it('should throw an error if the beforeEach section has more than one child', () => {
expect(() => plugin(withInvalidBeforeEachHookAST, file)).toThrow(
`# --before-each-- section must only contain a single code block`
);
});
it('should throw an error if the beforeEach section does not contain a code block', () => {
expect(() => plugin(withAnotherInvalidBeforeEachHookAST, file)).toThrow(
`# --before-each-- section must contain a code block`
);
});
it('should throw an error if the beforeEach code language is not javascript', () => {
expect(() => plugin(withNonJSBeforeEachHookAST, file)).toThrow(
`# --before-each-- hook must be written in JavaScript`
);
});
it('populates `hooks.afterEach` with the contents of the code block', () => {
plugin(withAfterEachHookAST, file);
expect(file.data.hooks.afterEach).toBe(`// after each code
function cleanup() {
return 'cleaned up';
}
cleanup();`);
});
it('should throw an error if the afterEach section has more than one child', () => {
expect(() => plugin(withInvalidAfterEachHookAST, file)).toThrow(
`# --after-each-- section must only contain a single code block`
);
});
it('should throw an error if the afterEach section does not contain a code block', () => {
expect(() => plugin(withAnotherInvalidAfterEachHookAST, file)).toThrow(
`# --after-each-- section must contain a code block`
);
});
it('should throw an error if the afterEach code language is not javascript', () => {
expect(() => plugin(withNonJSAfterEachHookAST, file)).toThrow(
`# --after-each-- hook must be written in JavaScript`
);
});
});