Build a TypeScript Blog API Test Lab

Build a TypeScript dashboard to test REST API responses and validate JSON.

Introduction

30 Second Summary

A request can finish without delivering the result you expected. When success and failure look the same, bugs slip through.

In this project, you will build a browser-based Blog API Test Lab using TypeScript that sends real requests to the JSONPlaceholder REST API. You will expose a false pass before protecting the dashboard with HTTP status checks plus runtime data validation.

What You'll Build

Picture clicking Run all tests as four cards turn green beside an inspector displaying each HTTP status with its parsed response.

By the end of this project, you'll have:

  • A browser test dashboard where you can run four visible checks against blog endpoints. Its response inspector shows the HTTP status with parsed JSON for each request.
  • An HTTP-aware missing-resource check that exposes the difference between a completed request and a successful response. You can demonstrate the original false pass beside HTTP 404.
  • A type-safe production build that validates unknown JSON through reusable type guards. TypeScript checks the code before Vite creates the production bundle.
  • Secret Mission: Trace the simulated POST through Chrome DevTools to verify its status, headers, payload, and response body.

Are there any prerequisites?

You only need basic familiarity with HTML, CSS, and JavaScript. The project runs locally on Windows with free tools.

Before We Start

This checkpoint is where you commit to a browser-based TypeScript Blog API Test Lab with three visible checks: successful GET requests / an expected HTTP 404 response / a simulated POST request. TypeScript protects the response boundary by treating parsed JSON as unknown until runtime type guards validate it.

Set Up the Windows Test Lab

Your test dashboard needs a compatible local toolchain plus a browser that can display each API result. Without that foundation, none of the later HTTP behavior is visible.

You will prepare Google Chrome, confirm your Node.js version, then create a strict TypeScript project in Visual Studio Code. Vite provides the local development server that connects these pieces.

In this step, get ready to:
  • Install Google Chrome on Windows.
  • Confirm that Node.js meets Vite's compatibility requirement.
  • Prepare the starter project and run it locally.
Install Google Chrome

The lab runs in a browser because each test reports its state on a visible dashboard. Chrome also gives you the browser tools needed for the optional Secret Mission.

  • Open the official Google Chrome installation guide in your current browser.
  • Use the guide's download link to download the Windows installation file.
  • Run the downloaded installation file.
  • Select Run if Windows displays that prompt.
  • Select Yes if Windows asks for permission.
  • Open Chrome when the installation finishes.

Chrome is ready when you can see its browser window. That browser becomes the display for every test result you create.

Chrome did not open?

  • Run the downloaded installation file again if the installation window closed before finishing.
  • Approve the Windows permission prompt if it appears behind another window.

Ask for help with the installation details you can see: Help me troubleshoot my Google Chrome installation on Windows.

Check the Node.js toolchain

Vite depends on a compatible Node.js runtime. A version check prevents an outdated installation from causing confusing dependency or server errors later.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code and press Enter.
  • Click File in the top menu.
  • Click Open Folder.
  • Choose your Desktop in the folder dialog.
  • Select New Folder.
  • Name the folder blog-api-test-lab.
  • Click Select Folder.

You should see blog-api-test-lab at the top of the Explorer sidebar. Visual Studio Code now treats this folder as your project workspace.

  • Select Yes, I trust the authors if the workspace trust prompt appears.
  • Click Terminal in the top menu.
  • Click New Terminal.
  • Check the installed Node.js and npm versions by running:
node -v
npm -v

What do these commands check?

  • The first command prints the installed Node.js version.
  • The second command confirms that the npm package manager is available.

✔️ I see a compatible version

Your runtime is compatible if Node.js satisfies 20.19+ or 22.12+. You can continue with the version shown by your terminal.

Your toolchain has cleared its first check. Node.js and npm are ready to install the project's pinned dependencies.

ⓧ I see an older version

The installed version is below Vite's requirement. The current Node.js LTS release is v24.20.0.

  • Open the official Node.js download page.
  • Download the current LTS release for Windows.
  • Run the downloaded installer.
  • Follow the installer prompts to replace the older version.
  • Close the Visual Studio Code window from earlier.
  • Reopen Visual Studio Code from Windows search.
  • Return to the blog-api-test-lab folder through File and Open Folder.
  • Create a fresh integrated terminal through Terminal and New Terminal.
  • Check the upgraded versions by running:
node -v
npm -v

What should the new check prove?

The fresh terminal picks up the upgraded Node.js installation. Confirm that its version now satisfies 20.19+ or 22.12+.

ⓧ Command not found

The terminal cannot currently find Node.js. Installing the current LTS release provides both Node.js and npm.

  • Open the official Node.js download page.
  • Download Node.js LTS v24.20.0 for Windows.
  • Run the downloaded installer.
  • Follow the installer prompts to complete the installation.
  • Close the Visual Studio Code window from earlier.
  • Reopen Visual Studio Code from Windows search.
  • Return to the blog-api-test-lab folder through File and Open Folder.
  • Create a fresh integrated terminal through Terminal and New Terminal.
  • Confirm that both tools are available by running:
node -v
npm -v

What confirms the installation?

Both commands should print version numbers. The Node.js version must satisfy 20.19+ or 22.12+.

Create and run the starter

The starter files give Vite an HTML entry point plus a TypeScript module to load. The configuration also enables strict checking without emitting compiled files into your source folder.

  • Select the New File button in the Explorer sidebar.
  • Name the file package.json.
  • Paste this project configuration into package.json:
{
  "name": "blog-api-test-lab",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "typescript": "6.0.2",
    "vite": "8.2.2"
  }
}

What does this configuration do?

  • The scripts object defines how npm starts the development server.
  • The build script runs TypeScript checking before Vite creates a production bundle.
  • The devDependencies object pins TypeScript to 6.0.2.
  • The same object pins Vite to 8.2.2.
  • Save package.json by pressing Ctrl+S.
  • Confirm that package.json appears beneath blog-api-test-lab in the Explorer.
  • Install the pinned dependencies by running:
npm install

What does this command do?

npm reads package.json and installs its development dependencies into a local node_modules folder. It also records the resolved dependency tree in package-lock.json.

You should see installation output in the terminal. The Explorer should now list node_modules plus package-lock.json.

Dependency installation failed?

  • Confirm that the integrated terminal path ends with blog-api-test-lab.
  • Open package.json and check for a red underline caused by a missing quote or comma.
  • Repeat the installation after confirming that your Node.js version is compatible.

