From 4428f0b61623068f4c7b3183cb4bc3ba837347b4 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:33:58 +0000 Subject: [PATCH 01/27] Initial concept --- docusaurus.config.js | 25 ++ sidebarsTutorial.js | 33 +++ src/components/TutorialChecklist/index.js | 62 +++++ .../TutorialChecklist/styles.module.css | 80 ++++++ src/components/TutorialTracker/index.js | 111 ++++++++ .../TutorialTracker/styles.module.css | 236 ++++++++++++++++++ src/theme/DocItem/Layout/index.js | 38 +++ src/theme/MDXComponents.js | 11 + src/theme/Root.js | 14 ++ src/tutorial/TutorialProgressContext.js | 142 +++++++++++ src/tutorial/tutorialData.js | 66 +++++ src/tutorial/useTutorialOutline.js | 52 ++++ tutorial/introduction/prerequisites.mdx | 79 ++++++ tutorial/introduction/welcome.mdx | 70 ++++++ tutorial/testing-our-module/first-test.mdx | 169 +++++++++++++ tutorial/testing-our-module/output.mdx | 176 +++++++++++++ .../testing-our-module/private-functions.mdx | 130 ++++++++++ .../testing-our-module/public-functions.mdx | 187 ++++++++++++++ tutorial/testing-our-module/setup.mdx | 104 ++++++++ 19 files changed, 1785 insertions(+) create mode 100644 sidebarsTutorial.js create mode 100644 src/components/TutorialChecklist/index.js create mode 100644 src/components/TutorialChecklist/styles.module.css create mode 100644 src/components/TutorialTracker/index.js create mode 100644 src/components/TutorialTracker/styles.module.css create mode 100644 src/theme/DocItem/Layout/index.js create mode 100644 src/theme/MDXComponents.js create mode 100644 src/theme/Root.js create mode 100644 src/tutorial/TutorialProgressContext.js create mode 100644 src/tutorial/tutorialData.js create mode 100644 src/tutorial/useTutorialOutline.js create mode 100644 tutorial/introduction/prerequisites.mdx create mode 100644 tutorial/introduction/welcome.mdx create mode 100644 tutorial/testing-our-module/first-test.mdx create mode 100644 tutorial/testing-our-module/output.mdx create mode 100644 tutorial/testing-our-module/private-functions.mdx create mode 100644 tutorial/testing-our-module/public-functions.mdx create mode 100644 tutorial/testing-our-module/setup.mdx diff --git a/docusaurus.config.js b/docusaurus.config.js index 900d3c50..fc0a1fcb 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -50,6 +50,13 @@ const config = { docId: 'quick-start', position: 'right' }, + { + type: 'docSidebar', + label: 'Tutorial', + docsPluginId: 'tutorial', + sidebarId: 'tutorial', + position: 'right' + }, { type: 'doc', label: 'Commands', @@ -152,6 +159,24 @@ const config = { copyright: `Copyright Pester Team © 2019-present`, }, }), + plugins: [ + [ + '@docusaurus/plugin-content-docs', + /** @type {import('@docusaurus/plugin-content-docs').Options} */ + ({ + // The follow-along tutorial lives in its own docs instance at /tutorial so it stays + // single-sourced. Content in /docs is copied verbatim into versioned_docs/ at every + // version cut, which would freeze and duplicate the tutorial per Pester version. + id: 'tutorial', + path: 'tutorial', + routeBasePath: 'tutorial', + sidebarPath: './sidebarsTutorial.js', + editUrl: 'https://github.com/pester/docs/edit/main', + // Do not set disableVersioning here. It throws for an instance with no versions file. + // Omitting it gives a single implicit version and no version dropdown, which is what we want. + }), + ], + ], presets: [ [ '@docusaurus/preset-classic', diff --git a/sidebarsTutorial.js b/sidebarsTutorial.js new file mode 100644 index 00000000..6b94d040 --- /dev/null +++ b/sidebarsTutorial.js @@ -0,0 +1,33 @@ +/** + * Sidebar for the follow-along tutorial (docs plugin instance "tutorial", served at /tutorial). + * + * Keep this in sync with src/tutorial/tutorialData.js, which drives the progress tracker. + * + * Note: do not use `link: {type: 'generated-index'}` on these categories. Generated index + * pages are not rendered through @theme/DocItem, so they would not show the progress tracker. + * Use a real doc as the category link instead. + */ + +/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ +module.exports = { + tutorial: [ + { + type: 'category', + label: 'Introduction', + collapsed: false, + items: ['introduction/welcome', 'introduction/prerequisites'], + }, + { + type: 'category', + label: 'Testing our module', + collapsed: false, + items: [ + 'testing-our-module/setup', + 'testing-our-module/first-test', + 'testing-our-module/public-functions', + 'testing-our-module/private-functions', + 'testing-our-module/output', + ], + }, + ], +}; diff --git a/src/components/TutorialChecklist/index.js b/src/components/TutorialChecklist/index.js new file mode 100644 index 00000000..d5886653 --- /dev/null +++ b/src/components/TutorialChecklist/index.js @@ -0,0 +1,62 @@ +import React, { useMemo } from 'react'; +import clsx from 'clsx'; +import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import { useTutorialProgress } from '@site/src/tutorial/TutorialProgressContext'; +import styles from './styles.module.css'; + +/** + * End-of-page checklist. Checking every box marks the page complete in the tracker. + * + * Registered globally in src/theme/MDXComponents.js, so tutorial pages use it without an + * import: + * + * + * + * Item ids must be stable strings, never array indices — reordering the list must not + * transfer a checkmark from one statement to another. + */ +export default function TutorialChecklist({ items, title = 'Before you move on' }) { + const { metadata } = useDoc(); + const { pages, setItemChecked } = useTutorialProgress(); + + const itemIds = useMemo(() => items.map((item) => item.id), [items]); + const state = pages[metadata.id]?.items ?? {}; + const done = itemIds.filter((id) => state[id] === true).length; + const complete = itemIds.length > 0 && done === itemIds.length; + + return ( +
+
+

{title}

+ + {done}/{itemIds.length} + +
+ + + + {complete &&

Page complete. Your progress is saved in this browser.

} +
+ ); +} diff --git a/src/components/TutorialChecklist/styles.module.css b/src/components/TutorialChecklist/styles.module.css new file mode 100644 index 00000000..67221844 --- /dev/null +++ b/src/components/TutorialChecklist/styles.module.css @@ -0,0 +1,80 @@ +/** + * End-of-page tutorial checklist. Reuses the --tut-* tokens declared by + * src/components/TutorialTracker/styles.module.css. + */ + +.checklist { + margin-top: 3rem; + padding: 1.25rem 1.5rem 1.5rem; + border: 1px solid var(--tut-border); + border-left: 4px solid var(--ifm-color-primary); + border-radius: 8px; + background: var(--tut-surface); +} + +.checklistComplete { + border-left-color: var(--ifm-color-success); +} + +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +.title { + margin: 0; + font-size: 1.1rem; +} + +.count { + font-size: 0.85rem; + font-weight: 600; + color: var(--tut-muted); + font-variant-numeric: tabular-nums; +} + +.items { + margin: 1rem 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.item { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.4rem 0.5rem; + border-radius: 6px; + cursor: pointer; + line-height: 1.5; +} + +.item:hover { + background: var(--tut-track); +} + +.itemChecked { + color: var(--tut-muted); +} + +.input { + flex: 0 0 auto; + /* Nudge the box onto the first line's optical centre. */ + margin-top: 0.25rem; + width: 1rem; + height: 1rem; + accent-color: var(--ifm-color-primary); + cursor: pointer; +} + +.done { + margin: 1rem 0 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--ifm-color-success); +} diff --git a/src/components/TutorialTracker/index.js b/src/components/TutorialTracker/index.js new file mode 100644 index 00000000..668fde5b --- /dev/null +++ b/src/components/TutorialTracker/index.js @@ -0,0 +1,111 @@ +import React from 'react'; +import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import { getPageIndex } from '@site/src/tutorial/tutorialData'; +import { useTutorialOutline } from '@site/src/tutorial/useTutorialOutline'; +import styles from './styles.module.css'; + +function CheckIcon() { + return ( + + ); +} + +function warnUnknownPage(currentPageId) { + if (process.env.NODE_ENV === 'development' && currentPageId && !getPageIndex().has(currentPageId)) { + console.warn( + `[TutorialTracker] Page "${currentPageId}" is not listed in src/tutorial/tutorialData.js, ` + + 'so its progress will not be tracked. Add it to the matching module.', + ); + } +} + +export default function TutorialTracker({ currentPageId }) { + const { hydrated, modules, currentModule, totalPages, totalDone, percent } = + useTutorialOutline(currentPageId); + + warnUnknownPage(currentPageId); + + return ( + + ); +} diff --git a/src/components/TutorialTracker/styles.module.css b/src/components/TutorialTracker/styles.module.css new file mode 100644 index 00000000..9d6b5415 --- /dev/null +++ b/src/components/TutorialTracker/styles.module.css @@ -0,0 +1,236 @@ +/** + * Tutorial progress tracker. + * + * Follows the --tut-* local custom property idiom used by src/pages/styles.module.css: + * declare a light/dark pair at the bottom, consume with var(). + */ + +.tracker { + margin-bottom: 2rem; + padding: 1rem 1.25rem 1.25rem; + border: 1px solid var(--tut-border); + border-radius: 10px; + background: var(--tut-surface); +} + +/* ---- header */ + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.headerText { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} + +.eyebrow { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--tut-muted); +} + +.count { + font-size: 0.95rem; + font-weight: 600; +} + +.percent { + font-size: 1.35rem; + font-weight: 700; + line-height: 1.2; + color: var(--ifm-color-primary); + font-variant-numeric: tabular-nums; +} + +/* ---- progress bar */ + +.bar { + height: 6px; + margin-top: 0.75rem; + border-radius: 999px; + background: var(--tut-track); + overflow: hidden; +} + +.barFill { + height: 100%; + border-radius: inherit; + background: var(--ifm-color-primary); + /* Animates 0% -> real value once progress loads, so the placeholder reads as intentional. */ + transition: width 400ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .barFill { + transition: none; + } +} + +/* ---- module disclosure */ + +.modules { + margin-top: 1rem; + border-top: 1px solid var(--tut-border); + padding-top: 0.75rem; +} + +.summary { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + cursor: pointer; + font-size: 0.9rem; + list-style-position: outside; +} + +.summaryLabel { + font-weight: 600; +} + +.summaryHint { + font-size: 0.8rem; + color: var(--tut-muted); +} + +.moduleList { + margin: 0.75rem 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.moduleItem { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + font-size: 0.875rem; +} + +.moduleLabel { + color: var(--tut-muted); +} + +.moduleLabelCurrent { + color: inherit; + font-weight: 600; +} + +.moduleCount { + color: var(--tut-muted); + font-variant-numeric: tabular-nums; +} + +.badge { + padding: 0.05rem 0.45rem; + border-radius: 999px; + background: var(--tut-track); + color: var(--tut-muted); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* ---- pages in the current module */ + +.pageList { + margin: 1rem 0 0; + padding: 0; + list-style: none; + counter-reset: tut-page; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.pageLink { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.35rem 0.5rem; + border-radius: 6px; + font-size: 0.9rem; + color: var(--ifm-font-color-base); + text-decoration: none; +} + +.pageLink:hover { + background: var(--tut-track); + color: var(--ifm-font-color-base); + text-decoration: none; +} + +.pageLinkCurrent { + background: var(--tut-current-bg); + font-weight: 600; +} + +.marker { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 18px; + height: 18px; + border: 1.5px solid var(--tut-border-strong); + border-radius: 50%; + color: transparent; +} + +.markerDone { + border-color: var(--ifm-color-primary); + background: var(--ifm-color-primary); + color: #fff; +} + +.icon { + display: block; +} + +.pageTitle { + min-width: 0; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ---- theme tokens */ + +:global(:root) { + --tut-surface: #f8f9fb; + --tut-border: var(--ifm-color-emphasis-200); + --tut-border-strong: var(--ifm-color-emphasis-300); + --tut-track: var(--ifm-color-emphasis-200); + --tut-current-bg: rgba(0, 114, 198, 0.1); + --tut-muted: var(--ifm-color-emphasis-700); +} + +:global([data-theme='dark']) { + --tut-surface: rgba(255, 255, 255, 0.03); + --tut-border: var(--ifm-color-emphasis-300); + --tut-border-strong: var(--ifm-color-emphasis-400); + --tut-track: var(--ifm-color-emphasis-300); + --tut-current-bg: rgba(2, 148, 255, 0.16); + --tut-muted: var(--ifm-color-emphasis-600); +} diff --git a/src/theme/DocItem/Layout/index.js b/src/theme/DocItem/Layout/index.js new file mode 100644 index 00000000..1d77fa69 --- /dev/null +++ b/src/theme/DocItem/Layout/index.js @@ -0,0 +1,38 @@ +import React from 'react'; +import OriginalDocItemLayout from '@theme-original/DocItem/Layout'; +import useRouteContext from '@docusaurus/useRouteContext'; +import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import TutorialTracker from '@site/src/components/TutorialTracker'; +import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData'; + +/** + * Renders the tutorial progress tracker above the content on /tutorial pages, so tutorial + * pages do not each have to import it. + * + * DocItem/Layout is the right injection point: it renders exactly once per doc page and sits + * inside DocProvider, so useDoc() gives us the current page id. + * + * The guard uses the plugin instance id rather than the pathname, which would break under a + * non-root baseUrl or a different trailing-slash setting. + * + * Note: this is an unsafe swizzle. It only prepends a sibling and forwards props unchanged, + * so the blast radius is small, but re-check it when @docusaurus/theme-classic is upgraded. + */ +export default function DocItemLayoutWrapper(props) { + const { plugin } = useRouteContext(); + const { metadata } = useDoc(); + + const isTutorial = + plugin?.name === 'docusaurus-plugin-content-docs' && plugin?.id === TUTORIAL_PLUGIN_ID; + + if (!isTutorial) { + return ; + } + + return ( + <> + + + + ); +} diff --git a/src/theme/MDXComponents.js b/src/theme/MDXComponents.js new file mode 100644 index 00000000..243b4244 --- /dev/null +++ b/src/theme/MDXComponents.js @@ -0,0 +1,11 @@ +import MDXComponents from '@theme-original/MDXComponents'; +import TutorialChecklist from '@site/src/components/TutorialChecklist'; + +/** + * Components registered here are available in every .mdx file without an import. + * TutorialChecklist is used at the end of each tutorial page. + */ +export default { + ...MDXComponents, + TutorialChecklist, +}; diff --git a/src/theme/Root.js b/src/theme/Root.js new file mode 100644 index 00000000..a68af3ab --- /dev/null +++ b/src/theme/Root.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { TutorialProgressProvider } from '@site/src/tutorial/TutorialProgressContext'; + +/** + * Root is a Docusaurus extension point that wraps the entire app, above the router, and is + * never unmounted on client-side navigation. That makes it the right place for the tutorial + * progress provider: both the tracker (rendered by the DocItem/Layout wrapper) and the + * checklist (rendered from MDX) are descendants, so they share state with no extra wiring. + * + * The provider is inert outside /tutorial — it holds state that nothing else reads. + */ +export default function Root({ children }) { + return {children}; +} diff --git a/src/tutorial/TutorialProgressContext.js b/src/tutorial/TutorialProgressContext.js new file mode 100644 index 00000000..894529e1 --- /dev/null +++ b/src/tutorial/TutorialProgressContext.js @@ -0,0 +1,142 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +/** + * Tutorial progress, persisted to localStorage. + * + * The version lives in both the storage key and a `version` field. Bumping the key is the + * escape hatch for a breaking rewrite (old data is orphaned and ignored); the field lets us + * migrate in place for additive changes. + * + * Only per-page checkbox booleans are stored. Module and overall totals are always derived + * from tutorialData at render time, so restructuring the tutorial can never desync progress. + * + * Shape: + * { version: 1, updatedAt: 0, pages: { "": { items: {"": true}, completedAt: null } } } + */ +const STORAGE_KEY = 'pester.tutorial.progress.v1'; +const SCHEMA_VERSION = 1; + +const EMPTY_PAGES = {}; + +const TutorialProgressContext = createContext(null); + +/** Coerce whatever is in storage into a valid `pages` object. Never throws. */ +function migrate(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return EMPTY_PAGES; + } + if (raw.version !== SCHEMA_VERSION) { + // Unknown or older schema. Progress is non-critical, so discard rather than guess. + return EMPTY_PAGES; + } + const { pages } = raw; + if (!pages || typeof pages !== 'object' || Array.isArray(pages)) { + return EMPTY_PAGES; + } + return pages; +} + +function readStorage() { + try { + return migrate(JSON.parse(window.localStorage.getItem(STORAGE_KEY))); + } catch { + // Missing key, invalid JSON, or blocked storage access. + return EMPTY_PAGES; + } +} + +function writeStorage(pages) { + try { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ version: SCHEMA_VERSION, updatedAt: Date.now(), pages }), + ); + } catch { + // Safari private mode throws on write, and quota can be exhausted. In-memory state + // still works for this session, so a failed write must not break the page. + } +} + +export function TutorialProgressProvider({ children }) { + // Starts empty so the first client render matches the prerendered HTML exactly. The + // effect below fills in the real value after mount, which is a normal update, not a + // hydration mismatch. + const [pages, setPages] = useState(EMPTY_PAGES); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setPages(readStorage()); + setHydrated(true); + + // Keep multiple open tabs in sync. + const onStorage = (event) => { + if (event.key === STORAGE_KEY) { + setPages(readStorage()); + } + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + const setItemChecked = useCallback((pageId, itemId, checked, allItemIds) => { + setPages((prev) => { + const page = prev[pageId] ?? { items: {}, completedAt: null }; + const items = { ...page.items, [itemId]: checked }; + const complete = allItemIds.length > 0 && allItemIds.every((id) => items[id] === true); + const next = { + ...prev, + // Preserve the original completedAt so re-checking a box doesn't reset the date. + [pageId]: { items, completedAt: complete ? (page.completedAt ?? Date.now()) : null }, + }; + writeStorage(next); + return next; + }); + }, []); + + const resetPage = useCallback((pageId) => { + setPages((prev) => { + if (!prev[pageId]) { + return prev; + } + const next = { ...prev }; + delete next[pageId]; + writeStorage(next); + return next; + }); + }, []); + + const resetAll = useCallback(() => { + setPages(EMPTY_PAGES); + writeStorage(EMPTY_PAGES); + }, []); + + const value = useMemo( + () => ({ hydrated, pages, setItemChecked, resetPage, resetAll }), + [hydrated, pages, setItemChecked, resetPage, resetAll], + ); + + return {children}; +} + +/** + * Access tutorial progress. Returns a safe inert value when used outside the provider so a + * stray in a non-tutorial page renders instead of crashing the build. + */ +export function useTutorialProgress() { + const context = useContext(TutorialProgressContext); + if (!context) { + return { + hydrated: false, + pages: EMPTY_PAGES, + setItemChecked: () => {}, + resetPage: () => {}, + resetAll: () => {}, + }; + } + return context; +} + +/** True when every checklist item recorded for the page is checked. */ +export function isPageComplete(pages, pageId) { + return Boolean(pages[pageId]?.completedAt); +} diff --git a/src/tutorial/tutorialData.js b/src/tutorial/tutorialData.js new file mode 100644 index 00000000..83af2e78 --- /dev/null +++ b/src/tutorial/tutorialData.js @@ -0,0 +1,66 @@ +/** + * Single source of truth for the tutorial structure. + * + * Every `page.id` MUST match the Docusaurus doc id of the corresponding file in /tutorial + * (which is the path relative to the tutorial folder, without the extension, unless the + * page overrides it with an `id` in its frontmatter). A mismatch silently untracks the + * page — TutorialTracker warns about this in development. + * + * Modules marked `upcoming: true` are rendered in the tracker but are not linkable and + * do not count toward progress. Add their pages here as they are written. + */ + +export const TUTORIAL_PLUGIN_ID = 'tutorial'; +export const TUTORIAL_ROUTE_BASE = '/tutorial'; + +export const modules = [ + { + id: 'introduction', + label: 'Introduction', + pages: [ + { id: 'introduction/welcome', title: 'Welcome' }, + { id: 'introduction/prerequisites', title: 'Prerequisites' }, + ], + }, + { + id: 'testing-our-module', + label: 'Testing our module', + pages: [ + { id: 'testing-our-module/setup', title: 'Creating the sample module' }, + { id: 'testing-our-module/first-test', title: 'Running your first test' }, + { id: 'testing-our-module/public-functions', title: 'Testing public functions' }, + { id: 'testing-our-module/private-functions', title: 'Testing private functions' }, + { id: 'testing-our-module/output', title: 'Output' }, + ], + }, + { id: 'mocking', label: 'Mocking', upcoming: true, pages: [] }, + { id: 'testdrive', label: 'Working with files', upcoming: true, pages: [] }, + { id: 'code-coverage', label: 'Code coverage', upcoming: true, pages: [] }, + { id: 'ci', label: 'Setting up CI', upcoming: true, pages: [] }, +]; + +/** Pages that count toward progress, i.e. everything in a module that is not `upcoming`. */ +export const trackedModules = modules.filter((m) => !m.upcoming); + +let pageIndex; + +/** + * Lazily built `Map` for locating the current page. + * Built once — the data above is static. + */ +export function getPageIndex() { + if (!pageIndex) { + pageIndex = new Map(); + modules.forEach((module, moduleIndex) => { + module.pages.forEach((page, pageIdx) => { + pageIndex.set(page.id, { module, moduleIndex, pageIndex: pageIdx }); + }); + }); + } + return pageIndex; +} + +/** Flat list of every tracked page id, in tutorial order. */ +export function getAllTrackedPageIds() { + return trackedModules.flatMap((m) => m.pages.map((p) => p.id)); +} diff --git a/src/tutorial/useTutorialOutline.js b/src/tutorial/useTutorialOutline.js new file mode 100644 index 00000000..005f4a38 --- /dev/null +++ b/src/tutorial/useTutorialOutline.js @@ -0,0 +1,52 @@ +import { useMemo } from 'react'; +import { useBaseUrlUtils } from '@docusaurus/useBaseUrl'; +import { modules, TUTORIAL_ROUTE_BASE } from './tutorialData'; +import { isPageComplete, useTutorialProgress } from './TutorialProgressContext'; + +/** + * Combines the static tutorial structure with stored progress into everything the tracker + * needs to render. + * + * Totals are derived here rather than persisted, so editing tutorialData.js can never leave + * stored progress inconsistent. Upcoming modules have no pages and are excluded from totals. + */ +export function useTutorialOutline(currentPageId) { + const { hydrated, pages } = useTutorialProgress(); + // useBaseUrlUtils rather than useBaseUrl, since we need to build many URLs in a loop and + // hooks cannot be called inside one. + const { withBaseUrl } = useBaseUrlUtils(); + + return useMemo(() => { + const outline = modules.map((module) => { + const modulePages = module.pages.map((page) => ({ + ...page, + permalink: withBaseUrl(`${TUTORIAL_ROUTE_BASE}/${page.id}`), + done: isPageComplete(pages, page.id), + isCurrent: page.id === currentPageId, + })); + + return { + id: module.id, + label: module.label, + upcoming: Boolean(module.upcoming), + pages: modulePages, + total: modulePages.length, + done: modulePages.filter((p) => p.done).length, + isCurrent: modulePages.some((p) => p.isCurrent), + }; + }); + + const tracked = outline.filter((m) => !m.upcoming); + const totalPages = tracked.reduce((sum, m) => sum + m.total, 0); + const totalDone = tracked.reduce((sum, m) => sum + m.done, 0); + + return { + hydrated, + modules: outline, + currentModule: outline.find((m) => m.isCurrent) ?? null, + totalPages, + totalDone, + percent: totalPages === 0 ? 0 : Math.round((totalDone / totalPages) * 100), + }; + }, [hydrated, pages, currentPageId, withBaseUrl]); +} diff --git a/tutorial/introduction/prerequisites.mdx b/tutorial/introduction/prerequisites.mdx new file mode 100644 index 00000000..4ac1abfe --- /dev/null +++ b/tutorial/introduction/prerequisites.mdx @@ -0,0 +1,79 @@ +--- +id: prerequisites +title: Prerequisites +sidebar_label: Prerequisites +description: Install Pester 6, confirm the version you are actually running and set up an editor before starting the tutorial +--- + +Before building anything, let's make sure the tools are in place. This page is short, but the version check at the end matters more than it looks — a surprising number of confusing Pester problems turn out to be "you are running an older version than you think". + +## PowerShell + +Pester v6 runs on: + +- **Windows PowerShell 5.1**, or +- **PowerShell 7.4 or newer** + +Check what you have: + +```powershell +$PSVersionTable.PSVersion +``` + +If you are on PowerShell 7 but older than 7.4, install a current release from [the PowerShell repository](https://github.com/PowerShell/PowerShell). This tutorial works on Windows, macOS and Linux. + +## Installing Pester + +Install from the PowerShell Gallery: + +```powershell +Install-Module -Name Pester -Force +``` + +On Windows you may already have Pester 3.4.0 installed — it ships with the operating system. That version cannot be updated in place, and it is different enough from v6 that following this tutorial with it will not work. If `Install-Module` complains about an existing installation, the [installation guide](../../docs/introduction/installation) covers the exact commands to get around it. + +## Confirming the version + +This is the step worth doing carefully. Importing the module and asking it what it is beats trusting what you think you installed: + +```powershell +Import-Module Pester -PassThru +``` + +``` +ModuleType Version PreRelease Name +---------- ------- ---------- ---- +Script 6.0.0 Pester +``` + +If the version shown starts with a `6`, you are ready. + +:::warning An old Pester can be loaded without you asking +PowerShell loads a module automatically the first time you call one of its commands, and it picks the highest version it can find — but if an older Pester is *already* loaded in your session, it stays loaded. If the version above is not what you expect, open a fresh PowerShell session and check again before doing anything else. +::: + +## An editor + +Any editor will do, but [Visual Studio Code](https://code.visualstudio.com/) with the [PowerShell extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.PowerShell) gives you the best experience here: tests get a green or red gutter icon next to them, and you can run a single test without leaving the file. + +The [VS Code page](../../docs/usage/vscode) has the setup details. It is genuinely optional — everything in this tutorial is done from the terminal and works the same way regardless of what you type it in. + +## A folder to work in + +Create somewhere to build the module and move into it: + +```powershell +New-Item -Path ./pester-tutorial -ItemType Directory +Set-Location ./pester-tutorial +``` + +Every path in this tutorial is relative to that folder. + + + +That is the setup done. Next you will create the Planetarium module itself. diff --git a/tutorial/introduction/welcome.mdx b/tutorial/introduction/welcome.mdx new file mode 100644 index 00000000..5cd51c6a --- /dev/null +++ b/tutorial/introduction/welcome.mdx @@ -0,0 +1,70 @@ +--- +id: welcome +title: Welcome to the Pester tutorial +sidebar_label: Welcome +description: A follow-along tutorial that takes a sample PowerShell module from no tests at all to a fully tested module with mocks, isolated file operations and code coverage +--- + +Most Pester documentation is written as reference material — you arrive knowing what you want, and you look it up. This tutorial is the other thing. It is a single story, told in order, where you build one real PowerShell module and test it from nothing to done. + +You will type every command yourself. Nothing is hidden behind a "download the finished sample" link. + +## What you will build + +The module is called **Planetarium**. It is small enough to keep in your head and awkward enough to be interesting: + +``` +Planetarium/ +├── Planetarium.psd1 +├── Planetarium.psm1 +├── Public/ +│ ├── Get-Planet.ps1 +│ ├── Get-PlanetDistance.ps1 +│ └── Export-PlanetReport.ps1 +└── Private/ + ├── ConvertTo-AstronomicalUnit.ps1 + └── Test-PlanetName.ps1 +``` + +It has public functions that users call, private helpers that they should not, a function that writes files to disk, and a dependency worth faking. Each of those is a testing problem with its own chapter. + +## How the tutorial is organised + +The tutorial is split into modules, and each module into pages. You are on the first page of the first module now. + +| Module | What it covers | +| --- | --- | +| **Introduction** | What you need installed before you start | +| **Testing our module** | Building Planetarium and testing it end to end | +| Mocking | Replacing dependencies your code calls | +| Working with files | Isolating file operations with `TestDrive` | +| Code coverage | Finding out what your tests never touch | +| Setting up CI | Running the whole thing on every push | + +The first two modules are written. The rest are on their way — you will see them greyed out in the progress panel. + +## Tracking your progress + +Every page in this tutorial opens with a progress panel and ends with a checklist. + +Tick every box in a page's checklist and that page is marked complete. The panel at the top of the page updates immediately and shows you where you are in the module and in the tutorial overall. + +:::note Your progress stays in this browser +Progress is saved to your browser's local storage. Nothing is sent anywhere and there is no account to create — but that also means your progress will not follow you to another browser or another machine, and clearing site data will reset it. +::: + +The checklists are for you, not for us. They are worth taking seriously precisely because nobody is checking: if you cannot honestly tick "I can explain why the test failed", the next page will be harder than it needs to be. + +## What you should already know + +You should be comfortable writing basic PowerShell — functions, parameters, `Get-ChildItem`, piping things around. You do not need to have written a test before, in PowerShell or anywhere else. Every Pester concept is introduced when it first becomes necessary rather than up front. + +If you have used Pester v4 and are coming back, be aware this tutorial teaches Pester v6 and a fair amount has changed. The [migration guide](../../docs/migrations/v5-to-v6) is the faster read for that. + + + +Next up: getting Pester installed and confirming the version you are running. diff --git a/tutorial/testing-our-module/first-test.mdx b/tutorial/testing-our-module/first-test.mdx new file mode 100644 index 00000000..cd159ad6 --- /dev/null +++ b/tutorial/testing-our-module/first-test.mdx @@ -0,0 +1,169 @@ +--- +id: first-test +title: Running your first test +sidebar_label: First test +description: Use New-Fixture to scaffold a function and its test file, watch the test fail, then implement Get-Planet and watch it pass +--- + +You are going to write the test before the function exists. Not out of dogma — it is just the fastest way to see what Pester actually does, because a test that fails for the right reason tells you more than one that passes. + +## Scaffolding with New-Fixture + +Pester ships a command that creates a function and its test file as a matching pair: + +```powershell +New-Fixture -Name Get-Planet -Path ./Planetarium/Public +``` + +``` + Directory: /home/you/pester-tutorial/Planetarium/Public + +UnixMode User Group LastWriteTime Size Name +-------- ---- ----- ------------- ---- ---- +-rw-r--r-- you you 07/19/2026 18:45 94 Get-Planet.ps1 +-rw-r--r-- you you 07/19/2026 18:45 195 Get-Planet.Tests.ps1 +``` + +Two files. Here is the function: + +```powershell title="Planetarium/Public/Get-Planet.ps1" +function Get-Planet { + throw [NotImplementedException]'Get-Planet is not implemented.' +} +``` + +And the test: + +```powershell title="Planetarium/Public/Get-Planet.Tests.ps1" +BeforeAll { + . $PSCommandPath.Replace('.Tests.ps1', '.ps1') +} + +Describe "Get-Planet" { + It "Returns expected output" { + Get-Planet | Should -Be "YOUR_EXPECTED_VALUE" + } +} +``` + +That is the anatomy of every Pester test file: + +- **`BeforeAll`** runs setup before the tests in its block. Here it loads the code under test by dot-sourcing the `.ps1` next to it — `$PSCommandPath` is the test file's own path, and `.Replace('.Tests.ps1', '.ps1')` turns it into the function's path. +- **`Describe`** groups related tests and names the thing being tested. +- **`It`** is a single test. The name should read as a sentence describing behaviour. +- **`Should`** is the assertion. + +:::warning Loading code belongs inside `BeforeAll`, not at the top of the file +Pester reads every test file twice — once to discover what tests exist, then again to run them. Code at the top level of the file runs during *both* passes. Putting your dot-source or `Import-Module` inside `BeforeAll` keeps it in the run pass, where it belongs. [Discovery and run](../../docs/usage/discovery-and-run) explains what this two-phase model buys you. +::: + +## Watching it fail + +Run it: + +```powershell +Invoke-Pester -Path ./Planetarium/Public +``` + +``` +Running tests from 1 files. +[-] Get-Planet.Returns expected output 29ms + NotImplementedException: Get-Planet is not implemented. + at Get-Planet, /home/you/pester-tutorial/Planetarium/Public/Get-Planet.ps1:2 + at , /home/you/pester-tutorial/Planetarium/Public/Get-Planet.Tests.ps1:7 +Tests completed in 333ms +Tests Passed: 0, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +`[-]` marks a failed test. Read the two `at` lines from the bottom up: line 7 of the test file called the function, and line 2 of the function threw. That is the correct failure — the scaffold is wired up properly and the function genuinely does not work yet. + +## Implementing the function + +Replace the generated function with something real: + +```powershell title="Planetarium/Public/Get-Planet.ps1" +function Get-Planet { + [CmdletBinding()] + param ( + [string] $Name = '*' + ) + + $planets = @( + [PSCustomObject] @{ Name = 'Mercury'; Order = 1; DistanceFromSunKm = 57909050 } + [PSCustomObject] @{ Name = 'Venus'; Order = 2; DistanceFromSunKm = 108208000 } + [PSCustomObject] @{ Name = 'Earth'; Order = 3; DistanceFromSunKm = 149598023 } + [PSCustomObject] @{ Name = 'Mars'; Order = 4; DistanceFromSunKm = 227939200 } + [PSCustomObject] @{ Name = 'Jupiter'; Order = 5; DistanceFromSunKm = 778570000 } + [PSCustomObject] @{ Name = 'Saturn'; Order = 6; DistanceFromSunKm = 1433530000 } + [PSCustomObject] @{ Name = 'Uranus'; Order = 7; DistanceFromSunKm = 2872460000 } + [PSCustomObject] @{ Name = 'Neptune'; Order = 8; DistanceFromSunKm = 4495060000 } + ) + + $planets | Where-Object Name -Like $Name +} +``` + +Now the test needs to assert something true. Replace the `It` block: + +```powershell title="Planetarium/Public/Get-Planet.Tests.ps1" +BeforeAll { + . $PSCommandPath.Replace('.Tests.ps1', '.ps1') +} + +Describe 'Get-Planet' { + It 'Returns all eight planets by default' { + (Get-Planet).Count | Should-Be 8 + } +} +``` + +```powershell +Invoke-Pester -Path ./Planetarium/Public +``` + +``` +Running tests from 1 files. +[+] /home/you/pester-tutorial/Planetarium/Public/Get-Planet.Tests.ps1 305ms +Tests completed in 313ms +Tests Passed: 1, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +`[+]` and one passing test. Red to green. + +## A note on the assertion + +You may have noticed the generated test used `Should -Be` and we switched to `Should-Be`. Both are real, both work, and the hyphen is the entire difference in appearance. + +:::note You will see both `Should-Be` and `Should -Be` +`Should-Be` is a command in its own right, added in Pester v6, and is [the recommended way to assert in v6](../../docs/assertions/should-command). `Should -Be` is the older operator style — `Should` with a `-Be` parameter — and it is what nearly all existing Pester code, blog posts and Stack Overflow answers use, including the file `New-Fixture` generates. + +They coexist in v6 and you can mix them freely, even in one file. This tutorial uses the newer `Should-Be` style throughout. +::: + +## Reading a failure + +Failures are the output you will spend the most time with, so break one on purpose. Change the expected count to `9` and run again: + +``` +Running tests from 1 files. +[-] Get-Planet.Returns all eight planets by default 70ms + Expected [int] 9, but got [int] 8. + at (Get-Planet).Count | Should-Be 9, /home/you/pester-tutorial/Planetarium/Public/Get-Planet.Tests.ps1:7 +Tests completed in 355ms +Tests Passed: 0, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +Pester reports the types as well as the values — `[int] 9` rather than just `9`. That detail earns its keep the day you assert `Should-Be 8` against the *string* `'8'` and cannot see why a test comparing two things that look identical is failing. + +Put the `8` back and confirm you are green again before moving on. + + + +One passing test against a dot-sourced file. Next, testing it the way it will actually be used — as a module. diff --git a/tutorial/testing-our-module/output.mdx b/tutorial/testing-our-module/output.mdx new file mode 100644 index 00000000..5b02522e --- /dev/null +++ b/tutorial/testing-our-module/output.mdx @@ -0,0 +1,176 @@ +--- +id: output +title: Output +sidebar_label: Output +description: Control what Pester prints with New-PesterConfiguration, inspect the result object in code and write a test result file for other tools to read +--- + +So far every run has used `Invoke-Pester -Path`, which is fine for one file and limiting for everything else. This page covers the configuration object, the three audiences for Pester's output, and how to get the right one for each. + +## The configuration object + +`New-PesterConfiguration` returns a settings object you fill in and hand to `Invoke-Pester`: + +```powershell +$config = New-PesterConfiguration +$config.Run.Path = './Planetarium' +$config.Output.Verbosity = 'Detailed' + +Invoke-Pester -Configuration $config +``` + +This is how Pester v6 is configured. The v4-era parameters — `-Script`, `-OutputFile`, `-OutputFormat`, `-EnableExit`, `-CodeCoverage` — were removed, and everything they did now lives on this object. `-Path` survives as a convenience for quick runs. + +Discovering what is available is easiest from the object itself, since every setting carries its own description: + +```powershell +$config.Output +``` + +The full list is in [Configuration](../../docs/usage/configuration). + +## Verbosity + +`Output.Verbosity` decides how much you see. The useful values are `None`, `Normal`, `Detailed` and `Diagnostic`. + +`Normal` is the default and reports per file — right for a suite you expect to pass: + +``` +Running tests from 4 files. +[+] /home/you/pester-tutorial/Planetarium/Private/ConvertTo-AstronomicalUnit.Tests.ps1 311ms +[+] /home/you/pester-tutorial/Planetarium/Private/Test-PlanetName.Tests.ps1 45ms +[+] /home/you/pester-tutorial/Planetarium/Public/Get-Planet.Tests.ps1 87ms +[+] /home/you/pester-tutorial/Planetarium/Public/Get-PlanetDistance.Tests.ps1 48ms +Tests completed in 502ms +Tests Passed: 15, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +`Detailed` reports every individual test, grouped by `Describe`: + +``` +Pester v6.0.0 + +Running tests from 4 files. + +Running tests from '/home/you/pester-tutorial/Planetarium/Private/ConvertTo-AstronomicalUnit.Tests.ps1' +Describing ConvertTo-AstronomicalUnit + [+] Is not exported from the module 42ms + [+] Converts one astronomical unit of kilometres to 1 19ms + [+] Rounds to three decimal places 3ms + +Running tests from '/home/you/pester-tutorial/Planetarium/Private/Test-PlanetName.Tests.ps1' +Describing Test-PlanetName + [+] Accepts Mercury 16ms + [+] Accepts Earth 2ms + [+] Accepts Neptune 2ms + [+] Rejects Pluto 2ms +... +Tests completed in 536ms +Tests Passed: 15, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +This is the view that makes your test *names* matter. Read those three `Accepts` lines — that is the `-ForEach` block from the previous page, and `<_>` is why each case is named after its own data instead of appearing three times as `Accepts <_>`. + +Reach for `Diagnostic` when a test is behaving impossibly and you need to see Pester's discovery and mock decisions. It is a firehose; do not start there. + +## The result object + +Printed output is for humans. When you need the numbers in code — a release gate, a summary comment, a custom report — ask for the result object instead: + +```powershell +$config = New-PesterConfiguration +$config.Run.Path = './Planetarium' +$config.Run.PassThru = $true + +$result = Invoke-Pester -Configuration $config + +"Result: $($result.Result)" +"Passed: $($result.PassedCount) Failed: $($result.FailedCount) Total: $($result.TotalCount)" +"Duration: $($result.Duration)" +``` + +``` +Result: Passed +Passed: 15 Failed: 0 Total: 15 +Duration: 00:00:00.4371584 +``` + +`Run.PassThru` is what makes `Invoke-Pester` return anything — without it you get output on screen and nothing you can act on. + +Every test is reachable through `$result.Tests`, which is how you build your own reporting: + +```powershell +$result.Tests | + Where-Object Result -eq 'Failed' | + Select-Object Name, @{ Name = 'Error'; Expression = { $_.ErrorRecord.Exception.Message } } +``` + +[The result object](../../docs/usage/result-object) documents the full structure. + +## Test result files + +The third audience is other tools. CI systems do not read console output — they read a test results file, and use it to render the run and annotate failures. + +```powershell +$config = New-PesterConfiguration +$config.Run.Path = './Planetarium' +$config.TestResult.Enabled = $true +$config.TestResult.OutputPath = './testResults.xml' + +Invoke-Pester -Configuration $config +``` + +```xml title="testResults.xml" + + + +``` + +The default format is `NUnitXml`, which nearly every CI system understands. `JUnitXml` and `NUnit3` are also available via `TestResult.OutputFormat`. See [Test results](../../docs/usage/test-results) for which to pick. + +Add the file to your `.gitignore` — it is a build artifact: + +``` +testResults.xml +``` + +## Putting it together + +A single configuration usually covers a whole project. This is a reasonable one to keep as `build.ps1` at the root of the tutorial folder: + +```powershell title="build.ps1" +$config = New-PesterConfiguration +$config.Run.Path = './Planetarium' +$config.Run.PassThru = $true +$config.Output.Verbosity = 'Detailed' +$config.TestResult.Enabled = $true +$config.TestResult.OutputPath = './testResults.xml' + +$result = Invoke-Pester -Configuration $config + +if ($result.FailedCount -gt 0) { + throw "$($result.FailedCount) test(s) failed." +} +``` + +```powershell +./build.ps1 +``` + +That last block is the part people forget. A failing test does not, on its own, make `Invoke-Pester` fail your build — so a pipeline without an explicit check will happily report success over a suite full of red. Throwing on `FailedCount` is what turns a test run into a gate, and it is exactly what the CI module later in this tutorial builds on. + + + +## What you have built + +Starting from an empty folder, you now have a PowerShell module with a manifest, a loader, public and private functions, and fifteen tests covering all of it — exported behaviour, error cases, wildcard filtering and internal helpers — plus a build script that gates on failures and emits a results file. + +The remaining tutorial modules pick up from exactly here. `Get-PlanetDistance` will get a real external dependency worth mocking, `Export-PlanetReport` will write files that need isolating with `TestDrive`, code coverage will show you the branches these tests never touch, and the CI module will run the whole thing on every push. + +Those pages are being written. In the meantime the reference documentation covers all four: [Mocking](../../docs/usage/mocking), [TestDrive](../../docs/usage/testdrive), [Code coverage](../../docs/usage/code-coverage) and [Test results](../../docs/usage/test-results). diff --git a/tutorial/testing-our-module/private-functions.mdx b/tutorial/testing-our-module/private-functions.mdx new file mode 100644 index 00000000..b2216e28 --- /dev/null +++ b/tutorial/testing-our-module/private-functions.mdx @@ -0,0 +1,130 @@ +--- +id: private-functions +title: Testing private functions +sidebar_label: Private functions +description: Reach non-exported module functions with InModuleScope, and understand why it should be your last resort rather than your default +--- + +`ConvertTo-AstronomicalUnit` and `Test-PlanetName` are not exported. Calling them from a test the ordinary way does not work: + +```powershell +ConvertTo-AstronomicalUnit -Kilometre 149597870.7 +``` + +``` +The term 'ConvertTo-AstronomicalUnit' is not recognized as a name of a cmdlet, function, script file, or executable program. +``` + +That is not a bug — it is the module doing its job. Private functions are private. But sometimes you still want a test pointed directly at one, and Pester gives you a way in. + +## Should you, though? + +Before the mechanics, the honest answer about when to use this. + +Private functions are already tested by your public tests. Every `Get-PlanetDistance` test you wrote on the previous page ran `Test-PlanetName` and `ConvertTo-AstronomicalUnit` too. Testing through the public surface is the better default: it tests the behaviour users depend on, and it lets you restructure your internals freely without rewriting tests. + +Reaching inside earns its keep when a helper has meaningful logic of its own — edge cases, rounding, parsing, boundaries — that would need a contrived public call to reach. `ConvertTo-AstronomicalUnit` qualifies: its rounding behaviour is worth pinning down directly rather than inferring from a distance lookup. + +:::warning Prefer testing through your public functions +The official guidance in [Unit testing within modules](../../docs/usage/modules) is blunt about this: `InModuleScope` "prevents you from properly testing your published functions, does not ensure that your functions are actually published and slows down test discovery by loading the module. Aim to avoid it altogether ... or at least limit it to inside the `It` block." + +That last clause is the practical rule. Keep `InModuleScope` inside individual `It` blocks, never wrapped around your `Describe`. +::: + +## InModuleScope + +`InModuleScope` runs a script block as if it were code inside the module, so everything private is in reach: + +```powershell title="Planetarium/Private/ConvertTo-AstronomicalUnit.Tests.ps1" +BeforeAll { + Import-Module $PSScriptRoot/../Planetarium.psd1 -Force +} + +Describe 'ConvertTo-AstronomicalUnit' { + It 'Is not exported from the module' { + Get-Command -Module Planetarium -Name 'ConvertTo-AstronomicalUnit' -ErrorAction SilentlyContinue | + Should-BeNull + } + + It 'Converts one astronomical unit of kilometres to 1' { + InModuleScope Planetarium { + ConvertTo-AstronomicalUnit -Kilometre 149597870.7 | Should-Be 1 + } + } + + It 'Rounds to three decimal places' { + InModuleScope Planetarium { + ConvertTo-AstronomicalUnit -Kilometre 57909050 | Should-Be 0.387 + } + } +} +``` + +Note the shape: each `InModuleScope` sits *inside* an `It`, wrapping only the code that needs module access. + +The first test is the odd one out, and it is deliberate. It asserts the function is **not** exported, from outside the module scope — an executable statement that this helper is meant to stay private. If someone later moves the file into `Public/`, that test fails and asks them whether they meant to. + +## Passing values in + +A script block handed to `InModuleScope` does not inherit your test's variables. This does not work: + +```powershell +$name = 'Earth' +InModuleScope Planetarium { + Test-PlanetName -Name $name | Should-BeTrue # $name is empty in here +} +``` + +Use `-Parameters` to hand values across the boundary. Combined with `-ForEach`, it makes short work of table-driven cases: + +```powershell title="Planetarium/Private/Test-PlanetName.Tests.ps1" +BeforeAll { + Import-Module $PSScriptRoot/../Planetarium.psd1 -Force +} + +Describe 'Test-PlanetName' { + It 'Accepts <_>' -ForEach @('Mercury', 'Earth', 'Neptune') { + InModuleScope Planetarium -Parameters @{ Name = $_ } { + Test-PlanetName -Name $Name | Should-BeTrue + } + } + + It 'Rejects Pluto' { + InModuleScope Planetarium { + Test-PlanetName -Name 'Pluto' | Should-BeFalse + } + } +} +``` + +`-ForEach` runs the `It` once per item, and `<_>` in the test name is replaced with the current value — so this produces three separately named, separately reported tests rather than one loop that stops at the first failure. [Data driven tests](../../docs/usage/data-driven-tests) goes further with this. + +`-Parameters @{ Name = $_ }` passes the current item in, where it arrives as `$Name`. + +:::tip Things you create inside `InModuleScope` do not persist +Variables and functions defined inside the script block disappear when it ends. If you need one to outlive the block, use the `script:` scope modifier. +::: + +## Running it + +```powershell +Invoke-Pester -Path ./Planetarium +``` + +``` +Tests Passed: 15, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +Fifteen tests: five for `Get-Planet`, three for `Get-PlanetDistance`, three for `ConvertTo-AstronomicalUnit` and four for `Test-PlanetName` — the `-ForEach` block counting as three. + + to generate one test per case'}, + {id: 'fifteen', label: 'My suite reports 15 passing tests'}, +]} /> + +The module is covered. Last page of this chapter: making Pester tell you what you want to know. diff --git a/tutorial/testing-our-module/public-functions.mdx b/tutorial/testing-our-module/public-functions.mdx new file mode 100644 index 00000000..311de1f2 --- /dev/null +++ b/tutorial/testing-our-module/public-functions.mdx @@ -0,0 +1,187 @@ +--- +id: public-functions +title: Testing public functions +sidebar_label: Public functions +description: Switch from dot-sourcing to importing the module so your tests exercise the same public surface your users get, and add a function that depends on private helpers +--- + +The test you have works, but it is testing something slightly different from what your users will run. It dot-sources one `.ps1` file directly. Your users import a module. This page closes that gap. + +## Why dot-sourcing is not enough + +Dot-sourcing `Get-Planet.ps1` loads that one file and nothing else. That means your test suite cannot tell you: + +- whether `Get-Planet` is actually **exported** — a function missing from `FunctionsToExport` passes every dot-sourced test and is invisible to users +- whether the function works when its **private helpers** are loaded alongside it, which is the only way it ever runs in reality + +Importing the module instead fixes both. The test then calls the function through the same front door your users do, and a broken export becomes a test failure instead of a support ticket. + +## Importing the module + +Change the `BeforeAll` in your test file: + +```powershell title="Planetarium/Public/Get-Planet.Tests.ps1" +BeforeAll { + Import-Module $PSScriptRoot/../Planetarium.psd1 -Force +} +``` + +`$PSScriptRoot` is the folder holding the test file, so `../Planetarium.psd1` walks up from `Public/` to the manifest. Import the **manifest**, not the `.psm1` — the manifest is what your users get, and it is the thing that carries `FunctionsToExport`. + +:::tip `-Force` is not optional here +Without it, a module already loaded in your session stays loaded, and you will spend a genuinely upsetting amount of time wondering why your fix changed nothing. +::: + +## Filling out the tests + +With the module imported, write tests for the behaviour that actually matters: + +```powershell title="Planetarium/Public/Get-Planet.Tests.ps1" +BeforeAll { + Import-Module $PSScriptRoot/../Planetarium.psd1 -Force +} + +Describe 'Get-Planet' { + It 'Returns all eight planets by default' { + (Get-Planet).Count | Should-Be 8 + } + + It 'Returns the planets in order from the sun' { + $names = (Get-Planet).Name + $names[0] | Should-Be 'Mercury' + $names[-1] | Should-Be 'Neptune' + } + + It 'Returns a single planet when given an exact name' { + $planet = Get-Planet -Name 'Earth' + $planet.Name | Should-Be 'Earth' + $planet.Order | Should-Be 3 + } + + It 'Supports wildcards' { + (Get-Planet -Name 'M*').Name | Should-BeCollection @('Mercury', 'Mars') + } + + It 'Returns nothing for an unknown planet' { + Get-Planet -Name 'Pluto' | Should-BeNull + } +} +``` + +Three assertions worth calling out: + +`Should-BeCollection` is not the same as `Should-Be`. Pester v6 splits value assertions from collection assertions: `Should-Be` compares one thing to one thing, `Should-BeCollection` compares sizes and then items. Reach for the collection one whenever the actual value is a list. + +`Should-BeNull` covers the "found nothing" case. It is easy to skip this test and easy to regret it — a filter that silently returns everything when it matches nothing is a classic bug. + +Multiple assertions in one `It` are fine. The test stops at the first failure, so keep them related, as they are here. + +Run it: + +```powershell +Invoke-Pester -Path ./Planetarium/Public +``` + +``` +Tests Passed: 5, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +## A function with dependencies + +Now add a function that leans on the private side of the module. First the two helpers: + +```powershell title="Planetarium/Private/ConvertTo-AstronomicalUnit.ps1" +function ConvertTo-AstronomicalUnit { + param ( + [Parameter(Mandatory)] + [double] $Kilometre + ) + + [math]::Round($Kilometre / 149597870.7, 3) +} +``` + +```powershell title="Planetarium/Private/Test-PlanetName.ps1" +function Test-PlanetName { + param ( + [string] $Name + ) + + $known = 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune' + $Name -in $known +} +``` + +Then the public function that uses both: + +```powershell title="Planetarium/Public/Get-PlanetDistance.ps1" +function Get-PlanetDistance { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $Name + ) + + if (-not (Test-PlanetName -Name $Name)) { + throw "Unknown planet '$Name'." + } + + $planet = Get-Planet -Name $Name + ConvertTo-AstronomicalUnit -Kilometre $planet.DistanceFromSunKm +} +``` + +This is where importing the module pays off. `Get-PlanetDistance` calls two functions that live in other files. Dot-sourcing `Get-PlanetDistance.ps1` on its own would fail immediately with "The term 'Test-PlanetName' is not recognized". Importing the module loads everything together, exactly as it runs in production. + +## Testing it + +```powershell title="Planetarium/Public/Get-PlanetDistance.Tests.ps1" +BeforeAll { + Import-Module $PSScriptRoot/../Planetarium.psd1 -Force +} + +Describe 'Get-PlanetDistance' { + It 'Reports Earth as one astronomical unit from the sun' { + Get-PlanetDistance -Name 'Earth' | Should-Be 1 + } + + It 'Reports Mercury as closer to the sun than Earth' { + Get-PlanetDistance -Name 'Mercury' | Should-BeLessThan 1 + } + + It 'Throws for a planet it does not know' { + { Get-PlanetDistance -Name 'Pluto' } | Should-Throw -ExceptionMessage "Unknown planet 'Pluto'." + } +} +``` + +The error-case test is the interesting one. Note the braces: `{ Get-PlanetDistance -Name 'Pluto' }` is a **script block**, not a call. You are handing Pester the code to run, so it can catch what comes out. Without the braces the exception would escape and fail the test before `Should-Throw` ever saw it. + +`-ExceptionMessage` pins down *which* error you expect. Leaving it off would pass on any exception at all, including a typo in the function name — so a test that asserts "it throws" often ends up asserting nothing useful. + +:::warning Do not test a missing mandatory parameter this way +It is tempting to add `{ Get-PlanetDistance } | Should-Throw` to check `-Name` is required. Do not. A missing mandatory parameter makes PowerShell *prompt* rather than throw, and your test run will hang there forever waiting for input that is never coming — which in CI means a build that times out with no useful output. +::: + +Run the whole module: + +```powershell +Invoke-Pester -Path ./Planetarium +``` + +``` +Tests Passed: 8, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +Eight passing tests across two files. For more on module-aware testing, [Unit testing within modules](../../docs/usage/modules) is the reference companion to this page. + + + +Both private helpers are now covered only indirectly, through the public function that happens to call them. Next: testing them head-on. diff --git a/tutorial/testing-our-module/setup.mdx b/tutorial/testing-our-module/setup.mdx new file mode 100644 index 00000000..350bc856 --- /dev/null +++ b/tutorial/testing-our-module/setup.mdx @@ -0,0 +1,104 @@ +--- +id: setup +title: Creating the sample module +sidebar_label: Setup +description: Build the Planetarium module scaffold with a manifest and a loader that dot-sources public and private functions +--- + +Before you can test a module, you need a module. This page builds the empty shell of Planetarium — no functions yet, just the structure that everything else hangs off. It is deliberately the same structure most real PowerShell modules use, because the testing problems in later pages come *from* that structure. + +## The layout + +Create the folders from inside your `pester-tutorial` working folder: + +```powershell +New-Item -Path ./Planetarium/Public -ItemType Directory -Force +New-Item -Path ./Planetarium/Private -ItemType Directory -Force +``` + +That gives you the two folders that matter: + +- **`Public/`** — functions your users call. These get exported. +- **`Private/`** — helpers that only the module itself uses. These do not. + +That split is the whole reason this module is interesting to test. Public functions are easy to reach from a test. Private ones, by design, are not — that is a page of its own later. + +## The loader + +The `.psm1` file is the module's root. Ours does not define any functions itself; it finds the `.ps1` files and dot-sources them. + +```powershell title="Planetarium/Planetarium.psm1" +$public = @(Get-ChildItem -Path $PSScriptRoot/Public/*.ps1 -Exclude *.Tests.ps1 -ErrorAction SilentlyContinue) +$private = @(Get-ChildItem -Path $PSScriptRoot/Private/*.ps1 -Exclude *.Tests.ps1 -ErrorAction SilentlyContinue) + +foreach ($file in @($public + $private)) { + . $file.FullName +} + +Export-ModuleMember -Function @($public.BaseName) +``` + +Two details are worth pausing on. + +`Export-ModuleMember -Function @($public.BaseName)` exports everything in `Public/` and nothing in `Private/`. The folder a file sits in is what decides whether it is part of your public surface — there is no attribute or naming convention doing it. + +`-Exclude *.Tests.ps1` matters because of where we are about to put our tests. Pester's convention is that test files sit next to the code they test, so `Public/` will shortly contain both `Get-Planet.ps1` and `Get-Planet.Tests.ps1`. Without that exclusion the loader would dot-source your test files into the module — and then export them. + +:::warning Test files next to your code need excluding from the loader +If you skip `-Exclude *.Tests.ps1`, your tests get loaded as part of the module. This tends to fail in confusing ways rather than obvious ones, because your `Describe` blocks run at import time. If you would rather keep tests entirely separate, a mirrored `tests/` folder is the other convention Pester supports — see [file placement and naming](../../docs/usage/file-placement-and-naming). +::: + +## The manifest + +The `.psd1` manifest is the module's metadata. Generate it rather than writing it by hand: + +```powershell +New-ModuleManifest -Path ./Planetarium/Planetarium.psd1 ` + -RootModule 'Planetarium.psm1' ` + -ModuleVersion '0.1.0' ` + -Author 'Your Name' ` + -Description 'Explore the solar system.' ` + -PowerShellVersion '5.1' +``` + +Open the generated file and find the `FunctionsToExport` line: + +```powershell title="Planetarium/Planetarium.psd1" +FunctionsToExport = '*' +``` + +This is a second gate in front of `Export-ModuleMember`. A function has to pass *both* to be visible to your users. Leaving it as `'*'` means the manifest waves everything through and the `.psm1` makes the real decision, which is what we want while functions are still being added. + +:::tip When a function you exported is not there +`FunctionsToExport` is the first thing to check. If it is set to an explicit list — or worse, to `@()` — then adding a function to `Public/` will not make it appear, no matter what `Export-ModuleMember` says. +::: + +## Checking it imports + +Nothing is in the module yet, but it should still load cleanly: + +```powershell +Import-Module ./Planetarium/Planetarium.psd1 -Force -PassThru +``` + +``` +ModuleType Version PreRelease Name +---------- ------- ---------- ---- +Script 0.1.0 Planetarium +``` + +An empty module that imports without error is exactly what you want right now. If instead you get an error, it is almost certainly a typo in the `.psm1` path handling — fix it before moving on, because every page from here imports this module. + +:::note Why `-Force` appears on every import in this tutorial +PowerShell will not re-import a module it already has loaded. Without `-Force` you would keep testing the version of the code you imported the first time, and edits would appear to have no effect. This will bite you at least once anyway; now you will know what it is. +::: + + + +The shell is up. Next you will write a test — before there is anything to test. From 2563bc51fa53c2ad8524cb73c70e74c2924e60bd Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:48:02 +0000 Subject: [PATCH 02/27] Update mobile design --- docusaurus.config.js | 3 + src/components/TutorialTracker/index.js | 152 +++++++++--- .../TutorialTracker/styles.module.css | 229 ++++++++++++------ src/theme/DocItem/Layout/index.js | 19 +- src/theme/DocItem/TOC/Desktop/index.js | 25 ++ src/theme/DocItem/TOC/Mobile/index.js | 25 ++ 6 files changed, 335 insertions(+), 118 deletions(-) create mode 100644 src/theme/DocItem/TOC/Desktop/index.js create mode 100644 src/theme/DocItem/TOC/Mobile/index.js diff --git a/docusaurus.config.js b/docusaurus.config.js index fc0a1fcb..2034fe9c 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -172,6 +172,9 @@ const config = { routeBasePath: 'tutorial', sidebarPath: './sidebarsTutorial.js', editUrl: 'https://github.com/pester/docs/edit/main', + // The progress tracker already shows the module, the page and where both sit in the + // tutorial, so breadcrumbs would only repeat it. + breadcrumbs: false, // Do not set disableVersioning here. It throws for an instance with no versions file. // Omitting it gives a single implicit version and no version dropdown, which is what we want. }), diff --git a/src/components/TutorialTracker/index.js b/src/components/TutorialTracker/index.js index 668fde5b..bf176757 100644 --- a/src/components/TutorialTracker/index.js +++ b/src/components/TutorialTracker/index.js @@ -1,18 +1,21 @@ import React from 'react'; import clsx from 'clsx'; import Link from '@docusaurus/Link'; +import TOCItems from '@theme/TOCItems'; +import { useCollapsible, Collapsible } from '@docusaurus/theme-common'; +import { useDoc } from '@docusaurus/plugin-content-docs/client'; import { getPageIndex } from '@site/src/tutorial/tutorialData'; import { useTutorialOutline } from '@site/src/tutorial/useTutorialOutline'; import styles from './styles.module.css'; function CheckIcon() { return ( -
-
- Tutorial progress - {/* Before hydration we have no stored progress, so show a placeholder rather than - a confidently wrong "0 of 12". */} - - {hydrated ? `${totalDone} of ${totalPages} pages complete` : `${totalPages} pages`} - -
- - {hydrated ? `${percent}%` : ''} - -
+ + ); +} -
-
-
+/** Overall progress bar. Shown in both variants, including while the mobile one is collapsed. */ +function ProgressBar({ hydrated, totalDone, totalPages, percent }) { + return ( +
+
+
+ ); +} +/** The navigable part: every module, then the current module's pages and headings. */ +function TrackerNav({ hydrated, modules, currentModule, highlight }) { + return ( + <>
- - {currentModule ? currentModule.label : 'All modules'} - - All modules + {currentModule ? currentModule.label : 'All modules'}
    {modules.map((module) => ( @@ -76,10 +91,10 @@ export default function TutorialTracker({ currentPageId }) { {module.label} {module.upcoming ? ( - Coming soon + Soon ) : ( - {hydrated ? `${module.done}/${module.total}` : `${module.total}`} + {hydrated ? `${module.done}/${module.total}` : module.total} )} @@ -88,7 +103,7 @@ export default function TutorialTracker({ currentPageId }) {
{currentModule && ( -
    +
      {currentModule.pages.map((page) => (
    • {page.title} {page.done && (complete)} + {page.isCurrent && }
    • ))} -
+ )} + + ); +} + +/** + * Tutorial progress tracker. Replaces the table of contents on tutorial pages, with the + * current page's headings nested inline so it serves both jobs. + * + * Desktop renders it in the right-hand column, styled like @theme/TOC. Mobile renders it + * above the content and collapses the navigation behind a toggle, the same way the theme's + * own TOCCollapsible does — the progress bar stays visible either way, since a progress + * tracker that hides your progress is not much of one. + */ +export default function TutorialTracker({ currentPageId, variant = 'desktop' }) { + const outline = useTutorialOutline(currentPageId); + const { hydrated, modules, currentModule, totalPages, totalDone, percent } = outline; + const { collapsed, toggleCollapsed } = useCollapsible({ initialState: true }); + + warnUnknownPage(currentPageId); + + const isDesktop = variant === 'desktop'; + const countLabel = hydrated ? `${totalDone}/${totalPages}` : `${totalPages} pages`; + + if (isDesktop) { + return ( + + ); + } + + return ( + ); } diff --git a/src/components/TutorialTracker/styles.module.css b/src/components/TutorialTracker/styles.module.css index 9d6b5415..dfcb5a7c 100644 --- a/src/components/TutorialTracker/styles.module.css +++ b/src/components/TutorialTracker/styles.module.css @@ -1,60 +1,121 @@ /** * Tutorial progress tracker. * + * Replaces the table of contents in the right-hand column on tutorial pages, so it is sized + * and positioned like @theme/TOC rather than like a banner. + * * Follows the --tut-* local custom property idiom used by src/pages/styles.module.css: * declare a light/dark pair at the bottom, consume with var(). */ .tracker { - margin-bottom: 2rem; - padding: 1rem 1.25rem 1.25rem; - border: 1px solid var(--tut-border); - border-radius: 10px; - background: var(--tut-surface); + font-size: 0.8rem; } -/* ---- header */ +/* Mirrors @theme/TOC so the panel behaves like the table of contents it replaces. */ +.trackerDesktop { + position: sticky; + top: calc(var(--ifm-navbar-height) + 1rem); + max-height: calc(100vh - (var(--ifm-navbar-height) + 2rem)); + overflow-y: auto; + /* Right padding keeps the module counts off the column edge and clear of the scrollbar. */ + padding: 0 0.5rem 0 1rem; + border-left: 1px solid var(--tut-border); +} -.header { +@media (max-width: 996px) { + .trackerDesktop { + display: none; + } +} + +/* The mobile variant sits above the content, where the collapsible TOC normally goes, and + matches @theme/TOCCollapsible so it reads as part of the theme. */ +.trackerMobile { + margin: 1rem 0 1.5rem; + padding: 0.4rem 0.8rem 0.6rem; + border-radius: var(--ifm-global-radius); + background-color: var(--ifm-menu-color-background-active); +} + +.toggle { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.3rem 0; + font-size: inherit; + text-align: left; +} + +/* Chevron, using the same token as the theme's own collapsible controls. */ +.toggle::after { + content: ''; + flex: 0 0 auto; + height: 1.25rem; + width: 1.25rem; + background: var(--ifm-menu-link-sublist-icon) 50% 50% / 2rem 2rem no-repeat; + filter: var(--ifm-menu-link-sublist-icon-filter); + transform: rotate(180deg); + transition: transform var(--ifm-transition-fast); +} + +.toggleExpanded::after { + transform: none; +} + +/* The count sits next to the chevron rather than pinned to the far edge. */ +.toggle .count { + margin-left: auto; +} + +.collapsibleContent { + border-top: 1px solid var(--ifm-color-emphasis-300); + margin-top: 0.6rem; +} + +@media (min-width: 997px) { + /* Prevent a hydration flash — the mobile tracker is server-rendered like the theme's own. */ + .trackerMobile { + display: none; + } } -.headerText { +@media print { + .tracker { + display: none; + } +} + +/* ---- header */ + +.header { display: flex; - flex-direction: column; - gap: 0.1rem; - min-width: 0; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; } .eyebrow { - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.06em; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.07em; text-transform: uppercase; color: var(--tut-muted); } .count { - font-size: 0.95rem; + font-size: 0.75rem; font-weight: 600; -} - -.percent { - font-size: 1.35rem; - font-weight: 700; - line-height: 1.2; - color: var(--ifm-color-primary); + color: var(--tut-muted); font-variant-numeric: tabular-nums; } /* ---- progress bar */ .bar { - height: 6px; - margin-top: 0.75rem; + height: 4px; + margin-top: 0.4rem; border-radius: 999px; background: var(--tut-track); overflow: hidden; @@ -77,45 +138,36 @@ /* ---- module disclosure */ .modules { - margin-top: 1rem; - border-top: 1px solid var(--tut-border); - padding-top: 0.75rem; + margin-top: 0.75rem; } .summary { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.75rem; cursor: pointer; - font-size: 0.9rem; - list-style-position: outside; -} - -.summaryLabel { - font-weight: 600; + font-weight: 700; + font-size: 0.85rem; + padding: 0.15rem 0; } -.summaryHint { - font-size: 0.8rem; +.summary::marker { color: var(--tut-muted); } .moduleList { - margin: 0.75rem 0 0; - padding: 0; + margin: 0.4rem 0 0; + padding: 0.4rem 0 0.1rem 0.75rem; + border-left: 1px solid var(--tut-border); list-style: none; display: flex; flex-direction: column; - gap: 0.35rem; + gap: 0.3rem; } .moduleItem { display: flex; align-items: baseline; justify-content: space-between; - gap: 0.75rem; - font-size: 0.875rem; + gap: 0.5rem; + font-size: 0.75rem; } .moduleLabel { @@ -133,11 +185,8 @@ } .badge { - padding: 0.05rem 0.45rem; - border-radius: 999px; - background: var(--tut-track); color: var(--tut-muted); - font-size: 0.7rem; + font-size: 0.65rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; @@ -146,34 +195,27 @@ /* ---- pages in the current module */ .pageList { - margin: 1rem 0 0; + margin: 0.6rem 0 0; padding: 0; list-style: none; - counter-reset: tut-page; - display: flex; - flex-direction: column; - gap: 0.15rem; } .pageLink { display: flex; - align-items: center; - gap: 0.6rem; - padding: 0.35rem 0.5rem; - border-radius: 6px; - font-size: 0.9rem; - color: var(--ifm-font-color-base); - text-decoration: none; + align-items: baseline; + gap: 0.5rem; + padding: 0.25rem 0; + color: var(--ifm-menu-color); + line-height: 1.4; } .pageLink:hover { - background: var(--tut-track); - color: var(--ifm-font-color-base); + color: var(--ifm-color-primary); text-decoration: none; } .pageLinkCurrent { - background: var(--tut-current-bg); + color: var(--ifm-color-primary); font-weight: 600; } @@ -182,8 +224,11 @@ align-items: center; justify-content: center; flex: 0 0 auto; - width: 18px; - height: 18px; + /* Baseline-aligned with the first line of the title rather than centred on the block. */ + align-self: flex-start; + margin-top: 0.15rem; + width: 14px; + height: 14px; border: 1.5px solid var(--tut-border-strong); border-radius: 50%; color: transparent; @@ -203,6 +248,50 @@ min-width: 0; } +/* ---- headings of the current page, nested under it */ + +.headings { + margin: 0.15rem 0 0.35rem; + /* Indent to line up with the page title, past the completion marker. */ + padding-left: 1.6rem; + list-style: none; + font-size: 0.78rem; +} + +.headings ul { + margin: 0; + padding-left: 0.75rem; + list-style: none; +} + +.headings li { + margin: 0; +} + +.headings :global(.table-of-contents__link) { + display: block; + padding: 0.15rem 0; + color: var(--tut-muted); +} + +.headings :global(.table-of-contents__link:hover), +.headings :global(.table-of-contents__link--active) { + color: var(--ifm-color-primary); + text-decoration: none; +} + +/* Mobile variant: no active-heading tracking, so style the plain link class instead. */ +.headingLink { + display: block; + padding: 0.15rem 0; + color: var(--tut-muted); +} + +.headingLink:hover { + color: var(--ifm-color-primary); + text-decoration: none; +} + .srOnly { position: absolute; width: 1px; @@ -220,9 +309,8 @@ :global(:root) { --tut-surface: #f8f9fb; --tut-border: var(--ifm-color-emphasis-200); - --tut-border-strong: var(--ifm-color-emphasis-300); + --tut-border-strong: var(--ifm-color-emphasis-400); --tut-track: var(--ifm-color-emphasis-200); - --tut-current-bg: rgba(0, 114, 198, 0.1); --tut-muted: var(--ifm-color-emphasis-700); } @@ -231,6 +319,5 @@ --tut-border: var(--ifm-color-emphasis-300); --tut-border-strong: var(--ifm-color-emphasis-400); --tut-track: var(--ifm-color-emphasis-300); - --tut-current-bg: rgba(2, 148, 255, 0.16); --tut-muted: var(--ifm-color-emphasis-600); } diff --git a/src/theme/DocItem/Layout/index.js b/src/theme/DocItem/Layout/index.js index 1d77fa69..c5a7ed8b 100644 --- a/src/theme/DocItem/Layout/index.js +++ b/src/theme/DocItem/Layout/index.js @@ -6,32 +6,33 @@ import TutorialTracker from '@site/src/components/TutorialTracker'; import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData'; /** - * Renders the tutorial progress tracker above the content on /tutorial pages, so tutorial - * pages do not each have to import it. + * Fallback so a tutorial page always shows the progress tracker. * - * DocItem/Layout is the right injection point: it renders exactly once per doc page and sits - * inside DocProvider, so useDoc() gives us the current page id. + * The tracker normally replaces the table of contents (see the DocItem/TOC wrappers), but + * DocItem/Layout only renders either TOC when the page has headings and does not set + * `hide_table_of_contents`. Without this, a heading-less tutorial page would silently lose + * the tracker altogether. In that case only, render it above the content instead. * - * The guard uses the plugin instance id rather than the pathname, which would break under a - * non-root baseUrl or a different trailing-slash setting. + * Every tutorial page currently has headings, so this path is not normally taken. * * Note: this is an unsafe swizzle. It only prepends a sibling and forwards props unchanged, * so the blast radius is small, but re-check it when @docusaurus/theme-classic is upgraded. */ export default function DocItemLayoutWrapper(props) { const { plugin } = useRouteContext(); - const { metadata } = useDoc(); + const { metadata, frontMatter, toc } = useDoc(); const isTutorial = plugin?.name === 'docusaurus-plugin-content-docs' && plugin?.id === TUTORIAL_PLUGIN_ID; + const tocWillRender = !frontMatter.hide_table_of_contents && toc.length > 0; - if (!isTutorial) { + if (!isTutorial || tocWillRender) { return ; } return ( <> - + ); diff --git a/src/theme/DocItem/TOC/Desktop/index.js b/src/theme/DocItem/TOC/Desktop/index.js new file mode 100644 index 00000000..7e439cfc --- /dev/null +++ b/src/theme/DocItem/TOC/Desktop/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +import OriginalDocItemTOCDesktop from '@theme-original/DocItem/TOC/Desktop'; +import useRouteContext from '@docusaurus/useRouteContext'; +import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import TutorialTracker from '@site/src/components/TutorialTracker'; +import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData'; + +/** + * On tutorial pages the progress tracker takes the place of the table of contents in the + * right-hand column. The current page's headings are nested inside the tracker, so nothing + * is lost — see src/components/TutorialTracker. + */ +export default function DocItemTOCDesktopWrapper(props) { + const { plugin } = useRouteContext(); + const { metadata } = useDoc(); + + const isTutorial = + plugin?.name === 'docusaurus-plugin-content-docs' && plugin?.id === TUTORIAL_PLUGIN_ID; + + if (!isTutorial) { + return ; + } + + return ; +} diff --git a/src/theme/DocItem/TOC/Mobile/index.js b/src/theme/DocItem/TOC/Mobile/index.js new file mode 100644 index 00000000..3d54142f --- /dev/null +++ b/src/theme/DocItem/TOC/Mobile/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +import OriginalDocItemTOCMobile from '@theme-original/DocItem/TOC/Mobile'; +import useRouteContext from '@docusaurus/useRouteContext'; +import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import TutorialTracker from '@site/src/components/TutorialTracker'; +import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData'; + +/** + * Mobile counterpart of the desktop TOC replacement. Both variants are always in the DOM — + * CSS decides which is visible — so the mobile one renders its headings without active-link + * tracking to avoid two components competing over the same highlight classes. + */ +export default function DocItemTOCMobileWrapper(props) { + const { plugin } = useRouteContext(); + const { metadata } = useDoc(); + + const isTutorial = + plugin?.name === 'docusaurus-plugin-content-docs' && plugin?.id === TUTORIAL_PLUGIN_ID; + + if (!isTutorial) { + return ; + } + + return ; +} From 98f1f3c8e535607005f5f8a0e775bc7c89a2f498 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:03:10 +0000 Subject: [PATCH 03/27] Make modules list clickable links in tracker --- src/components/TutorialTracker/index.js | 17 ++++++++++++++--- .../TutorialTracker/styles.module.css | 5 +++++ src/tutorial/useTutorialOutline.js | 2 ++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/components/TutorialTracker/index.js b/src/components/TutorialTracker/index.js index bf176757..87a6ad4b 100644 --- a/src/components/TutorialTracker/index.js +++ b/src/components/TutorialTracker/index.js @@ -87,9 +87,20 @@ function TrackerNav({ hydrated, modules, currentModule, highlight }) {
    {modules.map((module) => (
  • - - {module.label} - + {/* Linking the label to page one makes jumping between modules a single tap, + which matters most on mobile where the list is behind a toggle. */} + {module.permalink ? ( + + {module.label} + + ) : ( + + {module.label} + + )} {module.upcoming ? ( Soon ) : ( diff --git a/src/components/TutorialTracker/styles.module.css b/src/components/TutorialTracker/styles.module.css index dfcb5a7c..31109c73 100644 --- a/src/components/TutorialTracker/styles.module.css +++ b/src/components/TutorialTracker/styles.module.css @@ -174,6 +174,11 @@ color: var(--tut-muted); } +.moduleLink:hover { + color: var(--ifm-color-primary); + text-decoration: none; +} + .moduleLabelCurrent { color: inherit; font-weight: 600; diff --git a/src/tutorial/useTutorialOutline.js b/src/tutorial/useTutorialOutline.js index 005f4a38..bddaf6c0 100644 --- a/src/tutorial/useTutorialOutline.js +++ b/src/tutorial/useTutorialOutline.js @@ -29,6 +29,8 @@ export function useTutorialOutline(currentPageId) { id: module.id, label: module.label, upcoming: Boolean(module.upcoming), + // Entry point for the module, so its label can be a link straight to page one. + permalink: modulePages[0]?.permalink, pages: modulePages, total: modulePages.length, done: modulePages.filter((p) => p.done).length, From af128b27edfa2822dc335d2cd39fac03a995d192 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:26:26 +0000 Subject: [PATCH 04/27] Refactor to be dynamic and easier to maintain --- sidebarsTutorial.js | 31 +++---- src/components/TutorialTracker/index.js | 16 ++-- src/tutorial/tutorialData.js | 74 ++++----------- src/tutorial/useTutorialOutline.js | 89 ++++++++++++------- .../1-welcome.mdx} | 3 +- .../2-prerequisites.mdx} | 1 - tutorial/1-introduction/_category_.json | 4 + .../1-setup.mdx} | 1 - .../2-first-test.mdx} | 1 - .../3-public-functions.mdx} | 1 - .../4-private-functions.mdx} | 1 - .../5-output.mdx} | 1 - tutorial/2-testing-our-module/_category_.json | 4 + 13 files changed, 102 insertions(+), 125 deletions(-) rename tutorial/{introduction/welcome.mdx => 1-introduction/1-welcome.mdx} (98%) rename tutorial/{introduction/prerequisites.mdx => 1-introduction/2-prerequisites.mdx} (99%) create mode 100644 tutorial/1-introduction/_category_.json rename tutorial/{testing-our-module/setup.mdx => 2-testing-our-module/1-setup.mdx} (99%) rename tutorial/{testing-our-module/first-test.mdx => 2-testing-our-module/2-first-test.mdx} (99%) rename tutorial/{testing-our-module/public-functions.mdx => 2-testing-our-module/3-public-functions.mdx} (99%) rename tutorial/{testing-our-module/private-functions.mdx => 2-testing-our-module/4-private-functions.mdx} (99%) rename tutorial/{testing-our-module/output.mdx => 2-testing-our-module/5-output.mdx} (99%) create mode 100644 tutorial/2-testing-our-module/_category_.json diff --git a/sidebarsTutorial.js b/sidebarsTutorial.js index 6b94d040..255374c4 100644 --- a/sidebarsTutorial.js +++ b/sidebarsTutorial.js @@ -1,33 +1,24 @@ /** * Sidebar for the follow-along tutorial (docs plugin instance "tutorial", served at /tutorial). * - * Keep this in sync with src/tutorial/tutorialData.js, which drives the progress tracker. + * Fully autogenerated from the folder structure. Ordering comes from the number prefixes on + * folders and files (`2-testing-our-module/1-setup.mdx`), which Docusaurus strips from both + * the doc id and the URL — so the order lives next to the content and adding a page needs no + * edit here. Module labels come from each folder's _category_.json. * - * Note: do not use `link: {type: 'generated-index'}` on these categories. Generated index - * pages are not rendered through @theme/DocItem, so they would not show the progress tracker. - * Use a real doc as the category link instead. + * The progress tracker reads this same generated sidebar at runtime, so it stays in sync + * automatically. See src/tutorial/useTutorialOutline.js. + * + * Note: do not give these categories a `link: {type: 'generated-index'}`. Generated index + * pages are not rendered through @theme/DocItem, so they would not show the tracker. */ /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ module.exports = { tutorial: [ { - type: 'category', - label: 'Introduction', - collapsed: false, - items: ['introduction/welcome', 'introduction/prerequisites'], - }, - { - type: 'category', - label: 'Testing our module', - collapsed: false, - items: [ - 'testing-our-module/setup', - 'testing-our-module/first-test', - 'testing-our-module/public-functions', - 'testing-our-module/private-functions', - 'testing-our-module/output', - ], + type: 'autogenerated', + dirName: '.', }, ], }; diff --git a/src/components/TutorialTracker/index.js b/src/components/TutorialTracker/index.js index 87a6ad4b..5f395cfc 100644 --- a/src/components/TutorialTracker/index.js +++ b/src/components/TutorialTracker/index.js @@ -4,7 +4,6 @@ import Link from '@docusaurus/Link'; import TOCItems from '@theme/TOCItems'; import { useCollapsible, Collapsible } from '@docusaurus/theme-common'; import { useDoc } from '@docusaurus/plugin-content-docs/client'; -import { getPageIndex } from '@site/src/tutorial/tutorialData'; import { useTutorialOutline } from '@site/src/tutorial/useTutorialOutline'; import styles from './styles.module.css'; @@ -23,11 +22,16 @@ function CheckIcon() { ); } -function warnUnknownPage(currentPageId) { - if (process.env.NODE_ENV === 'development' && currentPageId && !getPageIndex().has(currentPageId)) { +function warnUnknownPage(currentPageId, modules) { + if (process.env.NODE_ENV !== 'development' || !currentPageId) { + return; + } + const known = modules.some((module) => module.pages.some((page) => page.id === currentPageId)); + if (!known) { console.warn( - `[TutorialTracker] Page "${currentPageId}" is not listed in src/tutorial/tutorialData.js, ` + - 'so its progress will not be tracked. Add it to the matching module.', + `[TutorialTracker] Page "${currentPageId}" is not in the tutorial sidebar, so its ` + + 'progress will not be tracked. Check that it sits inside a module folder and is not ' + + 'excluded from the autogenerated sidebar.', ); } } @@ -151,7 +155,7 @@ export default function TutorialTracker({ currentPageId, variant = 'desktop' }) const { hydrated, modules, currentModule, totalPages, totalDone, percent } = outline; const { collapsed, toggleCollapsed } = useCollapsible({ initialState: true }); - warnUnknownPage(currentPageId); + warnUnknownPage(currentPageId, modules); const isDesktop = variant === 'desktop'; const countLabel = hydrated ? `${totalDone}/${totalPages}` : `${totalPages} pages`; diff --git a/src/tutorial/tutorialData.js b/src/tutorial/tutorialData.js index 83af2e78..e92aae8c 100644 --- a/src/tutorial/tutorialData.js +++ b/src/tutorial/tutorialData.js @@ -1,66 +1,26 @@ /** - * Single source of truth for the tutorial structure. + * The tutorial structure is NOT declared here. * - * Every `page.id` MUST match the Docusaurus doc id of the corresponding file in /tutorial - * (which is the path relative to the tutorial folder, without the extension, unless the - * page overrides it with an `id` in its frontmatter). A mismatch silently untracks the - * page — TutorialTracker warns about this in development. + * Modules and pages are derived at runtime from the generated sidebar — categories become + * modules, doc links become pages, and their labels are the page titles. See + * src/tutorial/useTutorialOutline.js. That keeps the sidebar the single source of truth, so + * adding a page means adding a file and nothing else. * - * Modules marked `upcoming: true` are rendered in the tracker but are not linkable and - * do not count toward progress. Add their pages here as they are written. + * Only what the sidebar cannot express lives here. */ export const TUTORIAL_PLUGIN_ID = 'tutorial'; -export const TUTORIAL_ROUTE_BASE = '/tutorial'; - -export const modules = [ - { - id: 'introduction', - label: 'Introduction', - pages: [ - { id: 'introduction/welcome', title: 'Welcome' }, - { id: 'introduction/prerequisites', title: 'Prerequisites' }, - ], - }, - { - id: 'testing-our-module', - label: 'Testing our module', - pages: [ - { id: 'testing-our-module/setup', title: 'Creating the sample module' }, - { id: 'testing-our-module/first-test', title: 'Running your first test' }, - { id: 'testing-our-module/public-functions', title: 'Testing public functions' }, - { id: 'testing-our-module/private-functions', title: 'Testing private functions' }, - { id: 'testing-our-module/output', title: 'Output' }, - ], - }, - { id: 'mocking', label: 'Mocking', upcoming: true, pages: [] }, - { id: 'testdrive', label: 'Working with files', upcoming: true, pages: [] }, - { id: 'code-coverage', label: 'Code coverage', upcoming: true, pages: [] }, - { id: 'ci', label: 'Setting up CI', upcoming: true, pages: [] }, -]; - -/** Pages that count toward progress, i.e. everything in a module that is not `upcoming`. */ -export const trackedModules = modules.filter((m) => !m.upcoming); - -let pageIndex; /** - * Lazily built `Map` for locating the current page. - * Built once — the data above is static. + * Modules that are planned but not written yet. + * + * These have no docs, so they cannot come from the sidebar. They are shown in the tracker, + * greyed out and not linkable, so the shape of the whole tutorial is visible up front. Delete + * an entry when its module is written — the sidebar will pick it up automatically. */ -export function getPageIndex() { - if (!pageIndex) { - pageIndex = new Map(); - modules.forEach((module, moduleIndex) => { - module.pages.forEach((page, pageIdx) => { - pageIndex.set(page.id, { module, moduleIndex, pageIndex: pageIdx }); - }); - }); - } - return pageIndex; -} - -/** Flat list of every tracked page id, in tutorial order. */ -export function getAllTrackedPageIds() { - return trackedModules.flatMap((m) => m.pages.map((p) => p.id)); -} +export const upcomingModules = [ + { id: 'mocking', label: 'Mocking' }, + { id: 'testdrive', label: 'Working with files' }, + { id: 'code-coverage', label: 'Code coverage' }, + { id: 'ci', label: 'Setting up CI' }, +]; diff --git a/src/tutorial/useTutorialOutline.js b/src/tutorial/useTutorialOutline.js index bddaf6c0..678eb176 100644 --- a/src/tutorial/useTutorialOutline.js +++ b/src/tutorial/useTutorialOutline.js @@ -1,54 +1,75 @@ import { useMemo } from 'react'; -import { useBaseUrlUtils } from '@docusaurus/useBaseUrl'; -import { modules, TUTORIAL_ROUTE_BASE } from './tutorialData'; +import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; +import { upcomingModules } from './tutorialData'; import { isPageComplete, useTutorialProgress } from './TutorialProgressContext'; /** - * Combines the static tutorial structure with stored progress into everything the tracker - * needs to render. + * Derives the tracker's view of the tutorial from the generated sidebar plus stored progress. * - * Totals are derived here rather than persisted, so editing tutorialData.js can never leave - * stored progress inconsistent. Upcoming modules have no pages and are excluded from totals. + * Top-level sidebar categories are modules and their doc links are pages. Because the sidebar + * is autogenerated from the folder structure, adding a page to the tutorial needs no change + * here, and a page can never be missing from the tracker. + * + * Sidebar item labels are the page titles: the tutorial pages deliberately set no + * `sidebar_label`, and Docusaurus falls back to `title`. + * + * Totals are derived, never persisted, so restructuring the tutorial cannot leave stored + * progress inconsistent. */ export function useTutorialOutline(currentPageId) { const { hydrated, pages } = useTutorialProgress(); - // useBaseUrlUtils rather than useBaseUrl, since we need to build many URLs in a loop and - // hooks cannot be called inside one. - const { withBaseUrl } = useBaseUrlUtils(); + const sidebar = useDocsSidebar(); return useMemo(() => { - const outline = modules.map((module) => { - const modulePages = module.pages.map((page) => ({ - ...page, - permalink: withBaseUrl(`${TUTORIAL_ROUTE_BASE}/${page.id}`), - done: isPageComplete(pages, page.id), - isCurrent: page.id === currentPageId, - })); + const written = (sidebar?.items ?? []) + .filter((item) => item.type === 'category') + .map((category) => { + const modulePages = category.items + .filter((item) => item.type === 'link' && item.docId) + .map((item) => ({ + id: item.docId, + title: item.label, + // Sidebar hrefs already include baseUrl, so they are used as-is. + permalink: item.href, + done: isPageComplete(pages, item.docId), + isCurrent: item.docId === currentPageId, + })); + + return { + // First segment of a page id is the module folder — stable, unlike the label. + id: modulePages[0]?.id.split('/')[0] ?? category.label, + label: category.label, + upcoming: false, + permalink: modulePages[0]?.permalink, + pages: modulePages, + total: modulePages.length, + done: modulePages.filter((p) => p.done).length, + isCurrent: modulePages.some((p) => p.isCurrent), + }; + }) + // A module with no pages would be an empty category; nothing to show. + .filter((module) => module.total > 0); - return { - id: module.id, - label: module.label, - upcoming: Boolean(module.upcoming), - // Entry point for the module, so its label can be a link straight to page one. - permalink: modulePages[0]?.permalink, - pages: modulePages, - total: modulePages.length, - done: modulePages.filter((p) => p.done).length, - isCurrent: modulePages.some((p) => p.isCurrent), - }; - }); + const planned = upcomingModules.map((module) => ({ + ...module, + upcoming: true, + permalink: undefined, + pages: [], + total: 0, + done: 0, + isCurrent: false, + })); - const tracked = outline.filter((m) => !m.upcoming); - const totalPages = tracked.reduce((sum, m) => sum + m.total, 0); - const totalDone = tracked.reduce((sum, m) => sum + m.done, 0); + const totalPages = written.reduce((sum, m) => sum + m.total, 0); + const totalDone = written.reduce((sum, m) => sum + m.done, 0); return { hydrated, - modules: outline, - currentModule: outline.find((m) => m.isCurrent) ?? null, + modules: [...written, ...planned], + currentModule: written.find((m) => m.isCurrent) ?? null, totalPages, totalDone, percent: totalPages === 0 ? 0 : Math.round((totalDone / totalPages) * 100), }; - }, [hydrated, pages, currentPageId, withBaseUrl]); + }, [hydrated, pages, currentPageId, sidebar]); } diff --git a/tutorial/introduction/welcome.mdx b/tutorial/1-introduction/1-welcome.mdx similarity index 98% rename from tutorial/introduction/welcome.mdx rename to tutorial/1-introduction/1-welcome.mdx index 5cd51c6a..240bd2ee 100644 --- a/tutorial/introduction/welcome.mdx +++ b/tutorial/1-introduction/1-welcome.mdx @@ -1,7 +1,6 @@ --- id: welcome -title: Welcome to the Pester tutorial -sidebar_label: Welcome +title: Welcome description: A follow-along tutorial that takes a sample PowerShell module from no tests at all to a fully tested module with mocks, isolated file operations and code coverage --- diff --git a/tutorial/introduction/prerequisites.mdx b/tutorial/1-introduction/2-prerequisites.mdx similarity index 99% rename from tutorial/introduction/prerequisites.mdx rename to tutorial/1-introduction/2-prerequisites.mdx index 4ac1abfe..4c097ac3 100644 --- a/tutorial/introduction/prerequisites.mdx +++ b/tutorial/1-introduction/2-prerequisites.mdx @@ -1,7 +1,6 @@ --- id: prerequisites title: Prerequisites -sidebar_label: Prerequisites description: Install Pester 6, confirm the version you are actually running and set up an editor before starting the tutorial --- diff --git a/tutorial/1-introduction/_category_.json b/tutorial/1-introduction/_category_.json new file mode 100644 index 00000000..16a97d6e --- /dev/null +++ b/tutorial/1-introduction/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Introduction", + "collapsed": false +} diff --git a/tutorial/testing-our-module/setup.mdx b/tutorial/2-testing-our-module/1-setup.mdx similarity index 99% rename from tutorial/testing-our-module/setup.mdx rename to tutorial/2-testing-our-module/1-setup.mdx index 350bc856..ef3cb936 100644 --- a/tutorial/testing-our-module/setup.mdx +++ b/tutorial/2-testing-our-module/1-setup.mdx @@ -1,7 +1,6 @@ --- id: setup title: Creating the sample module -sidebar_label: Setup description: Build the Planetarium module scaffold with a manifest and a loader that dot-sources public and private functions --- diff --git a/tutorial/testing-our-module/first-test.mdx b/tutorial/2-testing-our-module/2-first-test.mdx similarity index 99% rename from tutorial/testing-our-module/first-test.mdx rename to tutorial/2-testing-our-module/2-first-test.mdx index cd159ad6..32909530 100644 --- a/tutorial/testing-our-module/first-test.mdx +++ b/tutorial/2-testing-our-module/2-first-test.mdx @@ -1,7 +1,6 @@ --- id: first-test title: Running your first test -sidebar_label: First test description: Use New-Fixture to scaffold a function and its test file, watch the test fail, then implement Get-Planet and watch it pass --- diff --git a/tutorial/testing-our-module/public-functions.mdx b/tutorial/2-testing-our-module/3-public-functions.mdx similarity index 99% rename from tutorial/testing-our-module/public-functions.mdx rename to tutorial/2-testing-our-module/3-public-functions.mdx index 311de1f2..3d47236b 100644 --- a/tutorial/testing-our-module/public-functions.mdx +++ b/tutorial/2-testing-our-module/3-public-functions.mdx @@ -1,7 +1,6 @@ --- id: public-functions title: Testing public functions -sidebar_label: Public functions description: Switch from dot-sourcing to importing the module so your tests exercise the same public surface your users get, and add a function that depends on private helpers --- diff --git a/tutorial/testing-our-module/private-functions.mdx b/tutorial/2-testing-our-module/4-private-functions.mdx similarity index 99% rename from tutorial/testing-our-module/private-functions.mdx rename to tutorial/2-testing-our-module/4-private-functions.mdx index b2216e28..3c71c8ab 100644 --- a/tutorial/testing-our-module/private-functions.mdx +++ b/tutorial/2-testing-our-module/4-private-functions.mdx @@ -1,7 +1,6 @@ --- id: private-functions title: Testing private functions -sidebar_label: Private functions description: Reach non-exported module functions with InModuleScope, and understand why it should be your last resort rather than your default --- diff --git a/tutorial/testing-our-module/output.mdx b/tutorial/2-testing-our-module/5-output.mdx similarity index 99% rename from tutorial/testing-our-module/output.mdx rename to tutorial/2-testing-our-module/5-output.mdx index 5b02522e..5223aa37 100644 --- a/tutorial/testing-our-module/output.mdx +++ b/tutorial/2-testing-our-module/5-output.mdx @@ -1,7 +1,6 @@ --- id: output title: Output -sidebar_label: Output description: Control what Pester prints with New-PesterConfiguration, inspect the result object in code and write a test result file for other tools to read --- diff --git a/tutorial/2-testing-our-module/_category_.json b/tutorial/2-testing-our-module/_category_.json new file mode 100644 index 00000000..2c18b431 --- /dev/null +++ b/tutorial/2-testing-our-module/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Testing our module", + "collapsed": false +} From ad0d0b9dd11cfb42619ae5eb364211a727475145 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:54:30 +0000 Subject: [PATCH 05/27] Add support for diff-highlighting in codeblocks --- docusaurus.config.js | 26 +++++++++++++++++- src/css/custom.css | 64 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/docusaurus.config.js b/docusaurus.config.js index 2034fe9c..e90e266a 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -81,7 +81,28 @@ const config = { 'bash', 'powershell', 'yaml' - ] + ], + // Declaring magicComments replaces the defaults, so the built-in highlight directives + // are repeated here. The diff directives mark lines added or removed relative to the + // previous version of a snippet — used by the tutorial when it evolves a file. + // Styling lives in src/css/custom.css. + magicComments: [ + { + className: 'theme-code-block-highlighted-line', + line: 'highlight-next-line', + block: { start: 'highlight-start', end: 'highlight-end' }, + }, + { + className: 'code-block-diff-add-line', + line: 'diff-add', + block: { start: 'diff-add-start', end: 'diff-add-end' }, + }, + { + className: 'code-block-diff-remove-line', + line: 'diff-remove', + block: { start: 'diff-remove-start', end: 'diff-remove-end' }, + }, + ], }, // Please note that the Algolia DocSearch crawler only runs once every 24 hours. // Configuration options below described at https://docusaurus.io/docs/search. @@ -167,6 +188,9 @@ const config = { // The follow-along tutorial lives in its own docs instance at /tutorial so it stays // single-sourced. Content in /docs is copied verbatim into versioned_docs/ at every // version cut, which would freeze and duplicate the tutorial per Pester version. + + // "id" below MUST match TUTORIAL_PLUGIN_ID in src/tutorial/tutorialData.js. + // This is used to determine when to override the TableOfContents (TOC) with the combined tutorial tracker. id: 'tutorial', path: 'tutorial', routeBasePath: 'tutorial', diff --git a/src/css/custom.css b/src/css/custom.css index bfa58328..cb96f82c 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -216,4 +216,66 @@ table { max-width: 400px; padding: 10px; vertical-align: middle; -} \ No newline at end of file +} +/** + * Diff highlighting in code blocks. + * + * Driven by the `diff-add` / `diff-remove` magic comments configured in + * docusaurus.config.js, e.g. in a powershell block: + * + * # diff-add + * [switch] $Force + * + * The negative margin lets the highlight span the full width of the block, matching how + * Docusaurus styles its own highlighted lines. The +/- marker sits in the block's left + * padding so it never shifts the code, and means the change is not signalled by colour alone. + */ +.code-block-diff-add-line, +.code-block-diff-remove-line { + position: relative; + display: block; + margin: 0 calc(var(--ifm-pre-padding) * -1); + padding: 0 var(--ifm-pre-padding); + border-left: 3px solid transparent; +} + +.code-block-diff-add-line::before, +.code-block-diff-remove-line::before { + position: absolute; + left: 0.4rem; + font-weight: 700; + opacity: 0.7; +} + +.code-block-diff-add-line { + background-color: rgba(46, 160, 67, 0.15); + border-left-color: #2ea043; +} + +.code-block-diff-add-line::before { + content: '+'; + color: #2ea043; +} + +.code-block-diff-remove-line { + background-color: rgba(248, 81, 73, 0.15); + border-left-color: #f85149; +} + +.code-block-diff-remove-line::before { + content: '-'; + color: #f85149; +} + +[data-theme='dark'] .code-block-diff-add-line { + background-color: rgba(63, 185, 80, 0.18); + border-left-color: #3fb950; +} + +[data-theme='dark'] .code-block-diff-add-line::before { + color: #3fb950; +} + +[data-theme='dark'] .code-block-diff-remove-line { + background-color: rgba(248, 81, 73, 0.18); +} From b88e51d4b81308872ab072c8f34cddf8ebe844be Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:56:17 +0000 Subject: [PATCH 06/27] Fix dark mode contrast issues --- .../TutorialChecklist/styles.module.css | 4 +-- .../TutorialTracker/styles.module.css | 28 +++++++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/components/TutorialChecklist/styles.module.css b/src/components/TutorialChecklist/styles.module.css index 67221844..c7cdb2a1 100644 --- a/src/components/TutorialChecklist/styles.module.css +++ b/src/components/TutorialChecklist/styles.module.css @@ -7,7 +7,7 @@ margin-top: 3rem; padding: 1.25rem 1.5rem 1.5rem; border: 1px solid var(--tut-border); - border-left: 4px solid var(--ifm-color-primary); + border-left: 4px solid var(--tut-accent); border-radius: 8px; background: var(--tut-surface); } @@ -68,7 +68,7 @@ margin-top: 0.25rem; width: 1rem; height: 1rem; - accent-color: var(--ifm-color-primary); + accent-color: var(--tut-accent); cursor: pointer; } diff --git a/src/components/TutorialTracker/styles.module.css b/src/components/TutorialTracker/styles.module.css index 31109c73..07756b00 100644 --- a/src/components/TutorialTracker/styles.module.css +++ b/src/components/TutorialTracker/styles.module.css @@ -124,7 +124,7 @@ .barFill { height: 100%; border-radius: inherit; - background: var(--ifm-color-primary); + background: var(--tut-accent); /* Animates 0% -> real value once progress loads, so the placeholder reads as intentional. */ transition: width 400ms ease; } @@ -175,7 +175,7 @@ } .moduleLink:hover { - color: var(--ifm-color-primary); + color: var(--tut-accent); text-decoration: none; } @@ -215,12 +215,12 @@ } .pageLink:hover { - color: var(--ifm-color-primary); + color: var(--tut-accent); text-decoration: none; } .pageLinkCurrent { - color: var(--ifm-color-primary); + color: var(--tut-accent); font-weight: 600; } @@ -240,9 +240,9 @@ } .markerDone { - border-color: var(--ifm-color-primary); - background: var(--ifm-color-primary); - color: #fff; + border-color: var(--tut-marker-bg); + background: var(--tut-marker-bg); + color: var(--tut-marker-fg); } .icon { @@ -281,7 +281,7 @@ .headings :global(.table-of-contents__link:hover), .headings :global(.table-of-contents__link--active) { - color: var(--ifm-color-primary); + color: var(--tut-accent); text-decoration: none; } @@ -293,7 +293,7 @@ } .headingLink:hover { - color: var(--ifm-color-primary); + color: var(--tut-accent); text-decoration: none; } @@ -317,6 +317,12 @@ --tut-border-strong: var(--ifm-color-emphasis-400); --tut-track: var(--ifm-color-emphasis-200); --tut-muted: var(--ifm-color-emphasis-700); + /* Accent for active and hovered links. The dark value follows the same rule custom.css + applies to --ifm-link-color and .table-of-contents__link--active: the base primary is + too dark to read against a dark background. */ + --tut-accent: var(--ifm-color-primary); + --tut-marker-bg: var(--ifm-color-primary); + --tut-marker-fg: #fff; } :global([data-theme='dark']) { @@ -325,4 +331,8 @@ --tut-border-strong: var(--ifm-color-emphasis-400); --tut-track: var(--ifm-color-emphasis-300); --tut-muted: var(--ifm-color-emphasis-600); + --tut-accent: var(--ifm-color-primary-lightest); + --tut-marker-bg: var(--ifm-color-primary-lightest); + /* Dark tick on the lighter marker — white on #3eadff is too low contrast. */ + --tut-marker-fg: #10161d; } From 5c1c40c7c04d8fb964110088a4d4cd8bb2acd7f3 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:56:34 +0000 Subject: [PATCH 07/27] Add tutorial link to quick start --- docs/quick-start.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/quick-start.mdx b/docs/quick-start.mdx index 68ef15dd..b563aab3 100644 --- a/docs/quick-start.mdx +++ b/docs/quick-start.mdx @@ -7,6 +7,10 @@ description: Get started using Pester to test your PowerShell scripts, functions > **tl;dr:** Here is a [summary.](#summary) +:::tip Prefer to learn by building? +This page is the fast tour. If you would rather work through a single worked example from an empty folder to a tested module running in CI, start the [follow-along tutorial](/tutorial/introduction/welcome) instead. +::: + ## What is Pester? Pester is a testing and mocking framework for PowerShell. From 51813d620a423a3e9cc8dd1dc2c3d31de44c8175 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:37:42 +0000 Subject: [PATCH 08/27] Add first draft of remaining modules --- src/tutorial/tutorialData.js | 9 +- tutorial/1-introduction/1-welcome.mdx | 17 ++- .../3-public-functions.mdx | 3 + tutorial/2-testing-our-module/5-output.mdx | 31 ++-- tutorial/3-mocking/1-a-real-dependency.mdx | 111 ++++++++++++++ tutorial/3-mocking/2-your-first-mock.mdx | 114 ++++++++++++++ tutorial/3-mocking/3-verifying-calls.mdx | 97 ++++++++++++ tutorial/3-mocking/_category_.json | 4 + .../4-working-with-files/1-writing-files.mdx | 87 +++++++++++ tutorial/4-working-with-files/2-testdrive.mdx | 106 +++++++++++++ tutorial/4-working-with-files/_category_.json | 4 + tutorial/5-code-coverage/1-measuring.mdx | 132 ++++++++++++++++ .../5-code-coverage/2-closing-the-gaps.mdx | 144 ++++++++++++++++++ tutorial/5-code-coverage/_category_.json | 4 + tutorial/6-ci/1-test-script.mdx | 113 ++++++++++++++ tutorial/6-ci/2-github-actions.mdx | 126 +++++++++++++++ tutorial/6-ci/_category_.json | 4 + 17 files changed, 1079 insertions(+), 27 deletions(-) create mode 100644 tutorial/3-mocking/1-a-real-dependency.mdx create mode 100644 tutorial/3-mocking/2-your-first-mock.mdx create mode 100644 tutorial/3-mocking/3-verifying-calls.mdx create mode 100644 tutorial/3-mocking/_category_.json create mode 100644 tutorial/4-working-with-files/1-writing-files.mdx create mode 100644 tutorial/4-working-with-files/2-testdrive.mdx create mode 100644 tutorial/4-working-with-files/_category_.json create mode 100644 tutorial/5-code-coverage/1-measuring.mdx create mode 100644 tutorial/5-code-coverage/2-closing-the-gaps.mdx create mode 100644 tutorial/5-code-coverage/_category_.json create mode 100644 tutorial/6-ci/1-test-script.mdx create mode 100644 tutorial/6-ci/2-github-actions.mdx create mode 100644 tutorial/6-ci/_category_.json diff --git a/src/tutorial/tutorialData.js b/src/tutorial/tutorialData.js index e92aae8c..c463fcd6 100644 --- a/src/tutorial/tutorialData.js +++ b/src/tutorial/tutorialData.js @@ -17,10 +17,7 @@ export const TUTORIAL_PLUGIN_ID = 'tutorial'; * These have no docs, so they cannot come from the sidebar. They are shown in the tracker, * greyed out and not linkable, so the shape of the whole tutorial is visible up front. Delete * an entry when its module is written — the sidebar will pick it up automatically. + * + * Format: {id: string, label: string} */ -export const upcomingModules = [ - { id: 'mocking', label: 'Mocking' }, - { id: 'testdrive', label: 'Working with files' }, - { id: 'code-coverage', label: 'Code coverage' }, - { id: 'ci', label: 'Setting up CI' }, -]; +export const upcomingModules = []; diff --git a/tutorial/1-introduction/1-welcome.mdx b/tutorial/1-introduction/1-welcome.mdx index 240bd2ee..83e94757 100644 --- a/tutorial/1-introduction/1-welcome.mdx +++ b/tutorial/1-introduction/1-welcome.mdx @@ -16,16 +16,21 @@ The module is called **Planetarium**. It is small enough to keep in your head an Planetarium/ ├── Planetarium.psd1 ├── Planetarium.psm1 +├── Data/ +│ └── planets.csv ├── Public/ │ ├── Get-Planet.ps1 │ ├── Get-PlanetDistance.ps1 │ └── Export-PlanetReport.ps1 └── Private/ ├── ConvertTo-AstronomicalUnit.ps1 + ├── Get-PlanetData.ps1 └── Test-PlanetName.ps1 ``` -It has public functions that users call, private helpers that they should not, a function that writes files to disk, and a dependency worth faking. Each of those is a testing problem with its own chapter. +It has public functions that users call, private helpers that they should not, a data file worth faking and a function that writes reports to disk. Each of those is a testing problem with its own chapter. + +You will not build it all at once. It starts as an empty folder and grows a piece at a time, with tests arriving alongside each piece. ## How the tutorial is organised @@ -35,12 +40,12 @@ The tutorial is split into modules, and each module into pages. You are on the f | --- | --- | | **Introduction** | What you need installed before you start | | **Testing our module** | Building Planetarium and testing it end to end | -| Mocking | Replacing dependencies your code calls | -| Working with files | Isolating file operations with `TestDrive` | -| Code coverage | Finding out what your tests never touch | -| Setting up CI | Running the whole thing on every push | +| **Mocking** | Replacing the data source your code depends on | +| **Working with files** | Isolating file operations with `TestDrive` | +| **Code coverage** | Finding the code your tests never touch | +| **Setting up CI** | Running the whole thing on every push | -The first two modules are written. The rest are on their way — you will see them greyed out in the progress panel. +They are meant to be done in order — each one builds on the module the previous one left behind. ## Tracking your progress diff --git a/tutorial/2-testing-our-module/3-public-functions.mdx b/tutorial/2-testing-our-module/3-public-functions.mdx index 3d47236b..4a444c35 100644 --- a/tutorial/2-testing-our-module/3-public-functions.mdx +++ b/tutorial/2-testing-our-module/3-public-functions.mdx @@ -21,6 +21,9 @@ Change the `BeforeAll` in your test file: ```powershell title="Planetarium/Public/Get-Planet.Tests.ps1" BeforeAll { + # diff-remove + . $PSCommandPath.Replace('.Tests.ps1', '.ps1') + # diff-add Import-Module $PSScriptRoot/../Planetarium.psd1 -Force } ``` diff --git a/tutorial/2-testing-our-module/5-output.mdx b/tutorial/2-testing-our-module/5-output.mdx index 5223aa37..e87367b8 100644 --- a/tutorial/2-testing-our-module/5-output.mdx +++ b/tutorial/2-testing-our-module/5-output.mdx @@ -135,41 +135,42 @@ testResults.xml ## Putting it together -A single configuration usually covers a whole project. This is a reasonable one to keep as `build.ps1` at the root of the tutorial folder: +Retyping that configuration every time gets old fast. Save it as `test.ps1` in the root of your working folder — the one containing `Planetarium`: -```powershell title="build.ps1" +```powershell title="test.ps1" $config = New-PesterConfiguration $config.Run.Path = './Planetarium' -$config.Run.PassThru = $true $config.Output.Verbosity = 'Detailed' $config.TestResult.Enabled = $true $config.TestResult.OutputPath = './testResults.xml' -$result = Invoke-Pester -Configuration $config - -if ($result.FailedCount -gt 0) { - throw "$($result.FailedCount) test(s) failed." -} +Invoke-Pester -Configuration $config ``` ```powershell -./build.ps1 +./test.ps1 ``` -That last block is the part people forget. A failing test does not, on its own, make `Invoke-Pester` fail your build — so a pipeline without an explicit check will happily report success over a suite full of red. Throwing on `FailedCount` is what turns a test run into a gate, and it is exactly what the CI module later in this tutorial builds on. +**This is how you run the suite for the rest of the tutorial.** Every later module adds to this file rather than starting a new one, and the additions are shown as diffs so you can see exactly what changed. + +It is named `test.ps1` rather than `build.ps1` because running tests is all it does — nothing here compiles, packages or publishes. + +When you are iterating on one file, `Invoke-Pester -Path ./Planetarium/Public/Get-Planet.Tests.ps1` is still the quicker loop. `./test.ps1` is for "is everything still green". + +:::note This script does not fail yet +A failing test does not, on its own, make `Invoke-Pester` return an error — so depending on how this script is invoked, a pipeline can report success over a suite full of red. The [CI module](../ci/test-script) fixes that with one more line, once there is a pipeline to fix it for. +::: ## What you have built -Starting from an empty folder, you now have a PowerShell module with a manifest, a loader, public and private functions, and fifteen tests covering all of it — exported behaviour, error cases, wildcard filtering and internal helpers — plus a build script that gates on failures and emits a results file. - -The remaining tutorial modules pick up from exactly here. `Get-PlanetDistance` will get a real external dependency worth mocking, `Export-PlanetReport` will write files that need isolating with `TestDrive`, code coverage will show you the branches these tests never touch, and the CI module will run the whole thing on every push. +Starting from an empty folder, you now have a PowerShell module with a manifest, a loader, public and private functions, and fifteen tests covering all of it — exported behaviour, error cases, wildcard filtering and internal helpers — plus a script that gates on failures and emits a results file. -Those pages are being written. In the meantime the reference documentation covers all four: [Mocking](../../docs/usage/mocking), [TestDrive](../../docs/usage/testdrive), [Code coverage](../../docs/usage/code-coverage) and [Test results](../../docs/usage/test-results). +The remaining modules pick up from exactly here. The planet data moves out of the function and into a file, which finally gives you a dependency worth mocking. `Export-PlanetReport` arrives and needs `TestDrive` to test without leaving debris. Code coverage finds a branch none of these tests touch. And the whole thing ends up running on three operating systems on every push. diff --git a/tutorial/3-mocking/1-a-real-dependency.mdx b/tutorial/3-mocking/1-a-real-dependency.mdx new file mode 100644 index 00000000..bbfdf1a6 --- /dev/null +++ b/tutorial/3-mocking/1-a-real-dependency.mdx @@ -0,0 +1,111 @@ +--- +id: a-real-dependency +title: Giving the module a dependency +description: Move the planet data out of the function and into a CSV file shipped with the module, creating a real dependency worth isolating in tests +--- + +There is nothing to mock yet. `Get-Planet` holds its data in a hard-coded array, so it has no dependencies and always behaves identically. That is convenient and unrealistic — real functions read files, call APIs and query databases. + +This page moves the data into a file. The next two pages deal with the consequences. + +## Moving the data into a file + +Create a `Data` folder in the module and put the planets in a CSV: + +```powershell +New-Item -Path ./Planetarium/Data -ItemType Directory -Force +``` + +```csv title="Planetarium/Data/planets.csv" +Name,Order,DistanceFromSunKm +Mercury,1,57909050 +Venus,2,108208000 +Earth,3,149598023 +Mars,4,227939200 +Jupiter,5,778570000 +Saturn,6,1433530000 +Uranus,7,2872460000 +Neptune,8,4495060000 +``` + +## Reading it + +Add a private helper that loads the file. It is private because it is an implementation detail — users ask for planets, not for the file the planets happen to live in. + +```powershell title="Planetarium/Private/Get-PlanetData.ps1" +function Get-PlanetData { + [CmdletBinding()] + param () + + $path = Join-Path $PSScriptRoot '../Data/planets.csv' + + Import-Csv -Path $path | ForEach-Object { + [PSCustomObject] @{ + Name = $_.Name + Order = [int] $_.Order + DistanceFromSunKm = [double] $_.DistanceFromSunKm + } + } +} +``` + +Two things here are load-bearing. + +**`$PSScriptRoot` resolves to the folder of the file that defines the function** — `Private/` — even though the function is dot-sourced into the module. That is what makes `../Data/planets.csv` correct regardless of the directory the user happens to be in when they call your module. + +**The `ForEach-Object` block re-types the data.** `Import-Csv` returns every column as a string, so without this `Order` would be `'3'` rather than `3`, and the astronomical-unit arithmetic would be doing string division. Your existing tests would catch it — `$planet.Order | Should-Be 3` fails against `'3'`, which is exactly the kind of thing the type in Pester's failure message is there to reveal. + +## Simplifying Get-Planet + +`Get-Planet` now just filters whatever the data source hands back: + +```powershell title="Planetarium/Public/Get-Planet.ps1" +function Get-Planet { + [CmdletBinding()] + param ( + [string] $Name = '*' + ) + + # diff-remove-start + $planets = @( + [PSCustomObject] @{ Name = 'Mercury'; Order = 1; DistanceFromSunKm = 57909050 } + # ...six more planets... + [PSCustomObject] @{ Name = 'Neptune'; Order = 8; DistanceFromSunKm = 4495060000 } + ) + + $planets | Where-Object Name -Like $Name + # diff-remove-end + # diff-add + Get-PlanetData | Where-Object Name -Like $Name +} +``` + +## Nothing should have broken + +This was a refactor: the behaviour is meant to be identical. Your test suite is how you find out whether it actually is. + +```powershell +./test.ps1 +``` + +``` +Tests Passed: 15, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +Fifteen tests, all still green, without a single test file changing. That is the return on the work from the previous module — you just restructured how the module gets its data and confirmed in under a second that nothing regressed. + +:::note This is also the moment the tests got worse +Every one of those tests now reads a real file from disk. They still pass, but they are no longer testing `Get-Planet` alone — they are testing `Get-Planet`, plus `Get-PlanetData`, plus `Import-Csv`, plus the filesystem, plus the contents of `planets.csv`. Edit that CSV and unrelated tests start failing. + +That coupling is what the next page removes. +::: + + + +Next: cutting the tests loose from that file. diff --git a/tutorial/3-mocking/2-your-first-mock.mdx b/tutorial/3-mocking/2-your-first-mock.mdx new file mode 100644 index 00000000..2f671b50 --- /dev/null +++ b/tutorial/3-mocking/2-your-first-mock.mdx @@ -0,0 +1,114 @@ +--- +id: your-first-mock +title: Your first mock +description: Replace a module's internal data source with Mock -ModuleName so tests control their own data instead of depending on a file on disk +--- + +Your tests currently depend on the contents of `planets.csv`. Add a planet to that file and `Returns all eight planets by default` starts failing — a test about filtering, broken by a data edit. + +A mock replaces a command for the duration of a test. Instead of `Get-PlanetData` reading a file, it returns whatever you tell it to. + +## Mocking inside a module + +```powershell title="Planetarium/Public/Get-Planet.Mocking.Tests.ps1" +BeforeAll { + Import-Module $PSScriptRoot/../Planetarium.psd1 -Force +} + +Describe 'Get-Planet with mocked data' { + BeforeAll { + Mock -ModuleName Planetarium Get-PlanetData { + @( + [PSCustomObject] @{ Name = 'Aiur'; Order = 1; DistanceFromSunKm = 100000000 } + [PSCustomObject] @{ Name = 'Shakuras'; Order = 2; DistanceFromSunKm = 200000000 } + ) + } + } + + It 'Returns whatever the data source provides' { + (Get-Planet).Name | Should-BeCollection @('Aiur', 'Shakuras') + } + + It 'Filters the mocked data the same way' { + (Get-Planet -Name 'A*').Name | Should-Be 'Aiur' + } +} +``` + +```powershell +Invoke-Pester -Path ./Planetarium/Public/Get-Planet.Mocking.Tests.ps1 +``` + +``` +Tests Passed: 2, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +The planets are obviously fictional, and that is the point. These tests assert that `Get-Planet` returns and filters *whatever its data source gives it* — which is the actual behaviour of the function. The real solar system is data, not logic, and it is not this test's job. + +## Why `-ModuleName` is required + +This is the detail that costs people the most time. + +`Get-Planet` runs inside the module's own scope. When it calls `Get-PlanetData`, PowerShell resolves that name *within the module*, and a mock defined in your test file lives outside it. Without `-ModuleName`, your mock sits somewhere the module will never look, and the real function runs — your test passes or fails for reasons unrelated to the mock you thought you installed. + +`-ModuleName Planetarium` injects the mock into the module's scope, where the call actually resolves. + +:::warning A mock without `-ModuleName` fails silently +Nothing errors. The mock is simply never used, and the real command runs instead. If a mock appears to have no effect, this is the first thing to check. +::: + +Note also what you did *not* have to do: no `InModuleScope` wrapper. `-ModuleName` reaches the private `Get-PlanetData` without dragging your whole test inside the module — which is exactly the preference the [modules guide](../../docs/usage/modules) recommends over `InModuleScope`. + +## Mocking a built-in command + +You can mock commands you did not write, including PowerShell's own. Here the target is `Import-Csv`, one layer deeper: + +```powershell title="Planetarium/Public/Get-Planet.Mocking.Tests.ps1" +Describe 'Get-PlanetData' { + It 'Converts the CSV strings into numbers' { + Mock -ModuleName Planetarium Import-Csv { + @([PSCustomObject] @{ Name = 'Aiur'; Order = '3'; DistanceFromSunKm = '149597870.7' }) + } + + InModuleScope Planetarium { + $planet = Get-PlanetData + $planet.Order | Should-HaveType ([int]) + $planet.DistanceFromSunKm | Should-HaveType ([double]) + } + } +} +``` + +This is the test that pins down the re-typing from the previous page. Feeding in strings — `'3'`, not `3` — and asserting the types that come out is the only way to prove the conversion happens, and it is impossible to write without a mock, because the real CSV would make it pass either way. + +`InModuleScope` is here because the test calls the private `Get-PlanetData` directly. The mock still uses `-ModuleName`, since it must be installed into the module regardless of where the call is made from. + +Run the file: + +``` +Describing Get-Planet with mocked data + [+] Returns whatever the data source provides 73ms + [+] Filters the mocked data the same way 5ms + +Describing Get-PlanetData + [+] Converts the CSV strings into numbers 16ms +``` + +## Which layer to mock + +You have now mocked at two depths, and the choice matters: + +- **`Get-PlanetData`** — mocking your own seam. Tests stay readable, and they survive a change of storage format. Switch the CSV to JSON and these tests do not care. Prefer this. +- **`Import-Csv`** — mocking the plumbing. Necessary when the thing under test *is* the plumbing, as with the type conversion above, but it welds your test to the current implementation. Move to JSON and this test breaks even though the behaviour did not change. + +The rule of thumb: mock the seam you own, as close to the thing under test as you can get. + + + +Next: asserting that the mock was actually called, and the way Pester v6 refuses to guess. diff --git a/tutorial/3-mocking/3-verifying-calls.mdx b/tutorial/3-mocking/3-verifying-calls.mdx new file mode 100644 index 00000000..005c0a87 --- /dev/null +++ b/tutorial/3-mocking/3-verifying-calls.mdx @@ -0,0 +1,97 @@ +--- +id: verifying-calls +title: Verifying calls +description: Assert that a mocked command was called with Should-Invoke, target specific calls with -ParameterFilter, and understand why Pester v6 mocks never fall through +--- + +A mock lets you control what a command returns. `Should-Invoke` lets you assert it was called at all — which is how you test behaviour that leaves no return value behind. + +## Should-Invoke + +```powershell title="Planetarium/Public/Get-Planet.Mocking.Tests.ps1" + It 'Reads the data source exactly once per call' { + Get-Planet | Out-Null + + Should-Invoke Get-PlanetData -ModuleName Planetarium -Times 1 -Exactly + } +``` + +`-Exactly` is doing real work. `-Times 1` on its own means "at least once", so a function that read the file three times would pass. With `-Exactly` this test would catch a refactor that accidentally re-reads the CSV per planet — a bug that is invisible in the return value and obvious in the call count. + +The `-ModuleName` rule from the previous page applies here too: you are asking about a mock that lives in the module's scope, so you have to say so. + +:::tip Assert on calls only when the call is the behaviour +`Should-Invoke` is the right tool for "it wrote to the log", "it retried twice", "it did not delete anything". It is the wrong tool for things you can check by looking at the result. Asserting on both the return value and the exact call sequence tends to produce tests that fail every time you tidy up the implementation, without ever catching a real bug. +::: + +## Targeting specific calls + +`-ParameterFilter` narrows a mock to calls whose arguments match: + +```powershell + It 'Reads the CSV shipped with the module' { + Mock -ModuleName Planetarium Import-Csv { + @([PSCustomObject] @{ Name = 'Aiur'; Order = '1'; DistanceFromSunKm = '100' }) + } -ParameterFilter { $Path -like '*planets.csv' } + + InModuleScope Planetarium { (Get-PlanetData).Name | Should-Be 'Aiur' } + + Should-Invoke Import-Csv -ModuleName Planetarium -Times 1 -Exactly + } +``` + +Inside the filter, parameters are available as variables — `$Path` is whatever was passed as `-Path`. The mock only applies when the filter returns true, so this one says: intercept reads of `planets.csv`, and nothing else. + +That doubles as an assertion. If the module ever reads a different file, the filter stops matching — and in v6 that is loud. + +## Mocks do not fall through + +This is the biggest behavioural change from Pester v5, and worth meeting deliberately rather than at 2am. + +In v5, a call that matched no `-ParameterFilter` quietly ran the **real** command. In v6 it throws. Point the filter at the wrong file to see it: + +```powershell + Mock -ModuleName Planetarium Import-Csv { ... } -ParameterFilter { $Path -like '*moons.csv' } +``` + +``` +[-] No fall-through.Throws when no mock matches 148ms + RuntimeException: No mock for command 'Import-Csv' matched the call: none of the parameter filters matched, and there is no default mock to fall back to. Add a default mock (e.g. `Mock Import-Csv { ... }`) or adjust an existing -ParameterFilter. + The following parameter filters were evaluated and did not match: + { $Path -like '*moons.csv' } bound parameters: Path = /home/you/pester-tutorial/Planetarium/Private/../Data/planets.csv +``` + +Read the last line: it shows the filter that was evaluated *and* the arguments it was evaluated against. Filter expects `*moons.csv`, actual path ends in `planets.csv` — the mismatch is right there. + +This is a substantial improvement. Under v5 that same mistake produced a test that silently read the real file and often still passed, leaving you with a mock you believed in that was never used. + +When you genuinely want "handle this specific case, and everything else generically", say so explicitly with a default mock: + +```powershell +Mock Get-Thing { 'default' } # everything else +Mock Get-Thing { 'one' } -ParameterFilter { $Id -eq 1 } # the specific case +``` + +## The full file + +```powershell +./test.ps1 +``` + +``` +Tests Passed: 20, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +Twenty tests: the original fifteen, plus five that no longer care what is in `planets.csv`. + +There is also `Should-NotInvoke` for asserting a command was *not* called — useful for "it did not overwrite the file", which is a test you will write in the next module. + + + +Next module: a function that writes files, and how to test it without leaving debris on your disk. diff --git a/tutorial/3-mocking/_category_.json b/tutorial/3-mocking/_category_.json new file mode 100644 index 00000000..e94b22fb --- /dev/null +++ b/tutorial/3-mocking/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Mocking", + "collapsed": false +} diff --git a/tutorial/4-working-with-files/1-writing-files.mdx b/tutorial/4-working-with-files/1-writing-files.mdx new file mode 100644 index 00000000..2bcb964c --- /dev/null +++ b/tutorial/4-working-with-files/1-writing-files.mdx @@ -0,0 +1,87 @@ +--- +id: writing-files +title: A function that writes files +description: Add Export-PlanetReport to the module and see why testing code that touches the filesystem needs more care than testing code that returns values +--- + +Every function so far has returned a value. Testing those is easy: call it, look at what came back. This page adds a function whose entire purpose is a side effect — it writes a file and returns nothing useful. + +## Export-PlanetReport + +```powershell title="Planetarium/Public/Export-PlanetReport.ps1" +function Export-PlanetReport { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $Path, + + [string] $Name = '*' + ) + + $planets = @(Get-Planet -Name $Name) + + if ($planets.Count -eq 0) { + throw "No planets matched '$Name'." + } + + $report = foreach ($planet in $planets) { + '{0,-8} {1} AU' -f $planet.Name, (ConvertTo-AstronomicalUnit -Kilometre $planet.DistanceFromSunKm) + } + + Set-Content -Path $Path -Value $report +} +``` + +It leans on almost everything built so far: `Get-Planet` for the data, the private `ConvertTo-AstronomicalUnit` for the conversion, and the module loader to wire them together. It goes in `Public/`, so it is exported automatically. + +Note `@(Get-Planet -Name $Name)`. Without the array subexpression, a single match returns a bare object with no `.Count`, and the emptiness check would misbehave. This is a normal PowerShell trap rather than a Pester one, but it is the kind of thing a test catches and a quick manual check does not. + +Try it: + +```powershell +Import-Module ./Planetarium/Planetarium.psd1 -Force +Export-PlanetReport -Path ./report.txt +Get-Content ./report.txt +``` + +``` +Mercury 0.387 AU +Venus 0.723 AU +Earth 1 AU +Mars 1.524 AU +Jupiter 5.204 AU +Saturn 9.582 AU +Uranus 19.201 AU +Neptune 30.047 AU +``` + +## The problem with testing this + +You have just created a file in your source folder. Delete it: + +```powershell +Remove-Item ./report.txt +``` + +Now think about what a test for this function has to do. It needs a path to write to, and afterwards that file should not still be there. Doing this by hand goes wrong quickly: + +- **Writing into the repo** leaves junk in `git status`, and one forgotten cleanup means a test that passes only because a previous run left the file behind. +- **Writing to a fixed temp path** breaks the moment two tests use the same name, and breaks harder when two Pester runs happen at once. +- **Cleaning up in `AfterEach`** is correct until a test fails midway and the cleanup never runs — so the failure poisons the next run too. + +Every one of these produces the same nasty class of bug: tests whose result depends on what previous runs left lying around. They pass on your machine, fail in CI, and pass again after you delete something by hand. + +:::note Could you just mock `Set-Content`? +You could — `Mock Set-Content` and `Should-Invoke` would tell you the function tried to write. But it would not tell you the file has eight lines, or that Earth's line reads `Earth 1 AU`. You would be asserting that your code called a command, not that it produced a correct report. + +Mock the filesystem when the write itself is the behaviour. When the *content* matters, write real files somewhere disposable — which is exactly what the next page is about. +::: + + + +Next: the disposable filesystem Pester gives you for free. diff --git a/tutorial/4-working-with-files/2-testdrive.mdx b/tutorial/4-working-with-files/2-testdrive.mdx new file mode 100644 index 00000000..2ea19837 --- /dev/null +++ b/tutorial/4-working-with-files/2-testdrive.mdx @@ -0,0 +1,106 @@ +--- +id: testdrive +title: Isolating files with TestDrive +description: Use Pester's TestDrive to give every test file a disposable filesystem that cleans itself up, then assert on the files your code actually wrote +--- + +Pester gives every test file its own temporary directory, cleans it up afterwards, and names it randomly so parallel runs cannot collide. It is called **TestDrive**, and it needs no setup at all — it is simply there. + +## Two ways to reach it + +- **`TestDrive:\`** — a PowerShell drive, for use with PowerShell commands. +- **`$TestDrive`** — the same location as a plain filesystem path. + +Prefer `$TestDrive` with `Join-Path`. `TestDrive:\` only exists inside PowerShell, so the moment a path reaches a .NET method or an external executable it breaks — and `Export-PlanetReport` hands its path to `Set-Content`, which is fine, right up until someone changes it to `[System.IO.File]::WriteAllLines`. + +## Testing the report + +```powershell title="Planetarium/Public/Export-PlanetReport.Tests.ps1" +BeforeAll { + Import-Module $PSScriptRoot/../Planetarium.psd1 -Force +} + +Describe 'Export-PlanetReport' { + It 'Creates the report file' { + $path = Join-Path $TestDrive 'report.txt' + + Test-Path -Path $path | Should-BeFalse + Export-PlanetReport -Path $path + Test-Path -Path $path | Should-BeTrue + } + + It 'Writes one line per planet' { + $path = Join-Path $TestDrive 'all.txt' + + Export-PlanetReport -Path $path + + (Get-Content -Path $path).Count | Should-Be 8 + } + + It 'Writes the name and the distance in astronomical units' { + $path = Join-Path $TestDrive 'earth.txt' + + Export-PlanetReport -Path $path -Name 'Earth' + + Get-Content -Path $path | Should-Be 'Earth 1 AU' + } + + It 'Throws when no planet matches' { + $path = Join-Path $TestDrive 'nothing.txt' + + { Export-PlanetReport -Path $path -Name 'Pluto' } | + Should-Throw -ExceptionMessage "No planets matched 'Pluto'." + } +} +``` + +```powershell +Invoke-Pester -Path ./Planetarium/Public/Export-PlanetReport.Tests.ps1 +``` + +``` +Describing Export-PlanetReport + [+] Creates the report file 45ms + [+] Writes one line per planet 8ms + [+] Writes the name and the distance in astronomical units 4ms + [+] Throws when no planet matches 24ms +Tests completed in 362ms +Tests Passed: 4, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +Four real files were written and four are already gone. Check `git status` — nothing. + +There is no `Should-Exist` in Pester v6, so file existence is asserted with `Test-Path` piped into `Should-BeTrue` or `Should-BeFalse`. The first test asserts the file is absent *before* the call as well as present after: without that, a leftover file from an earlier run could make the test pass on its own. + +## Scoping + +TestDrive is not one directory for the whole run. The rules that matter day to day: + +1. A clean drive is created **per test file**, at the first top-level `Describe` or `Context`. +2. Files made in a block are visible to everything nested inside it. +3. On leaving a block, files created *during* that block are removed. +4. When the file finishes, the whole drive goes. + +So each `It` above got the same drive but wrote a distinct filename. They could safely have reused one name — but distinct names make a failure easier to read, since the filename tells you which test wrote it. + +:::warning Modifications to inherited files are not undone +Cleanup works by tracking which paths existed when a block was entered. Create a file in `Describe`, change it inside a `Context`, and the change survives after the `Context` ends — the file already existed, so it is excluded from that block's cleanup. + +The practical rule: create files in the block that uses them. Sharing a mutable file down the tree is how you get tests that pass alone and fail together, or that depend on execution order. +::: + +## What this bought you + +Compare against the alternatives from the previous page. No `AfterEach` to forget. No fixed paths to collide. No cleanup skipped because a test threw halfway. Nothing left behind when a test fails — and a failing test is precisely when stale files do the most damage. + +The parallel-safety is not theoretical either: the directory name is randomised specifically so several Pester processes can run at once, which is what your CI will do the moment the suite gets slow enough to shard. + + + +Next module: finding out which parts of the module your tests never touch. diff --git a/tutorial/4-working-with-files/_category_.json b/tutorial/4-working-with-files/_category_.json new file mode 100644 index 00000000..398ebc35 --- /dev/null +++ b/tutorial/4-working-with-files/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Working with files", + "collapsed": false +} diff --git a/tutorial/5-code-coverage/1-measuring.mdx b/tutorial/5-code-coverage/1-measuring.mdx new file mode 100644 index 00000000..70562e99 --- /dev/null +++ b/tutorial/5-code-coverage/1-measuring.mdx @@ -0,0 +1,132 @@ +--- +id: measuring +title: Measuring coverage +description: Turn on Pester's code coverage to find out which parts of the Planetarium module the test suite never executes +--- + +Twenty-four tests pass. That tells you the things you thought to test work. It says nothing about the code you forgot. + +Code coverage answers a narrower question than people usually assume: *which lines of my code ran while the tests ran?* Not whether they were tested well — just whether they executed at all. Lines that never execute are definitionally untested, and that is worth knowing. + +## Turning it on + +Coverage is a configuration setting, so it goes in `test.ps1` alongside everything else: + +```powershell title="test.ps1" +$config = New-PesterConfiguration +$config.Run.Path = './Planetarium' +$config.Output.Verbosity = 'Detailed' +$config.TestResult.Enabled = $true +$config.TestResult.OutputPath = './testResults.xml' +# diff-add-start +$config.CodeCoverage.Enabled = $true +$config.CodeCoverage.Path = './Planetarium' +# diff-add-end + +Invoke-Pester -Configuration $config +``` + +```powershell +./test.ps1 +``` + +``` +Tests completed in 870ms +Tests Passed: 24, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +Processing code coverage result. +Covered 100% / 75%. 40 analyzed Commands in 7 Files. +``` + +Read that last line carefully — it trips people up. **`100%` is what you achieved; `75%` is the target you are being measured against.** The target is `CodeCoverage.CoveragePercentTarget`, which defaults to 75. + +:::warning Set `CodeCoverage.Path` or you may measure nothing +`Run.Path` says which *tests* to run; `CodeCoverage.Path` says which *code* to measure. If you leave the second one out and run from a directory that does not contain your source, you get an empty report and a confusing 0%. Setting both explicitly costs one line and avoids a genuinely baffling afternoon. +::: + +Pester v6 uses a profiler-based tracer by default, which is fast enough to leave on routinely. If you need the older breakpoint-based collector, `CodeCoverage.UseBreakpoints = $true` brings it back — slower, and rarely what you want. + +## 100% is not the finish line + +The module is at 100%, and it would be a mistake to read that as "fully tested". + +Coverage measures execution, not assertion. This test would give `ConvertTo-AstronomicalUnit` full coverage while checking nothing at all: + +```powershell +It 'Runs' { + InModuleScope Planetarium { ConvertTo-AstronomicalUnit -Kilometre 149597870.7 } +} +``` + +The line ran, so it is covered. Nothing was asserted, so it is untested. Coverage cannot tell those apart — which is why chasing a coverage number as a goal reliably produces tests that execute code without checking it. + +What coverage is genuinely good at is the opposite direction: **it finds code you forgot entirely.** A 100% score is weak evidence of quality; an uncovered line is strong evidence of a gap. + +## Adding a feature + +Time to create a real gap the way it actually happens — by adding a feature. + +`Export-PlanetReport` currently overwrites without asking, which is unfriendly for something that writes files. Add a guard: + +```powershell title="Planetarium/Public/Export-PlanetReport.ps1" +function Export-PlanetReport { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $Path, + + # diff-remove + [string] $Name = '*' + # diff-add-start + [string] $Name = '*', + + [switch] $Force + # diff-add-end + ) + + $planets = @(Get-Planet -Name $Name) + + if ($planets.Count -eq 0) { + throw "No planets matched '$Name'." + } + + # diff-add-start + if ((Test-Path -Path $Path) -and -not $Force) { + throw "'$Path' already exists. Use -Force to overwrite it." + } + # diff-add-end + + $report = foreach ($planet in $planets) { + '{0,-8} {1} AU' -f $planet.Name, (ConvertTo-AstronomicalUnit -Kilometre $planet.DistanceFromSunKm) + } + + Set-Content -Path $Path -Value $report +} +``` + +Run the suite: + +```powershell +./test.ps1 +``` + +``` +Tests Passed: 24, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +``` + +Still all green. Every existing test writes to a fresh path in TestDrive, so none of them hits the new branch — and none of them fails. Nothing in that output hints that you just shipped an untested code path. + +Coverage does: + +``` +Covered 95.00% / 75%. 44 analyzed Commands in 7 Files. +``` + + + +Next: finding the exact line and closing it. diff --git a/tutorial/5-code-coverage/2-closing-the-gaps.mdx b/tutorial/5-code-coverage/2-closing-the-gaps.mdx new file mode 100644 index 00000000..4bb9aa18 --- /dev/null +++ b/tutorial/5-code-coverage/2-closing-the-gaps.mdx @@ -0,0 +1,144 @@ +--- +id: closing-the-gaps +title: Closing the gaps +description: Read the missed-command list from the result object, write the tests it points at, and produce a coverage report file for CI to consume +--- + +Coverage says 95%. It will also tell you exactly which line it means. + +## Finding the missed lines + +The percentage is on screen; the detail is on the result object, so ask for it with `Run.PassThru`. + +This one is a throwaway — run it in the console rather than adding it to `test.ps1`: + +```powershell +$config = New-PesterConfiguration +$config.Run.Path = './Planetarium' +$config.Run.PassThru = $true +$config.CodeCoverage.Enabled = $true +$config.CodeCoverage.Path = './Planetarium' +$config.Output.Verbosity = 'None' + +$result = Invoke-Pester -Configuration $config + +'Coverage: {0:N2}%' -f $result.CodeCoverage.CoveragePercent +$result.CodeCoverage.CommandsMissed | + ForEach-Object { ' {0}:{1} {2}' -f (Split-Path $_.File -Leaf), $_.Line, $_.Command } +``` + +:::tip Why `PassThru` does not belong in `test.ps1` +With `Run.PassThru` set, `Invoke-Pester` returns the result object — and anything a script returns without capturing gets printed. Leave it on in `test.ps1` and every run ends in a wall of formatted object properties after the test output. Keep it for the runs where you actually want the object. +::: + +``` +Coverage: 95.00% +Missed commands: + Export-PlanetReport.ps1:19 throw "'$Path' already exists. Use -Force to overwrite it." + Export-PlanetReport.ps1:19 throw "'$Path' already exists. Use -Force to overwrite it." +``` + +File, line number and the actual source text of what never ran. No hunting. + +The line appears twice because Pester counts commands rather than lines, and that statement contains more than one — the `throw` and the string expansion inside it. Two entries, one gap. + +`CommandsMissed` is the single most useful thing in the coverage result. Printing it beats reading a percentage, because a percentage tells you that you have a problem while this tells you where it is. + +## Writing the missing tests + +The uncovered branch has two behaviours worth pinning: it refuses by default, and `-Force` overrides it. + +```powershell title="Planetarium/Public/Export-PlanetReport.Tests.ps1" + It 'Refuses to overwrite an existing report' { + $path = Join-Path $TestDrive 'existing.txt' + Export-PlanetReport -Path $path + + { Export-PlanetReport -Path $path } | + Should-Throw -ExceptionMessage "*already exists. Use -Force to overwrite it." + } + + It 'Overwrites an existing report when -Force is used' { + $path = Join-Path $TestDrive 'forced.txt' + Export-PlanetReport -Path $path + Export-PlanetReport -Path $path -Name 'Earth' -Force + + Get-Content -Path $path | Should-Be 'Earth 1 AU' + } +``` + +Both call `Export-PlanetReport` twice: once to create the file, once against the now-existing path. This is exactly the scenario every earlier test avoided by using a fresh filename each time — which is why the branch went uncovered. + +The leading `*` in the expected message matters. The real message starts with the full TestDrive path, which is randomised per run, so matching it literally would fail. `-ExceptionMessage` accepts wildcards; match the stable part. + +The second test asserts *content*, not just that no error occurred. Writing `Earth` over an eight-planet report and then checking the file holds exactly one line proves the overwrite really happened rather than the write being skipped. + +## Back to green + +Run the throwaway again: + +``` +Coverage: 100.00% +Missed: 0 +``` + +And the suite itself: + +```powershell +./test.ps1 +``` + +``` +Tests Passed: 26, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +Covered 100% / 75%. 44 analyzed Commands in 7 Files. +``` + +Twenty-six tests, no uncovered commands. + +## A report file for CI + +Like test results, coverage can be written to a file for other tools. Add it to `test.ps1`: + +```powershell title="test.ps1" +$config.CodeCoverage.Enabled = $true +$config.CodeCoverage.Path = './Planetarium' +# diff-add +$config.CodeCoverage.OutputPath = './coverage.xml' +``` + +```xml title="coverage.xml" + + + + + +Last module: running all of this automatically on every push. diff --git a/tutorial/5-code-coverage/_category_.json b/tutorial/5-code-coverage/_category_.json new file mode 100644 index 00000000..a0dd0edb --- /dev/null +++ b/tutorial/5-code-coverage/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Code coverage", + "collapsed": false +} diff --git a/tutorial/6-ci/1-test-script.mdx b/tutorial/6-ci/1-test-script.mdx new file mode 100644 index 00000000..44eddc82 --- /dev/null +++ b/tutorial/6-ci/1-test-script.mdx @@ -0,0 +1,113 @@ +--- +id: test-script +title: A test script that fails +description: Turn the Pester configuration into a reusable script that produces artifacts and, critically, exits non-zero when tests fail +--- + +`test.ps1` has been growing since the output module: it runs the suite, prints detailed results and writes both artifacts. It needs one more line before a pipeline can rely on it — the one that makes it fail. + +## The missing line + +```powershell title="test.ps1" +$config = New-PesterConfiguration +$config.Run.Path = './Planetarium' +# diff-add +$config.Run.Exit = $true +$config.Output.Verbosity = 'Detailed' +$config.TestResult.Enabled = $true +$config.TestResult.OutputPath = './testResults.xml' +$config.CodeCoverage.Enabled = $true +$config.CodeCoverage.Path = './Planetarium' +$config.CodeCoverage.OutputPath = './coverage.xml' + +Invoke-Pester -Configuration $config +``` + +That is the finished script: run the tests, write both artifacts, exit non-zero if anything failed. + +## Why the exit code is the important line + +**A failing Pester test does not, by itself, fail your build.** + +By default `Invoke-Pester` reports failures and returns normally. Whether that becomes a red build then depends entirely on *how the script is invoked* — which is a horrible thing to leave to chance: + +```powershell +# Without Run.Exit, one failing test: +pwsh -NoProfile -File ./test.ps1 # exit code 0 — the build goes green +``` + +Pester does set `$LASTEXITCODE` either way, so some runners catch it regardless. GitHub Actions is one: its `pwsh` shell appends an `exit $LASTEXITCODE` for you, so the step fails even without `Run.Exit`. Run the same script under `pwsh -File` — a scheduled task, a Makefile, most other CI systems — and the failure vanishes. + +`Run.Exit = $true` removes the guesswork by making the script itself exit non-zero. Prove both directions rather than trusting it: + +```powershell +pwsh -NoProfile -File ./test.ps1 +$LASTEXITCODE +``` + +``` +Tests Passed: 26, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0 +0 +``` + +Now break something on purpose — change a `Should-Be 8` to `Should-Be 9` — and run it again: + +``` +Tests Passed: 25, Failed: 1, Skipped: 0, Inconclusive: 0, NotRun: 0 +1 +``` + +Exit code 1. *That* is what makes CI red. Put the `8` back. + +The code is not merely non-zero, it is **the number of failures**: five failing tests exit with `5`. Failed blocks count too, so a `BeforeAll` that throws while wrapping two tests exits with `3` — the two failed tests plus the failed block. Handy at a glance in a job list, though read the results file rather than doing arithmetic on an exit code. + +:::note `Invoke-Pester -CI` is the shorthand — but not for this +Pester has a `-CI` switch that sets exactly two things: + +```powershell +TestResult.Enabled = $true +Run.Exit = $true +``` + +It does **not** enable code coverage. Since this script wants coverage too — along with control over the output paths and verbosity — it sets those options directly instead. If you only need test results and a correct exit code, `Invoke-Pester -Path ./Planetarium -CI` is the one-liner version of most of this page. +::: + +## It still works locally + +You have been running this script since the output module, and adding `Run.Exit` does not change that: + +```powershell +./test.ps1 +$LASTEXITCODE +``` + +`Run.Exit` is safe interactively. It ends the script rather than your session, and leaves `$LASTEXITCODE` behind so you can check the result. + +Running the identical script locally and in CI removes an entire genre of problem — the one where a build fails remotely and cannot be reproduced because CI was doing something subtly different. + +## Artifacts + +Two files come out of every run: + +- `testResults.xml` — NUnit format, per-test results +- `coverage.xml` — JaCoCo format, coverage data + +Both are build output, so keep them out of version control: + +``` +testResults.xml +coverage.xml +``` + +They exist for the machine that runs the build, not for you. The next page hands them to GitHub. + + + +Next: running it on every push. diff --git a/tutorial/6-ci/2-github-actions.mdx b/tutorial/6-ci/2-github-actions.mdx new file mode 100644 index 00000000..72d0c273 --- /dev/null +++ b/tutorial/6-ci/2-github-actions.mdx @@ -0,0 +1,126 @@ +--- +id: github-actions +title: Running on every push +description: Wire the test script into a GitHub Actions workflow that installs Pester, runs the suite across operating systems and publishes the results +--- + +The test script is the hard part and it is already done. A CI workflow is mostly plumbing: check out the code, install Pester, run `./test.ps1`, keep the artifacts. + +## The workflow + +```yaml title=".github/workflows/test.yml" +name: Test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Pester + shell: pwsh + run: Install-Module Pester -MinimumVersion 6.0.0 -Force -Scope CurrentUser + + - name: Run tests + shell: pwsh + run: ./test.ps1 + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + testResults.xml + coverage.xml +``` + +Commit that and push. Every push to `main` and every pull request now runs your 26 tests. + +Three details are doing real work. + +**`shell: pwsh`** on every PowerShell step. The default shell on `ubuntu-latest` is bash, and PowerShell 7 is preinstalled but not the default. Omit this and the step tries to run PowerShell as bash. On Windows runners the default is `powershell` — Windows PowerShell 5.1 — which is a *different* shell from `pwsh`, so being explicit avoids a second, subtler version of the same problem. + +**`-Scope CurrentUser`** on the install. The runner user is not an administrator, and an all-users install needs elevation. + +**`if: always()`** on the upload. Without it, the artifact step is skipped whenever a previous step fails — which is precisely when you most want the test results. This one line is the difference between a red build you can diagnose from the artifact and one you have to reproduce locally. + +## Testing on more than one platform + +A PowerShell module that claims Windows PowerShell 5.1 support should be tested on it. A matrix runs the same job several times: + +```yaml title=".github/workflows/test.yml" +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Install Pester + shell: pwsh + run: Install-Module Pester -MinimumVersion 6.0.0 -Force -Scope CurrentUser + + - name: Run tests + shell: pwsh + run: ./test.ps1 + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }} + path: | + testResults.xml + coverage.xml +``` + +`fail-fast: false` matters here. The default cancels every other job the moment one fails, so a Windows-only bug would abort the Linux and macOS runs and hide whether the failure is platform-specific. Turning it off costs a few runner minutes and tells you far more. + +The artifact name has to include `${{ matrix.os }}` — three jobs uploading to the same artifact name is an error. + +:::note This is where the cross-platform bugs show up +`Join-Path` and `$TestDrive` have been quietly protecting you. Hard-coded `\` separators, case-sensitivity assumptions, and `C:\temp` paths all work on your machine and fail on Linux. A matrix is how you find them, and it is the main reason this module is worth doing even for a small module. +::: + +## Where to go from here + +The workflow above is a complete, working setup. Natural next steps, in rough order of value: + +- **Publish the test results** as a check run so failures annotate the pull request directly, using something like `dorny/test-reporter`, which reads the NUnit file you are already producing. +- **Send `coverage.xml` to a coverage service** — the JaCoCo format is widely supported. +- **Require the check** in branch protection, so a red build actually blocks the merge. Until you do this, CI is advisory. +- **Cache the Pester install** if the install step becomes a meaningful share of the run time. + +## You are done + +Starting from an empty folder, you have built a PowerShell module with a manifest, a loader, public and private functions, an external data file and a function that writes reports — and tested all of it: + +- 26 tests across six test files +- Public functions tested through the module's real front door, which proves they are exported +- Private helpers reached with `InModuleScope`, sparingly +- A data source replaced by mocks so tests own their data +- File operations isolated in `TestDrive`, leaving nothing behind +- 100% coverage, arrived at by reading `CommandsMissed` rather than chasing a number +- A test script that fails correctly, running on three operating systems on every push + +The reference documentation goes deeper on everything here: [mocking](../../docs/usage/mocking), [TestDrive](../../docs/usage/testdrive), [code coverage](../../docs/usage/code-coverage), [configuration](../../docs/usage/configuration) and [the result object](../../docs/usage/result-object). If you write assertions you wish existed, [custom assertions](../../docs/assertions/custom-assertions) is the next thing worth reading. + + diff --git a/tutorial/6-ci/_category_.json b/tutorial/6-ci/_category_.json new file mode 100644 index 00000000..289627ad --- /dev/null +++ b/tutorial/6-ci/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Setting up CI", + "collapsed": false +} From 803c455ee3ee7c2236be7c8728c53065081d093a Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:14:43 +0000 Subject: [PATCH 09/27] Minor adjustments --- docusaurus.config.js | 12 ++++++------ src/theme/DocItem/TOC/Desktop/index.js | 2 ++ src/theme/DocItem/TOC/Mobile/index.js | 2 ++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docusaurus.config.js b/docusaurus.config.js index e90e266a..bbeb241d 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -50,6 +50,12 @@ const config = { docId: 'quick-start', position: 'right' }, + { + type: 'doc', + label: 'Commands', + docId: 'commands/Add-ShouldOperator', + position: 'right' + }, { type: 'docSidebar', label: 'Tutorial', @@ -57,12 +63,6 @@ const config = { sidebarId: 'tutorial', position: 'right' }, - { - type: 'doc', - label: 'Commands', - docId: 'commands/Add-ShouldOperator', - position: 'right' - }, { label: 'GitHub', href: 'https://github.com/pester/pester', diff --git a/src/theme/DocItem/TOC/Desktop/index.js b/src/theme/DocItem/TOC/Desktop/index.js index 7e439cfc..fc06d799 100644 --- a/src/theme/DocItem/TOC/Desktop/index.js +++ b/src/theme/DocItem/TOC/Desktop/index.js @@ -9,6 +9,8 @@ import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData'; * On tutorial pages the progress tracker takes the place of the table of contents in the * right-hand column. The current page's headings are nested inside the tracker, so nothing * is lost — see src/components/TutorialTracker. + * + * Note: this is an unsafe swizzle. Check it when @docusaurus/theme-classic is upgraded. */ export default function DocItemTOCDesktopWrapper(props) { const { plugin } = useRouteContext(); diff --git a/src/theme/DocItem/TOC/Mobile/index.js b/src/theme/DocItem/TOC/Mobile/index.js index 3d54142f..65b2643c 100644 --- a/src/theme/DocItem/TOC/Mobile/index.js +++ b/src/theme/DocItem/TOC/Mobile/index.js @@ -9,6 +9,8 @@ import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData'; * Mobile counterpart of the desktop TOC replacement. Both variants are always in the DOM — * CSS decides which is visible — so the mobile one renders its headings without active-link * tracking to avoid two components competing over the same highlight classes. + * + * Note: this is an unsafe swizzle. Check it when @docusaurus/theme-classic is upgraded. */ export default function DocItemTOCMobileWrapper(props) { const { plugin } = useRouteContext(); From 8ecebed4f6c8326df3edbdeef797a9e0c1629c23 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:17:07 +0000 Subject: [PATCH 10/27] Hide version dropdown in navbar when not relevant --- .../DocsVersionDropdownNavbarItem.js | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js diff --git a/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js b/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js new file mode 100644 index 00000000..e8fe9a34 --- /dev/null +++ b/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js @@ -0,0 +1,24 @@ +import React from 'react'; +import useRouteContext from '@docusaurus/useRouteContext'; +import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem'; +import { TUTORIAL_PLUGIN_ID } from '@site/src/tutorial/tutorialData'; + +/** + * Note: this is an unsafe swizzle. It only adds a guard, so should be low risk, + * but check it when @docusaurus/theme-classic is upgraded. + */ +export default function DocsVersionDropdownNavbarItemWrapper(props) { + const { plugin } = useRouteContext(); + + // Only show version dropdown while browsing versioned docs (default plugin) + if (plugin?.name === 'docusaurus-plugin-content-docs' && plugin?.id === 'default') { + return ( + <> + + + ); + } + + // Hide version dropdown everywhere else, ex. home page and unversioned tutorial plugin. + return null; +} From d95a6ae0b903db262206e03a3b7f86d8df54766e Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:30:23 +0000 Subject: [PATCH 11/27] Trigger CI --- src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js b/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js index e8fe9a34..b6b1ed71 100644 --- a/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js +++ b/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js @@ -19,6 +19,6 @@ export default function DocsVersionDropdownNavbarItemWrapper(props) { ); } - // Hide version dropdown everywhere else, ex. home page and unversioned tutorial plugin. + // Hide version dropdown everywhere else, like home page and unversioned tutorial plugin. return null; } From bf6bc23148a5c39f9f0b941158037e3b535bdbf2 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:43:24 +0000 Subject: [PATCH 12/27] Minor update to trigger CI --- tutorial/1-introduction/2-prerequisites.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorial/1-introduction/2-prerequisites.mdx b/tutorial/1-introduction/2-prerequisites.mdx index 4c097ac3..3a4d0bb9 100644 --- a/tutorial/1-introduction/2-prerequisites.mdx +++ b/tutorial/1-introduction/2-prerequisites.mdx @@ -53,7 +53,7 @@ PowerShell loads a module automatically the first time you call one of its comma ## An editor -Any editor will do, but [Visual Studio Code](https://code.visualstudio.com/) with the [PowerShell extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.PowerShell) gives you the best experience here: tests get a green or red gutter icon next to them, and you can run a single test without leaving the file. +Any editor will do, but [Visual Studio Code](https://code.visualstudio.com/) with the [PowerShell extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.PowerShell) gives you the best experience: discovering the tests and lets you run or debug individual tests without leaving the file. The [VS Code page](../../docs/usage/vscode) has the setup details. It is genuinely optional — everything in this tutorial is done from the terminal and works the same way regardless of what you type it in. From 2fb6c44bc59d40f5b88702d0cb3fe207e30cf92f Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:57:01 +0000 Subject: [PATCH 13/27] Split Commands to own sidebar and use a root category in each sidebar for visibility --- docusaurus.config.js | 4 +- sidebars.js | 140 ++++++++++++++++------------- sidebarsTutorial.js | 15 +++- src/tutorial/useTutorialOutline.js | 2 + 4 files changed, 94 insertions(+), 67 deletions(-) diff --git a/docusaurus.config.js b/docusaurus.config.js index bbeb241d..7d49e00f 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -48,12 +48,14 @@ const config = { type: 'doc', label: 'Docs', docId: 'quick-start', + sidebarId: 'docs', position: 'right' }, { type: 'doc', label: 'Commands', docId: 'commands/Add-ShouldOperator', + sidebarId: 'commands', position: 'right' }, { @@ -198,7 +200,7 @@ const config = { editUrl: 'https://github.com/pester/docs/edit/main', // The progress tracker already shows the module, the page and where both sit in the // tutorial, so breadcrumbs would only repeat it. - breadcrumbs: false, + breadcrumbs: true, // Do not set disableVersioning here. It throws for an instance with no versions file. // Omitting it gives a single implicit version and no version dropdown, which is what we want. }), diff --git a/sidebars.js b/sidebars.js index 9bf724c6..bf0cafe6 100644 --- a/sidebars.js +++ b/sidebars.js @@ -1,11 +1,6 @@ /** - * Copyright (c) 2017-present, Facebook, Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * PlEASE NOTE: - * The API pages for pester-docs are generated using the + * NOTE: + * The Commands-pages for pester-docs are generated using the * Alt3.Docusaurus.Powershell module. It takes the Get-Help * information produced by the Pester module and uses it to * 1) generate .mdx files with the required Docusaurus (markdown) @@ -22,60 +17,79 @@ import commands from "./docs/commands/docusaurus.sidebar.js"; -module.exports = { - docs: { - Introduction: [ - "quick-start", - "introduction/installation", - ], - Usage: [ - "usage/file-placement-and-naming", - "usage/importing-tested-functions", - "usage/test-file-structure", - "usage/discovery-and-run", - "usage/parallel", - "usage/data-driven-tests", - "usage/setup-and-teardown", - "usage/tags", - "usage/skip", - "usage/mocking", - "usage/modules", - "usage/testdrive", - "usage/testregistry", - "usage/test-results", - "usage/code-coverage", - "usage/configuration", - "usage/output", - "usage/result-object", - "usage/vscode", - "usage/troubleshooting", - ], - "Assertions": [ - "assertions/should-command", - "assertions/should-beequivalent", - "assertions/soft-assertions", - "assertions/assertions", - "assertions/custom-assertions", - ], - "Migration Guides": [ - "migrations/v5-to-v6", - "migrations/v4-to-v5", - "migrations/breaking-changes-in-v5", - "migrations/v3-to-v4", - ], - "Additional Resources": [ - "additional-resources/articles", - "additional-resources/courses", - "additional-resources/misc", - "additional-resources/projects", - "additional-resources/videos", - ], - "Contributing": [ - "contributing/introduction", - "contributing/reporting-issues", - "contributing/feature-requests", - "contributing/pull-requests", - ], - "Command Reference": commands - } +/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ +const sidebars = { + docs: [ + { + type: 'category', + label: 'Documentation', + collapsible: false, + items: [ + { + "Introduction": [ + "quick-start", + "introduction/installation", + ], + "Usage": [ + "usage/file-placement-and-naming", + "usage/importing-tested-functions", + "usage/test-file-structure", + "usage/discovery-and-run", + "usage/parallel", + "usage/data-driven-tests", + "usage/setup-and-teardown", + "usage/tags", + "usage/skip", + "usage/mocking", + "usage/modules", + "usage/testdrive", + "usage/testregistry", + "usage/test-results", + "usage/code-coverage", + "usage/configuration", + "usage/output", + "usage/result-object", + "usage/vscode", + "usage/troubleshooting", + ], + "Assertions": [ + "assertions/should-command", + "assertions/should-beequivalent", + "assertions/soft-assertions", + "assertions/assertions", + "assertions/custom-assertions", + ], + "Migration Guides": [ + "migrations/v5-to-v6", + "migrations/v4-to-v5", + "migrations/breaking-changes-in-v5", + "migrations/v3-to-v4", + ], + "Additional Resources": [ + "additional-resources/articles", + "additional-resources/courses", + "additional-resources/misc", + "additional-resources/projects", + "additional-resources/videos", + ], + "Contributing": [ + "contributing/introduction", + "contributing/reporting-issues", + "contributing/feature-requests", + "contributing/pull-requests", + ], + } + ], + } + ], + commands: [ + { + type: 'category', + label: 'Command Reference', + items: commands, + collapsible: false, + }, + ], }; + +export default sidebars; diff --git a/sidebarsTutorial.js b/sidebarsTutorial.js index 255374c4..889fda0d 100644 --- a/sidebarsTutorial.js +++ b/sidebarsTutorial.js @@ -14,11 +14,20 @@ */ /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ -module.exports = { +const sidebars = { tutorial: [ { - type: 'autogenerated', - dirName: '.', + type: 'category', + label: 'Tutorial', + collapsible: false, + items: [ + { + type: 'autogenerated', + dirName: '.', + } + ], }, ], }; + +export default sidebars; diff --git a/src/tutorial/useTutorialOutline.js b/src/tutorial/useTutorialOutline.js index 678eb176..5b1915b7 100644 --- a/src/tutorial/useTutorialOutline.js +++ b/src/tutorial/useTutorialOutline.js @@ -22,6 +22,8 @@ export function useTutorialOutline(currentPageId) { return useMemo(() => { const written = (sidebar?.items ?? []) + // Start from root "Tutorial" category + .filter((item) => item.type === 'category' && item.label === 'Tutorial')[0]?.items .filter((item) => item.type === 'category') .map((category) => { const modulePages = category.items From e8091d286be0b6346b7963d862a5a34cca8ac437 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:59:10 +0000 Subject: [PATCH 14/27] Underline active navbar link (sidebar) --- src/css/custom.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/css/custom.css b/src/css/custom.css index cb96f82c..006774f7 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -131,7 +131,8 @@ font-size: 19px; } -.navbar__link:hover { +.navbar__link:hover, +.navbar__link--active { text-decoration: underline; } From 77b39661334540c28cb065ab98e93393cf3045c4 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:44:02 +0000 Subject: [PATCH 15/27] Adjust title on tracker --- src/components/TutorialTracker/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/TutorialTracker/index.js b/src/components/TutorialTracker/index.js index 5f395cfc..6951e17c 100644 --- a/src/components/TutorialTracker/index.js +++ b/src/components/TutorialTracker/index.js @@ -164,7 +164,7 @@ export default function TutorialTracker({ currentPageId, variant = 'desktop' }) return (
- {complete &&

Page complete. Your progress is saved in this browser.

} + {complete &&

Page complete.

} ); } From 1d9a5165d2b8ec207de064c8155d79585373ef07 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:58:59 +0000 Subject: [PATCH 26/27] Cleanup config --- docusaurus.config.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docusaurus.config.js b/docusaurus.config.js index 7d49e00f..db8655f8 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -198,11 +198,6 @@ const config = { routeBasePath: 'tutorial', sidebarPath: './sidebarsTutorial.js', editUrl: 'https://github.com/pester/docs/edit/main', - // The progress tracker already shows the module, the page and where both sit in the - // tutorial, so breadcrumbs would only repeat it. - breadcrumbs: true, - // Do not set disableVersioning here. It throws for an instance with no versions file. - // Omitting it gives a single implicit version and no version dropdown, which is what we want. }), ], ], From 092264efe1b991c07c248ba91f693ea5e1bab1e2 Mon Sep 17 00:00:00 2001 From: Frode Flaten <3436158+fflaten@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:20:01 +0000 Subject: [PATCH 27/27] Revert "Fix stuck hover effect on mobile" This reverts commit 172e2e218e56d942f09657e574564a9e91a0c15e. --- src/css/custom.css | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/css/custom.css b/src/css/custom.css index 1064a6e4..22b80593 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -80,7 +80,7 @@ --ifm-navbar-link-color: #fff; --ifm-navbar-link-hover-color: var(--ifm-navbar-link-color); /* Hover overlay shared by the navbar links and the colour-mode toggle. */ - --nav-hover-overlay: rgb(0, 0, 0, 0.2); + --nav-hover-overlay: rgb(0, 0, 0, 0.2); --ifm-code-font-size: 90%; --ifm-font-weight-semibold: 600; --ifm-font-size-base: 16px; @@ -116,10 +116,8 @@ color: #fff; } -@media (hover: hover) { - .navbar button[class*='toggleButton']:hover { - background: var(--nav-hover-overlay); - } +.navbar button[class*='toggleButton']:hover { + background: var(--nav-hover-overlay); } /* navbar styling */ @@ -141,11 +139,9 @@ transition: background-color var(--ifm-transition-fast) var(--ifm-transition-timing-default); } -@media (hover: hover) { - .navbar__link.navbar__item:hover { - background-color: var(--nav-hover-overlay); - border-radius: var(--ifm-global-radius); - } +.navbar__link.navbar__item:hover { + background-color: var(--nav-hover-overlay); + border-radius: var(--ifm-global-radius); } /* Adds a border below the active navbar item (sidebar) on desktop. */