mirror of
https://github.com/logos-co/logos-tutorial.git
synced 2026-08-30 20:31:06 +00:00
472 lines
19 KiB
Markdown
472 lines
19 KiB
Markdown
# 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 |
|
|
|
|
**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 <binary> --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 `--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
|
|
- By default the temp directory is **deleted** when the run finishes
|
|
|
|
### Working directory
|
|
|
|
The runner needs a directory to work in. There are three 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. **`--workdir <path>`:** Use your own directory. It is never deleted. Useful for re-running specific phases against a previous build or for debugging.
|
|
|
|
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.
|
|
|
|
## 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 (all phases)
|
|
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --verbose
|
|
|
|
# Run specific phases
|
|
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --phase scaffold,files,build
|
|
|
|
# Run into a specific directory (kept after run, useful for inspecting results)
|
|
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --workdir /tmp/my-tutorial-test --verbose
|
|
|
|
# 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 just the logoscore phase against a previous build
|
|
python3 tools/tutorial_runner.py run tests/tutorial-wrapping-c-library.test.yaml --workdir /tmp/my-tutorial-test --phase logoscore --verbose
|
|
|
|
# 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
|
|
```
|