Share the visible terminal message without including private information: Help me diagnose my npm installation error.

  • Select the New File button in the Explorer.
  • Name the file tsconfig.json.
  • Paste this compiler configuration into tsconfig.json:
{
  "compilerOptions": {
    "target": "es2023",
    "module": "esnext",
    "lib": ["ES2023", "DOM"],
    "types": ["vite/client"],
    "allowArbitraryExtensions": true,
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

What does this configuration protect?

  • The DOM library supplies browser API types to TypeScript.
  • The strict setting enables stronger correctness checks.
  • The noEmit setting makes TypeScript check the source without producing separate output files.
  • The include setting limits project checking to the src folder.
  • Save tsconfig.json.
  • Confirm that tsconfig.json appears in the Explorer.

Configuration showing errors?

  • Check that the filename is exactly tsconfig.json.
  • Compare every quote plus comma with the reference code.

Get focused help with the highlighted line: Help me fix my tsconfig.json error.

  • Select the New Folder button in the Explorer.
  • Name the folder src.
  • Select the new src folder in the Explorer.
  • Select the New File button.
  • Name the file style.css.
  • Save the empty src/style.css file.
  • Confirm that style.css appears inside the src folder.

The empty stylesheet gives the TypeScript entry point a valid file to import. You will replace it with the complete dashboard styles in the next step.

  • Select the src folder in the Explorer.
  • Select the New File button.
  • Name the file main.ts.
  • Paste this stylesheet import into src/main.ts:
import './style.css'

What does this import do?

The import tells Vite to include src/style.css when it loads the TypeScript entry point. Every dashboard style added later flows through this connection.

  • Save src/main.ts.
  • Confirm that the import has no red underline.

Stylesheet import underlined?

  • Check that main.ts sits inside the same src folder as style.css.
  • Check that the import uses the exact relative path ./style.css.

Ask for help with the file tree you can see: Help me fix the stylesheet import in src/main.ts.

  • Select blog-api-test-lab at the top of the Explorer.
  • Select the New File button.
  • Name the file index.html.
  • Paste this starter page into index.html:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="A TypeScript dashboard for testing JSONPlaceholder blog endpoints."
    />
    <title>Blog API Test Lab</title>
  </head>
  <body>
    <div id="app">
      <h1>Blog API Test Lab</h1>
    </div>

    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

How does the starter load?

  • The app container gives the dashboard a single page area.
  • The heading provides a visible result for this setup check.
  • The module script loads /src/main.ts through Vite.
  • Save index.html.
  • Confirm that the Explorer lists index.html plus package.json.
  • Confirm that the Explorer lists tsconfig.json plus the src folder.

Starter page showing editor errors?

  • Check that index.html sits directly inside blog-api-test-lab.
  • Check that the module path is exactly /src/main.ts.

Ask for help with the highlighted markup: Help me fix my starter index.html file.

✔️ Awesome, I've got everything!

Great. Save every open file before starting Vite. Keep src/style.css empty for now.

ⓧ I'd like to double check the full code

Compare your complete package.json with this reference:

{
  "name": "blog-api-test-lab",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "typescript": "6.0.2",
    "vite": "8.2.2"
  }
}

Compare your complete tsconfig.json with this reference:

{
  "compilerOptions": {
    "target": "es2023",
    "module": "esnext",
    "lib": ["ES2023", "DOM"],
    "types": ["vite/client"],
    "allowArbitraryExtensions": true,
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

Compare your complete index.html with this starter reference:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="A TypeScript dashboard for testing JSONPlaceholder blog endpoints."
    />
    <title>Blog API Test Lab</title>
  </head>
  <body>
    <div id="app">
      <h1>Blog API Test Lab</h1>
    </div>

    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Compare your complete src/main.ts with this reference:

import './style.css'

Your src/style.css file should exist with no content.

Before you start the server, what do you expect Chrome to display from the current HTML?

  • Start the Vite development server by running:
npm run dev

What does this command do?

npm runs the dev script from package.json. Vite starts a local server and prints its address in the terminal.

The server keeps this terminal occupied while it runs. Leave it running so Chrome can reach the starter page.

  • Return to Google Chrome from earlier.
  • Enter http://localhost:5173 in the address bar.
  • Press Enter.

You should see the Blog API Test Lab heading at http://localhost:5173. Your browser, TypeScript entry point, and Vite server now work together.

Starter page not loading?

  • Confirm that the Vite terminal is still running.
  • Use the local address printed by Vite if it differs from the default address.
  • Save index.html plus both files inside src.

Share the visible browser or terminal message for targeted help: Help me get my Vite starter page loading.

Your Windows test lab is running in Chrome. Next, you will replace the starter heading with four test cards plus a response inspector.

Build the Visible Test Dashboard

Your local Vite server is running in Google Chrome. The starter page now needs a visible workspace where every API check can report what happened.

In this step, you will build the dashboard structure and style each result state. You will also add TypeScript lookups that connect your code to the page's DOM elements.

In this step, get ready to:
  • Build the dashboard header, controls, test cards, and response inspector.
  • Style the dashboard and its four test states.
  • Add typed DOM lookups for the controls and response inspector.
Build the dashboard structure

The dashboard starts with an accessible HTML structure. Each button and card needs a stable attribute that later TypeScript code can find.

  • In the Visual Studio Code file tree, select index.html.
  • Replace its existing contents with this document shell:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="A TypeScript dashboard for testing JSONPlaceholder blog endpoints."
    />
    <title>Blog API Test Lab</title>
  </head>
  <body>
    <div id="app">
      <header class="hero">
        <p class="eyebrow">TypeScript API testing</p>
        <h1>Blog API Test Lab</h1>
        <p class="hero-copy">
          Run typed checks against a public REST API and inspect every response.
        </p>
      </header>

      <main class="layout">
      </main>
    </div>

    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

What does this markup do?

  • The #app container holds the complete interface.
  • The hero introduces the lab before the interactive test controls.
  • The module script loads src/main.ts through Vite.
  • Save index.html.
  • Switch back to Chrome.

You should see the Blog API Test Lab heading and its introductory text replace the starter page.

Still seeing the starter page?

Confirm that you edited the index.html file inside blog-api-test-lab. Check that the Vite terminal is still running.

If the page did not update, help me check why Vite is not showing my updated index.html file.

  • Inside <main class="layout">, add the suite-status toolbar:
        <section class="toolbar" aria-label="Test suite controls">
          <div>
            <p class="toolbar-label">Suite status</p>
            <p id="summary" class="summary" aria-live="polite">
              No tests run yet.
            </p>
          </div>
          <div class="button-row">
            <button id="run-all" class="button button-primary" type="button">
              Run all tests
            </button>
            <button id="clear-results" class="button button-secondary" type="button">
              Clear results
            </button>
          </div>
        </section>

How does the toolbar help?

  • The #summary region gives the test suite one visible status message.
  • The aria-live attribute allows status updates to be announced by assistive technology.
  • The two button IDs give later TypeScript code stable control references.
  • Save index.html.
  • Return to Chrome.

You should now see the suite status and two control buttons below the hero.

Toolbar missing?

Make sure the toolbar sits between the opening and closing main tags. Check that every button has its closing tag.

If the markup is difficult to place, help me find why my toolbar is missing from the dashboard.

Each test card displays the request method and current state. Its data attributes let later code identify the card and update its result.

  • Below the toolbar, add the test grid with its first card:
        <section class="test-grid" aria-label="API tests">
          <article class="test-card" data-card="list" data-state="idle">
            <div class="test-heading">
              <span class="method method-get">GET</span>
              <span class="state" data-status>Idle</span>
            </div>
            <h2>List blog posts</h2>
            <code>/posts</code>
            <p data-detail>Check that the API returns a non-empty list of valid posts.</p>
            <button class="button button-test" type="button" data-test="list">
              Run test
            </button>
          </article>
        </section>

How is a test card identified?

  • The data-card value identifies the result card.
  • The data-state value begins at idle so CSS can apply the matching state style.
  • The data-status and data-detail attributes mark the text that later code updates.
  • Save index.html.
  • Return to Chrome.

You should see the List blog posts card with a GET badge and an Idle state.

First card missing?

Check that the test grid appears after the toolbar. Confirm that the card remains inside the grid's opening and closing section tags.

For help checking the structure, help me debug the missing List blog posts card in index.html.

  • Inside the test-grid section, add the single-post card after the list card:
          <article class="test-card" data-card="single" data-state="idle">
            <div class="test-heading">
              <span class="method method-get">GET</span>
              <span class="state" data-status>Idle</span>
            </div>
            <h2>Read one post</h2>
            <code>/posts/1</code>
            <p data-detail>Check the status, response shape, and requested post ID.</p>
            <button class="button button-test" type="button" data-test="single">
              Run test
            </button>
          </article>

What makes this card distinct?

The single identifier connects this card to the future /posts/1 check. Its status begins at Idle.

  • Save index.html.
  • Return to Chrome.

You should see the Read one post card join the first card.

Second card outside the grid?

Place the new article before the test grid's closing section tag. Keep it outside the first card's closing tag.

If the cards are nested, help me correct the test-card nesting in my dashboard HTML.

  • Inside the test-grid section, add the missing-post card after the single-post card:
          <article class="test-card" data-card="missing" data-state="idle">
            <div class="test-heading">
              <span class="method method-get">GET</span>
              <span class="state" data-status>Idle</span>
            </div>
            <h2>Handle a missing post</h2>
            <code>/posts/999999</code>
            <p data-detail>Check that the runner deliberately recognizes an HTTP 404.</p>
            <button class="button button-test" type="button" data-test="missing">
              Run test
            </button>
          </article>

Why include a missing post?

This card gives the lab a visible place to report the missing-resource check. You will use it in the next step to expose a false pass.

  • Save index.html.
  • Return to Chrome.

You should see the Handle a missing post card with its /posts/999999 endpoint.

Missing-post details incorrect?

Confirm that this card uses missing for both data-card and data-test. Check the endpoint text for extra or missing digits.

For a focused comparison, help me check the identifiers on my missing-post test card.

  • Inside the test-grid section, add the simulated-post card after the missing-post card:
          <article class="test-card" data-card="create" data-state="idle">
            <div class="test-heading">
              <span class="method method-post">POST</span>
              <span class="state" data-status>Idle</span>
            </div>
            <h2>Create a simulated post</h2>
            <code>/posts</code>
            <p data-detail>Check that the API echoes a valid simulated post response.</p>
            <button class="button button-test" type="button" data-test="create">
              Run test
            </button>
          </article>

How does the POST card differ?

The card uses the method-post class for a distinct method badge. The create identifier reserves this card for the simulated POST check.

  • Save index.html.
  • Return to Chrome.

You should now see four test cards in the grid.

POST card missing?

Confirm that the fourth card sits before the test grid's closing tag. Check that its method text is POST.

If the fourth card does not render, help me debug the simulated-post card markup.

The response inspector gives every test a shared place to display its HTTP status and parsed JSON body. It stays visible even before a test runs.

  • Below the test grid, add the response inspector:
        <section class="inspector" aria-labelledby="inspector-title">
          <div class="inspector-heading">
            <h2 id="inspector-title">Response inspector</h2>
            <span id="response-meta">Waiting for a test</span>
          </div>
          <pre id="response-body">Run a test to inspect its parsed JSON response.</pre>
        </section>

What will the inspector show?

  • The #response-meta element will show the request method and HTTP status.
  • The #response-body element will show formatted response data.
  • Save index.html.
  • Return to Chrome.

You should see the Response inspector below all four cards.

Inspector appearing inside the grid?

Move the inspector after the test grid's closing section tag. Keep it inside the main element.

If the page structure still looks wrong, help me fix the placement of my response inspector.

✔️ Awesome, I've got everything!

Great. Save index.html before you continue.

ⓧ I'd like to double check the full code

Compare your complete index.html with this reference.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="A TypeScript dashboard for testing JSONPlaceholder blog endpoints."
    />
    <title>Blog API Test Lab</title>
  </head>
  <body>
    <div id="app">
      <header class="hero">
        <p class="eyebrow">TypeScript API testing</p>
        <h1>Blog API Test Lab</h1>
        <p class="hero-copy">
          Run typed checks against a public REST API and inspect every response.
        </p>
      </header>

      <main class="layout">
        <section class="toolbar" aria-label="Test suite controls">
          <div>
            <p class="toolbar-label">Suite status</p>
            <p id="summary" class="summary" aria-live="polite">
              No tests run yet.
            </p>
          </div>
          <div class="button-row">
            <button id="run-all" class="button button-primary" type="button">
              Run all tests
            </button>
            <button id="clear-results" class="button button-secondary" type="button">
              Clear results
            </button>
          </div>
        </section>

        <section class="test-grid" aria-label="API tests">
          <article class="test-card" data-card="list" data-state="idle">
            <div class="test-heading">
              <span class="method method-get">GET</span>
              <span class="state" data-status>Idle</span>
            </div>
            <h2>List blog posts</h2>
            <code>/posts</code>
            <p data-detail>Check that the API returns a non-empty list of valid posts.</p>
            <button class="button button-test" type="button" data-test="list">
              Run test
            </button>
          </article>

          <article class="test-card" data-card="single" data-state="idle">
            <div class="test-heading">
              <span class="method method-get">GET</span>
              <span class="state" data-status>Idle</span>
            </div>
            <h2>Read one post</h2>
            <code>/posts/1</code>
            <p data-detail>Check the status, response shape, and requested post ID.</p>
            <button class="button button-test" type="button" data-test="single">
              Run test
            </button>
          </article>

          <article class="test-card" data-card="missing" data-state="idle">
            <div class="test-heading">
              <span class="method method-get">GET</span>
              <span class="state" data-status>Idle</span>
            </div>
            <h2>Handle a missing post</h2>
            <code>/posts/999999</code>
            <p data-detail>Check that the runner deliberately recognizes an HTTP 404.</p>
            <button class="button button-test" type="button" data-test="missing">
              Run test
            </button>
          </article>

          <article class="test-card" data-card="create" data-state="idle">
            <div class="test-heading">
              <span class="method method-post">POST</span>
              <span class="state" data-status>Idle</span>
            </div>
            <h2>Create a simulated post</h2>
            <code>/posts</code>
            <p data-detail>Check that the API echoes a valid simulated post response.</p>
            <button class="button button-test" type="button" data-test="create">
              Run test
            </button>
          </article>
        </section>

        <section class="inspector" aria-labelledby="inspector-title">
          <div class="inspector-heading">
            <h2 id="inspector-title">Response inspector</h2>
            <span id="response-meta">Waiting for a test</span>
          </div>
          <pre id="response-body">Run a test to inspect its parsed JSON response.</pre>
        </section>
      </main>
    </div>

    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
Style every dashboard state

The HTML establishes what each region means. The stylesheet now turns those regions into a responsive dashboard with visible idle, running, passed, and failed states.

  • In the Visual Studio Code file tree, select src/style.css.
  • Replace its existing contents with the global page styles:
:root {
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  color: #172033;
  background: #eef2ff;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, #dbeafe 0, transparent 34rem),
    linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
}

button,
code,
pre {
  font: inherit;
}

What do the global styles control?

  • The root styles establish the font stack and the page colours.
  • The universal box sizing keeps element dimensions predictable.
  • The body gradients create the dashboard background.
  • Save src/style.css.
  • Return to Chrome.

You should see the page background change to a pale blue gradient with updated typography.

Global styles missing?

Confirm that src/main.ts still begins with import './style.css'. Check that the stylesheet was saved.

For help tracing the stylesheet, help me find why src/style.css is not affecting my Vite page.

  • At the bottom of src/style.css, add the button defaults and hero layout:
button {
  cursor: pointer;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

.hero {
  padding: 3.5rem 1.25rem 2.5rem;
  color: #f8fafc;
  background: linear-gradient(135deg, #111827, #312e81 58%, #1d4ed8);
}

.hero > *,
.layout {
  width: min(1100px, 100%);
  margin-inline: auto;
}

.eyebrow,
.toolbar-label {
  margin: 0 0 0.5rem;
  font-size: 0.75rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

How does the hero take shape?

  • The hero uses a dark gradient to separate the title from the test workspace.
  • The shared width keeps the hero content aligned with the dashboard.
  • The eyebrow style turns the small label into a compact section marker.
  • Save src/style.css.
  • Return to Chrome.

You should see a dark gradient hero with aligned title content.

Hero still plain?

Check that the selector begins with .hero and matches class="hero" in the HTML. Confirm that the closing brace is present.

If the selector is not applying, help me debug the hero styles in src/style.css.

  • At the bottom of src/style.css, add the hero typography and shared panel surface:
.hero h1 {
  margin: 0;
  font-size: clamp(2rem, 7vw, 4rem);
  letter-spacing: -0.04em;
}

.hero-copy {
  max-width: 44rem;
  margin: 1rem 0 0;
  color: #c7d2fe;
  font-size: 1.05rem;
  line-height: 1.7;
}

.layout {
  padding: 1.5rem 1.25rem 4rem;
}

.toolbar,
.inspector,
.test-card {
  border: 1px solid #dbe3f0;
  border-radius: 1rem;
  background: rgba(255, 255, 255, 0.94);
  box-shadow: 0 16px 40px rgba(30, 41, 59, 0.08);
}

Why share panel styles?

The toolbar, inspector, and cards use one surface treatment. Their shared border and shadow make the page feel like one test workspace.

  • Save src/style.css.
  • Return to Chrome.

You should see rounded white surfaces for the toolbar, cards, and inspector.

Panels missing their surface?

Check the commas between .toolbar, .inspector, and .test-card. A missing comma changes how the selector is interpreted.

For help comparing the selector, help me fix the shared panel CSS selector.

  • At the bottom of src/style.css, add the toolbar and base button layout:
.toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1.25rem;
}

.toolbar-label {
  color: #6366f1;
}

.summary {
  margin: 0;
  font-weight: 700;
}

.button-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.button {
  border: 0;
  border-radius: 0.7rem;
  padding: 0.75rem 1rem;
  font-weight: 800;
}

How are the controls arranged?

  • The toolbar separates the suite summary from the control group.
  • The button row can wrap when the available width becomes narrow.
  • The base button class gives every control consistent spacing.
  • Save src/style.css.
  • Return to Chrome.

You should see the suite status and controls arranged across the toolbar.

Toolbar controls stacking early?

Confirm that display: flex appears inside the .toolbar selector. Check that the button row has its own closing brace.

If the layout remains uneven, help me debug the toolbar flex styles.

  • At the bottom of src/style.css, add the button colours and test grid:
.button-primary {
  color: white;
  background: #4f46e5;
}

.button-secondary,
.button-test {
  color: #312e81;
  background: #e0e7ff;
}

.button:hover:not(:disabled) {
  filter: brightness(0.96);
}

.button:focus-visible {
  outline: 3px solid #93c5fd;
  outline-offset: 2px;
}

.test-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
  margin-top: 1rem;
}

What changes here?

  • Primary and secondary controls now have distinct colours.
  • The focus outline makes keyboard focus visible.
  • The test grid places the cards in two equal columns.
  • Save src/style.css.
  • Return to Chrome.

You should see coloured buttons and a two-column test grid.

Cards still in one column?

Check that .test-grid matches the HTML class. Confirm that the grid declaration sits inside its selector.

If the cards remain stacked on a wide screen, help me debug the test-grid CSS.

  • At the bottom of src/style.css, add the card layout and state borders:
.test-card {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  min-height: 250px;
  padding: 1.25rem;
  transition: border-color 160ms ease, transform 160ms ease;
}

.test-card:hover {
  transform: translateY(-2px);
}

.test-card[data-state='running'] {
  border-color: #f59e0b;
}

.test-card[data-state='passed'] {
  border-color: #22c55e;
}

.test-card[data-state='failed'] {
  border-color: #ef4444;
}

How do card states become visible?

The data-state selectors map running, passed, and failed results to distinct border colours. Later TypeScript code only needs to update the attribute.

  • Save src/style.css.
  • Return to Chrome.

You should see evenly padded cards that lift slightly when you point at them.

Cards not lifting?

Check that the hover selector is .test-card:hover. Confirm that no closing brace appears before the transform rule.

For help checking the interaction style, help me debug the test-card hover rule.

  • At the bottom of src/style.css, add the method and state badge styles:
.test-heading,
.inspector-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

.method,
.state {
  border-radius: 999px;
  padding: 0.3rem 0.65rem;
  font-size: 0.75rem;
  font-weight: 900;
}

.method-get {
  color: #075985;
  background: #e0f2fe;
}

.method-post {
  color: #166534;
  background: #dcfce7;
}

.state {
  color: #475569;
  background: #f1f5f9;
}

Why use badges?

The method badges separate GET and POST requests at a glance. The state badge gives every card a compact result label.

  • Save src/style.css.
  • Return to Chrome.

You should see rounded GET, POST, and Idle badges at the top of each card.

Badges still plain text?

Confirm that the HTML uses method and state classes. Check the grouped selector for its comma.

If the badges remain unstyled, help me compare the badge class names and CSS selectors.

  • At the bottom of src/style.css, add the result-state badges and endpoint styling:
.test-card[data-state='running'] .state {
  color: #92400e;
  background: #fef3c7;
}

.test-card[data-state='passed'] .state {
  color: #166534;
  background: #dcfce7;
}

.test-card[data-state='failed'] .state {
  color: #991b1b;
  background: #fee2e2;
}

.test-card h2,
.inspector h2 {
  margin: 0;
}

.test-card code {
  width: fit-content;
  border-radius: 0.45rem;
  padding: 0.35rem 0.5rem;
  color: #4338ca;
  background: #eef2ff;
}

How will results stand apart?

Each active result state changes the badge colour as well as the card border. Endpoint paths receive their own code-style label.

  • Save src/style.css.
  • Return to Chrome.

You should see each endpoint inside a small purple label.

Endpoint labels unstyled?

Check that the selector includes a space between .test-card and code. Confirm that the endpoint remains inside each card.

For help with the selector, help me debug the endpoint label styles.

  • At the bottom of src/style.css, add the card detail and inspector heading styles:
.test-card p {
  flex: 1;
  margin: 0;
  color: #64748b;
  line-height: 1.6;
}

.inspector {
  margin-top: 1rem;
  overflow: hidden;
}

.inspector-heading {
  padding: 1rem 1.25rem;
  border-bottom: 1px solid #dbe3f0;
}

#response-meta {
  color: #64748b;
  font-size: 0.9rem;
}

How is the inspector separated?

The inspector heading uses a dividing border above the response body. Muted metadata leaves the response content as the main focus.

  • Save src/style.css.
  • Return to Chrome.

You should see a padded inspector heading with a divider beneath it.

Inspector heading cramped?

Check that the padding appears inside .inspector-heading. Confirm that the response metadata keeps the response-meta ID.

If the spacing remains wrong, help me debug the inspector heading styles.

  • At the bottom of src/style.css, add the response body and responsive layout:
#response-body {
  min-height: 220px;
  max-height: 420px;
  margin: 0;
  padding: 1.25rem;
  overflow: auto;
  color: #dbeafe;
  background: #0f172a;
  line-height: 1.55;
  white-space: pre-wrap;
}

@media (max-width: 720px) {
  .toolbar,
  .inspector-heading {
    align-items: flex-start;
    flex-direction: column;
  }

  .test-grid {
    grid-template-columns: 1fr;
  }

  .button-row,
  .button {
    width: 100%;
  }
}

What happens on smaller screens?

  • The response body uses a dark scrollable surface for formatted JSON.
  • The media query changes the test grid to one column below 720px.
  • The controls expand to the available width on narrow screens.
  • Save src/style.css.
  • Return to Chrome.

You should see a dark response area beneath the cards. Narrowing the Chrome window should move the cards into one column.

Responsive layout not changing?

Check that the media query closes with its final brace. Confirm that grid-template-columns: 1fr remains inside the query.

If the grid stays in two columns, help me debug the responsive media query in src/style.css.

✔️ Awesome, I've got everything!

Your complete dashboard stylesheet is ready. Save src/style.css before continuing.

ⓧ I'd like to double check the full code

Compare your complete src/style.css with this reference.

:root {
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  color: #172033;
  background: #eef2ff;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, #dbeafe 0, transparent 34rem),
    linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
}

button,
code,
pre {
  font: inherit;
}

button {
  cursor: pointer;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

.hero {
  padding: 3.5rem 1.25rem 2.5rem;
  color: #f8fafc;
  background: linear-gradient(135deg, #111827, #312e81 58%, #1d4ed8);
}

.hero > *,
.layout {
  width: min(1100px, 100%);
  margin-inline: auto;
}

.eyebrow,
.toolbar-label {
  margin: 0 0 0.5rem;
  font-size: 0.75rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.hero h1 {
  margin: 0;
  font-size: clamp(2rem, 7vw, 4rem);
  letter-spacing: -0.04em;
}

.hero-copy {
  max-width: 44rem;
  margin: 1rem 0 0;
  color: #c7d2fe;
  font-size: 1.05rem;
  line-height: 1.7;
}

.layout {
  padding: 1.5rem 1.25rem 4rem;
}

.toolbar,
.inspector,
.test-card {
  border: 1px solid #dbe3f0;
  border-radius: 1rem;
  background: rgba(255, 255, 255, 0.94);
  box-shadow: 0 16px 40px rgba(30, 41, 59, 0.08);
}

.toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1.25rem;
}

.toolbar-label {
  color: #6366f1;
}

.summary {
  margin: 0;
  font-weight: 700;
}

.button-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.button {
  border: 0;
  border-radius: 0.7rem;
  padding: 0.75rem 1rem;
  font-weight: 800;
}

.button-primary {
  color: white;
  background: #4f46e5;
}

.button-secondary,
.button-test {
  color: #312e81;
  background: #e0e7ff;
}

.button:hover:not(:disabled) {
  filter: brightness(0.96);
}

.button:focus-visible {
  outline: 3px solid #93c5fd;
  outline-offset: 2px;
}

.test-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
  margin-top: 1rem;
}

.test-card {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  min-height: 250px;
  padding: 1.25rem;
  transition: border-color 160ms ease, transform 160ms ease;
}

.test-card:hover {
  transform: translateY(-2px);
}

.test-card[data-state='running'] {
  border-color: #f59e0b;
}

.test-card[data-state='passed'] {
  border-color: #22c55e;
}

.test-card[data-state='failed'] {
  border-color: #ef4444;
}

.test-heading,
.inspector-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

.method,
.state {
  border-radius: 999px;
  padding: 0.3rem 0.65rem;
  font-size: 0.75rem;
  font-weight: 900;
}

.method-get {
  color: #075985;
  background: #e0f2fe;
}

.method-post {
  color: #166534;
  background: #dcfce7;
}

.state {
  color: #475569;
  background: #f1f5f9;
}

.test-card[data-state='running'] .state {
  color: #92400e;
  background: #fef3c7;
}

.test-card[data-state='passed'] .state {
  color: #166534;
  background: #dcfce7;
}

.test-card[data-state='failed'] .state {
  color: #991b1b;
  background: #fee2e2;
}

.test-card h2,
.inspector h2 {
  margin: 0;
}

.test-card code {
  width: fit-content;
  border-radius: 0.45rem;
  padding: 0.35rem 0.5rem;
  color: #4338ca;
  background: #eef2ff;
}

.test-card p {
  flex: 1;
  margin: 0;
  color: #64748b;
  line-height: 1.6;
}

.inspector {
  margin-top: 1rem;
  overflow: hidden;
}

.inspector-heading {
  padding: 1rem 1.25rem;
  border-bottom: 1px solid #dbe3f0;
}

#response-meta {
  color: #64748b;
  font-size: 0.9rem;
}

#response-body {
  min-height: 220px;
  max-height: 420px;
  margin: 0;
  padding: 1.25rem;
  overflow: auto;
  color: #dbeafe;
  background: #0f172a;
  line-height: 1.55;
  white-space: pre-wrap;
}

@media (max-width: 720px) {
  .toolbar,
  .inspector-heading {
    align-items: flex-start;
    flex-direction: column;
  }

  .test-grid {
    grid-template-columns: 1fr;
  }

  .button-row,
  .button {
    width: 100%;
  }
}
Add typed DOM lookups

The visible controls need typed references before the runner can update them. A generic lookup helper checks that each required element exists and returns the specific element type your code expects.

  • In the Visual Studio Code file tree, select src/main.ts.
  • Replace its existing contents with the typed dashboard foundation:
import './style.css'

const API_BASE = 'https://jsonplaceholder.typicode.com'

function getElement<T extends Element>(selector: string): T {
  const element = document.querySelector<T>(selector)

  if (!element) {
    throw new Error(`Missing required element: ${selector}`)
  }

  return element
}

const runAllButton = getElement<HTMLButtonElement>('#run-all')
const clearButton = getElement<HTMLButtonElement>('#clear-results')
const summary = getElement<HTMLParagraphElement>('#summary')
const responseMeta = getElement<HTMLSpanElement>('#response-meta')
const responseBody = getElement<HTMLPreElement>('#response-body')

What does this TypeScript do?

  • The API_BASE constant stores the base address for later blog requests.
  • The getElement<T extends Element>() helper returns a typed element when the selector matches.
  • The helper throws immediately when a required dashboard element is missing.
  • The five constants give the controls and inspector their precise DOM element types.
  • Save src/main.ts.

Before you return to Chrome, which dashboard regions do you expect to remain visible after Vite applies the update?

  • Switch back to Chrome.
  • Confirm the dashboard after the hot update.

You should see the hero, suite-status toolbar, four idle test cards, and response inspector. The buttons remain visible for the runner you will add next.

Dashboard stopped updating?

Compare every selector in src/main.ts with its matching ID in index.html. A missing character causes getElement() to throw.

If the update still fails, help me compare my typed DOM selectors with the dashboard HTML.

✔️ Awesome, I've got everything!

Your typed dashboard foundation is saved. The page is ready for its first API runner.

ⓧ I'd like to double check the full code

Compare your complete src/main.ts with this reference.

import './style.css'

const API_BASE = 'https://jsonplaceholder.typicode.com'

function getElement<T extends Element>(selector: string): T {
  const element = document.querySelector<T>(selector)

  if (!element) {
    throw new Error(`Missing required element: ${selector}`)
  }

  return element
}

const runAllButton = getElement<HTMLButtonElement>('#run-all')
const clearButton = getElement<HTMLButtonElement>('#clear-results')
const summary = getElement<HTMLParagraphElement>('#summary')
const responseMeta = getElement<HTMLSpanElement>('#response-meta')
const responseBody = getElement<HTMLPreElement>('#response-body')

That visible test lab is now your feedback loop. Next, you will make the missing-resource card report a convincing false pass before teaching the runner what HTTP success actually means.

Expose the False Pass

Your dashboard now makes each API check visible. The next goal is to connect the missing-post card to a real request.

The Fetch API resolves its promise when an HTTP response arrives. The response status determines whether the requested resource was available.

You will use TypeScript to track the test result. The request targets a missing post on JSONPlaceholder.

In this step, get ready to:
  • Define the test models and result helpers.
  • Connect the missing-post card to its API request.
  • Compare the card verdict with the response inspector.
Define the test result models

A test runner needs a fixed set of test names and card states. Union types limit those values before the browser runs your code.

  • Switch back to src/main.ts in Visual Studio Code.
  • Place these model declarations directly below the existing import './style.css' line:
type TestId = 'list' | 'single' | 'missing' | 'create'
type TestState = 'idle' | 'running' | 'passed' | 'failed'

interface TestResult {
  id: TestId
  state: Exclude<TestState, 'idle' | 'running'>
  detail: string
  statusCode?: number
  body?: unknown
}

interface ApiTest {
  id: TestId
  name: string
  method: 'GET' | 'POST'
  endpoint: string
  run: () => Promise<TestResult>
}

What Do These Models Track?

  • The TestId union links each runner entry to one dashboard card.
  • The TestState union limits every card to one of four visual states.
  • The TestResult interface stores the verdict and any response details.
  • The ApiTest interface gives each test an asynchronous run function.
  • Save src/main.ts.
  • Check the running dashboard in Chrome. You should still see four idle test cards and the response inspector.

Seeing a TypeScript Error?

Check that both type aliases sit above the interfaces. Confirm every property matches the capitalization shown in the snippet.

Ask for help with the exact editor message: Help me fix my test model declarations.

Small helper functions keep every test result consistent. They also preserve response data as unknown until a later step validates its shape.

  • Find the closing brace of getElement in src/main.ts.
  • Add these helpers below that closing brace:
function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : 'Unknown error'
}

