Warm-up · Activity 1 of 7
Warm-up from lesson I1.5. What does this print?
const {promise, resolve} = Promise.withResolvers();
resolve("a");
resolve("b");
console.log(await promise);// I1.6 · ~40 min · Intermediate
You build a quiz page in which each question waits for a click or for the countdown, whichever comes first: two promises, one Promise.race, and a loop with await.
Lesson 6 of 6 in I1 Asynchronous JavaScript
You will be able to
Warm-up · Activity 1 of 7
const {promise, resolve} = Promise.withResolvers();
resolve("a");
resolve("b");
console.log(await promise);Predict · Activity 2 of 7
const noAnswer = new Promise(() => {}); // nobody clicks
const timeUp = new Promise((resolve) => setTimeout(() => resolve(null), 20));
const answer = await Promise.race([noAnswer, timeUp]);
console.log(answer === null ? "Time's up!" : answer);Practice · Activity 3 of 7
function countdown(seconds, tick) {
let left = seconds;
const id = setInterval(() => (left -= 1), tick);
return {stop: () => ____(id), left: () => left};
}Practice · Activity 4 of 7
Practice · Activity 5 of 7
const ask = () => new Promise((resolve) => setTimeout(() => resolve("reject"), 10));
const timeUp = () => new Promise((resolve) => setTimeout(() => resolve(null), 30));
const answer = await Promise.race([ask(), timeUp()]);
console.log(answer === null ? "Time's up!" : answer === "reject" ? "Right!" : "Wrong.");Brain teaser · Activity 6 of 7
const questions = ["q1", "q2"];
questions.forEach(async (question) => {
console.log("ask", question);
await null;
console.log("answered", question);
});
console.log("score");Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
The page has a question, an empty #answers box, a timer, a result line and a Start button. main.js holds three questions as data. ask() and countdown() return promises, and run() races them for each question in a for...of loop. At the end the script plays its own quiz, the way the exercise tests do: it answers the first question right, the second wrong, and lets the time run out on the third.
main.js
const questions = [
{ text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
{ text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
{ text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question
const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');
// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
questionText.textContent = question.text;
const buttons = question.options.map((option) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = option;
return button;
});
answers.replaceChildren(...buttons);
return new Promise((resolve) => {
for (const button of buttons) {
button.addEventListener('click', () => resolve(button.textContent), { once: true });
}
});
}
// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
const { promise, resolve } = Promise.withResolvers();
let left = seconds;
timerText.textContent = `${left} s left`;
const id = setInterval(() => {
left -= 1;
timerText.textContent = `${left} s left`;
if (left === 0) {
clearInterval(id);
resolve(null);
}
}, 1000);
return { promise, stop: () => clearInterval(id) };
}
async function run() {
startButton.disabled = true;
result.textContent = '';
let score = 0;
for (const question of questions) {
const timer = countdown(SECONDS);
const answer = await Promise.race([ask(question), timer.promise]);
timer.stop();
if (answer === question.answer) score += 1;
result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
}
questionText.textContent = 'Done.';
answers.replaceChildren();
timerText.textContent = '';
result.textContent = `Score: ${score} of ${questions.length}`;
startButton.disabled = false;
}
startButton.addEventListener('click', run);
// The script plays its own quiz, the way the exercise tests do.
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const click = (label) => [...answers.querySelectorAll('button')].find((b) => b.textContent === label).click();
startButton.click();
console.log('1:', questionText.textContent, '|', timerText.textContent);
click('Promise.all');
await pause(0);
console.log('->', result.textContent);
click('resolve');
await pause(0);
console.log('->', result.textContent);
console.log('3:', questionText.textContent);
await pause(3200); // no answer
console.log('->', questionText.textContent, '|', result.textContent);
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quick quiz</title>
</head>
<body>
<h1>Quick quiz</h1>
<p id="question">Press Start.</p>
<div id="answers"></div>
<p id="timer"></p>
<p id="result"></p>
<button id="start" type="button">Start</button>
<script type="module" src="main.js"></script>
</body>
</html>
To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).
Output
1: Which combinator waits for every promise to fulfil? | 3 s left
-> Right!
-> Wrong.
3: What does await do with a rejected promise?
-> Done. | Score: 1 of 3
<!-- #result -->
<p id="result">Score: 1 of 3</p>Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Your page
Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.
Run the program to see its page here.
Exercise 1 of 3
Finish ask(question). It already shows the question and its answer buttons. Make it return a promise that resolves with the text of the first button clicked; give each button a click listener with {once: true} that calls resolve. The starter returns nothing, so the quiz rushes through every question at once.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Your page
Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.
Run the program to see its page here.
Run the tests: the first question is already gone. What does run() get when it awaits a function that returns nothing?
Return new Promise((resolve) => { … }) at the end of ask, and add the listeners inside it.
for (const button of buttons) button.addEventListener('click', () => resolve(button.textContent), { once: true });
One way to solve it. Yours can look different and still pass the checks.
const questions = [
{ text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
{ text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
{ text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question
const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');
// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
questionText.textContent = question.text;
const buttons = question.options.map((option) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = option;
return button;
});
answers.replaceChildren(...buttons);
return new Promise((resolve) => {
for (const button of buttons) {
button.addEventListener('click', () => resolve(button.textContent), { once: true });
}
});
}
// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
const { promise, resolve } = Promise.withResolvers();
let left = seconds;
timerText.textContent = `${left} s left`;
const id = setInterval(() => {
left -= 1;
timerText.textContent = `${left} s left`;
if (left === 0) {
clearInterval(id);
resolve(null);
}
}, 1000);
return { promise, stop: () => clearInterval(id) };
}
async function run() {
startButton.disabled = true;
result.textContent = '';
let score = 0;
for (const question of questions) {
const timer = countdown(SECONDS);
const answer = await Promise.race([ask(question), timer.promise]);
timer.stop();
if (answer === question.answer) score += 1;
result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
}
questionText.textContent = 'Done.';
answers.replaceChildren();
timerText.textContent = '';
result.textContent = `Score: ${score} of ${questions.length}`;
startButton.disabled = false;
}
startButton.addEventListener('click', run);
Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
const questions = [
{ text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
{ text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
{ text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question
const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');
// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
questionText.textContent = question.text;
const buttons = question.options.map((option) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = option;
return button;
});
answers.replaceChildren(...buttons);
// TODO: return a promise that resolves with the text of the first button clicked.
}
// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
const { promise, resolve } = Promise.withResolvers();
let left = seconds;
timerText.textContent = `${left} s left`;
const id = setInterval(() => {
left -= 1;
timerText.textContent = `${left} s left`;
if (left === 0) {
clearInterval(id);
resolve(null);
}
}, 1000);
return { promise, stop: () => clearInterval(id) };
}
async function run() {
startButton.disabled = true;
result.textContent = '';
let score = 0;
for (const question of questions) {
const timer = countdown(SECONDS);
const answer = await Promise.race([ask(question), timer.promise]);
timer.stop();
if (answer === question.answer) score += 1;
result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
}
questionText.textContent = 'Done.';
answers.replaceChildren();
timerText.textContent = '';
result.textContent = `Score: ${score} of ${questions.length}`;
startButton.disabled = false;
}
startButton.addEventListener('click', run);
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const text = (page, selector) => page.document.querySelector(selector).textContent;
const labels = (page) => [...page.document.querySelectorAll('#answers button')].map((b) => b.textContent);
async function answer(page, label) {
const button = [...page.document.querySelectorAll('#answers button')].find((b) => b.textContent === label);
assert.ok(button, `there is no answer button ${label}; the buttons are ${JSON.stringify(labels(page))}`);
button.click();
await pause(0);
}
async function start() {
const page = await loadPage();
page.document.querySelector('#start').click();
await pause(0);
return page;
}
test('Start shows the first question and its three answers', async () => {
const page = await start();
assert.equal(text(page, '#question'), 'Which combinator waits for every promise to fulfil?', 'the question is ' + text(page, '#question'));
assert.deepEqual(labels(page), ['Promise.race', 'Promise.all', 'Promise.any'], 'the answer buttons are ' + JSON.stringify(labels(page)));
});
test('A right answer says Right! and shows the next question', async () => {
const page = await start();
await answer(page, 'Promise.all');
assert.equal(text(page, '#result'), 'Right!', 'the result is ' + text(page, '#result'));
assert.equal(text(page, '#question'), 'Which function makes a new promise fail?', 'the question is ' + text(page, '#question'));
});
test('A wrong answer says Wrong.', async () => {
const page = await start();
await answer(page, 'Promise.race');
assert.equal(text(page, '#result'), 'Wrong.', 'the result is ' + text(page, '#result'));
});
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quick quiz</title>
</head>
<body>
<h1>Quick quiz</h1>
<p id="question">Press Start.</p>
<div id="answers"></div>
<p id="timer"></p>
<p id="result"></p>
<button id="start" type="button">Start</button>
<script type="module" src="main.js"></script>
</body>
</html>
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).
Run the checks (needs learnrun.js in the same folder):
npm install jsdom@28.1.0
node --testDownload learnrun.jsExercise 2 of 3
Finish countdown(seconds). It already shows the seconds left and takes one off every second. When left reaches 0, it must stop the interval with clearInterval and resolve the promise with null, so that the race ends with "Time’s up!". The starter never resolves, so a question without an answer waits forever while the timer counts below zero.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Your page
Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.
Run the program to see its page here.
Run the tests: after 3 seconds the timer shows 0 s left, then goes below zero. What should happen at 0?
Inside the interval callback, after updating the text: if (left === 0) { … }.
if (left === 0) { clearInterval(id); resolve(null); }
One way to solve it. Yours can look different and still pass the checks.
const questions = [
{ text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
{ text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
{ text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question
const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');
// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
questionText.textContent = question.text;
const buttons = question.options.map((option) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = option;
return button;
});
answers.replaceChildren(...buttons);
return new Promise((resolve) => {
for (const button of buttons) {
button.addEventListener('click', () => resolve(button.textContent), { once: true });
}
});
}
// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
const { promise, resolve } = Promise.withResolvers();
let left = seconds;
timerText.textContent = `${left} s left`;
const id = setInterval(() => {
left -= 1;
timerText.textContent = `${left} s left`;
if (left === 0) {
clearInterval(id);
resolve(null);
}
}, 1000);
return { promise, stop: () => clearInterval(id) };
}
async function run() {
startButton.disabled = true;
result.textContent = '';
let score = 0;
for (const question of questions) {
const timer = countdown(SECONDS);
const answer = await Promise.race([ask(question), timer.promise]);
timer.stop();
if (answer === question.answer) score += 1;
result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
}
questionText.textContent = 'Done.';
answers.replaceChildren();
timerText.textContent = '';
result.textContent = `Score: ${score} of ${questions.length}`;
startButton.disabled = false;
}
startButton.addEventListener('click', run);
Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
const questions = [
{ text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
{ text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
{ text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question
const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');
// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
questionText.textContent = question.text;
const buttons = question.options.map((option) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = option;
return button;
});
answers.replaceChildren(...buttons);
return new Promise((resolve) => {
for (const button of buttons) {
button.addEventListener('click', () => resolve(button.textContent), { once: true });
}
});
}
// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
const { promise, resolve } = Promise.withResolvers();
let left = seconds;
timerText.textContent = `${left} s left`;
const id = setInterval(() => {
left -= 1;
timerText.textContent = `${left} s left`;
// TODO: at 0, stop the interval and resolve the promise with null.
}, 1000);
return { promise, stop: () => clearInterval(id) };
}
async function run() {
startButton.disabled = true;
result.textContent = '';
let score = 0;
for (const question of questions) {
const timer = countdown(SECONDS);
const answer = await Promise.race([ask(question), timer.promise]);
timer.stop();
if (answer === question.answer) score += 1;
result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
}
questionText.textContent = 'Done.';
answers.replaceChildren();
timerText.textContent = '';
result.textContent = `Score: ${score} of ${questions.length}`;
startButton.disabled = false;
}
startButton.addEventListener('click', run);
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const text = (page, selector) => page.document.querySelector(selector).textContent;
const labels = (page) => [...page.document.querySelectorAll('#answers button')].map((b) => b.textContent);
async function answer(page, label) {
const button = [...page.document.querySelectorAll('#answers button')].find((b) => b.textContent === label);
assert.ok(button, `there is no answer button ${label}; the buttons are ${JSON.stringify(labels(page))}`);
button.click();
await pause(0);
}
async function start() {
const page = await loadPage();
page.document.querySelector('#start').click();
await pause(0);
return page;
}
test('The timer starts at 3 s left and shows 2 s left a second later', async () => {
const page = await start();
assert.equal(text(page, '#timer'), '3 s left', 'at the start the timer shows ' + text(page, '#timer'));
await pause(1100);
assert.equal(text(page, '#timer'), '2 s left', 'after a second the timer shows ' + text(page, '#timer'));
});
test('After 3 seconds without an answer, the time is up and the next question shows', async () => {
const page = await start();
try {
await pause(3300);
assert.equal(text(page, '#result'), "Time's up!", 'the result is ' + text(page, '#result'));
assert.equal(text(page, '#question'), 'Which function makes a new promise fail?', 'the question is ' + text(page, '#question'));
assert.equal(text(page, '#timer'), '3 s left', 'the next countdown shows ' + text(page, '#timer'));
} finally {
page.window.close(); // stops a timer that never ends, so the test run can finish
}
});
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quick quiz</title>
</head>
<body>
<h1>Quick quiz</h1>
<p id="question">Press Start.</p>
<div id="answers"></div>
<p id="timer"></p>
<p id="result"></p>
<button id="start" type="button">Start</button>
<script type="module" src="main.js"></script>
</body>
</html>
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).
Run the checks (needs learnrun.js in the same folder):
npm install jsdom@28.1.0
node --testDownload learnrun.jsExercise 3 of 3
Fix run(). The starter uses questions.forEach with an async callback, and forEach does not wait for it: all three questions are asked at once and the score appears straight away. Use a for...of loop, so that each await pauses run() until that question is decided. Start must stay disabled until the score is shown.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Your page
Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.
Run the program to see its page here.
Run the tests: the page shows the third question and the score at once. Who waits for the promises the forEach callback returns?
Replace questions.forEach(async (question) => { … }); with for (const question of questions) { … } and keep the body.
The await inside for...of pauses run() itself, so the lines after the loop run only after the last question.
One way to solve it. Yours can look different and still pass the checks.
const questions = [
{ text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
{ text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
{ text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question
const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');
// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
questionText.textContent = question.text;
const buttons = question.options.map((option) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = option;
return button;
});
answers.replaceChildren(...buttons);
return new Promise((resolve) => {
for (const button of buttons) {
button.addEventListener('click', () => resolve(button.textContent), { once: true });
}
});
}
// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
const { promise, resolve } = Promise.withResolvers();
let left = seconds;
timerText.textContent = `${left} s left`;
const id = setInterval(() => {
left -= 1;
timerText.textContent = `${left} s left`;
if (left === 0) {
clearInterval(id);
resolve(null);
}
}, 1000);
return { promise, stop: () => clearInterval(id) };
}
async function run() {
startButton.disabled = true;
result.textContent = '';
let score = 0;
for (const question of questions) {
const timer = countdown(SECONDS);
const answer = await Promise.race([ask(question), timer.promise]);
timer.stop();
if (answer === question.answer) score += 1;
result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
}
questionText.textContent = 'Done.';
answers.replaceChildren();
timerText.textContent = '';
result.textContent = `Score: ${score} of ${questions.length}`;
startButton.disabled = false;
}
startButton.addEventListener('click', run);
Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
const questions = [
{ text: 'Which combinator waits for every promise to fulfil?', options: ['Promise.race', 'Promise.all', 'Promise.any'], answer: 'Promise.all' },
{ text: 'Which function makes a new promise fail?', options: ['resolve', 'reject', 'finally'], answer: 'reject' },
{ text: 'What does await do with a rejected promise?', options: ['It gives undefined', 'It throws', 'It waits forever'], answer: 'It throws' }
];
const SECONDS = 3; // per question
const questionText = document.querySelector('#question');
const answers = document.querySelector('#answers');
const timerText = document.querySelector('#timer');
const result = document.querySelector('#result');
const startButton = document.querySelector('#start');
// Shows a question and its answer buttons.
// The promise resolves with the text of the first button clicked.
function ask(question) {
questionText.textContent = question.text;
const buttons = question.options.map((option) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = option;
return button;
});
answers.replaceChildren(...buttons);
return new Promise((resolve) => {
for (const button of buttons) {
button.addEventListener('click', () => resolve(button.textContent), { once: true });
}
});
}
// Counts down once a second. The promise resolves with null when the time is up;
// stop() ends the countdown early.
function countdown(seconds) {
const { promise, resolve } = Promise.withResolvers();
let left = seconds;
timerText.textContent = `${left} s left`;
const id = setInterval(() => {
left -= 1;
timerText.textContent = `${left} s left`;
if (left === 0) {
clearInterval(id);
resolve(null);
}
}, 1000);
return { promise, stop: () => clearInterval(id) };
}
async function run() {
startButton.disabled = true;
result.textContent = '';
let score = 0;
// TODO: ask one question at a time; forEach does not wait for the await inside.
questions.forEach(async (question) => {
const timer = countdown(SECONDS);
const answer = await Promise.race([ask(question), timer.promise]);
timer.stop();
if (answer === question.answer) score += 1;
result.textContent = answer === null ? "Time's up!" : answer === question.answer ? 'Right!' : 'Wrong.';
});
questionText.textContent = 'Done.';
answers.replaceChildren();
timerText.textContent = '';
result.textContent = `Score: ${score} of ${questions.length}`;
startButton.disabled = false;
}
startButton.addEventListener('click', run);
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const text = (page, selector) => page.document.querySelector(selector).textContent;
const labels = (page) => [...page.document.querySelectorAll('#answers button')].map((b) => b.textContent);
async function answer(page, label) {
const button = [...page.document.querySelectorAll('#answers button')].find((b) => b.textContent === label);
assert.ok(button, `there is no answer button ${label}; the buttons are ${JSON.stringify(labels(page))}`);
button.click();
await pause(0);
}
async function start() {
const page = await loadPage();
page.document.querySelector('#start').click();
await pause(0);
return page;
}
test('Start shows only the first question, and Start is disabled', async () => {
const page = await start();
assert.equal(text(page, '#question'), 'Which combinator waits for every promise to fulfil?', 'the question is ' + text(page, '#question'));
assert.equal(page.document.querySelector('#start').disabled, true, 'Start should be disabled while the quiz runs');
});
test('Three answers give the score, and Start works again', async () => {
const page = await start();
await answer(page, 'Promise.all');
await answer(page, 'finally');
await answer(page, 'It throws');
assert.equal(text(page, '#question'), 'Done.', 'at the end the question line is ' + text(page, '#question'));
assert.equal(text(page, '#result'), 'Score: 2 of 3', 'the result is ' + text(page, '#result'));
assert.equal(page.document.querySelector('#start').disabled, false, 'Start should be enabled after the quiz');
});
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quick quiz</title>
</head>
<body>
<h1>Quick quiz</h1>
<p id="question">Press Start.</p>
<div id="answers"></div>
<p id="timer"></p>
<p id="result"></p>
<button id="start" type="button">Start</button>
<script type="module" src="main.js"></script>
</body>
</html>
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).
Run the checks (needs learnrun.js in the same folder):
npm install jsdom@28.1.0
node --testDownload learnrun.jsfunction countdown(ms) {
const {promise, resolve} = Promise.withResolvers();
const id = setTimeout(() => resolve(null), ms);
return {promise, stop: () => clearTimeout(id)};
}
const noAnswer = new Promise(() => {});
const timer = countdown(20);
const answer = await Promise.race([noAnswer, timer]);
timer.stop();
console.log(answer.toLowerCase());
What Node.js prints
TypeError: answer.toLowerCase is not a functionWhy, and the fix
timer is the object {promise, stop}, not a promise. race treats a value that is not a promise as already fulfilled, so it wins at once and answer is that object, before anyone could click. Race the promise inside it: Promise.race([noAnswer, timer.promise]).
const noAnswer = new Promise(() => {});
const timeUp = new Promise((resolve) => setTimeout(() => resolve(null), 20));
const answer = await Promise.race([noAnswer, timeUp]);
console.log(answer.toLowerCase() === "reject" ? "Right!" : "Wrong.");
What Node.js prints
TypeError: Cannot read properties of null (reading 'toLowerCase')Why, and the fix
When nobody answers in time, the countdown wins and answer is null, and null has no methods. Check for the timeout first: if (answer === null) show "Time’s up!", and compare answers only after that.
const [promise, resolve] = Promise.withResolvers();
setTimeout(() => resolve(null), 20);
console.log(await promise);
What Node.js prints
TypeError: object is not iterable (cannot read property Symbol(Symbol.iterator))Why, and the fix
withResolvers returns an object with the keys promise, resolve and reject, and an object cannot be destructured with square brackets. Use curly braces and the exact names: const {promise, resolve} = Promise.withResolvers();
JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.