# Tutorial YAML Spec Format This document describes the YAML format used by `tools/tutorial_runner.py` to define executable tutorials. Each `.test.yaml` file is the **single source of truth** — it drives both: - **Execution** (`run`): steps are executed in a temp directory, commands run, outputs verified - **Markdown generation** (`generate`): a `.md` tutorial is produced from the same YAML ## Quick example ```yaml name: "My Tutorial" output: my-tutorial.md release: "" intro: | One-paragraph description of what this tutorial covers. what_you_build: "A short sentence describing the end result." what_you_learn: - First learning objective - Second learning objective prerequisites: - "**Nix** with flakes enabled." sections: - title: "Set Up the Project" phase: scaffold text: | Intro paragraph for this section. steps: - title: "Create the directory" run: "mkdir -p my-project" - title: "Write the config" text: "Create `config.json`:" file: path: config.json language: json content: | { "name": "example" } - title: "Build" run: "nix build" expect_contains: - "Build successful" ``` ## Top-level fields | Field | Required | Type | Description | |-------|----------|------|-------------| | `name` | yes | string | Tutorial title. Used as the `# heading` in generated markdown and in runner output. | | `output` | no | string | Default output filename for `generate` (relative to the tutorial directory, e.g., `tutorial-wrapping-c-library.md`). Can be overridden with `-o`. | | `project_name` | no | string | Directory name for this tutorial's project (e.g., `logos-calc-module`). Used when chaining tutorials via `requires:` — each tutorial runs in a subdirectory of a shared parent. Ignored when running standalone. | | `requires` | no | list of strings | Paths to prerequisite `.test.yaml` specs (relative to the current spec). The runner executes each prerequisite first in a sibling subdirectory (named by its `project_name`), then runs the current tutorial. Prerequisites are resolved **transitively** — if A requires B and B requires C, the run order is C, B, A. Shared prerequisites run once (deduped), and circular `requires:` are reported as an error. This enables cross-tutorial references like `../logos-calc-module`. Requires `project_name` on both the current and prerequisite specs. | | `intro` | no | string | Introductory paragraph(s). Rendered after the title in the markdown. Supports full markdown. | | `what_you_build` | no | string | One-line summary prefixed with "**What you'll build:**" in the markdown. | | `what_you_learn` | no | list of strings | Bullet list prefixed with "**What you'll learn:**". | | `comparison` | no | string | Free-form markdown block rendered after the learning objectives (useful for comparison tables). | | `prerequisites` | no | list of strings | Rendered as a bullet list under a prerequisites heading. Each item can contain markdown (code blocks, links, etc.). | | `release` | no | string | Git tag applied to all `{release}` placeholders in GitHub URLs (e.g., `tutorial-v2`). See [Release tags](#release-tags). | | `build_overrides` | no | map | Nix `--override-input` flags for the runner. Keys are input names, values are relative paths to local repos. Only affects execution, not generation. | | `sections` | yes | list | The tutorial content. See below. | ## Sections Each section becomes a `## heading` in the markdown. Sections with `step: true` get auto-numbered as "Step N: Title". | Field | Required | Type | Description | |-------|----------|------|-------------| | `title` | yes | string | Section heading. | | `step` | no | boolean | If `true`, this section is numbered as "Step N: Title" in markdown and gets a `---` separator after it (except the last section). Sections without `step` render as plain `## Title`. | | `text` | no | string | Introductory prose rendered before the steps. Supports full markdown (tables, blockquotes, code blocks, etc.). | | `steps` | no | list | Ordered list of steps. See below. | Sections without `steps` are prose-only — the `text` is rendered as-is. This is useful for reference sections like "Troubleshooting" or "Common Patterns". ## Steps Steps are the core building blocks. Each step can combine multiple fields. The rendering order in the generated markdown is: 1. `title` → `### heading` 2. `text` → prose paragraph 3. `file` → code block with file contents 4. `run` → bash code block 5. `post_text` → prose after the action 6. `extra_run` → additional command block (no heading) A step without a `title` renders its content inline under the previous heading — useful for continuation content like "Then run:" followed by a code block. ### Step fields #### `title` (string, optional) Rendered as a `### heading` in the markdown. Steps without a title don't get a heading — their content flows under the previous step's heading. #### `text` (string, optional) Prose rendered before any action. Supports full markdown. #### `file` (object, optional) Writes a file to disk during execution and renders it as a code block in the markdown. | Subfield | Type | Description | |----------|------|-------------| | `path` | string | Relative path within the project (e.g., `src/main.cpp`, `lib/libcalc.h`). | | `content` | string | The file contents. | | `language` | string | Syntax highlighting hint for the markdown code block. Auto-detected from extension if omitted (`.c` → `c`, `.h`/`.cpp` → `cpp`, `.json` → `json`, `.nix` → `nix`, etc.). | | `encoding` | string | Set to `base64` for binary files. Renders as `*Binary file: \`path\`*` instead of a code block. | **Runner behavior:** Creates parent directories and writes the file to disk. **Generator behavior:** Renders ` ```language ` code block with the content. #### `run` (string, optional) A shell command to execute and display. | Subfield | Type | Description | |----------|------|-------------| | (value) | string | The command to execute. Supports `{ext}` (expands to `so` or `dylib`) and `{shared_flags}` (expands to `-shared -fPIC` or `-dynamiclib`). | **Runner behavior:** Expands platform placeholders, injects nix overrides if applicable, and runs the command. Checks exit code (0 = pass, non-zero = fail). **Generator behavior:** Renders the command in a ` ```bash ` block. If `code_block` is present on the step, renders that instead (see below). #### `code_block` (string, optional) The exact content to show in the generated markdown, used **instead of** the `run` command. This is for cases where: - The executed command differs from what readers should see (e.g., `&&`-chained commands displayed as separate lines) - Platform-specific variants should be shown (Linux and macOS versions) - The display should include comments, blank lines, or additional context The runner ignores `code_block` — it only uses `run` for execution. ```yaml - title: "Build the shared library" run: "cd lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd .." code_block: | cd lib # Linux gcc -shared -fPIC -o libcalc.so libcalc.c # macOS # gcc -shared -fPIC -o libcalc.dylib libcalc.c cd .. ``` #### `expect_contains` (list of strings, optional) Assertions checked by the runner against command output. Not rendered in the markdown. ```yaml run: "./lm/bin/lm metadata result/lib/calc_module_plugin.{ext}" expect_contains: - "Name: calc_module" - "Version: 1.0.0" ``` **Runner behavior:** Captures stdout+stderr, checks that every string appears in the output. **Generator behavior:** Ignored — not rendered. Use `post_text` to show expected output to readers. #### `check_file` (string, optional) Verifies a file exists. Runner-only, not rendered in the markdown. ```yaml - check_file: "result/lib/calc_module_plugin.{ext}" ``` **Runner behavior:** Expands `{ext}`, globs for the file, passes if found. **Generator behavior:** Ignored — not rendered. #### `ui_test` (object, optional) Runs headless UI tests against a Qt app using [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp). The app is launched with `QT_QPA_PLATFORM=offscreen` (no display needed) and tests connect to the QML inspector to verify elements, click buttons, and check results. Two modes: - **Launch mode** (preferred): `launch` runs the app as a background process, tests connect to its inspector, app is killed when done. The `launch` command is rendered in the generated markdown. - **Binary mode**: `build` + `binary` let the test framework manage the app via `--ci`. Not rendered in markdown. | Subfield | Type | Description | |----------|------|-------------| | `launch` | string | Command to launch the app (e.g., `nix run .`). Launched as a background process with offscreen Qt. **Rendered** in generated markdown as a bash code block. | | `build` | string | Command to build the app binary (binary mode only, not rendered). | | `binary` | string | Path to the app binary or `nix-app` to auto-resolve from flake (binary mode only). | | `qt_mcp` | string | Path to the logos-qt-mcp package, relative to workdir (e.g., `result-mcp`). Falls back to `--qt-mcp` CLI flag or `LOGOS_QT_MCP` env var. | | `setup` | list of strings | Commands to run before testing (e.g., `nix build 'github:logos-co/logos-qt-mcp' -o result-mcp`). | | `inspector_port` | integer | TCP port for the QML inspector (default: 3768). | | `tests` | list of objects | Test actions to execute. See below. | **Test actions:** | Action | Fields | What it does | |--------|--------|--------------| | `click` | `target` | Find element by text and click it | | `wait_for` | `texts`, `timeout` (ms, default 10000), `name` | Poll until all texts are visible | | `expect_texts` | `texts` | Assert all texts are visible now | | `set_text` | `find_by`, `find_value`, `value` | Find element by property and set its `text` property | | `sleep` | `ms` | Wait a fixed duration | Any action may also carry an optional **`screenshot`** field (a filename, e.g. `screenshot: "result.png"`). After that action runs, the runner captures the headless app via the qt-mcp `app.screenshot()` API and writes the PNG to an `images/` directory next to the generated `.md` (`/images/`; temp runs fall back to the workdir). The generator then embeds it inline in that section as `![](images/.png)`, so screenshots appear in the published tutorial with no extra markup. The `.png` extension is added if omitted; the value is reduced to a basename. The two-column HTML report (`--report`) inlines each captured screenshot as a base64 `data:` URI, so the report stays a single self-contained file that renders the screenshots even when served from GitHub Pages (where only the `index.html` is published). If a capture is missing (e.g. its step failed), the report falls back to the relative `images/.png` link. **Runner behavior (launch mode):** Runs setup commands, launches the app in the background with `QT_QPA_PLATFORM=offscreen`, waits for the QML inspector to be available, generates a `.mjs` test file, runs it, then kills the app. Reports pass/fail. **Runner behavior (binary mode):** Runs setup + build, generates a `.mjs` test file, runs it via `node test.mjs --ci --verbose`. Reports pass/fail. **Generator behavior:** In launch mode, `launch` is rendered as a ` ```bash ` code block. In binary mode, nothing is rendered. Use `text:` and `post_text:` for additional user-facing prose. ```yaml # Launch mode (preferred) — what's shown is what's executed - ui_test: launch: "nix run ." setup: - "nix build 'github:logos-co/logos-qt-mcp' -o result-mcp" qt_mcp: "result-mcp" tests: - name: "Title visible" action: wait_for texts: ["Logos Calculator"] timeout: 15000 - name: "Enter number" action: set_text find_by: "placeholderText" find_value: "a" value: "3" - name: "Click Add" action: click target: "Add" - name: "Result shows 3" action: wait_for texts: ["3"] timeout: 10000 ``` #### `post_text` (string, optional) Prose rendered **after** the step's action (file, run). Supports full markdown including code blocks, tables, blockquotes. Use this for: - Expected output blocks - Explanations of what just happened - Callout boxes and tips ```yaml - title: "View metadata" run: "./lm/bin/lm metadata result/lib/plugin.{ext}" post_text: | Output: ``` Plugin Metadata: ================ Name: my_module Version: 1.0.0 ``` ``` #### `extra_run` (object, optional) A continuation command rendered under the same step heading (no separate `###`). Useful when a step has two related commands (e.g., build then verify). | Subfield | Type | Description | |----------|------|-------------| | `run` | string | Command to execute. | | `code_block` | string | Display override (same as step-level `code_block`). | | `post_text` | string | Prose after the extra command. | ```yaml - title: "Build the shared library" run: "cd lib && gcc {shared_flags} -o libcalc.{ext} libcalc.c && cd .." code_block: | cd lib gcc -shared -fPIC -o libcalc.so libcalc.c cd .. post_text: "Verify the symbols are exported:" extra_run: run: "nm -gU lib/libcalc.{ext} | grep calc" code_block: | # Linux nm -D lib/libcalc.so | grep calc # macOS # nm -gU lib/libcalc.dylib | grep calc post_text: | You should see symbols marked with `T`. ``` ## Platform placeholders These placeholders are expanded at execution time by the runner and at generation time in rendered content: | Placeholder | Linux | macOS | |-------------|-------|-------| | `{ext}` | `so` | `dylib` | | `{shared_flags}` | `-shared -fPIC` | `-dynamiclib` | Platform placeholders work in `run`, `check_file`, `extra_run.run`, and `file.content` fields. They are **not** expanded in `code_block`, `text`, or `post_text` — those are rendered verbatim. When a command uses platform placeholders, provide a `code_block` showing both platform variants for the markdown. ## Release tags The `release` field lets you pin all GitHub URLs to a specific git tag. This avoids updating every URL individually when you want all `nix build 'github:logos-co/...'` commands to use the same release. Use the `{release}` placeholder in `run` commands, `code_block`, and `file.content`: ```yaml release: "tutorial-v2" sections: - title: "Set Up" steps: - run: "nix flake init -t github:logos-co/logos-module-builder{release}#with-external-lib" - run: "nix build 'github:logos-co/logos-module{release}#lm' --out-link ./lm" ``` When `release` is set to `"tutorial-v2"`, `{release}` expands to `/tutorial-v2`: ```bash nix build 'github:logos-co/logos-module/tutorial-v2#lm' --out-link ./lm ``` When `release` is empty or omitted, `{release}` expands to nothing: ```bash nix build 'github:logos-co/logos-module#lm' --out-link ./lm ``` The `--release` CLI flag overrides the YAML field: ```bash # Use a specific tag (overrides whatever is in the YAML) python3 tools/tutorial_runner.py run spec.yaml --release tutorial-v3 # Generate markdown with a tag python3 tools/tutorial_runner.py generate spec.yaml --release tutorial-v2 # Clear the tag even if the YAML sets one python3 tools/tutorial_runner.py run spec.yaml --release "" ``` ## Runner behavior - Creates a fresh temp directory (or uses `--output-dir` / `--workdir`) — all files, builds, and commands happen there - Walks sections and steps in order - Executes `file`, `run`, `check_file`, `ui_test` actions - Tracks pass/fail/skip counts - **Stops on first failure** by default (use `--continue-on-fail` to override) - Prints a summary report at the end (and an HTML report with `--report `) - By default the temp directory is **deleted** when the run finishes (use `--output-dir` or `--keep-workdir` to keep it) ### Working directory The runner needs a directory to work in. There are four modes: 1. **Default (temp dir, auto-deleted):** A fresh `/tmp/tutorial-test-XXXXX/` is created and removed after the run. 2. **`--keep-workdir`:** Same temp dir, but it's kept after the run so you can inspect the results (built artifacts, installed modules, etc.). 3. **`--output-dir `:** Run into `` and **keep it** — created if missing, never auto-deleted. This is the mode to use when you want the finished tutorial output (built modules, `.lgx` packages, `result/` symlinks) in a known location. For a chained tutorial (`requires:`) `` becomes the chain root and each project lands in `//`; for a standalone spec the project is written directly into ``. The path is printed as `output : ` at the end of the run. 4. **`--workdir `:** Use an **existing** directory. It is never created for you, and it runs the spec **standalone** — `requires:` prerequisite chains are *not* run in this mode (the directory is used as the single project's workdir). Useful for re-running a spec against a previous build or for debugging. Note: unless `--keep-workdir` is also given, the directory is deleted on exit. Prefer `--output-dir` when you want a persistent, chain-aware output location. The workdir path is printed at the top of every run: ``` workdir : /tmp/tutorial-test-abc123 ``` ### Tutorial chaining (`requires`) When a spec has `requires:`, the runner creates a shared parent directory and runs each prerequisite before the main tutorial. Each tutorial gets its own subdirectory named by `project_name`: ``` # Part 2 requires Part 1: requires: - tutorial-wrapping-c-library.test.yaml project_name: logos-calc-ui ``` ``` # Running Part 2 automatically runs Part 1 first: python3 tools/tutorial_runner.py run tests/tutorial-qml-ui-app.test.yaml # Resulting directory structure: /tmp/tutorial-chain-XXXXX/ ├── logos-calc-module/ # Part 1 (prerequisite) └── logos-calc-ui/ # Part 2 (main) ``` Commands like `../logos-calc-module` in Part 2 resolve to Part 1's output. Results are cumulative — a failure in any tutorial stops the chain (unless `--continue-on-fail`). `requires:` is resolved **transitively**. A spec only needs to declare its *direct* prerequisites; their prerequisites are pulled in automatically. For the three-part series, Part 3 can simply declare Part 2: ``` # Part 3 requires Part 2, which itself requires Part 1: requires: - tutorial-qml-ui-app.test.yaml project_name: logos-calc-ui-cpp ``` ``` # Running Part 3 resolves the whole graph and runs in dependency order: python3 tools/tutorial_runner.py run tests/tutorial-cpp-ui-app.test.yaml # Resulting directory structure (Part 1 → Part 2 → Part 3): /tmp/tutorial-chain-XXXXX/ ├── logos-calc-module/ # Part 1 (transitive prerequisite, runs first) ├── logos-calc-ui/ # Part 2 (direct prerequisite, runs second) └── logos-calc-ui-cpp/ # Part 3 (main, runs last) ``` The graph is walked depth-first in post-order, so a prerequisite always runs before the spec that needs it. A prerequisite shared by multiple specs runs only once, and a circular `requires:` chain is reported as an error rather than looping forever. When running standalone (no `requires:`), `project_name` is ignored and the runner behaves as before. ### HTML execution report (`--report`) `run --report ` writes a self-contained HTML report next to (or instead of) the console output. The report has **two columns per step**: - **Left:** the step's rendered tutorial markdown — identical to what `generate` produces, so the report shows exactly what the reader sees. - **Right:** the command(s) actually executed for that step and their captured output, each with a pass/fail badge and exit code. It records every executed action type: | Step type | Recorded on the right | |-----------|-----------------------| | `file` | the path written and a pass/fail marker (contents already shown on the left) | | `run` / `extra_run` | the expanded command, exit code, and combined stdout/stderr (both the command and its `(verify)` continuation appear) | | `check_file` | the glob checked and which file matched | | `ui_test` | the launch command, the list of UI test actions, and the framework output (plus the app log tail on failure) | When a chain runs (`requires:`), every tutorial in the chain is included; a dropdown at the top switches between them. Markdown is rendered client-side via [marked.js](https://marked.js.org/) loaded from a CDN, so viewing the report needs network access on first open. Pair `--report` with `--continue-on-fail` so the report captures the full run rather than stopping at the first failure. The CI workflow ([`.github/workflows/ci.yml`](../.github/workflows/ci.yml)) runs with `--report` and publishes the result to GitHub Pages, linked from a PR comment. ### Live TUI (`--tui` / `--iterative`) `run --tui` shows the same two-column view live in the terminal instead of (or in addition to) writing an HTML file. The left pane renders the current step's tutorial markdown; the right pane shows the command being executed and its output, updating as the run proceeds. Execution goes through the exact same handlers as a normal run, so behaviour is identical — only the presentation differs. - **Auto mode (default):** steps execute back-to-back; failed steps pause briefly so they're readable. - **`--iterative`:** the runner waits for a keypress before executing each step — **down arrow**, **right arrow**, or **space** to advance; **`q`** to quit. (Using `--iterative` without `--tui` is an error.) `--tui` requires an interactive terminal (a TTY) and the [`rich`](https://github.com/Textualize/rich) package. `rich` is an **optional** dependency used only for the TUI; if it's not installed the runner prints an install hint and exits. All other functionality needs only `pyyaml`. ## Generator behavior - Walks the same YAML structure - Produces markdown with proper headings, code blocks, and prose - Sections with `phase` get numbered as "Step 1:", "Step 2:", etc. - Sections without `phase` get plain `## Title` headings - `expect_contains` and `check_file` are not rendered (runner-only) - `code_block` overrides the `run` value for display - `file` content is rendered as a fenced code block with syntax highlighting - Triple blank lines are collapsed to double ## File layout ``` repos/logos-tutorial/ ├── tests/ │ └── tutorial-wrapping-c-library.test.yaml # The spec ├── tools/ │ ├── tutorial_runner.py # Runner + generator │ └── run-tutorial # Nix wrapper script ├── tutorial-wrapping-c-library.md # Generated output └── docs/ └── spec.md # This file ``` ## Usage ```bash # Run a tutorial (temp dir, auto-deleted) python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --verbose # Run into a persistent output directory (created if missing, never deleted). # For a chained tutorial each part lands in //. python3 tools/tutorial_runner.py run tests/tutorial-cpp-ui-app.test.yaml \ --output-dir ./outputs --continue-on-fail # Keep the auto-generated temp directory for debugging python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --keep-workdir --verbose # Re-run a spec standalone against an existing directory python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --workdir /tmp/my-tutorial-test --verbose # Write a two-column HTML report (rendered tutorial + commands run and their output) python3 tools/tutorial_runner.py run tests/tutorial-cpp-ui-app.test.yaml \ --report ./tutorial-report.html --continue-on-fail # Generate markdown python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test.yaml # Generate to a specific file python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test.yaml -o my-output.md # Use nix wrapper (ensures python3 + pyyaml are available) ./tools/run-tutorial run tests/tutorial-wrapping-c-library.test.yaml --verbose ```