async function readJson(response: Response): Promise<unknown> {
  return (await response.json()) as unknown
}

function passed(
  id: TestId,
  detail: string,
  response: Response,
  body: unknown,
): TestResult {
  return {
    id,
    state: 'passed',
    detail,
    statusCode: response.status,
    body,
  }
}

How Do These Helpers Protect the Runner?

  • The errorMessage helper safely converts an unknown caught value into readable text.
  • The readJson helper preserves parsed response data as unknown.
  • The passed helper captures the card verdict and the HTTP status in one result.
  • Save src/main.ts.
  • Confirm Chrome still displays the dashboard without a browser error.

Helper Types Not Matching?

Check that readJson returns Promise<unknown>. Confirm passed returns a TestResult.

Ask for help with the exact mismatch: Help me debug my result helpers.

The runner also needs a consistent shape for failures. Network problems use this helper when no response reaches the browser.

  • Place the failure helper directly below passed in src/main.ts by adding:
function failed(
  id: TestId,
  detail: string,
  response?: Response,
  body?: unknown,
): TestResult {
  return {
    id,
    state: 'failed',
    detail,
    statusCode: response?.status,
    body,
  }
}

What Does the Failure Helper Store?

The failed helper accepts optional response details because some request failures never produce an HTTP response. The card can still display a useful failed result.

  • Save src/main.ts.
  • Confirm the dashboard remains loaded with all four cards in their idle state.

