From e87e0798c0ca35c4844f4539eca0cb6e61bd069e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:50:30 +0000 Subject: [PATCH 1/2] Add live lab mode and a trick to keep on finished labs LIVE=1 make lab shows one step at a time, waits for Enter, reveals Expect after it, then runs the proofs. Labs may carry a one-line trick shown once the lab is done. Co-Authored-By: Claude Sonnet 5.5 --- labs/start/01-first-prompt.md | 4 ++ labs/start/02-run-the-gate.md | 4 ++ labs/start/04-local-memory.md | 4 ++ scripts/labs/catalog.test.ts | 19 +++++++ scripts/labs/catalog.ts | 8 +++ scripts/labs/cli.test.ts | 42 +++++++++++++- scripts/labs/cli.ts | 32 ++++++++++- scripts/labs/live.test.ts | 103 ++++++++++++++++++++++++++++++++++ scripts/labs/live.ts | 45 +++++++++++++++ scripts/labs/main.ts | 13 ++++- scripts/labs/render.test.ts | 21 ++++++- scripts/labs/render.ts | 43 +++++++++++--- 12 files changed, 324 insertions(+), 14 deletions(-) create mode 100644 scripts/labs/live.test.ts create mode 100644 scripts/labs/live.ts diff --git a/labs/start/01-first-prompt.md b/labs/start/01-first-prompt.md index a185cbc..c8d0dc9 100644 --- a/labs/start/01-first-prompt.md +++ b/labs/start/01-first-prompt.md @@ -48,6 +48,10 @@ Answer: Nothing prints and the answer lands in the file, because -p writes one a 2. `-p` prints one answer and exits, so `>` can capture it. 3. If `claude` is not found, install it from the official docs, then run `make doctor` again. +## Trick + +Add `--output-format json` to `claude -p` and the answer comes back as one JSON result that a script can parse. + ## What you just learned `claude -p` is Claude Code as a command: a question in, an answer out, no chat window. diff --git a/labs/start/02-run-the-gate.md b/labs/start/02-run-the-gate.md index 5807855..cf19d06 100644 --- a/labs/start/02-run-the-gate.md +++ b/labs/start/02-run-the-gate.md @@ -57,6 +57,10 @@ Answer: The full test summary, with a passed line and no failed line, because a 2. `2>&1` sends errors into the same log as the output. 3. If a test fails, you changed something under `shared/`. Undo it and run the step again. +## Trick + +Type `/gate` in Claude Code. The project command in `.claude/commands/gate.md` runs `make check` and reports only what failed. + ## What you just learned A change is only done when a command you can run says so. Know the green output first, then judge Claude's work against it. diff --git a/labs/start/04-local-memory.md b/labs/start/04-local-memory.md index 7a08613..83aff0c 100644 --- a/labs/start/04-local-memory.md +++ b/labs/start/04-local-memory.md @@ -54,6 +54,10 @@ Answer: No. CLAUDE.local.md is gitignored, so the note stays on your machine and 2. The file is already in `.gitignore`, so it never reaches a commit. 3. Use `>>` to append. A single `>` replaces the file. +## Trick + +Type `/memory` in Claude Code to see which instruction files loaded, so you can confirm your `CLAUDE.local.md` came in. + ## What you just learned `AGENTS.md` is for the team and `CLAUDE.local.md` is for you. Private habits never belong in a shared file. diff --git a/scripts/labs/catalog.test.ts b/scripts/labs/catalog.test.ts index f90a463..e756b84 100644 --- a/scripts/labs/catalog.test.ts +++ b/scripts/labs/catalog.test.ts @@ -73,6 +73,25 @@ describe("parseLab", () => { expect(lab.hints).toHaveLength(2); }); + it("reads an optional one-line trick and defaults to none", () => { + expect(parseLab("start", "01-first-prompt.md", good).trick).toBeUndefined(); + const withTrick = good.replace( + "## What you just learned", + "## Trick\nPress Esc twice to rewind.\n\n## What you just learned", + ); + expect(parseLab("start", "01-first-prompt.md", withTrick).trick).toBe( + "Press Esc twice to rewind.", + ); + }); + + it("rejects a trick over 200 characters or on more than one line", () => { + const put = (t: string) => + good.replace("## What you just learned", `## Trick\n${t}\n\n## What you just learned`); + expect(() => parseLab("start", "01-first-prompt.md", put("x".repeat(201)))).toThrow(); + expect(() => parseLab("start", "01-first-prompt.md", put("one\ntwo"))).toThrow(); + expect(parseLab("start", "01-first-prompt.md", put("x".repeat(200))).trick).toHaveLength(200); + }); + it("rejects an unknown window tag", () => { expect(() => parseLab("start", "01-x.md", good.replace("[claude]", "[browser]"))).toThrow( /window tag/, diff --git a/scripts/labs/catalog.ts b/scripts/labs/catalog.ts index b438dd7..a5c4827 100644 --- a/scripts/labs/catalog.ts +++ b/scripts/labs/catalog.ts @@ -62,6 +62,13 @@ export const labSchema = z.object({ proof: z.array(proofItem).min(1), // Two or more, so the first nudges and the last gives the answer away. hints: z.array(z.string().min(1)).min(2), + // A Claude Code habit the learner did not ask about but now uses; shown once the lab is done. + trick: z + .string() + .min(1) + .max(200) + .regex(/^[^\n]+$/, "must be one line") + .optional(), learned: oneLine, }); export type Lab = z.infer; @@ -161,6 +168,7 @@ export function parseLab(lane: string, file: string, text: string): Lab { steps: listItems(parts.get("steps") ?? "").map(stepOf), proof: proofOf(parts.get("proof") ?? ""), hints: listItems(parts.get("hints") ?? ""), + trick: parts.get("trick"), learned: parts.get("what you just learned"), }); } diff --git a/scripts/labs/cli.test.ts b/scripts/labs/cli.test.ts index e8252b6..101df83 100644 --- a/scripts/labs/cli.test.ts +++ b/scripts/labs/cli.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import type { Lab } from "./catalog.ts"; -import { type CliDeps, run, runWatch } from "./cli.ts"; +import { type CliDeps, run, runLab, runWatch } from "./cli.ts"; import { loadProgress, saveProgress } from "./progress.ts"; const lab = (id: string, lane: Lab["lane"]): Lab => ({ @@ -291,3 +291,43 @@ describe("cli watch", () => { expect(s.stops).toEqual([]); }); }); + +describe("LIVE=1", () => { + const withTrick = (): Lab => ({ + ...lab("start-01", "start"), + trick: "Use /memory.", + steps: [{ where: "shell", text: "Do it.", expect: "EXPECT-HIDDEN" }], + }); + + it("walks the lab through the injected reader, one Enter per step", async () => { + const s = setup(true); + s.deps.labs = [withTrick()]; + let presses = 0; + s.deps.env = { LIVE: "1" }; + s.deps.enter = async () => { + presses += 1; + }; + expect(await runLab(["lab"], s.deps)).toBe(0); + expect(presses).toBe(1); + expect(s.text()).toContain("Trick to keep: Use /memory."); + }); + + it("without LIVE the normal flow runs and never waits", async () => { + const s = setup(true); + s.deps.labs = [withTrick()]; + s.deps.enter = async () => { + throw new Error("must not wait"); + }; + expect(await runLab(["lab"], s.deps)).toBe(0); + expect(s.text()).toContain("Trick to keep: Use /memory."); + expect(s.text()).toContain("EXPECT-HIDDEN"); + }); + + it("LIVE with no path chosen still asks for the path", async () => { + const s = setup(true, false); + s.deps.env = { LIVE: "1" }; + s.deps.enter = async () => {}; + expect(await runLab(["lab"], s.deps)).toBe(2); + expect(s.text()).toContain("PATH_ID=pro"); + }); +}); diff --git a/scripts/labs/cli.ts b/scripts/labs/cli.ts index ceb1b7f..e09cbc7 100644 --- a/scripts/labs/cli.ts +++ b/scripts/labs/cli.ts @@ -1,4 +1,5 @@ import { type Lab, PATH_IDS, type PathId } from "./catalog.ts"; +import { runLive } from "./live.ts"; import { currentLab, laneStats, @@ -10,7 +11,7 @@ import { saveGuess, saveProgress, } from "./progress.ts"; -import { paint, renderBrief, renderMap } from "./render.ts"; +import { paint, renderBrief, renderMap, renderTrick } from "./render.ts"; export type CliDeps = { labs: Lab[]; @@ -27,6 +28,10 @@ export type CliDeps = { watch?: (onChange: () => void) => () => void; /** Calls onKey for each key typed; returns a stop function. */ keys?: (onKey: (key: string) => void) => () => void; + /** The process environment; `LIVE=1` turns on live mode. */ + env?: Record; + /** Resolves when the learner presses Enter. Injected so tests need no terminal. */ + enter?: () => Promise; }; const tail = (text: string, n: number) => text.trimEnd().split("\n").slice(-n).join("\n"); @@ -50,13 +55,14 @@ function showMap(deps: CliDeps, progress: Progress): void { deps.out(`\nThe other workshop's labs are hidden. Switch with: make lab PATH_ID=${other}`); } -function finish(deps: CliDeps, progress: Progress, lab: Lab): void { +export function finish(deps: CliDeps, progress: Progress, lab: Lab): void { const c = deps.color; deps.out(`${paint(c, "green", `PASS ${lab.id}`)} ${lab.title}`); const guess = progress.guesses?.[lab.id]; deps.out(guess ? `Your guess: ${guess}` : "You saved no guess for this lab."); deps.out(`${paint(c, "bold", "Answer")} ${lab.predict.answer}`); deps.out(`${paint(c, "bold", "What you just learned")} ${lab.learned}`); + if (lab.trick) deps.out(renderTrick(lab.trick, c)); const after = markDone(progress, lab.id, deps.now()); saveProgress(deps.progressPath, after); const next = currentLab(deps.labs, after); @@ -72,7 +78,10 @@ function hint(deps: CliDeps, progress: Progress, lab: Lab): void { deps.out(renderBrief(lab, loadProgress(deps.progressPath), deps.color)); } -export function run(args: string[], deps: CliDeps): number { +type Picked = { lab: Lab; progress: Progress }; + +/** Everything before the proof: parse, pick the path, find the lab. A number is an early exit code. */ +function prepare(args: string[], deps: CliDeps): number | Picked { const [command = "progress", ...rest] = args; const id = rest.find((a) => !a.startsWith("--")); let progress = loadProgress(deps.progressPath); @@ -119,6 +128,14 @@ export function run(args: string[], deps: CliDeps): number { hint(deps, progress, lab); return 0; } + return { lab, progress }; +} + +export function run(args: string[], deps: CliDeps): number { + const picked = prepare(args, deps); + if (typeof picked === "number") return picked; + const { lab } = picked; + let { progress } = picked; const guess = deps.guess?.trim(); if (guess) { @@ -156,6 +173,15 @@ export function run(args: string[], deps: CliDeps): number { return 1; } +/** `make lab` with `LIVE=1` walks the lab step by step; anything else is the plain run. */ +export async function runLab(args: string[], deps: CliDeps): Promise { + const { enter } = deps; + if (deps.env?.LIVE !== "1" || !enter) return run(args, deps); + const picked = prepare(args, deps); + if (typeof picked === "number") return picked; + return runLive(picked.lab, picked.progress, { ...deps, enter }); +} + const failLine = (deps: CliDeps, lab: Lab) => `${paint(deps.color, "red", `FAIL ${lab.id}`)} the proof is red. It reruns when you save. h hint, q quit.`; diff --git a/scripts/labs/live.test.ts b/scripts/labs/live.test.ts new file mode 100644 index 0000000..55add55 --- /dev/null +++ b/scripts/labs/live.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { Lab } from "./catalog.ts"; +import type { CliDeps } from "./cli.ts"; +import { runLive } from "./live.ts"; +import { loadProgress, saveProgress } from "./progress.ts"; + +const lab: Lab = { + id: "start-01", + lane: "start", + title: "First prompt", + pairs: "docs/README.md", + goal: "Ask one question.", + help: "guided", + steps: [ + { where: "shell", text: "Make the folder.", expect: "SEE-ONE" }, + { where: "claude", text: "Ask it.", expect: "SEE-TWO" }, + { where: "editor", text: "Save it." }, + ], + proof: [{ text: "It holds.", command: "p" }], + hints: ["one", "two"], + learned: "A prompt is a question.", + trick: "Press Esc twice to rewind.", + predict: { question: "Will it edit?", answer: "No." }, +}; + +function setup(ok: boolean, target: Lab = lab) { + const out: string[] = []; + const seen: string[] = []; + const log: string[] = []; + const dir = mkdtempSync(join(tmpdir(), "labs-live-")); + const progressPath = join(dir, "progress.json"); + const deps = { + labs: [target], + progressPath, + color: false, + proof: () => { + log.push("proof"); + return { ok, output: "boom\n" }; + }, + now: () => "2026-11-01", + out: (s: string) => out.push(s), + status: (s: string) => out.push(s), + isTTY: false, + // Records everything shown so far each time the learner would press Enter. + enter: async () => { + log.push("enter"); + seen.push(out.join("\n")); + }, + } satisfies CliDeps & { enter: () => Promise }; + saveProgress(progressPath, { done: {}, hints: {}, attempts: {}, path: "pro" }); + return { deps, text: () => out.join("\n"), seen, log, progressPath }; +} + +describe("runLive", () => { + it("shows one step, waits for Enter, then reveals that step's Expect", async () => { + const s = setup(true); + await runLive(lab, loadProgress(s.progressPath), s.deps); + expect(s.seen).toHaveLength(3); + expect(s.seen[0]).toContain("Make the folder."); + expect(s.seen[0]).not.toContain("Ask it."); + expect(s.seen[0]).not.toContain("SEE-ONE"); + expect(s.seen[1]).toContain("SEE-ONE"); + expect(s.seen[1]).toContain("Ask it."); + expect(s.seen[1]).not.toContain("SEE-TWO"); + expect(s.seen[2]).toContain("SEE-TWO"); + expect(s.text()).toContain("Save it."); + }); + + it("runs the proofs only after the last Enter, then prints the trick and records the pass", async () => { + const s = setup(true); + expect(await runLive(lab, loadProgress(s.progressPath), s.deps)).toBe(0); + expect(s.log).toEqual(["enter", "enter", "enter", "proof"]); + expect(s.text()).toContain("PASS start-01"); + expect(s.text()).toContain("Trick to keep: Press Esc twice to rewind."); + expect(loadProgress(s.progressPath).done["start-01"]).toBeDefined(); + }); + + it("on a red proof exits 1, names the failure, and prints no trick", async () => { + const s = setup(false); + expect(await runLive(lab, loadProgress(s.progressPath), s.deps)).toBe(1); + expect(s.text()).toContain("FAIL It holds."); + expect(s.text()).not.toContain("Trick to keep"); + expect(loadProgress(s.progressPath).done["start-01"]).toBeUndefined(); + }); + + it("prints no trick line for a lab without one", async () => { + const { trick: _drop, ...bare } = lab; + const s = setup(true, bare); + await runLive(bare, loadProgress(s.progressPath), s.deps); + expect(s.text()).not.toContain("Trick to keep"); + }); + + it("a goal-only lab has no steps to walk, so it shows the proof and runs it", async () => { + const goal: Lab = { ...lab, help: "goal" }; + const s = setup(true, goal); + await runLive(goal, loadProgress(s.progressPath), s.deps); + expect(s.text()).toContain("1. It holds."); + expect(s.log).toEqual(["proof"]); + }); +}); diff --git a/scripts/labs/live.ts b/scripts/labs/live.ts new file mode 100644 index 0000000..3203212 --- /dev/null +++ b/scripts/labs/live.ts @@ -0,0 +1,45 @@ +import type { Lab } from "./catalog.ts"; +import { type CliDeps, finish } from "./cli.ts"; +import type { Progress } from "./progress.ts"; +import { paint, renderExpect, renderGoalItems, renderStep, rule } from "./render.ts"; + +export type LiveDeps = CliDeps & { enter: () => Promise }; + +// One step on screen at a time, so the learner does it before reading what comes next. +// Expect stays hidden until Enter: the learner compares their screen to it, not the reverse. +export async function runLive(lab: Lab, progress: Progress, deps: LiveDeps): Promise { + const c = deps.color; + deps.out(paint(c, "bold", `${lab.id} ${lab.title}`)); + deps.out(rule(c)); + deps.out(`${paint(c, "bold", "Goal")} ${lab.goal}`); + deps.out(`${paint(c, "bold", "Predict first")} ${lab.predict.question}`); + deps.out(""); + + if (lab.help === "goal") { + // A goal lab hides its steps by design, so live mode shows the proof and runs it. + for (const line of renderGoalItems(lab, c)) deps.out(line); + } else { + for (const [i, step] of lab.steps.entries()) { + deps.out(renderStep(lab, step, i, c)); + deps.out(paint(c, "dim", " Do it, then press Enter.")); + await deps.enter(); + if (step.expect) deps.out(renderExpect(step, c)); + deps.out(""); + } + } + + // Every item runs, so one red item never hides another. + const results = lab.proof.map((item) => ({ item, result: deps.proof(item.command) })); + for (const { item, result } of results) { + const mark = result.ok ? paint(c, "green", "PASS") : paint(c, "red", "FAIL"); + deps.out(`${mark} ${item.text}`); + } + if (results.every((r) => r.result.ok)) { + finish(deps, progress, lab); + return 0; + } + deps.out( + `\n${paint(c, "red", `FAIL ${lab.id}`)} fix the items marked FAIL, then run LIVE=1 make lab again.`, + ); + return 1; +} diff --git a/scripts/labs/main.ts b/scripts/labs/main.ts index 7746e45..8ad9b28 100644 --- a/scripts/labs/main.ts +++ b/scripts/labs/main.ts @@ -1,13 +1,16 @@ import { spawnSync } from "node:child_process"; import { mkdirSync, watch } from "node:fs"; +import { createInterface } from "node:readline"; import { pathToFileURL } from "node:url"; import { loadLabs } from "./catalog.ts"; -import { type CliDeps, run, runWatch } from "./cli.ts"; +import { type CliDeps, runLab, runWatch } from "./cli.ts"; import { colorEnabled } from "./render.ts"; // Gitignored, so progress stays on the learner's machine and never reaches a commit. const PROGRESS_FILE = ".labs/progress.json"; +let lines: AsyncIterator | undefined; + const WORK_DIR = ".labs/work"; const DEBOUNCE_MS = 300; @@ -27,6 +30,12 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { out: (text) => console.log(text), status: (text) => console.log(`\x1b[2K${text}`), isTTY: Boolean(process.stdout.isTTY && process.stdin.isTTY), + env: process.env, + enter: async () => { + // One reader for the whole run: a reader per step would drop lines already buffered. + lines ??= createInterface({ input: process.stdin })[Symbol.asyncIterator](); + await lines.next(); + }, watch: (onChange) => { mkdirSync(WORK_DIR, { recursive: true }); let timer: NodeJS.Timeout | undefined; @@ -56,6 +65,6 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { }; }, }; - const code = args.includes("--watch") ? await runWatch(args, deps) : run(args, deps); + const code = args.includes("--watch") ? await runWatch(args, deps) : await runLab(args, deps); process.exit(code); } diff --git a/scripts/labs/render.test.ts b/scripts/labs/render.test.ts index ef7f863..cddf9e3 100644 --- a/scripts/labs/render.test.ts +++ b/scripts/labs/render.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Lab } from "./catalog.ts"; -import { bar, colorEnabled, renderBrief, renderMap, WIDTH } from "./render.ts"; +import { bar, colorEnabled, renderBrief, renderMap, renderTrick, WIDTH } from "./render.ts"; const lab: Lab = { id: "start-01", @@ -204,3 +204,22 @@ describe("screens fit the terminal", () => { expect(stripped.replace(/`/g, "")).toBe(plain.replace(/[`[\]]/g, "")); }); }); + +describe("renderTrick", () => { + it("is one plain line with colour off and fits 70 columns for a 200-character trick", () => { + const plain = renderTrick("Press Esc twice to rewind.", false); + expect(plain).toBe("Trick to keep: Press Esc twice to rewind."); + expect(ESC.test(plain)).toBe(false); + expect( + renderTrick("word ".repeat(40).trim(), false) + .split("\n") + .every((l) => l.length <= WIDTH), + ).toBe(true); + }); + + it("paints the label with colour on and strips back to the same text", () => { + const coloured = renderTrick("Press Esc twice to rewind.", true); + expect(ESC.test(coloured)).toBe(true); + expect(coloured.replace(COLOUR, "")).toBe(renderTrick("Press Esc twice to rewind.", false)); + }); +}); diff --git a/scripts/labs/render.ts b/scripts/labs/render.ts index 5818c39..0c3b098 100644 --- a/scripts/labs/render.ts +++ b/scripts/labs/render.ts @@ -71,19 +71,48 @@ export function renderMap( return lines.join("\n"); } +// The step line alone; the Expect line is separate so live mode can hold it back. +export function renderStep(lab: Lab, s: Step, i: number, color: boolean): string { + const text = lab.help === "hinted" ? hideCommands(s.text) : commands(s.text, color); + return `${i + 1}. ${tag(s.where, color)} ${text}`; +} + +export const renderExpect = (s: Step, color: boolean): string => + paint(color, "dim", ` Expect: ${s.expect ?? ""}`); + +export function renderGoalItems(lab: Lab, color: boolean): string[] { + const items = lab.proof.map((item, i) => `${i + 1}. ${item.text}`); + return [paint(color, "bold", "Done when all of these pass"), ...items, ""]; +} + function body(lab: Lab, color: boolean): string[] { - if (lab.help === "goal") { - const items = lab.proof.map((item, i) => `${i + 1}. ${item.text}`); - return [paint(color, "bold", "Done when all of these pass"), ...items, ""]; - } - const show = (s: string) => (lab.help === "hinted" ? hideCommands(s) : commands(s, color)); + if (lab.help === "goal") return renderGoalItems(lab, color); const step = (s: Step, i: number) => [ - `${i + 1}. ${tag(s.where, color)} ${show(s.text)}`, - ...(s.expect ? [paint(color, "dim", ` Expect: ${s.expect}`)] : []), + renderStep(lab, s, i, color), + ...(s.expect ? [renderExpect(s, color)] : []), ]; return [paint(color, "bold", "Steps"), ...lab.steps.flatMap(step), ""]; } +const TRICK_LABEL = "Trick to keep:"; + +// Wraps at WIDTH so a long trick never runs past the 70 columns; later lines hang under the text. +export function renderTrick(trick: string, color: boolean): string { + const pad = " ".repeat(TRICK_LABEL.length + 1); + const lines: string[] = []; + let line = TRICK_LABEL; + for (const word of trick.split(" ")) { + if (line.length + 1 + word.length > WIDTH && line.trim() !== TRICK_LABEL) { + lines.push(line); + line = pad.slice(0, -1); + } + line += ` ${word}`; + } + lines.push(line); + lines[0] = (lines[0] ?? "").replace(TRICK_LABEL, paint(color, "bold", TRICK_LABEL)); + return lines.join("\n"); +} + // The form's `lab` field is prefilled from the query string, so a report names its lab. export const feedbackUrl = (id: string): string => `https://github.com/learnwithparam/claude-code-workshop/issues/new?template=lab-feedback.yml&lab=${id}`; From 602d407215592abae86904d1f6c4080fd6717240 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Sun, 1 Nov 2026 12:56:58 +0200 Subject: [PATCH 2/2] Split cli.test.ts so each file stays under 300 lines Shared fixtures move to cli-fixtures.ts and the LIVE=1 tests to cli-live.test.ts. Co-Authored-By: Claude Sonnet 5.5 --- scripts/labs/cli-fixtures.ts | 39 +++++++++++++++++ scripts/labs/cli-live.test.ts | 44 +++++++++++++++++++ scripts/labs/cli.test.ts | 81 ++--------------------------------- 3 files changed, 86 insertions(+), 78 deletions(-) create mode 100644 scripts/labs/cli-fixtures.ts create mode 100644 scripts/labs/cli-live.test.ts diff --git a/scripts/labs/cli-fixtures.ts b/scripts/labs/cli-fixtures.ts new file mode 100644 index 0000000..1948bf3 --- /dev/null +++ b/scripts/labs/cli-fixtures.ts @@ -0,0 +1,39 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Lab } from "./catalog.ts"; +import type { CliDeps } from "./cli.ts"; +import { saveProgress } from "./progress.ts"; + +// Shared by the cli tests: a lab, and deps whose output and proof are fakes. +export const lab = (id: string, lane: Lab["lane"]): Lab => ({ + id, + lane, + title: `Title ${id}`, + pairs: "docs/README.md", + goal: `Goal ${id}`, + help: "guided", + steps: [{ where: "shell", text: "Do it." }], + proof: [{ text: `Item for ${id}.`, command: `proof-${id}` }], + hints: ["first hint", "second hint"], + learned: `Learned ${id}`, + predict: { question: `Question ${id}?`, answer: `Because reason ${id}.` }, +}); + +export function setup(passes: boolean, withPath = true, guess?: string) { + const out: string[] = []; + const dir = mkdtempSync(join(tmpdir(), "labs-cli-")); + const deps: CliDeps = { + labs: [lab("start-01", "start"), lab("start-02", "start")], + progressPath: join(dir, "progress.json"), + color: false, + proof: () => ({ ok: passes, output: "boom line\n" }), + guess, + now: () => "2026-10-30", + out: (s) => out.push(s), + status: (s) => out.push(s), + isTTY: false, + }; + if (withPath) saveProgress(deps.progressPath, { done: {}, hints: {}, attempts: {}, path: "pro" }); + return { deps, out, text: () => out.join("\n") }; +} diff --git a/scripts/labs/cli-live.test.ts b/scripts/labs/cli-live.test.ts new file mode 100644 index 0000000..0b6cf39 --- /dev/null +++ b/scripts/labs/cli-live.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import type { Lab } from "./catalog.ts"; +import { runLab } from "./cli.ts"; +import { lab, setup } from "./cli-fixtures.ts"; + +describe("LIVE=1", () => { + const withTrick = (): Lab => ({ + ...lab("start-01", "start"), + trick: "Use /memory.", + steps: [{ where: "shell", text: "Do it.", expect: "EXPECT-HIDDEN" }], + }); + + it("walks the lab through the injected reader, one Enter per step", async () => { + const s = setup(true); + s.deps.labs = [withTrick()]; + let presses = 0; + s.deps.env = { LIVE: "1" }; + s.deps.enter = async () => { + presses += 1; + }; + expect(await runLab(["lab"], s.deps)).toBe(0); + expect(presses).toBe(1); + expect(s.text()).toContain("Trick to keep: Use /memory."); + }); + + it("without LIVE the normal flow runs and never waits", async () => { + const s = setup(true); + s.deps.labs = [withTrick()]; + s.deps.enter = async () => { + throw new Error("must not wait"); + }; + expect(await runLab(["lab"], s.deps)).toBe(0); + expect(s.text()).toContain("Trick to keep: Use /memory."); + expect(s.text()).toContain("EXPECT-HIDDEN"); + }); + + it("LIVE with no path chosen still asks for the path", async () => { + const s = setup(true, false); + s.deps.env = { LIVE: "1" }; + s.deps.enter = async () => {}; + expect(await runLab(["lab"], s.deps)).toBe(2); + expect(s.text()).toContain("PATH_ID=pro"); + }); +}); diff --git a/scripts/labs/cli.test.ts b/scripts/labs/cli.test.ts index 101df83..9c69a4b 100644 --- a/scripts/labs/cli.test.ts +++ b/scripts/labs/cli.test.ts @@ -1,42 +1,7 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import type { Lab } from "./catalog.ts"; -import { type CliDeps, run, runLab, runWatch } from "./cli.ts"; -import { loadProgress, saveProgress } from "./progress.ts"; - -const lab = (id: string, lane: Lab["lane"]): Lab => ({ - id, - lane, - title: `Title ${id}`, - pairs: "docs/README.md", - goal: `Goal ${id}`, - help: "guided", - steps: [{ where: "shell", text: "Do it." }], - proof: [{ text: `Item for ${id}.`, command: `proof-${id}` }], - hints: ["first hint", "second hint"], - learned: `Learned ${id}`, - predict: { question: `Question ${id}?`, answer: `Because reason ${id}.` }, -}); - -function setup(passes: boolean, withPath = true, guess?: string) { - const out: string[] = []; - const dir = mkdtempSync(join(tmpdir(), "labs-cli-")); - const deps: CliDeps = { - labs: [lab("start-01", "start"), lab("start-02", "start")], - progressPath: join(dir, "progress.json"), - color: false, - proof: () => ({ ok: passes, output: "boom line\n" }), - guess, - now: () => "2026-10-30", - out: (s) => out.push(s), - status: (s) => out.push(s), - isTTY: false, - }; - if (withPath) saveProgress(deps.progressPath, { done: {}, hints: {}, attempts: {}, path: "pro" }); - return { deps, out, text: () => out.join("\n") }; -} +import { run, runWatch } from "./cli.ts"; +import { lab, setup } from "./cli-fixtures.ts"; +import { loadProgress } from "./progress.ts"; describe("cli", () => { it("with no path set, lab prints the choice, exits 2 and runs no proof", () => { @@ -291,43 +256,3 @@ describe("cli watch", () => { expect(s.stops).toEqual([]); }); }); - -describe("LIVE=1", () => { - const withTrick = (): Lab => ({ - ...lab("start-01", "start"), - trick: "Use /memory.", - steps: [{ where: "shell", text: "Do it.", expect: "EXPECT-HIDDEN" }], - }); - - it("walks the lab through the injected reader, one Enter per step", async () => { - const s = setup(true); - s.deps.labs = [withTrick()]; - let presses = 0; - s.deps.env = { LIVE: "1" }; - s.deps.enter = async () => { - presses += 1; - }; - expect(await runLab(["lab"], s.deps)).toBe(0); - expect(presses).toBe(1); - expect(s.text()).toContain("Trick to keep: Use /memory."); - }); - - it("without LIVE the normal flow runs and never waits", async () => { - const s = setup(true); - s.deps.labs = [withTrick()]; - s.deps.enter = async () => { - throw new Error("must not wait"); - }; - expect(await runLab(["lab"], s.deps)).toBe(0); - expect(s.text()).toContain("Trick to keep: Use /memory."); - expect(s.text()).toContain("EXPECT-HIDDEN"); - }); - - it("LIVE with no path chosen still asks for the path", async () => { - const s = setup(true, false); - s.deps.env = { LIVE: "1" }; - s.deps.enter = async () => {}; - expect(await runLab(["lab"], s.deps)).toBe(2); - expect(s.text()).toContain("PATH_ID=pro"); - }); -});