Seeing an Optional Property Error?

Check the question marks after the response and body parameters. Check the optional access operator in response?.status.

Ask for help with the exact type error: Help me fix the failed helper.

Build the temporary missing-post runner

The test registry connects a card ID to an endpoint and an asynchronous request. This temporary registry activates only the missing-post check.

  • Place the test registry below the result helpers in src/main.ts by adding:
const tests: ApiTest[] = [
  {
    id: 'missing',
    name: 'Handle a missing post',
    method: 'GET',
    endpoint: '/posts/999999',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts/999999`)
      const data = await readJson(response)

      return passed('missing', 'Correctly recognized the expected HTTP 404.', response, data)
    },
  },
]

const resultById = new Map<TestId, TestResult>()
const testById = new Map<TestId, ApiTest>(tests.map((test) => [test.id, test]))

const stateLabels: Record<TestState, string> = {
  idle: 'Idle',
  running: 'Running',
  passed: 'Passed',
  failed: 'Failed',
}

How Is the Test Registered?

  • The tests array describes the card and its missing-post endpoint.
  • The request parses the response body through readJson.
  • The two maps provide quick access to test definitions and completed results.
  • The stateLabels object converts internal states into card text.
  • Save src/main.ts.
  • Confirm Chrome still shows the missing-post card with its Idle badge.

Registry Showing an Error?

Check that API_BASE remains above the new registry. Confirm the endpoint contains /posts/999999.

Ask for help with the exact problem: Help me repair the missing test registry.

The next renderer applies a state to one card. Its selectors reuse the dashboard attributes already present in index.html.

  • Place the card renderer below stateLabels by adding:
function renderCard(id: TestId, state: TestState, detail: string): void {
  const card = getElement<HTMLElement>(`[data-card="${id}"]`)
  const status = getElement<HTMLSpanElement>(`[data-card="${id}"] [data-status]`)
  const detailElement = getElement<HTMLParagraphElement>(`[data-card="${id}"] [data-detail]`)

  card.dataset.state = state
  status.textContent = stateLabels[state]
  detailElement.textContent = detail
}

How Does the Card Change?

The renderCard function finds one card and updates its state attribute. Your existing CSS converts that state into the matching border and badge colours.

  • Save src/main.ts.
  • Confirm Chrome continues to show the styled dashboard.

Card Selector Error?

Check that the selectors contain data-card, data-status, and data-detail exactly as shown.

Ask for help with the selector mismatch: Help me debug renderCard.

The suite summary counts every result currently stored in resultById. The first run uses a registry containing one active test.

  • Place the summary renderer directly below renderCard by adding:
function renderSummary(): void {
  const results = [...resultById.values()]

  if (results.length === 0) {
    summary.textContent = 'No tests run yet.'
    return
  }

  const passCount = results.filter((result) => result.state === 'passed').length
  const failCount = results.filter((result) => result.state === 'failed').length
  summary.textContent = `${passCount} passed, ${failCount} failed, ${tests.length - results.length} not run`
}

How Is the Summary Calculated?

The renderSummary function counts stored pass and failure states. It also subtracts completed results from the active test total.

  • Save src/main.ts.
  • Confirm the suite status still reads No tests run yet. in Chrome.

Summary Count Showing an Error?

Check both filter expressions for the exact passed and failed state values. Confirm the final assignment uses summary.textContent.

Ask for help with the calculation: Help me debug renderSummary.

The response inspector shows which request ran and which status came back. It also formats the parsed response body for inspection.

  • Place the inspector renderer directly below renderSummary by adding:
function renderInspector(test: ApiTest, result: TestResult): void {
  const statusLabel =
    result.statusCode === undefined ? 'No HTTP response' : `HTTP ${result.statusCode}`

  responseMeta.textContent = `${test.method} ${test.endpoint} · ${statusLabel}`
  responseBody.textContent = JSON.stringify(
    result.body ?? { message: result.detail },
    null,
    2,
  )
}

What Reaches the Inspector?

The renderInspector function combines the method and endpoint with the returned status. It formats the parsed body with indentation so you can inspect it on screen.

  • Save src/main.ts.
  • Confirm the inspector still shows its waiting message in Chrome.

Inspector Renderer Not Compiling?

Check the parentheses around JSON.stringify. Confirm its final two arguments are null and 2.

Ask for help with the exact issue: Help me fix renderInspector.

The central runner controls the card lifecycle. It records elapsed time and routes completed results to every renderer.

  • Place the test runner directly below renderInspector by adding:
async function runTest(id: TestId): Promise<void> {
  const test = testById.get(id)

  if (!test) {
    return
  }

  renderCard(id, 'running', 'Waiting for the API response...')
  const startedAt = performance.now()

  try {
    const result = await test.run()
    const duration = Math.round(performance.now() - startedAt)
    const timedResult = {
      ...result,
      detail: `${result.detail} (${duration} ms)`,
    }

    resultById.set(id, timedResult)
    renderCard(id, timedResult.state, timedResult.detail)
    renderInspector(test, timedResult)
  } catch (error) {
    const result = failed(id, `Request failed: ${errorMessage(error)}`)
    resultById.set(id, result)
    renderCard(id, result.state, result.detail)
    renderInspector(test, result)
  }

  renderSummary()
}

How Does the Runner Manage a Test?

  • The runner changes the selected card to running before it waits for the response.
  • A completed result receives its request duration before reaching the renderers.
  • A thrown request error becomes a failed result through failed.
  • The summary updates after either path finishes.
  • Save src/main.ts.
  • Confirm the browser still displays all four cards.

Runner Showing a Type Error?

Check that timedResult spreads result before replacing its detail. Confirm every renderer receives the same test ID.

Ask for help with the exact error: Help me debug runTest.

The card buttons already carry a data-test value. The final listener reads that value and starts the matching test.

  • Place the button wiring at the bottom of src/main.ts by adding:
function isTestId(value: string | undefined): value is TestId {
  return tests.some((test) => test.id === value)
}

document.querySelectorAll<HTMLButtonElement>('[data-test]').forEach((button) => {
  button.addEventListener('click', () => {
    const id = button.dataset.test

    if (isTestId(id)) {
      void runTest(id)
    }
  })
})

How Does a Button Start the Runner?

The isTestId guard confirms that a button value exists in the active registry. The listener sends a valid value to runTest.

  • Save src/main.ts.
  • Confirm Chrome shows the missing-post card with an enabled Run test button.

Button Still Does Nothing?

Check that the listener selector is [data-test]. Confirm the missing-post button in index.html has data-test="missing".

Ask for help with the event wiring: Help me connect the missing test button.

✔️ Awesome, I've got everything!

Great. Make sure src/main.ts is saved before you run the missing-resource check.

ⓧ I'd like to double check the full code

Compare your cumulative src/main.ts with this version:

import './style.css'

type TestId = 'list' | 'single' | 'missing' | 'create'
type TestState = 'idle' | 'running' | 'passed' | 'failed'

interface TestResult {
  id: TestId
  state: Exclude<TestState, 'idle' | 'running'>
  detail: string
  statusCode?: number
  body?: unknown
}

interface ApiTest {
  id: TestId
  name: string
  method: 'GET' | 'POST'
  endpoint: string
  run: () => Promise<TestResult>
}

const API_BASE = 'https://jsonplaceholder.typicode.com'

function getElement<T extends Element>(selector: string): T {
  const element = document.querySelector<T>(selector)

  if (!element) {
    throw new Error(`Missing required element: ${selector}`)
  }

  return element
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : 'Unknown error'
}

async function readJson(response: Response): Promise<unknown> {
  return (await response.json()) as unknown
}

function passed(
  id: TestId,
  detail: string,
  response: Response,
  body: unknown,
): TestResult {
  return {
    id,
    state: 'passed',
    detail,
    statusCode: response.status,
    body,
  }
}

function failed(
  id: TestId,
  detail: string,
  response?: Response,
  body?: unknown,
): TestResult {
  return {
    id,
    state: 'failed',
    detail,
    statusCode: response?.status,
    body,
  }
}

const tests: ApiTest[] = [
  {
    id: 'missing',
    name: 'Handle a missing post',
    method: 'GET',
    endpoint: '/posts/999999',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts/999999`)
      const data = await readJson(response)

      return passed('missing', 'Correctly recognized the expected HTTP 404.', response, data)
    },
  },
]

const resultById = new Map<TestId, TestResult>()
const testById = new Map<TestId, ApiTest>(tests.map((test) => [test.id, test]))
const runAllButton = getElement<HTMLButtonElement>('#run-all')
const clearButton = getElement<HTMLButtonElement>('#clear-results')
const summary = getElement<HTMLParagraphElement>('#summary')
const responseMeta = getElement<HTMLSpanElement>('#response-meta')
const responseBody = getElement<HTMLPreElement>('#response-body')

const stateLabels: Record<TestState, string> = {
  idle: 'Idle',
  running: 'Running',
  passed: 'Passed',
  failed: 'Failed',
}

function isTestId(value: string | undefined): value is TestId {
  return tests.some((test) => test.id === value)
}

function renderCard(id: TestId, state: TestState, detail: string): void {
  const card = getElement<HTMLElement>(`[data-card="${id}"]`)
  const status = getElement<HTMLSpanElement>(`[data-card="${id}"] [data-status]`)
  const detailElement = getElement<HTMLParagraphElement>(`[data-card="${id}"] [data-detail]`)

  card.dataset.state = state
  status.textContent = stateLabels[state]
  detailElement.textContent = detail
}

function renderSummary(): void {
  const results = [...resultById.values()]

  if (results.length === 0) {
    summary.textContent = 'No tests run yet.'
    return
  }

  const passCount = results.filter((result) => result.state === 'passed').length
  const failCount = results.filter((result) => result.state === 'failed').length
  summary.textContent = `${passCount} passed, ${failCount} failed, ${tests.length - results.length} not run`
}

function renderInspector(test: ApiTest, result: TestResult): void {
  const statusLabel =
    result.statusCode === undefined ? 'No HTTP response' : `HTTP ${result.statusCode}`

  responseMeta.textContent = `${test.method} ${test.endpoint} · ${statusLabel}`
  responseBody.textContent = JSON.stringify(
    result.body ?? { message: result.detail },
    null,
    2,
  )
}

async function runTest(id: TestId): Promise<void> {
  const test = testById.get(id)

  if (!test) {
    return
  }

  renderCard(id, 'running', 'Waiting for the API response...')
  const startedAt = performance.now()

  try {
    const result = await test.run()
    const duration = Math.round(performance.now() - startedAt)
    const timedResult = {
      ...result,
      detail: `${result.detail} (${duration} ms)`,
    }

    resultById.set(id, timedResult)
    renderCard(id, timedResult.state, timedResult.detail)
    renderInspector(test, timedResult)
  } catch (error) {
    const result = failed(id, `Request failed: ${errorMessage(error)}`)
    resultById.set(id, result)
    renderCard(id, result.state, result.detail)
    renderInspector(test, result)
  }

  renderSummary()
}

document.querySelectorAll<HTMLButtonElement>('[data-test]').forEach((button) => {
  button.addEventListener('click', () => {
    const id = button.dataset.test

    if (isTestId(id)) {
      void runTest(id)
    }
  })
})

What Should This File Contain?

This cumulative file keeps the existing dashboard references. It adds the temporary missing-post test and the rendering path needed to display its result.

Run the missing-resource check

The card verdict and the response inspector now come from the same request. Before you click, do you think those two parts of the dashboard will agree?

  • Return to the running dashboard in Google Chrome.
  • Click Run test on the Handle a missing post card.

You will see the card turn green with a Passed badge. The response inspector shows GET /posts/999999 with HTTP 404.

Why Is This a False Pass?

A fulfilled fetch confirms that the browser received an HTTP response. The current runner immediately calls passed after parsing that response.

The green card therefore conflicts with the returned 404 status. You have exposed the exact gap that HTTP-aware checks need to close.

Missing Card Not Turning Green?

Confirm the Vite development server is still running. Check that Chrome is showing http://localhost:5173.

Check that the button uses data-test="missing" and the registry uses id: 'missing'.

Ask for help with what you observe: Help me reproduce the missing-post false pass.

You have proved that request completion alone can produce a misleading verdict. Next up, you will make the runner inspect HTTP status and validate response data before a card can pass.

Make the Tests HTTP-Aware and Type-Safe

Your dashboard has exposed a false pass. The missing-resource card turns green even though the inspector shows HTTP 404.

The Fetch API can fulfill a request when the server returns an HTTP error. TypeScript needs runtime checks before untrusted JSON can become a trusted Post.

In this step, get ready to:
  • Correct the missing-resource test with explicit HTTP status checks.
  • Validate unknown response data with reusable type guards.
  • Add HTTP-aware tests for the post list and single post.
Recognize the expected 404

A fulfilled request only confirms that the browser received a response. The runner must inspect response.ok and response.status to decide whether that response matches the test.

  • Return to src/main.ts in Visual Studio Code.
  • Find the existing errorMessage() function.
  • Replace that helper section with this code to preserve errorMessage() and add readJson():
function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : 'Unknown error'
}

async function readJson(response: Response): Promise<unknown> {
  return (await response.json()) as unknown
}

Why keep parsed data unknown?

  • response.json() parses the response body into a JavaScript value.
  • Promise<unknown> prevents the runner from assuming that the parsed value has the expected fields.
  • readJson() gives every test the same cautious response boundary.
  • Save src/main.ts.
  • Switch back to the existing Visual Studio Code terminal.

You should see Vite report an update for src/main.ts while the development server stays active.

No update from Vite?

Confirm that you edited the src/main.ts file inside the Blog API Test Lab folder. Check that the terminal running Vite is still active.

Help me check why my saved TypeScript changes are not reaching the running Vite page.

  • Find the object with id: 'missing' inside the tests array.
  • Replace that entire object with the HTTP-aware version below:
  {
    id: 'missing',
    name: 'Handle a missing post',
    method: 'GET',
    endpoint: '/posts/999999',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts/999999`)
      const data = await readJson(response)

      if (response.ok) {
        return failed('missing', 'Expected an HTTP error, but the response was successful.', response, data)
      }

      if (response.status !== 404) {
        return failed('missing', `Expected HTTP 404 but received HTTP ${response.status}.`, response, data)
      }

      return passed('missing', 'Correctly recognized the expected HTTP 404.', response, data)
    },
  },

What does this test prove?

  • response.ok catches an unexpected successful response.
  • response.status !== 404 rejects every HTTP error except the one this test expects.
  • passed() runs only after the response is confirmed as the expected missing-resource result.
  • Save src/main.ts.

Before you rerun the test, do you expect the card to pass for the same reason as before?

  • Return to the dashboard in Google Chrome.
  • Click Run test on the Handle a missing post card.

You should see Passed with a detail saying the expected HTTP 404 was recognized. The inspector still shows HTTP 404 because that status is now the expected result.

Still seeing the old false-pass detail?

Check that the missing test calls readJson(response) before checking response.ok. Confirm that its final return uses the expected HTTP 404 detail.

Help me compare my missing-resource test with the corrected HTTP-aware version.

That misleading pass now has a precise meaning. The test turns green because the API returned the expected missing-resource status.

Validate unknown JSON before trusting it

A TypeScript interface describes the shape your code expects during static checking. A type guard inspects the real response value before the runner treats it as that shape.

  • Return to src/main.ts in Visual Studio Code.
  • Find the TestState type near the top of the file.
  • Add the Post interface directly below it:
interface Post {
  userId: number
  id: number
  title: string
  body: string
}

What does the Post interface describe?

  • userId and id must be numbers.
  • title and body must be strings.
  • Post gives the guards one trusted shape to establish at runtime.
  • Save src/main.ts.
  • Switch back to the existing terminal.

You should see Vite report another update for src/main.ts.

Seeing a TypeScript syntax problem?

Check that the Post fields sit between one opening brace and one closing brace. Confirm that each field uses a colon before its type.

Help me find the syntax problem in my Post interface.

  • Find the closing brace of getElement().
  • Add the runtime type guards directly below that function:
function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null
}

function isPost(value: unknown): value is Post {
  return (
    isRecord(value) &&
    typeof value.userId === 'number' &&
    typeof value.id === 'number' &&
    typeof value.title === 'string' &&
    typeof value.body === 'string'
  )
}

function isPostList(value: unknown): value is Post[] {
  return Array.isArray(value) && value.length > 0 && value.every(isPost)
}

How do the guards build trust?

  • isRecord() confirms that a value is an object with accessible fields.
  • isPost() checks every field required by the Post interface.
  • isPostList() requires a non-empty array where every item passes isPost().
  • Save src/main.ts.
  • Return to the dashboard in Google Chrome.
  • Click Run test on the Handle a missing post card.

You should still see the corrected missing-resource test pass. This confirms that the dashboard remains runnable after adding the response guards.

Did the dashboard stop updating?

Check the parentheses inside isPost() for a missing closing symbol. Confirm that isPostList() calls value.every(isPost).

Help me debug my isRecord, isPost, and isPostList type guards.

Add the validated GET checks

The list test must prove that the endpoint returned a successful response containing valid posts. The single-post test adds one more requirement by checking that the returned post has ID 1.

  • Return to the tests array in src/main.ts.
  • Insert the list test before the existing missing-resource test:
  {
    id: 'list',
    name: 'List blog posts',
    method: 'GET',
    endpoint: '/posts',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts`)
      const data = await readJson(response)

      if (!response.ok) {
        return failed('list', `Expected success but received HTTP ${response.status}.`, response, data)
      }

      if (!isPostList(data)) {
        return failed('list', 'The response was not a non-empty Post array.', response, data)
      }

      return passed('list', `Validated ${data.length} posts.`, response, data)
    },
  },

What does the list test check?

  • !response.ok records a clear failure when the server returns an unsuccessful HTTP status.
  • !isPostList(data) rejects empty arrays and values that do not match Post[].
  • data.length becomes safe to use after isPostList() succeeds.
  • Save src/main.ts.

Before you run the list check, do you expect a successful response alone to be enough for a pass?

  • Return to Google Chrome.
  • Click Run test on the List blog posts card.

You should see the card change to Passed with a detail reporting how many posts were validated. The inspector shows the successful HTTP status with a parsed array of post objects.

Does the list test fail?

Confirm that isPostList() checks userId, id, title, and body through isPost().

Help me diagnose why the list test is rejecting the parsed API response.

  • Return to the tests array in src/main.ts.
  • Insert the single-post test between the list test and the missing-resource test:
  {
    id: 'single',
    name: 'Read one post',
    method: 'GET',
    endpoint: '/posts/1',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts/1`)
      const data = await readJson(response)

      if (!response.ok) {
        return failed('single', `Expected success but received HTTP ${response.status}.`, response, data)
      }

      if (!isPost(data) || data.id !== 1) {
        return failed('single', 'The response was not the requested Post with ID 1.', response, data)
      }

      return passed('single', 'Validated the Post shape and ID.', response, data)
    },
  },

What does the single-post test check?

  • !response.ok stops an unsuccessful HTTP response from reaching the success path.
  • !isPost(data) rejects a response with missing fields or incorrect field types.
  • data.id !== 1 confirms that the API returned the requested post.
  • Save src/main.ts.

Before you run the three checks, which test should pass because it receives an HTTP error?

  • Return to the dashboard in Google Chrome.
  • Click Run test on the Read one post card.
  • Click Run test on the List blog posts card.
  • Click Run test on the Handle a missing post card.

You should see all three cards marked Passed. The GET cards pass after their response shapes validate, while the missing-resource card passes after deliberately recognizing HTTP 404.

Is one of the three cards failing?

Use the response inspector to identify the failing endpoint and its parsed body. Compare that body with the field checks in isPost() or the status checks in the matching test.

Help me debug the failed GET test using the card detail and response inspector.

✔️ Awesome, I've got everything!

Great work. Your three GET checks now distinguish HTTP outcomes and validate external data before using it.

ⓧ I'd like to double check the full code

Compare your cumulative src/main.ts with this version:

import './style.css'

type TestId = 'list' | 'single' | 'missing' | 'create'
type TestState = 'idle' | 'running' | 'passed' | 'failed'

interface Post {
  userId: number
  id: number
  title: string
  body: string
}

interface TestResult {
  id: TestId
  state: Exclude<TestState, 'idle' | 'running'>
  detail: string
  statusCode?: number
  body?: unknown
}

interface ApiTest {
  id: TestId
  name: string
  method: 'GET' | 'POST'
  endpoint: string
  run: () => Promise<TestResult>
}

const API_BASE = 'https://jsonplaceholder.typicode.com'

function getElement<T extends Element>(selector: string): T {
  const element = document.querySelector<T>(selector)

  if (!element) {
    throw new Error(`Missing required element: ${selector}`)
  }

  return element
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null
}

function isPost(value: unknown): value is Post {
  return (
    isRecord(value) &&
    typeof value.userId === 'number' &&
    typeof value.id === 'number' &&
    typeof value.title === 'string' &&
    typeof value.body === 'string'
  )
}

function isPostList(value: unknown): value is Post[] {
  return Array.isArray(value) && value.length > 0 && value.every(isPost)
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : 'Unknown error'
}

async function readJson(response: Response): Promise<unknown> {
  return (await response.json()) as unknown
}

function passed(
  id: TestId,
  detail: string,
  response: Response,
  body: unknown,
): TestResult {
  return {
    id,
    state: 'passed',
    detail,
    statusCode: response.status,
    body,
  }
}

function failed(
  id: TestId,
  detail: string,
  response?: Response,
  body?: unknown,
): TestResult {
  return {
    id,
    state: 'failed',
    detail,
    statusCode: response?.status,
    body,
  }
}

const tests: ApiTest[] = [
  {
    id: 'list',
    name: 'List blog posts',
    method: 'GET',
    endpoint: '/posts',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts`)
      const data = await readJson(response)

      if (!response.ok) {
        return failed('list', `Expected success but received HTTP ${response.status}.`, response, data)
      }

      if (!isPostList(data)) {
        return failed('list', 'The response was not a non-empty Post array.', response, data)
      }

      return passed('list', `Validated ${data.length} posts.`, response, data)
    },
  },
  {
    id: 'single',
    name: 'Read one post',
    method: 'GET',
    endpoint: '/posts/1',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts/1`)
      const data = await readJson(response)

      if (!response.ok) {
        return failed('single', `Expected success but received HTTP ${response.status}.`, response, data)
      }

      if (!isPost(data) || data.id !== 1) {
        return failed('single', 'The response was not the requested Post with ID 1.', response, data)
      }

      return passed('single', 'Validated the Post shape and ID.', response, data)
    },
  },
  {
    id: 'missing',
    name: 'Handle a missing post',
    method: 'GET',
    endpoint: '/posts/999999',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts/999999`)
      const data = await readJson(response)

      if (response.ok) {
        return failed('missing', 'Expected an HTTP error, but the response was successful.', response, data)
      }

      if (response.status !== 404) {
        return failed('missing', `Expected HTTP 404 but received HTTP ${response.status}.`, response, data)
      }

      return passed('missing', 'Correctly recognized the expected HTTP 404.', response, data)
    },
  },
]

const resultById = new Map<TestId, TestResult>()
const testById = new Map<TestId, ApiTest>(tests.map((test) => [test.id, test]))
const runAllButton = getElement<HTMLButtonElement>('#run-all')
const clearButton = getElement<HTMLButtonElement>('#clear-results')
const summary = getElement<HTMLParagraphElement>('#summary')
const responseMeta = getElement<HTMLSpanElement>('#response-meta')
const responseBody = getElement<HTMLPreElement>('#response-body')

const stateLabels: Record<TestState, string> = {
  idle: 'Idle',
  running: 'Running',
  passed: 'Passed',
  failed: 'Failed',
}

function isTestId(value: string | undefined): value is TestId {
  return tests.some((test) => test.id === value)
}

function renderCard(id: TestId, state: TestState, detail: string): void {
  const card = getElement<HTMLElement>(`[data-card="${id}"]`)
  const status = getElement<HTMLSpanElement>(`[data-card="${id}"] [data-status]`)
  const detailElement = getElement<HTMLParagraphElement>(`[data-card="${id}"] [data-detail]`)

  card.dataset.state = state
  status.textContent = stateLabels[state]
  detailElement.textContent = detail
}

function renderSummary(): void {
  const results = [...resultById.values()]

  if (results.length === 0) {
    summary.textContent = 'No tests run yet.'
    return
  }

  const passCount = results.filter((result) => result.state === 'passed').length
  const failCount = results.filter((result) => result.state === 'failed').length
  summary.textContent = `${passCount} passed, ${failCount} failed, ${tests.length - results.length} not run`
}

function renderInspector(test: ApiTest, result: TestResult): void {
  const statusLabel =
    result.statusCode === undefined ? 'No HTTP response' : `HTTP ${result.statusCode}`

  responseMeta.textContent = `${test.method} ${test.endpoint} · ${statusLabel}`
  responseBody.textContent = JSON.stringify(
    result.body ?? { message: result.detail },
    null,
    2,
  )
}

async function runTest(id: TestId): Promise<void> {
  const test = testById.get(id)

  if (!test) {
    return
  }

  renderCard(id, 'running', 'Waiting for the API response...')
  const startedAt = performance.now()

  try {
    const result = await test.run()
    const duration = Math.round(performance.now() - startedAt)
    const timedResult = {
      ...result,
      detail: `${result.detail} (${duration} ms)`,
    }

    resultById.set(id, timedResult)
    renderCard(id, timedResult.state, timedResult.detail)
    renderInspector(test, timedResult)
  } catch (error) {
    const result = failed(id, `Request failed: ${errorMessage(error)}`)
    resultById.set(id, result)
    renderCard(id, result.state, result.detail)
    renderInspector(test, result)
  }

  renderSummary()
}

document.querySelectorAll<HTMLButtonElement>('[data-test]').forEach((button) => {
  button.addEventListener('click', () => {
    const id = button.dataset.test

    if (isTestId(id)) {
      void runTest(id)
    }
  })
})

What should this file contain now?

This cumulative version contains the list, single-post, and missing-resource checks. The simulated POST test and final suite controls belong to the next step.

Your GET tests now understand HTTP outcomes and protect the boundary around external data. Next up, you'll add the simulated POST check and complete the controls for running the full suite.

Complete the Test Suite

Your dashboard now handles the three GET scenarios correctly. It recognizes the expected 404 without creating another false pass.

The last API gap is the simulated write check. You will also finish the suite controls with timing details. A production build then proves your TypeScript passes static checking.

In this step, get ready to:
  • Add a simulated POST test that validates the echoed response.
  • Complete the timing details and suite controls.
  • Build the project and run all four tests.
Add the simulated POST test

JSONPlaceholder accepts a simulated POST request and echoes the submitted fields in its response. Your Fetch API check can validate that response without claiming the new post is stored permanently.

  • Switch back to Visual Studio Code.
  • Select src/main.ts in the Explorer sidebar.
  • Find the closing }, of the missing test inside tests.
  • Paste this new entry immediately below it:
  {
    id: 'create',
    name: 'Create a simulated post',
    method: 'POST',
    endpoint: '/posts',
    run: async () => {
      const draft = {
        title: 'TypeScript test post',
        body: 'Created by the Blog API Test Lab',
        userId: 1,
      }

      const response = await fetch(`${API_BASE}/posts`, {
        method: 'POST',
        body: JSON.stringify(draft),
        headers: {
          'Content-type': 'application/json; charset=UTF-8',
        },
      })
      const data = await readJson(response)

      if (!isPost(data)) {
        return failed('create', 'The response was not a valid Post.', response, data)
      }

      return passed('create', `Validated simulated post ID ${data.id}.`, response, data)
    },
  },

What does this test send?

  • The draft object holds the title, body, and user ID sent by the lab.
  • JSON.stringify(draft) converts the draft into a JSON request body.
  • Content-type tells the API that the request body contains JSON.
  • isPost(data) narrows the unknown response before the test reads data.id.
  • Find if (!isPost(data)) inside the new create test.
  • Paste this HTTP status check immediately above it:
      if (!response.ok) {
        return failed('create', `Expected success but received HTTP ${response.status}.`, response, data)
      }

Why check the status first?

response.ok confirms the server returned a successful HTTP status. The test records a failure before validating the body when that status check fails.

  • Save src/main.ts.
  • Click Create a simulated post in Google Chrome.

You should see the create card report Passed. The response inspector should show POST /posts with a successful HTTP status and parsed JSON.

POST test not passing?

Check that the new object sits inside the tests array. Confirm that a comma separates it from the previous test.

Confirm that the header name is Content-type and its value is application/json; charset=UTF-8.

Help me debug the simulated POST test.

A valid Post shape proves the API returned the expected field types. The final comparison proves those fields contain the values your lab submitted.

  • Find the final return passed('create', ...) line in the create test.
  • Paste this field comparison immediately above it:
      if (
        data.title !== draft.title ||
        data.body !== draft.body ||
        data.userId !== draft.userId
      ) {
        return failed('create', 'The API did not echo the submitted post fields.', response, data)
      }

What does this comparison prove?

The condition compares each submitted field with the corresponding response field. Any mismatch turns the result into a visible failure.

  • Save src/main.ts.
  • Click Create a simulated post again in Google Chrome.

Good work. The create card should pass after validating the HTTP status, the Post shape, and all three echoed fields.

Seeing an echoed-field failure?

Compare the draft values with the parsed JSON in the response inspector. A spelling or capitalization difference causes the strict comparisons to fail.

Help me investigate the echoed POST fields.

Finish timing and suite controls

A useful test runner shows how long each request took. It also runs the suite in a predictable sequence so every result remains easy to inspect.

  • Find the existing runTest function in src/main.ts.
  • Replace the complete function with this timed version:
async function runTest(id: TestId): Promise<void> {
  const test = testById.get(id)

  if (!test) {
    return
  }

  renderCard(id, 'running', 'Waiting for the API response...')
  const startedAt = performance.now()

  try {
    const result = await test.run()
    const duration = Math.round(performance.now() - startedAt)
    const timedResult = {
      ...result,
      detail: `${result.detail} (${duration} ms)`,
    }

    resultById.set(id, timedResult)
    renderCard(id, timedResult.state, timedResult.detail)
    renderInspector(test, timedResult)
  } catch (error) {
    const result = failed(id, `Request failed: ${errorMessage(error)}`)
    resultById.set(id, result)
    renderCard(id, result.state, result.detail)
    renderInspector(test, result)
  }

  renderSummary()
}

How does the timing work?

  • performance.now() records a precise start time before the request runs.
  • duration measures the elapsed time after the test resolves.
  • timedResult adds the rounded millisecond value to the card detail.
  • Save src/main.ts.
  • Click Read one post in Google Chrome.

The card detail should now end with a duration in milliseconds. This confirms the timed runner is active.

Timing detail missing?

Confirm that every result-rendering call inside the try block uses timedResult.

Help me fix the missing timing detail.

  • Find the existing document.querySelectorAll<HTMLButtonElement>('[data-test]') listener near the bottom of src/main.ts.
  • Paste this suite handler immediately below that listener:
runAllButton.addEventListener('click', async () => {
  resultById.clear()
  renderSummary()
  setControlsDisabled(true)

  try {
    for (const test of tests) {
      await runTest(test.id)
    }
  } finally {
    setControlsDisabled(false)
  }
})

How does the suite runner work?

  • resultById.clear() removes results from the previous suite run.
  • setControlsDisabled(true) prevents overlapping requests while the suite runs.
  • for...of waits for each test before starting the next one.
  • finally restores every control even when a request fails.
  • Save src/main.ts.

Before you run the suite, predict what each of the four cards will report.

  • Click Run all tests in Google Chrome.

You should see the cards move through Running one at a time. All four cards should finish as Passed with 4 passed, 0 failed, 0 not run in the summary.

  • Return to src/main.ts in Visual Studio Code.
  • Paste this reset handler immediately below the runAllButton handler:
clearButton.addEventListener('click', () => {
  resultById.clear()

  for (const test of tests) {
    renderCard(test.id, 'idle', 'Ready to run.')
  }

  renderSummary()
  responseMeta.textContent = 'Waiting for a test'
  responseBody.textContent = 'Run a test to inspect its parsed JSON response.'
})

What gets reset?

The handler clears stored results and restores every card to idle. It also restores the original summary and response inspector messages.

  • Save src/main.ts.
  • Click Clear results in Google Chrome.

Every card should return to Idle. The summary should show No tests run yet. while the inspector returns to its waiting state.

Suite controls not responding?

Confirm that the handlers sit after the existing typed button references. Check the browser console for a syntax error caused by a missing brace or parenthesis.

Help me debug the suite controls.

✔️ Awesome, I've got everything!

Your final src/main.ts now includes the simulated POST test, request timing, sequential suite execution, and result clearing.

ⓧ I'd like to double check the full code

Compare your complete src/main.ts with this final version:

import './style.css'

type TestId = 'list' | 'single' | 'missing' | 'create'
type TestState = 'idle' | 'running' | 'passed' | 'failed'

interface Post {
  userId: number
  id: number
  title: string
  body: string
}

interface TestResult {
  id: TestId
  state: Exclude<TestState, 'idle' | 'running'>
  detail: string
  statusCode?: number
  body?: unknown
}

interface ApiTest {
  id: TestId
  name: string
  method: 'GET' | 'POST'
  endpoint: string
  run: () => Promise<TestResult>
}

const API_BASE = 'https://jsonplaceholder.typicode.com'

function getElement<T extends Element>(selector: string): T {
  const element = document.querySelector<T>(selector)

  if (!element) {
    throw new Error(`Missing required element: ${selector}`)
  }

  return element
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null
}

function isPost(value: unknown): value is Post {
  return (
    isRecord(value) &&
    typeof value.userId === 'number' &&
    typeof value.id === 'number' &&
    typeof value.title === 'string' &&
    typeof value.body === 'string'
  )
}

function isPostList(value: unknown): value is Post[] {
  return Array.isArray(value) && value.length > 0 && value.every(isPost)
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : 'Unknown error'
}

async function readJson(response: Response): Promise<unknown> {
  return (await response.json()) as unknown
}

function passed(
  id: TestId,
  detail: string,
  response: Response,
  body: unknown,
): TestResult {
  return {
    id,
    state: 'passed',
    detail,
    statusCode: response.status,
    body,
  }
}

function failed(
  id: TestId,
  detail: string,
  response?: Response,
  body?: unknown,
): TestResult {
  return {
    id,
    state: 'failed',
    detail,
    statusCode: response?.status,
    body,
  }
}

const tests: ApiTest[] = [
  {
    id: 'list',
    name: 'List blog posts',
    method: 'GET',
    endpoint: '/posts',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts`)
      const data = await readJson(response)

      if (!response.ok) {
        return failed('list', `Expected success but received HTTP ${response.status}.`, response, data)
      }

      if (!isPostList(data)) {
        return failed('list', 'The response was not a non-empty Post array.', response, data)
      }

      return passed('list', `Validated ${data.length} posts.`, response, data)
    },
  },
  {
    id: 'single',
    name: 'Read one post',
    method: 'GET',
    endpoint: '/posts/1',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts/1`)
      const data = await readJson(response)

      if (!response.ok) {
        return failed('single', `Expected success but received HTTP ${response.status}.`, response, data)
      }

      if (!isPost(data) || data.id !== 1) {
        return failed('single', 'The response was not the requested Post with ID 1.', response, data)
      }

      return passed('single', 'Validated the Post shape and ID.', response, data)
    },
  },
  {
    id: 'missing',
    name: 'Handle a missing post',
    method: 'GET',
    endpoint: '/posts/999999',
    run: async () => {
      const response = await fetch(`${API_BASE}/posts/999999`)
      const data = await readJson(response)

      if (response.ok) {
        return failed('missing', 'Expected an HTTP error, but the response was successful.', response, data)
      }

      if (response.status !== 404) {
        return failed('missing', `Expected HTTP 404 but received HTTP ${response.status}.`, response, data)
      }

      return passed('missing', 'Correctly recognized the expected HTTP 404.', response, data)
    },
  },
  {
    id: 'create',
    name: 'Create a simulated post',
    method: 'POST',
    endpoint: '/posts',
    run: async () => {
      const draft = {
        title: 'TypeScript test post',
        body: 'Created by the Blog API Test Lab',
        userId: 1,
      }

      const response = await fetch(`${API_BASE}/posts`, {
        method: 'POST',
        body: JSON.stringify(draft),
        headers: {
          'Content-type': 'application/json; charset=UTF-8',
        },
      })
      const data = await readJson(response)

      if (!response.ok) {
        return failed('create', `Expected success but received HTTP ${response.status}.`, response, data)
      }

      if (!isPost(data)) {
        return failed('create', 'The response was not a valid Post.', response, data)
      }

      if (
        data.title !== draft.title ||
        data.body !== draft.body ||
        data.userId !== draft.userId
      ) {
        return failed('create', 'The API did not echo the submitted post fields.', response, data)
      }

      return passed('create', `Validated simulated post ID ${data.id}.`, response, data)
    },
  },
]

const resultById = new Map<TestId, TestResult>()
const testById = new Map<TestId, ApiTest>(tests.map((test) => [test.id, test]))
const runAllButton = getElement<HTMLButtonElement>('#run-all')
const clearButton = getElement<HTMLButtonElement>('#clear-results')
const summary = getElement<HTMLParagraphElement>('#summary')
const responseMeta = getElement<HTMLSpanElement>('#response-meta')
const responseBody = getElement<HTMLPreElement>('#response-body')

const stateLabels: Record<TestState, string> = {
  idle: 'Idle',
  running: 'Running',
  passed: 'Passed',
  failed: 'Failed',
}

function isTestId(value: string | undefined): value is TestId {
  return tests.some((test) => test.id === value)
}

function renderCard(id: TestId, state: TestState, detail: string): void {
  const card = getElement<HTMLElement>(`[data-card="${id}"]`)
  const status = getElement<HTMLSpanElement>(`[data-card="${id}"] [data-status]`)
  const detailElement = getElement<HTMLParagraphElement>(`[data-card="${id}"] [data-detail]`)

  card.dataset.state = state
  status.textContent = stateLabels[state]
  detailElement.textContent = detail
}

function renderSummary(): void {
  const results = [...resultById.values()]

  if (results.length === 0) {
    summary.textContent = 'No tests run yet.'
    return
  }

  const passCount = results.filter((result) => result.state === 'passed').length
  const failCount = results.filter((result) => result.state === 'failed').length
  summary.textContent = `${passCount} passed, ${failCount} failed, ${tests.length - results.length} not run`
}

function renderInspector(test: ApiTest, result: TestResult): void {
  const statusLabel =
    result.statusCode === undefined ? 'No HTTP response' : `HTTP ${result.statusCode}`

  responseMeta.textContent = `${test.method} ${test.endpoint} · ${statusLabel}`
  responseBody.textContent = JSON.stringify(
    result.body ?? { message: result.detail },
    null,
    2,
  )
}

function setControlsDisabled(disabled: boolean): void {
  document.querySelectorAll<HTMLButtonElement>('button').forEach((button) => {
    button.disabled = disabled
  })
}

async function runTest(id: TestId): Promise<void> {
  const test = testById.get(id)

  if (!test) {
    return
  }

  renderCard(id, 'running', 'Waiting for the API response...')
  const startedAt = performance.now()

  try {
    const result = await test.run()
    const duration = Math.round(performance.now() - startedAt)
    const timedResult = {
      ...result,
      detail: `${result.detail} (${duration} ms)`,
    }

    resultById.set(id, timedResult)
    renderCard(id, timedResult.state, timedResult.detail)
    renderInspector(test, timedResult)
  } catch (error) {
    const result = failed(id, `Request failed: ${errorMessage(error)}`)
    resultById.set(id, result)
    renderCard(id, result.state, result.detail)
    renderInspector(test, result)
  }

  renderSummary()
}

document.querySelectorAll<HTMLButtonElement>('[data-test]').forEach((button) => {
  button.addEventListener('click', () => {
    const id = button.dataset.test

    if (isTestId(id)) {
      void runTest(id)
    }
  })
})

runAllButton.addEventListener('click', async () => {
  resultById.clear()
  renderSummary()
  setControlsDisabled(true)

  try {
    for (const test of tests) {
      await runTest(test.id)
    }
  } finally {
    setControlsDisabled(false)
  }
})

clearButton.addEventListener('click', () => {
  resultById.clear()

  for (const test of tests) {
    renderCard(test.id, 'idle', 'Ready to run.')
  }

  renderSummary()
  responseMeta.textContent = 'Waiting for a test'
  responseBody.textContent = 'Run a test to inspect its parsed JSON response.'
})

How to use this reference

Compare the test entries, typed DOM references, rendering helpers, and event handlers in order. Save src/main.ts after correcting any differences.

Build and verify the complete lab

The development server proves the app can run during local work. The production build adds a stricter checkpoint by running static checking before bundling the app with Vite.

  • Click the + button in Visual Studio Code's terminal panel to open another terminal.

Before you run the build, predict whether TypeScript checking will accept every response boundary.

  • Prove the final source passes static checking and bundling by running:
npm run build

What does this build prove?

The build script runs tsc && vite build. TypeScript checks the source before Vite creates the production bundle.

You should return to the terminal prompt without build errors. The output should confirm that Vite created the production files.

Build not completing?

Read the first TypeScript error in the terminal. Its file path and line number identify the earliest mismatch to fix.

Confirm that src/main.ts matches the complete reference above. Run the build again after saving the correction.

Help me fix my production build.

  • Return to the running dashboard in Google Chrome.

Before the final run, predict whether the expected 404 and the simulated POST will both finish as passed checks.

  • Click Run all tests.

You should see four passed cards. The summary should read 4 passed, 0 failed, 0 not run.

Why the created post is temporary

JSONPlaceholder fakes write operations without permanently changing its server data. Your test proves that the simulated POST response is valid and echoes the submitted fields.

That completes your test lab. You now have a statically checked dashboard that reports real API behavior without trusting unknown JSON too early.

Secret mission

Verify the POST in Chrome DevTools

The dashboard summarizes your simulated POST as a visible test result. Trace the request through Chrome DevTools to verify its status, headers, payload, and response body for yourself.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the local lab available, pause its development server, or remove the project files. Everything runs on your Windows computer, so this project has no ongoing cloud costs.

Resources you used:

  • Local Vite development server serving the Blog API Test Lab at http://localhost:5173.
  • Local project workspace containing package.json, tsconfig.json, index.html, src/main.ts, src/style.css, the installed dependencies, and the generated build output.
  • Google Chrome session containing the dashboard and its Network audit.

Why Isn't JSONPlaceholder Listed?

JSONPlaceholder fakes write operations, so your POST test did not create a lasting server resource. There is nothing to delete from the service.

Keep everything running

No action is needed. This option keeps the completed lab ready for demonstrations or more request inspection.

  • Keep the Vite terminal running while you use the dashboard at http://localhost:5173.
  • Leave the local project workspace intact so the source files and build output remain available.
  • Leave the Google Chrome tab open if you want to inspect more requests.

Pause - I'll come back to this later

Stop the running development process to free up local memory. Your project files remain ready for your next session.

  • Switch back to the terminal running Vite.
  • Press Ctrl+C to stop the development server.
  • Refresh the open dashboard tab in Google Chrome.

You should see that the local page can no longer connect. This confirms the Vite development server has stopped.

  • Close Chrome DevTools to clear the Network audit from your screen.
  • Close the dashboard tab to finish pausing the browser session.

Your source files and build output stay untouched. You can restart the lab later with the same development script from Step 1.

Delete - I don't want to use this again

This option removes the local project resources so you can start fresh. The simulated POST left no lasting resource on JSONPlaceholder.

  • Switch back to the terminal running Vite.
  • Press Ctrl+C to stop the development server.
  • Refresh the dashboard tab in Google Chrome.

You should see that the local page can no longer connect. This proves the development server is no longer running.

  • Open File Explorer from the Windows taskbar.
  • Locate the project folder that contains package.json, tsconfig.json, index.html, and the src folder.
  • Select that project folder from its parent location.
  • Press Delete to remove the project workspace.
  • Approve the deletion prompt if one appears.
  • Confirm that the project folder no longer appears in File Explorer.
  • Close Chrome DevTools.
  • Close the dashboard tab in Google Chrome.

Nice Work!

Nice Work!

Your test lab is complete! You've built a browser dashboard in TypeScript for real JSONPlaceholder requests.

You've learned how to:

  • Built a visible test dashboard that runs four blog API checks. Its typed cards expose each result state. The response inspector displays the HTTP status beside parsed JSON.
  • Exposed a false pass from a fulfilled Fetch API request. Added HTTP status handling so the expected 404 becomes a deliberate pass.
  • Protected untrusted JSON with reusable runtime type guards before using it as a Post. Proved the code passes static checks through a Vite production build.
  • Secret Mission: audited the simulated POST in Chrome DevTools across its Headers, Payload, and Response tabs. Confirmed the successful exchange does not permanently store the post in JSONPlaceholder.

Ready to quiz yourself?